Concurrency — the execution of several tasks simultaneously — is widely used to improve throughput (by using several processors for a single computation) or to improve responsiveness (by allowing one part of a program to progress while another is waiting for a response). The support provided by the C++ standard library is a portable and type-safe variant of what has been used in C++ for more than 20 years, aimed primarily at supporting systems-level concurrency; higher-level models can be supplied as libraries built using the standard-library facilities. The thread running through this chapter: think in terms of concurrent tasks, not threads and locks — and keep it simple.
Why it matters: this is the chapter where the standard library's pieces
from Chapters 9–17 (containers, algorithms, chrono time, parallel policies) meet real
parallelism. The core mental model: a task is work that may run concurrently; a
thread is its system-level representation. The traps are data races (uncontrolled
concurrent access to mutable data — guarded against by the memory model), forgotten joins
(solved by jthread's RAII), and deadlock (solved by
scoped_lock acquiring several locks at once). The chapter keeps pushing you up
the abstraction ladder: shared data with mutexes, then condition variables, then
future/promise, then packaged_task, then
async() — and only if you need it, coroutines.
To allow concurrent execution of multiple threads in a single address space, C++ provides a suitable memory model and a set of atomic operations. The atomic operations allow lock-free programming. The memory model ensures that as long as a programmer avoids data races (uncontrolled concurrent access to mutable data), everything works as one would naively expect. Most users will see concurrency only in terms of the standard library and libraries built on top of that: threads, mutexes, lock() operations, packaged_tasks, and futures. But:
We call a computation that can potentially be executed concurrently with other computations a
task. A thread is the system-level representation of a task
in a program. A task to be executed concurrently with other tasks is launched by constructing
a thread (found in <thread>) with the task as its argument. A
task is a function or a function object:
void f(); // function
struct F { // function object
void operator()(); // F's call operator (§7.3.2)
};
void user() {
thread t1 {f}; // f() executes in separate thread
thread t2 {F{}}; // F{}() executes in separate thread
t1.join(); // wait for t1
t2.join(); // wait for t2
}
The join()s ensure that we don't exit user()
until the threads have completed. To "join" a thread means to "wait for the thread to
terminate." It is easy to forget to
join(), and the results are usually bad — so the standard library provides
jthread, a "joining thread" that follows RAII by having its destructor
join():
void user() {
jthread t1 {f}; // f() executes in separate thread
jthread t2 {F{}}; // F{}() executes in separate thread
} // join automatically (reverse order: t2 before t1)
Threads of a program share a single address space — in this, threads differ from processes, which generally do not directly share data. Since threads share an address space, they can communicate through shared objects (§18.3). Such communication is typically controlled by locks or other mechanisms to prevent data races. Programming concurrent tasks can be very tricky; consider this bad error:
void f() { cout << "Hello "; }
struct F { void operator()() { cout << "Parallel World!\n"; } };
Here, f and F{} each use the object cout without any
form of synchronization. The resulting output is unpredictable and can vary between executions
— the order of the individual operations in the two tasks is not defined. The program may
produce "odd" output, such as PaHerallllel o World!. Only a specific guarantee in
the standard saves us from a data race within the definition of ostream that
could lead to a crash. To avoid such problems with output streams, either have just one thread
use a stream or use an osyncstream (§11.7.5).
When defining tasks of a concurrent program, our aim is to keep tasks completely separate except where they communicate in simple and obvious ways. The simplest way of thinking of a concurrent task is as a function that happens to run concurrently with its caller — we just have to pass arguments, get a result back, and make sure that there is no use of shared data in between (no data races).
We can easily pass data (or pointers or references to the data) as arguments:
void f(vector<double>& v); // function: do something with v
struct F { // function object: do something with v
vector<double>& v;
F(vector<double>& vv) :v{vv} { }
void operator()();
};
int main() {
vector<double> some_vec {1, 2, 3, 4, 5, 6, 7, 8, 9};
vector<double> vec2 {10, 11, 12, 13, 14};
jthread t1 {f,ref(some_vec)}; // f(some_vec) executes in a separate thread
jthread t2 {F{vec2}}; // F(vec2)() executes in a separate thread
}
F{vec2} saves a reference to the argument vector in F;
F can now use that vector, and hopefully no other task accesses
vec2 while F is executing (passing vec2 by value would
eliminate that risk). The initialization {f,ref(some_vec)} uses the thread's
variadic template constructor, which can accept an arbitrary sequence of arguments (§8.4). The
ref() is a type function from <functional> that is needed to
tell the variadic template to treat some_vec as a reference rather than
as an object — without ref(), some_vec would be passed by value. The
compiler checks that the first argument can be invoked given the following arguments and
builds the necessary function object to pass to the thread.
Passing the arguments by non-const reference is a somewhat sneaky, but not uncommon, way of returning a result. A less obscure technique is to pass the input data by const reference and pass the location of a place to deposit the result as a separate argument:
void f(const vector<double>& v, double* res); // take input from v; place result in *res
class F {
public:
F(const vector<double>& vv, double* p) :v{vv}, res{p} { }
void operator()(); // place result in *res
private:
const vector<double>& v; // source of input
double* res; // target for output
};
double g(const vector<double>&); // use return value
void user(vector<double>& vec1, vector<double> vec2, vector<double> vec3) {
double res1, res2, res3;
thread t1 {f,cref(vec1),&res1}; // f(vec1,&res1) in a separate thread
thread t2 {F{vec2,&res2}}; // F{vec2,&res2}() in a separate thread
thread t3 { [&](){ res3 = g(vec3); } }; // capture local variables by reference
t1.join(); t2.join(); t3.join(); // join before using results
cout << res1 << ' ' << res2 << ' ' << res3 << '\n';
}
Here, cref(vec1) passes a const reference to vec1. This
works and is very common, but returning results through references is not particularly elegant
— we return to this topic with future and promise in §18.5.1.
Sometimes tasks need to share data. In that case, the access has to be synchronized so that at most one task at a time has access. (Experienced programmers will recognize this as a simplification — there is no problem with many tasks simultaneously reading immutable data.)
A mutex, a "mutual exclusion object," is a key element of general sharing of
data between threads. A thread acquires a mutex using a lock() operation:
mutex m; // controlling mutex
int sh; // shared data
void f() {
scoped_lock lck {m}; // acquire mutex
sh += 7; // manipulate shared data
} // release mutex implicitly
The type of lck is deduced to be scoped_lock<mutex> (§7.2.3).
The scoped_lock's constructor acquires the mutex (through a call
m.lock()); if another thread has already acquired the mutex, the thread waits
("blocks") until the other thread completes its access. Once a thread has completed its
access, the scoped_lock releases the mutex (with a call m.unlock()),
and threads waiting for it resume executing ("are woken up"). The mutual exclusion and locking
facilities are found in <mutex>. Note the use of RAII (§6.3): resource
handles such as scoped_lock and unique_lock (§18.4) are simpler and
far safer than explicitly locking and unlocking mutexes.
The correspondence between shared data and a mutex relies on
convention: the programmer has to know which mutex corresponds to which data.
Obviously this is error-prone, and obviously we try to make the correspondence clear — e.g.,
class Record { mutex rm; /* ... */ };: for a Record called
rec, you are supposed to acquire rec.rm before accessing the rest of
rec.
It is not uncommon to need to simultaneously access several resources to perform some action.
This can lead to deadlock: if thread1 acquires mutex1 and then tries to
acquire mutex2 while thread2 acquires mutex2 and then tries to acquire mutex1, neither task
will ever proceed further. The scoped_lock helps by enabling us to acquire
several locks simultaneously:
void f() {
scoped_lock lck {mutex1,mutex2,mutex3}; // acquire all three locks
// ... manipulate shared data ...
} // implicitly release all mutexes
This scoped_lock proceeds only after acquiring all
its mutexes and will never block while holding a mutex. The destructor ensures the mutexes are
released when the thread leaves the scope.
Communicating through shared data is pretty low level — the programmer has to devise ways of knowing what work has and has not been done by various tasks; in that regard, use of shared data is inferior to the notion of call and return. Some people are convinced that sharing must be more efficient than copying arguments and returns; that can indeed be so when large amounts of data are involved, but locking and unlocking are relatively expensive operations, and modern machines are very good at copying compact data such as vector elements. So don't choose shared data for communication because of "efficiency" without thought — and preferably not without measurement.
One of the most common ways of sharing data is among many readers and a single writer. This
"reader-writer lock" idiom is supported by
shared_mutex:
shared_mutex mx; // a mutex that can be shared
void reader() {
shared_lock lck {mx}; // willing to share access with other readers
// ... read ...
}
void writer() {
unique_lock lck {mx}; // needs exclusive (unique) access
// ... write ...
}
A mutex is a fairly heavyweight mechanism involving the operating system; it allows arbitrary amounts of work to be done free of data races. But there is a far simpler and cheaper mechanism for doing just a tiny amount of work: an atomic variable. Here is a simple variant of the classic double-checked locking:
mutex mut;
atomic<bool> init_x; // initially false
X x; // variable that requires nontrivial initialization
if (!init_x) {
lock_guard lck {mut};
if (!init_x) {
// ... do nontrivial initialization of x ...
init_x = true;
}
}
// ... use x ...
The atomic saves us from most uses of the much more expensive mutex. Had
init_x not been atomic, that initialization would have failed ever so
infrequently, causing mysterious and hard-to-find errors because there would have been a data
race on init_x. Here lock_guard was used rather than
scoped_lock
because only one mutex was needed, so the simplest lock sufficed.
Sometimes a thread needs to wait for some kind of external event, such as another thread completing a task or a certain amount of time having passed. The simplest "event" is simply time passing:
using namespace chrono; // see §16.2.1
auto t0 = high_resolution_clock::now();
this_thread::sleep_for(milliseconds{20});
auto t1 = high_resolution_clock::now();
cout << duration_cast<nanoseconds>(t1-t0).count() << " nanoseconds passed\n";
We didn't even have to launch a thread — by default,
this_thread can refer to the one and only thread. The basic support for
communicating using external events is provided by condition_variables found
in <condition_variable>. A condition_variable is a mechanism allowing one
thread to wait for another — in particular, it allows a thread to wait for some condition
(often called an event) to occur as the result of work done by other threads. Using
condition_variables supports many forms of elegant and efficient sharing but can be rather
tricky. Consider the classic example of two threads communicating by passing messages through
a queue:
class Message { /* ... */ }; // object to be communicated
queue<Message> mqueue; // the queue of messages
condition_variable mcond; // the variable communicating events
mutex mmutex; // for synchronizing access to mcond
void consumer() {
while(true) {
unique_lock lck {mmutex}; // acquire mmutex
mcond.wait(lck,[] { return !mqueue.empty(); }); // release mmutex and wait;
// re-acquire mmutex upon wakeup;
// don't wake up unless mqueue is non-empty
auto m = mqueue.front(); // get the message
mqueue.pop();
lck.unlock(); // release mmutex
// ... process m ...
}
}
Waiting on a condition_variable releases its lock argument until the wait is over and then
reacquires it. The explicit check of the condition, here !mqueue.empty(),
protects against waking up just to find that some other task has "gotten there first" so the
condition no longer holds. We used a unique_lock rather than a
scoped_lock for two reasons:
wait(). A
scoped_lock cannot be moved, but a unique_lock can.
unique_lock offers operations such as lock() and
unlock() for low-level control of synchronization.
On the other hand, unique_lock can handle only a single mutex. The corresponding
producer:
void producer() {
while(true) {
Message m;
// ... fill the message ...
scoped_lock lck {mmutex}; // protect operations
mqueue.push(m);
mcond.notify_one(); // notify
} // release mmutex (at end of scope)
}
The standard library provides a few facilities to allow programmers to operate at the
conceptual level of tasks (work to potentially be done concurrently) rather than
directly at the lower level of threads and locks — all found in <future>:
future and promise for returning a value from a task spawned on a
separate thread;
packaged_task to help launch tasks and connect up the mechanisms for returning
a result;
async() for launching a task in a manner very similar to calling a function.
The important point about future and promise
is that they enable a transfer of a value between two tasks
without explicit use of a lock; "the system" implements the transfer efficiently. The
basic idea is simple: when a task wants to pass a value to another, it puts the value into a
promise. Somehow, the implementation makes that value appear in the corresponding
future, from which it can be read (typically by the launcher of the task). If we
have a future<X> called fx, we can get() a value
of type X from it:
X v = fx.get(); // if necessary, wait for the value to get computed
If the value isn't there yet, our thread is blocked until it arrives. If the value couldn't be
computed, get() might throw an exception (from the system or transmitted from the
promise). The main purpose of a promise is to provide simple "put" operations —
set_value() and set_exception() — to match future's
get(). (The names "future" and "promise" are historical.)
void f(promise<X>& px) { // a task: place the result in px
try {
X res;
// ... compute a value for res ...
px.set_value(res);
}
catch (...) { // oops: couldn't compute res
px.set_exception(current_exception());
}
}
void g(future<X>& fx) { // a task: get the result from fx
// ...
X v = fx.get(); // if necessary, wait for the value to get computed
// ... use v ...
}
To deal with an exception transmitted through a future, the caller of
get() must be prepared to catch it somewhere. If the error doesn't need to be
handled by g() itself, the code reduces to the minimal form above — an exception
thrown from f()'s function is then implicitly propagated to g()'s
caller, exactly as it would have been had g() called f() directly.
How do we get a future into the task that needs a result and the corresponding promise into
the thread that should produce that result? The packaged_task type simplifies
setting up tasks connected with futures and promises to be run on threads: it provides wrapper
code to put the return value or exception from the task into a promise (like the code
shown in §18.5.1). If you ask it by calling get_future(), a
packaged_task will give you the future corresponding to its promise. For example,
we can set up two tasks to each add half of the elements of a
vector<double> using the standard-library
accumulate() (§17.3):
double accum(double* beg, double* end, double init) { // compute the sum of [beg:end) starting with init
return accumulate(beg,end,init);
}
double comp2(vector<double>& v) {
packaged_task pt0 {accum}; // package the task (i.e., accum)
packaged_task pt1 {accum};
future<double> f0 {pt0.get_future()}; // get hold of pt0's future
future<double> f1 {pt1.get_future()}; // get hold of pt1's future
double* first = &v[0];
thread t1 {move(pt0),first,first+v.size()/2,0}; // start a thread for pt0
thread t2 {move(pt1),first+v.size()/2,first+v.size(),0}; // start a thread for pt1
// ...
return f0.get()+f1.get(); // get the results
}
The packaged_task template takes the type of the task as its template argument
and the task as its constructor argument. The move() operations are needed
because a packaged_task cannot be copied: it is a resource handle — it
owns its promise and is (indirectly) responsible for whatever resources its task may own.
Please note the absence of explicit mention of locks in this code: we are able to concentrate
on tasks to be done, rather than on the mechanisms used to manage their communication. The two
tasks will be run on separate threads and thus potentially in parallel.
The line of thinking pursued in this chapter is the simplest yet still among the most
powerful:
treat a task as a function that may happen to run concurrently with other tasks. To
launch tasks to potentially run asynchronously, we can use async():
double comp4(vector<double>& v) { // spawn many tasks if v is large enough
if (v.size()<10'000) // is it worth using concurrency?
return accum(v.begin(),v.end(),0.0);
auto v0 = &v[0];
auto sz = v.size();
auto f0 = async(accum,v0,v0+sz/4,0.0); // first quarter
auto f1 = async(accum,v0+sz/4,v0+sz/2,0.0); // second quarter
auto f2 = async(accum,v0+sz/2,v0+sz*3/4,0.0); // third quarter
auto f3 = async(accum,v0+sz*3/4,v0+sz,0.0); // fourth quarter
return f0.get()+f1.get()+f2.get()+f3.get(); // collect and combine the results
}
Basically, async() separates the "call part" of a function call from the "get the
result part," and separates both from the actual execution of the task. Using
async(), you don't have to think about threads and locks — you think in terms of
tasks that potentially compute their results asynchronously. There is an obvious limitation:
don't even think of using async() for tasks that share resources needing
locking.
With async() you don't even know how many threads will be used — that's up to
async() to decide based on the system resources available at the time of a call
(it may check whether any idle cores are available). A guess about the cost of computation
relative to the cost of launching a thread, such as v.size()<10'000, is very
primitive and prone to gross mistakes about performance — don't take such an estimate as more
than a simple and probably poor guess. It is rarely necessary to manually parallelize a
standard-library algorithm such as accumulate(), because the parallel algorithms
(e.g., reduce(par_unseq, ...)) usually do a better job (§17.3.1) — however, the
technique is general. Note that async() is not just a mechanism for parallel
computation: it can also be used to spawn a task for getting information from a user, leaving
the "main program" active with something else.
Sometimes we want to stop a thread because we no longer are interested in its result. Just
"killing" it is usually not acceptable because a thread can own resources that must be
released (e.g., locks, sub-threads, and database connections). Instead, the standard library
provides a mechanism for politely requesting a thread to clean up and go away: a
stop_token. A thread can be programmed to terminate if it has a stop_token
and is requested to stop. Consider a parallel find_any() that spawns many threads
looking for a result; when a thread returns with an answer, we would like to stop the
remaining threads. The find() task has a main loop in which we can insert a test
of whether to continue or stop:
atomic<int> result = -1; // put a resulting index here
template<class T> struct Range { T* first; T* last; }; // a way of passing a range of Ts
void find(stop_token tok, const string* base, const Range<string> r, const string target) {
for (string* p = r.first; p!=r.last && !tok.stop_requested(); ++p)
if (match(*p,target)) { // match() applies some matching criteria to the two strings
result = p - base; // the index of the found element
return;
}
}
Here, !tok.stop_requested() tests whether some other thread has requested this
thread to terminate. A stop_token is the mechanism for safely (no data races) communicating
such a request. The stop_sources produce the stop_tokens through which requests
to stop are communicated to threads:
void find_all(vector<string>& vs, const string& key) {
int mid = vs.size()/2;
string* pvs = &vs[0];
stop_source ss1{};
jthread t1(find, ss1.get_token(), pvs, Range{pvs,pvs+mid}, key); // first half of vs
stop_source ss2{};
jthread t2(find, ss2.get_token(), pvs, Range{pvs+mid,pvs+vs.size()}, key); // second half of vs
while (result == -1) this_thread::sleep_for(10ms);
ss1.request_stop(); // we have a result: stop all threads
ss2.request_stop();
// ... use result ...
}
The synchronization and returning of a result here is the simplest possible: put the result in an atomic variable (§18.3.2) and do a spin loop on that. Of course, we could elaborate this simple example — many searcher threads, more general result return, different element types — but that would obscure the basic role of stop_source and stop_token.
A coroutine is a function that maintains its state between calls. In that, it's a bit like a function object, but the saving and restoring of its state between calls are implicit and complete. Consider a classic example:
generator<long long> fib() { // generate Fibonacci numbers
long long a = 0;
long long b = 1;
while (a<b) {
auto next = a+b;
co_yield next; // save state, return value, and wait
a = b;
b = next;
}
co_return 0; // a fib too far
}
void user(int max) {
for (int i=0; i++<max;) cout << fib() << ' ';
} // 1 2 3 5 8 13 ...
The generator return value is where the coroutine stores its state between calls.
We could, of course, have made a function object Fib that worked the same way —
but then we would have had to maintain its state ourselves. For larger states and more complex
computations, saving and restoring state get tedious, hard to optimize, and error-prone. In
effect, a coroutine is a function that saves its stack frame between calls. The
co_yield returns a value and waits for the next call; the
co_return returns a value and terminates the coroutine. Coroutines can be
synchronous (the caller waits for the result — the Fibonacci example is obviously
synchronous) or asynchronous (the caller does some other work until it looks for the
result). The coroutines are implemented as an extremely flexible framework designed by and for
experts; the library facilities to make simple uses simple are still missing in C++20 — for
example, generator is not (yet) part of the standard library (there are
proposals, and a Web search will find good implementations, e.g., the Cppcoro library).
Donald Knuth praises the usefulness of coroutines, but also bemoans that it is hard to give brief examples because coroutines are most useful in simplifying complex systems. Coroutines are ideal for event-driven simulations — a system represented as a network of simple tasks (coroutines) that collaborate to complete complex tasks. The keys to such designs:
It is essential for such systems not to use too much space — that's why we don't use processes or threads: a thread requires a megabyte or two (mostly for its stack), a coroutine often only a couple of dozen bytes. If you need many thousands of tasks, that can make a big difference. Context switching between coroutines is also far faster than between threads or processes.
struct Event_base {
virtual void operator()() = 0;
virtual ~Event_base() {}
};
template<class Act> struct Event : Event_base {
Event(const string n, Act a) : name{n}, act{move(a)} {}
string name;
Act act;
void operator()() override { act(); }
};
void test() {
vector<Event_base*> events = { // a couple of Events holding coroutines
new Event{"integers", sequencer(10)},
new Event{"chars", char_seq('a')}
};
vector order {0, 1, 1, 0, 1, 0, 1, 0, 0}; // choose some order
for (int x : order) // invoke coroutines in order
(*events[x])();
for (auto p : events) delete p; // clean up
}
So far, there is nothing specifically coroutine about this — it's just a conventional
object-oriented framework for executing operations on a set of objects of potentially
differing types. However,
sequencer and char_seq happen to be coroutines: the fact that they
maintain their state between calls is essential for real-world uses of such frameworks.
task sequencer(int start, int step =1) {
auto value = start;
while (true) {
cout << "value: " << value << '\n'; // communicate a result
co_yield 0; // sleep until someone resumes this coroutine
value += step; // update state
}
}
We can see that sequencer is a coroutine because it uses co_yield to
suspend itself between calls; this implies that task must be a coroutine handle.
The "magic" is in the return type task: it holds the state of the coroutine (in
effect the function's stack frame) between calls and determines the meaning of
co_yield. From a user's point of view task is trivial — it simply
provides an operator to invoke the coroutine. If task had been in a library,
preferably the standard library, that would be all we needed to know; the implementation is a
promise_type mapping to the language features (initial_suspend,
final_suspend, yield_value, get_return_object,
coroutine_handle<promise_type>). Stroustrup strongly encourages
not writing such code yourself unless you are a library implementer trying to save
others from the bother.
Here is a summary of the guidance from this chapter. All 36 items, with the section where each is introduced. The C++ Core Guidelines link each item to its recommended practice.
| # | Guideline | § |
|---|---|---|
| 1 | Use concurrency to improve responsiveness or to improve throughput. | 18.1 |
| 2 | Work at the highest level of abstraction that you can afford. | 18.1 |
| 3 | Consider processes as an alternative to threads. | 18.1 |
| 4 | The standard-library concurrency facilities are type safe. | 18.1 |
| 5 | The memory model exists to save most programmers from having to think about the machine architecture level of computers. | 18.1 |
| 6 | The memory model makes memory appear roughly as naively expected. | 18.1 |
| 7 | Atomics allow for lock-free programming. | 18.1 |
| 8 | Leave lock-free programming to experts. | 18.1 |
| 9 | Sometimes, a sequential solution is simpler and faster than a concurrent solution. | 18.1 |
| 10 | Avoid data races. | 18.1, 18.2 |
| 11 | Prefer parallel algorithms to direct use of concurrency. | 18.1, 18.5.3 |
| 12 | A thread is a type-safe interface to a system thread. | 18.2 |
| 13 | Use join() to wait for a thread to complete. |
18.2 |
| 14 | Prefer jthread over thread. |
18.2 |
| 15 | Avoid explicitly shared data whenever you can. | 18.2 |
| 16 | Prefer RAII to explicit lock/unlock. | 18.3 |
| 17 | Use scoped_lock to manage mutexes. |
18.3 |
| 18 | Use scoped_lock to acquire multiple locks. |
18.3 |
| 19 | Use shared_lock to implement reader-writer locks. |
18.3 |
| 20 | Define a mutex together with the data it protects. | 18.3 |
| 21 | Use atomics for very simple sharing. | 18.3.2 |
| 22 | Use condition_variables to manage communication among threads. | 18.4 |
| 23 |
Use unique_lock (rather than scoped_lock) when you need to copy
a lock or need lower-level manipulation of synchronization.
|
18.4 |
| 24 |
Use unique_lock (rather than scoped_lock) with
condition_variables.
|
18.4 |
| 25 | Don't wait without a condition. | 18.4 |
| 26 | Minimize time spent in a critical section. | 18.4 |
| 27 | Think in terms of concurrent tasks, rather than directly in terms of threads. | 18.5 |
| 28 | Value simplicity. | 18.5 |
| 29 |
Prefer packaged_tasks and futures over direct use of threads and
mutexes.
|
18.5 |
| 30 |
Return a result using a promise and get a result from a future.
|
18.5.1 |
| 31 | Use packaged_tasks to handle exceptions thrown by tasks. |
18.5.2 |
| 32 |
Use a packaged_task and a future to express a request to an
external service and wait for its response.
|
18.5.2 |
| 33 | Use async() to launch simple tasks. |
18.5.3 |
| 34 | Use stop_token to implement cooperative termination. |
18.5.4 |
| 35 | A coroutine can be very much smaller than a thread. | 18.6 |
| 36 | Prefer coroutine support libraries to hand-crafted code. | 18.6 |
What is the difference between a task and a thread, and why does jthread exist?
Why is two threads writing to cout without synchronization a bad error?
Why are ref() and cref() needed when launching threads, and how do you return a result?
How does scoped_lock manage a mutex, and how does it prevent deadlock?
How does shared_mutex support many readers and one writer?
Why use an atomic variable instead of a mutex for tiny amounts of work?
Why does the consumer use unique_lock and a predicate in wait()?
How do future and promise transfer a value — and an exception?
What does packaged_task wrap, and why can't it be copied?
What does async() separate, and what are its limits?
How do you politely stop a thread you no longer need?
What makes a coroutine different from a function object, and why do event-driven systems prefer them over threads?
task ⇒ computation that may run concurrently | thread
⇒ system-level representation | launch ⇒
thread t {f} or {F{}}
join() ⇒ wait for termination → jthread joins in
destructor (RAII, reverse order) | advice 13-14
threads share one address space v/s processes ⇒ no shared data | data race ⇒ uncontrolled concurrent access → cout garbling ('PaHerallllel o World!') | fix ⇒ one thread per stream / osyncstream
args ⇒ variadic thread ctor | ref()/cref()
⇒ force reference passing | results via pointers/references
→ elegant later (future/promise)
scoped_lock lck {m} ⇒ acquire in ctor, release in dtor (RAII,
advice 16-17) | block/wake ⇒ waiting threads
mutex ↔ data ⇒ by convention (Record {mutex rm}) | advice 20 ⇒ define mutex with its data
deadlock ⇒ m1→m2 v/s m2→m1 | scoped_lock {m1,m2,m3} acquires
ALL at once, never blocks holding one (advice 18)
sharing v/s copying ⇒ locks expensive, copies cheap → measure before 'efficiency' claims
reader-writer ⇒ shared_mutex | readers
shared_lock v/s writer unique_lock (advice 19)
tiny work ⇒ atomic<bool> cheaper than mutex (advice
21) | double-checked locking ⇒ non-atomic fails 'ever so
infrequently'
lock-free programming ⇒ possible via atomics (advice 7) → leave to experts (advice 8)
sleep_for/high_resolution_clock ⇒ time as the simplest
event | this_thread works without launching
condition_variable ⇒ wait for an event from another thread
| producer-consumer queue
wait(lck, pred) ⇒ releases lock while waiting, reacquires on wake
| predicate guards 'got there first' (advice 25)
unique_lock v/s scoped_lock ⇒ movable + lock()/unlock()
needed for wait() (advice 23-24) | single mutex only
producer ⇒ scoped_lock + push + notify_one() | minimize
critical section (advice 26)
<future> ⇒ future/promise | packaged_task | async() → think in tasks, not threads (advice 27-29)
promise ⇒ set_value() / set_exception(current_exception()) |
future ⇒ get() blocks until value → exceptions propagate like
direct call
packaged_task ⇒ wraps task → return value/exception into promise
| get_future() | move-only (owns promise)
| comp2 sums halves, no locks
async() ⇒ separates call from get-result | thread count
decided by system | × shared-resource tasks | size guess primitive
→ parallel algorithms better for accumulate
stop_token ⇒ cooperative termination | stop_source → token
| poll stop_requested() | request_stop() | atomic
result + spin loop
coroutine ⇒ function maintaining state between calls → saves stack frame (implicit, complete) v/s function object ⇒ manual state
co_yield ⇒ return value + wait | co_return
⇒ value + terminate | fib example
sizes ⇒ thread ~1-2 MB stack v/s coroutine ~dozen bytes | switching far faster | thousands of tasks feasible
event-driven ⇒ many stateful coroutines + polymorphism (Event_base) + scheduler | sequencer/char_seq
handle type ⇒ promise_type + coroutine_handle → expert territory
| generator not yet standard → Cppcoro, advice 36
Primary source: Stroustrup, B. (2022). A Tour of C++, 3rd ed.,
Chapter 18: "Concurrency." Addison-Wesley.
Reference:
Chapter 1–12 Quick Reference & Glossary —
keep it beside you while you study.
Recommended supplement: cppreference on
thread support library,
future/promise, and
coroutines.
Questions? Ask your agent — your teacher — about anything unclear: when a data race actually
crashes a program, why
scoped_lock can't be passed to wait(), or how
async() decides about threads. Follow-ups are expected, not optional.