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

  1. Make the bounded queue print its maximum size at the end and check that it never exceeds two.
  2. Add a second writer to the reader-writer program and protect the value with the same mutex.
  3. 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.

  1. 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.

  2. 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.

  3. What does a std::shared_lock allow?

    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.

  4. What does std::unique_lock on a shared_mutex give?

    Answer

    Exclusive access, excluding readers and writers

    The unique mode is the exclusive one, as with a plain mutex.

  5. 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.

  6. 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.

  7. What does std::call_once guarantee?

    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.

  8. 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.

  9. What does the compiler report for a std::shared_lock<std::shared_mutex> on a std::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.

  10. 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