
Chapter 4 - Part 1. Syncronizing concurrent operations

If not specified, all the code is written in c++23 standard, compiled with g++ 13.2
command: g\++ -std=c\++2b -O0 .cpp -o out && ./out
Chapter 4. Synchronizing concurrent operations - Part 1
Waiting for events or other threads
It’s common for one thread to wait for another thread to finish. One way to achieve this is use a task_completed flag, but it’s far from ideal.
Suppose there are two threads, one thread is fetching data from a remote API, and the other thread is used to process the data. In this case, the latter thread have to wait the former thread to finish. In this case, if we use a flag that is guaranteed to be thread-safe, we can achieve this, but there are significant drawbacks.
When the worker thread is waiting for the API thread, it has to constantly check the flag, occupying computing power that can be used by other threads. Also, if the thread-safety is achieved by using mutex, when the worker thread is waiting for the flag, the lock must be held by it, means API thread cannot update the flag since it requires unlocking mutex first.
The first problem is come with the nature of this method, so let’s solve the second problem. One solution will be unlocking the mutex for a short period of time and lock it back to provide a window for API thread to change flag, like the following code:
1 | bool flag; |
This code provides a window for API thread to change the flag, and it provides a remedy for first problem because when a thread is sleeping, it doesn’t occupy computing power. However, this is still not ideal, since the sleep time is hard to choose. If it’s too small, the API thread may not have enough time to change the flag, and if it’s too large, it will create significant lagging.
Condition variables
The third option is to use tools provided by STL, namely std::condition_variable
and std::condition_variable_any
. A condition variable is associated with a condition and when that condition is satisfied, it will notify other threads. Condition variables are used with a mutex. std::condition_variable
works exclusively with std::mutex
, but std::condition_variable_any
works with any mutex-like type. However, the latter requires more resources, so the former is preferred when you don’t need the flexibility.
With condition variable, we can rewrite a process waiting for a flag like this:
1 |
|
In dataFetcher
we simulated the process of getting data from a producer. When the producer obtains a data, it pushes it into a queue, and notify the consumer thread with notify_one()
.
In dataProcessor
, we lock the mutex with std::unique_lock
, then wait for the condition variable by calling wait()
. It checks the condition(the return value of provided callable object(in this case, a lambda expression)). If the condition is not satisfied, it will unlock the mutex and put the thread in a waiting state. Then, when the thread received a notify_one()
, it will check the condition again. If the condition is satisfied, it will lock the mutex and continue execution.
We can understand this process better by running it. The output is:
1 |
|
We can see that the consumer thread is notified after the producer thread pushed a data into the queue. Then, the consumer thread checked the condition, and since the queue is not empty, it popped the data and printed it. Then, it checked the condition again, and since the queue is empty, it unlocked the mutex and wait for another notification. When the producer thread finished pushing all the data, it notified the consumer thread, and the consumer thread checked the condition again. Since the queue is empty, it exited the loop and finished execution.
If we take a closer look at the implementation of std::condition_variable::wait
, we will find that it’s actually waiting for notification, wrapped with a while loop that checks the return value of given predicate. This means if the predicate is true, it will return immediately, but will lock the thread and wait for notification if the predicate is false. Also, to lock and unlock the mutex, the function requires a std::unique_lock
object.
implementation of `std
1 |
|
This looks like our own implementation of waiting for a flag, but it’s more efficient and well-designed. Instead of constantly checking the flag, it will only check the condition when it’s notified without locking mutex(since holding a mutex longer than necessary is a bad design decision) and computation power.
Application
With condition variables, we can implement a thread-safe wrapper for std::queue
. We should be able to read queue elements (front()
, back()
), write to queue (push()
, pop()
, emplace()
), and check if the queue is empty (empty()
). However, in our case, when we are transferring data between threads, one thread typically have to wait for the other threads to finish.
Therefore, it’s reasonable to add two methods: try_pop()
that will pop and retrieve the front element but always return immediately and indicate whether it’s successful, and wait_and_pop()
that waits until there is a value to pop and retrieve. These methods require a condition variable to notify them, so in our case, we will notify the consumer thread once we have data goes in.
Also, given that we can separate read and write, it’s the best to use std::shared_mutex
to yield better performance, but as a trade-off we have to use std::condition_variable_any
because std::condition_variable
only works with std::mutex
.
1 |
|
icon-padding
Note we marked the mutex as mutable, since it’s the best to mark methods as const, and as an overhead, we have to mark the mutex so that we can lock it in const methods.
Let’s test our implementation with a simulated consumer-producer scenario:
icon-padding
Note I defined a value as end of data, so that the consumer know when to stop.
The reason why I don’t check if the queue is empty is because the consumer thread might process data faster than producer thread, and exit before producer thread pushed all the data.
1 |
|
Output:
1 | pushed: 0 |
icon-padding
The output is machine-dependent and might scramble, because std::cout
is not thread-safe.
This is exactly what we want.
One-shot events
Sometimes the event that we are waiting for only occur once. For this scenario, C++ STL provided us std::future
and std::shared_future
that enables thread to periodically check for the event when it is doing other things and wait for the event to occur. std::future
has format like std::unique_ptr
. It accepts a template argument and can be only moved, and std::shared_future
is like std::shared_ptr
, it can be copied and shared between threads.
warning
std::future
and std::shared_future
doesn’t guarantee thread-safety, so they have to be protected by synchronization mechanisms like lock.
One good (and most primitive) use of std::future
would be waiting for the return value of a function. For example, we have a thread that is doing very heavy computation and another thread is waiting for it. In this case it’s a one-shot event, and we want to get the return value, so using std::condition_variable
is not a good idea. Instead, we can use std::future
to achieve this.
To obtain a std::future
object from a thread, we will use std::async
, which has similar syntax to std::thread
, but gives you a std::future
object containing the future return value of the function. Once we need the value, we call .get()
of the std::future
object, which will block the thread until the value is ready.
1 | int dice() { |
Output:
1 | dice rolled! |
Syntax of passing in parameters to std::async
is similar to std::thread
. In this case, if we are using member function of objects, the second parameter must be the pointer to the object. (cpp implicitly made this
pointer as the first parameter of member functions when calling from objects)
1 | int dice(int n) { |
In this case we defined a die roller and let it rolled a 1d20 !!for sanity check!!. In line 28, we extracted the member function pointer from the class and passed in the pointer to the object as the second parameter. After we obtained the value(after a long wait), we print it out.
1 | dice rolled! |
A natural 1, but it works.
With the natural 1 on sanity check, you take mental damage and have to do a medical check as psychiatric treatment. So you rolled a 2d6 to see how much damage you take and a d20 to see if you are cured. (For the sake of demonstration, we roll the dice and then get the result from the object)
1 | dice rolled! |
!!Sadly you take 12 damage, and you are not cured.!! We wait until your 1d20 finished rolling, then we print out the result. From this example we can see the syntax of std::async
is similar to std::thread
.
std::async
also supports rvalue reference. Assume we have a class that have move constructor implemented, std::async
will move the object rather than copying it. Consider following code:
1 | class MoveOnly { |
Output:
1 | Moved~ |
Behavior of std::async
std::async
can have different behaviors by providing different policies. Policies can be passed in as the first argument of std::async
. There are two types of policies:
std::launch::async
: The function will be executed asynchronously in a new thread.std::launch::deferred
: The function will be executed synchronously in the same thread when.get()
is called.
With bitwise or, we can combine these two policies. For example, std::launch::async | std::launch::deferred
means the function will be executed asynchronously in a new thread if .get()
is called, otherwise it will be executed synchronously in the same thread.
Consider the following code:
1 | auto ftr1 = std::async(std::launch::async, []{ |
Output:
1 | start |
We can see that ftr1
immediately started executing when std::async
is called, even before ftr1.get()
. However, ftr2
start executing when ftr2.get()
is called. And ftr3
choose to execute asynchronously, since it’s a combination of std::launch::deferred
and std::launch::async
and the behavior is implementation-defined.
Summary
- To wait for an event, we can use a flag, but it’s not ideal because it’s not efficient, and it’s hard to choose the sleep time. So we use condition variables as an optimized solution.
notify_one
only notify one waiting task, andnotify_all
notify all waiting tasks.std::condition_variable
works with only std::mutex, butstd::condition_variable_any
works with any mutex-like type.std::condition_variable::wait
only start to wait when the predicate is not satisfied.- One application of a condition variable is to implement a thread-safe queue.
std::future
is a very useful tool when passing data around.std::future
is likestd::unique_ptr
, it can only be moved.std::future
can be produced bystd::async
, andstd::async
accepts param in similar fashion asstd::thread
.
See more in next part~