Concurrency
Explore the concurrency features introduced in C++20, including atomic operations with std::atomic_ref, atomic smart pointers, semaphores, latches, barriers, cooperative thread interruption with std::jthread, and synchronized output streams. Understand how these tools improve thread safety and synchronization in concurrent programming.
We'll cover the following...
Atomics
The class template std::atomic_ref applies atomic operations to the referenced non-atomic object. Concurrent writing and reading of the referenced object can take place, therefore, with no data race. The lifetime of the referenced object must exceed the lifetime of the std::atomic_ref. Accessing a sub-object of the referenced object with std::atomic_ref is not thread-safe.
According to std::atomic, std::atomic_ref can be specialized and supports specializations for the built-in data types.
struct Counter {
int a;
int b;
};
Counter counter;
std::atomic_ref<Counter> cnt(counter);
With C++20, we get two atomic smart pointers that are partial specializations of std::atomic: std::atomic<std::shared_ptr<T>> and std::atomic<std::weak_ptr<T>>. Both atomic smart pointers guarantee that not only the control block, as in the case of std::shared_ptr, is thread-safe but also the associated object.
std::atomic gets more extensions (click here for more details). C++20 provides specializations for atomic floating-point types. This is quite convenient when you have a concurrently incremented floating-point type. A value of type std::atomic_flag is a kind of atomic boolean. It has a cleared and set state. For simplicity reasons, I call the clear state false and the set state true. The clear() member function enables you to set its value to false. With the test_and_set() member function, you can set the value to true and get the previous value. There is no member function to ask for the current value. This will change with C++20 because std::atomic_flag has a test() method.
Furthermore, std::atomic_flag can be used for thread synchronization via the member functions notify_one(), notify_all(), and wait(). With C++20, notifying and waiting are available on all partial and full specializations of std::atomic and std::atomic_ref. Specializations are available for bools, integrals, floats, and pointers.
Semaphores
Semaphores are a synchronization mechanism used to control concurrent access to a shared resource. A counting semaphore, such as the one which was added in C++20, is a special semaphore whose initial counter is bigger than zero. The counter is initialized in the constructor. Acquiring the semaphore decreases the counter, and releasing the semaphore increases the counter. If a thread tries to acquire the semaphore when the counter is zero, the thread blocks until another thread increments the counter by releasing the semaphore.
Latches and barriers
Latches and barriers are straightforward thread synchronization mechanisms that enable some threads to block until a counter becomes zero.
-
You can use a
std::latchonly once. -
A
std::latchis useful for managing one task by multiple threads.
-
You can use a
std::barriermore than once. -
A
std::barrieris useful for managing repeated tasks by multiple threads.
Furthermore, std::barrier can adjust the counter in each iteration.
The following is based on a code snippet from proposal N4204. I fixed a few typos and reformatted it.
The counter of the std::latch completion_latch is set to NTASKS (line 2). The thread pool executes NTASKS jobs (lines 4 - 10). At the end of each job, the counter is decremented (line 8). The thread running function DoWork blocks in line 12 until all tasks have been finished.
Cooperative interruption
Thanks to std::stop_token(), a std::jthread can be interrupted cooperatively.
This is the output of the program:
The main program starts two threads: nonInterruptable and interruptable (line 4 and line 13 respectively). Only the interruptable thread gets a std::stop_token, which it uses in line 17 to check if it is interrupted. The lambda immediately returns in case of an interruption.
The call to interruptable.request_stop() triggers the cancellation of the thread. Calling nonInterruptable.request_stop() has no effect.
std::jthread
std::jthread stands for “joining thread”. std::jthread extends std::thread by automatically joining the started thread. std::jthread can also be interrupted.
std::jthread is added to the C++20 standard because of the non-intuitive behavior of std::thread. If a std::thread is still joinable, std::terminate is called in its destructor. A thread thr is joinable if neither thr.join() nor thr.detach() was called.
The output of the program on the first attempt is:
The output of the program on the second attempt is:
Both executions of the program terminate. In the second run, the thread thr has enough time to display its message: "Joinable std::thread”.
In the modified example below, I use std::jthread from the C++20 standard.
Now, the thread thr automatically joins in its destructor if necessary.
Synchronized outputstreams
With C++20, we get synchronized outputstreams. What happens when more threads write concurrently to std::cout without synchronization?
You may get a mess as follows:
Switching from std::cout in the function sayHello to std::osyncstream(std::cout) turns the mess into harmony.
This time we get a synchronized output: