Concurrency patterns
Most concurrent programs are a small number of shapes repeated: a producer that feeds a consumer through a queue, several readers around an occasional writer, and a piece of work that must run exactly once before anything else.
The components are from the three previous lessons. What is new is the way they are put together, and the limits that make the shapes safe: a bound on the queue, a lock that knows the difference between reading and writing, and a flag that survives several threads at once.
The patterns compose: the pool of the previous lesson is a producer and its workers are the consumers, and a bounded queue is what keeps the memory of a fast producer in check. Recognising the shapes is most of the design work in a concurrent program.
The bound also decides what the producer does when the consumer stops: with back pressure the producer blocks, and a shutdown has to wake it, which is the flag that lesson 112 added to the queue.
A bounded queue and back pressure
An unbounded queue turns a slow consumer into a growing block of memory, and the producer never learns that it is too fast. A bounded queue makes the producer wait when the queue is full, which is called back pressure.
The program produces ten numbers into a queue that holds two and the consumer adds them. The total is 55, and at no point does the queue hold more than two items.
Two condition variables carry the two directions: not_empty for the consumer and not_full for the producer. The notify happens after the unlock, so the woken thread finds the mutex free instead of blocking on it again.
#include <condition_variable>
#include <cstddef>
#include <iostream>
#include <mutex>
#include <queue>
#include <thread>
std::mutex guard;
std::condition_variable not_empty;
std::condition_variable not_full;
std::queue<int> items;
const std::size_t capacity = 2;
void produce()
{
for (int i = 1; i <= 10; ++i) {
std::unique_lock<std::mutex> lock{guard};
not_full.wait(lock, [] { return items.size() < capacity; });
items.push(i);
lock.unlock();
not_empty.notify_one();
}
}
int main()
{
int total = 0;
std::thread consumer{[&] {
for (int i = 1; i <= 10; ++i) {
std::unique_lock<std::mutex> lock{guard};
not_empty.wait(lock, [] { return !items.empty(); });
total += items.front();
items.pop();
lock.unlock();
not_full.notify_one();
}
}};
produce();
consumer.join();
std::cout << total << '\n';
}
g++ -std=c++23 -Wall -Wextra -pthread bounded.cpp -o bounded
./bounded
55
Readers and one writer
A std::shared_mutex has two modes: a std::shared_lock for reading, which several threads can hold at once, and a std::unique_lock for writing, which excludes every other holder. It is the mutex of lesson 106 with a distinction that matters when reads are frequent.
The program writes 42, then starts two readers that copy the value and prints both copies. The readers run together and the result is the same for both.
The pattern pays only when readers are the majority. A shared_mutex is larger and slower than a plain mutex, and with one reader at a time the plain one wins; a writer-heavy workload is the case where the two modes cost more than they save.
#include <iostream>
#include <mutex>
#include <shared_mutex>
#include <thread>
std::shared_mutex guard;
int value = 0;
int main()
{
{
std::unique_lock lock{guard};
value = 42;
}
int first = 0;
int second = 0;
std::thread reader1{[&] {
std::shared_lock lock{guard};
first = value;
}};
std::thread reader2{[&] {
std::shared_lock lock{guard};
second = value;
}};
reader1.join();
reader2.join();
std::cout << first << ' ' << second << ' ' << first + second << '\n';
}
g++ -std=c++23 -Wall -Wextra -pthread rw.cpp -o rw
./rw
42 42 84
Work that runs once
A lazy singleton, a shared cache or a library initialisation has to happen once even when four threads race to trigger it. std::call_once with a std::once_flag runs the function exactly once and makes the other callers wait for it.
The program starts four threads that all call the same function through the same flag. The counter is incremented once, which is the print.
The check and the call are one operation, so there is no double-checked locking to get wrong. The flag is not resettable, and a second flag is the way to have a second one-time action.
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
std::once_flag once;
int initialised = 0;
void setup()
{
++initialised;
}
int main()
{
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back([] { std::call_once(once, setup); });
}
for (auto& thread : threads) {
thread.join();
}
std::cout << initialised << '\n';
}
g++ -std=c++23 -Wall -Wextra -pthread once.cpp -o once
./once
1
A shared lock on the wrong mutex
A std::shared_lock is a template over the mutex type and needs one that has the shared operations. A plain std::mutex has lock and unlock only, so the instantiation does not compile.
The program declares a plain mutex and tries to take a shared lock on it. The compiler reports that the conversion to std::shared_mutex does not exist, and the message names the two types.
The fix is either the plain std::lock_guard for a mutex that only excludes, or a real std::shared_mutex when readers should share. The two names look similar and behave differently, which is why the type of the lock is worth checking at the declaration.
#include <mutex>
#include <shared_mutex>
int main()
{
std::mutex guard;
std::shared_lock<std::shared_mutex> lock{guard};
}
g++ -std=c++23 -Wall -Wextra wrong_lock.cpp -o wrong_lock
wrong_lock.cpp: In function 'int main()':
wrong_lock.cpp:8:51: error: no matching function for call to 'std::shared_lock<std::shared_mutex>::shared_lock(<brace-enclosed initializer list>)'
8 | std::shared_lock<std::shared_mutex> lock{guard};
| ^
wrong_lock.cpp:8:51: note: there are 8 candidates
In file included from wrong_lock.cpp:2:
/usr/include/c++/15.2.0/shared_mutex:743:7: note: candidate 1: 'std::shared_lock<_Mutex>::shared_lock(mutex_type&) [with _Mutex = std::shared_mutex; mutex_type = std::shared_mutex]'
743 | shared_lock(mutex_type& __m)
| ^~~~~~~~~~~
/usr/include/c++/15.2.0/shared_mutex:743:31: note: no known conversion for argument 1 from 'std::mutex' to 'std::shared_lock<std::shared_mutex>::mutex_type&' {aka 'std::shared_mutex&'}
743 | shared_lock(mutex_type& __m)
| ~~~~~~~~~~~~^~~
/usr/include/c++/15.2.0/shared_mutex:763:9: note: candidate 2: 'template<class _Rep, class _Period> std::shared_lock<_Mutex>::shared_lock(mutex_type&, const std::chrono::duration<_Rep, _Period>&) [with _Period = _Rep; _Mutex = std::shared_mutex]'
763 | shared_lock(mutex_type& __m,
| ^~~~~~~~~~~
/usr/include/c++/15.2.0/shared_mutex:763:9: note: candidate expects 2 arguments, 1 provided
/usr/include/c++/15.2.0/shared_mutex:757:9: note: candidate 3: 'template<class _Clock, class _Duration> std::shared_lock<_Mutex>::shared_lock(mutex_type&, const std::chrono::time_point<_Clock, _Duration>&) [with _Duration = _Clock; _Mutex = std::shared_mutex]'
757 | shared_lock(mutex_type& __m,
| ^~~~~~~~~~~
/usr/include/c++/15.2.0/shared_mutex:757:9: note: candidate expects 2 arguments, 1 provided
/usr/include/c++/15.2.0/shared_mutex:777:7: note: candidate 4: 'std::shared_lock<_Mutex>::shared_lock(std::shared_lock<_Mutex>&&) [with _Mutex = std::shared_mutex]'
777 | shared_lock(shared_lock&& __sl) noexcept : shared_lock()
| ^~~~~~~~~~~
/usr/include/c++/15.2.0/shared_mutex:777:33: note: no known conversion for argument 1 from 'std::mutex' to 'std::shared_lock<std::shared_mutex>&&'
777 | shared_lock(shared_lock&& __sl) noexcept : shared_lock()
| ~~~~~~~~~~~~~~^~~~
/usr/include/c++/15.2.0/shared_mutex:753:7: note: candidate 5: 'std::shared_lock<_Mutex>::shared_lock(mutex_type&, std::adopt_lock_t) [with _Mutex = std::shared_mutex; mutex_type = std::shared_mutex]'
753 | shared_lock(mutex_type& __m, adopt_lock_t)
| ^~~~~~~~~~~
/usr/include/c++/15.2.0/shared_mutex:753:7: note: candidate expects 2 arguments, 1 provided
/usr/include/c++/15.2.0/shared_mutex:750:7: note: candidate 6: 'std::shared_lock<_Mutex>::shared_lock(mutex_type&, std::try_to_lock_t) [with _Mutex = std::shared_mutex; mutex_type = std::shared_mutex]'
750 | shared_lock(mutex_type& __m, try_to_lock_t)
| ^~~~~~~~~~~
/usr/include/c++/15.2.0/shared_mutex:750:7: note: candidate expects 2 arguments, 1 provided
/usr/include/c++/15.2.0/shared_mutex:747:7: note: candidate 7: 'std::shared_lock<_Mutex>::shared_lock(mutex_type&, std::defer_lock_t) [with _Mutex = std::shared_mutex; mutex_type = std::shared_mutex]'
747 | shared_lock(mutex_type& __m, defer_lock_t) noexcept
| ^~~~~~~~~~~
/usr/include/c++/15.2.0/shared_mutex:747:7: note: candidate expects 2 arguments, 1 provided
/usr/include/c++/15.2.0/shared_mutex:740:7: note: candidate 8: 'std::shared_lock<_Mutex>::shared_lock() [with _Mutex = std::shared_mutex]'
740 | shared_lock() noexcept : _M_pm(nullptr), _M_owns(false) { }
| ^~~~~~~~~~~
/usr/include/c++/15.2.0/shared_mutex:740:7: note: candidate expects 0 arguments, 1 provided
Exercises
- Make the bounded queue print its maximum size at the end and check that it never exceeds two.
- Add a second writer to the reader-writer program and protect the value with the same mutex.
- Replace call_once with a mutex and a bool and explain what the flag adds.
Questions
A new set of questions in a new order every time. Choose one answer for each, then check.
-
What is back pressure in a bounded queue?
Answer
The producer waits when the queue is full
The bound makes the producer block on not_full, so the queue cannot grow without limit.
-
Why does the producer unlock before calling notify_one?
Answer
So the woken consumer finds the mutex free instead of blocking on it
Notifying under the lock works, but the woken thread then waits for a mutex the notifier still holds.
-
What does a
std::shared_lockallow?Answer
Several readers to hold the lock at the same time
The shared mode is the read side; the unique mode excludes everyone and is the write side.
-
What does
std::unique_lockon a shared_mutex give?Answer
Exclusive access, excluding readers and writers
The unique mode is the exclusive one, as with a plain mutex.
-
What does this program print?
#include <condition_variable> #include <iostream> #include <mutex> #include <queue> std::mutex m; std::condition_variable not_empty, not_full; std::queue<int> items; int main() { int total = 0; std::thread consumer([&] { for (int i = 1; i <= 10; ++i) { std::unique_lock<std::mutex> lock{m}; not_empty.wait(lock, []{ return !items.empty(); }); total += items.front(); items.pop(); lock.unlock(); not_full.notify_one(); } }); for (int i = 1; i <= 10; ++i) { std::unique_lock<std::mutex> lock{m}; not_full.wait(lock, []{ return items.size() < 2; }); items.push(i); lock.unlock(); not_empty.notify_one(); } consumer.join(); std::cout << total; }Answer
55
The consumer adds 1 through 10, and the bound changes the timing but not the total.
-
What does this program print?
#include <iostream> #include <mutex> #include <shared_mutex> #include <thread> std::shared_mutex guard; int value = 0; int main() { { std::unique_lock lock{guard}; value = 42; } int first = 0, second = 0; std::thread a([&] { std::shared_lock lock{guard}; first = value; }); std::thread b([&] { std::shared_lock lock{guard}; second = value; }); a.join(); b.join(); std::cout << first << ' ' << second << ' ' << first + second; }Answer
42 42 84
The writer stores 42 before the readers start, and both readers copy it.
-
What does
std::call_onceguarantee?Answer
The function runs exactly once, and other callers wait for it
The flag records the state and the callers that lose the race wait for the winner.
-
What does this program print?
#include <iostream> #include <mutex> #include <thread> std::once_flag once; int initialised = 0; void setup() { ++initialised; } int main() { std::thread a([]{ std::call_once(once, setup); }); std::thread b([]{ std::call_once(once, setup); }); std::thread c([]{ std::call_once(once, setup); }); a.join(); b.join(); c.join(); std::cout << initialised; }Answer
1
Only one of the three calls runs setup, so the counter is incremented once.
-
What does the compiler report for a
std::shared_lock<std::shared_mutex>on astd::mutex?Answer
That the mutex cannot be converted to a shared_mutex
The two mutex types are unrelated, so the constructor has no matching argument.
-
When is a shared_mutex worth its cost over a plain mutex?
Answer
When reads are much more frequent than writes
The gain comes from several readers running together, and a writer-heavy workload never sees it.
The code on this page was built with g++ 15.2.0 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container. The programs whose section shows a compiler error were compiled until that error and were not run.
Article author: Arthur Isaev