Latches in C++20
Latches and barriers are coordination types that enable some threads to wait until a counter becomes zero. You can use a std::latch
only once, but you can use a std::barrier
more than once. Today, I have a closer look at latches.
Concurrent invocations of the member functions of a std::latch
or a std::barrier
are no data race. A data race is such a crucial term in concurrency that I want to write more words to it.
Data Race
A data race is a situation, in which at least two threads access a shared variable at the same time and at least one thread tries to modify the variable. If your program has a data race, it has undefined behavior. This means all outcomes are possible and therefore, reasoning about the program makes no sense anymore.
Let me show you a program with a data race.
// addMoney.cpp #include <functional> #include <iostream> #include <thread> #include <vector> struct Account{ int balance{100}; // (3) }; void addMoney(Account& to, int amount){ // (2) to.balance += amount; // (1) } int main(){ std::cout << '\n'; Account account; std::vector<std::thread> vecThreads(100); for (auto& thr: vecThreads) thr = std::thread(addMoney, std::ref(account), 50); for (auto& thr: vecThreads) thr.join(); std::cout << "account.balance: " << account.balance << '\n'; // (4) std::cout << '\n'; }
100 threads adding 50 euros to the same account (1) using the function addMoney
(2). The initial account is 100 (3). The crucial observation is that the writing to the account is done without synchronization. Therefore we have a data race and, consequently, undefined behavior. The final balance is between 5000 and 5100 euro (4).
Modernes C++ Mentoring
Do you want to stay informed: Subscribe.
What is happening? Why are a few additions missing? The update process to.balance += amount;
in line (1) is a so-called read-modify-write operation. As such, first, the old value of to.balance
is read, then it is updated, and finally is written. What may happen under the hood is the following. I use numbers to make my argumentation more obvious
- Thread A reads the value 500 euro and then Thread B kicks in.
- Thread B read also the value 500 euro, adds 50 euro to it, and updates
to.balance
to 550 euro. - Now Thread A finished its execution by adding 50 euro to
to.balance
and also writes 550 euro. - Essential the value 550 euro is written twice and instead of two additions of 50 euro, we only observe one.
- This means, that one modification is lost and we get the wrong final sum.
First, there are two questions to answer before I present std::latch
and std::barrier
in detail.
Two Questions
- What is the difference between these two mechanisms to coordinate threads? You can use a
std::latch
only once, but you can use astd::barrier
more than once. Astd::latch
is useful for managing one task by multiple threads; astd::barrier
is helpful for managing repeated tasks by multiple threads. Additionally, astd::barrier
enables you to execute a function in the so-called completion step. The completion step is the state when the counter becomes zero. - What use cases do latches and barriers support that cannot be done in C++11 with futures, threads, or condition variables combined with locks? Latches and barriers address no new use cases, but they are a lot easier to use. They are also more performant because they often use a lock-free mechanism internally.
Let me continue my post with the simpler data type of both.
std::latch
Now, let us have a closer look at the interface of a std::latch
.
The default value for upd
is 1
. When upd
is greater than the counter or negative, the behaviour is undefined. The call lat.try_wait()
does never wait as its name suggests.
The following program bossWorkers.cpp
uses two std::latch
to build a boss-workers workflow. I synchronized the output to std::cout
use the function synchronizedOut
(1). This synchronization makes it easier to follow the workflow.
// bossWorkers.cpp #include <iostream> #include <mutex> #include <latch> #include <thread> std::latch workDone(6); std::latch goHome(1); // (4) std::mutex coutMutex; void synchronizedOut(const std::string s) { // (1) std::lock_guard<std::mutex> lo(coutMutex); std::cout << s; } class Worker { public: Worker(std::string n): name(n) { }; void operator() (){ // notify the boss when work is done synchronizedOut(name + ": " + "Work done!\n"); workDone.count_down(); // (2) // waiting before going home goHome.wait(); // (5) synchronizedOut(name + ": " + "Good bye!\n"); } private: std::string name; }; int main() { std::cout << '\n'; std::cout << "BOSS: START WORKING! " << '\n'; Worker herb(" Herb"); std::thread herbWork(herb); Worker scott(" Scott"); std::thread scottWork(scott); Worker bjarne(" Bjarne"); std::thread bjarneWork(bjarne); Worker andrei(" Andrei"); std::thread andreiWork(andrei); Worker andrew(" Andrew"); std::thread andrewWork(andrew); Worker david(" David"); std::thread davidWork(david); workDone.wait(); // (3) std::cout << '\n'; goHome.count_down(); std::cout << "BOSS: GO HOME!" << '\n'; herbWork.join(); scottWork.join(); bjarneWork.join(); andreiWork.join(); andrewWork.join(); davidWork.join(); }
The idea of the workflow is straightforward. The six workers herb
, scott
, bjarne
, andrei
, andrew
, and david
in the main
-program have to fulfill their job. When they finished their job, they count down the std::latch workDone
(2). The boss (main
-thread) is blocked in line (3) until the counter becomes 0. When the counter is 0, the boss uses the second std::latch goHome
to signal its workers to go home. In this case, the initial counter is 1
(4). The call goHome.wait
(5) blocks until the counter becomes 0.
When you think about this workflow, you may notice that it can be performed without a boss. Here is the modern variant:
// workers.cpp #include <iostream> #include <latch> #include <mutex> #include <thread> std::latch workDone(6); std::mutex coutMutex; void synchronizedOut(const std::string& s) { std::lock_guard<std::mutex> lo(coutMutex); std::cout << s; } class Worker { public: Worker(std::string n): name(n) { }; void operator() () { synchronizedOut(name + ": " + "Work done!\n"); workDone.arrive_and_wait(); // wait until all work is done (1) synchronizedOut(name + ": " + "See you tomorrow!\n"); } private: std::string name; }; int main() { std::cout << '\n'; Worker herb(" Herb"); std::thread herbWork(herb); Worker scott(" Scott"); std::thread scottWork(scott); Worker bjarne(" Bjarne"); std::thread bjarneWork(bjarne); Worker andrei(" Andrei"); std::thread andreiWork(andrei); Worker andrew(" Andrew"); std::thread andrewWork(andrew); Worker david(" David"); std::thread davidWork(david); herbWork.join(); scottWork.join(); bjarneWork.join(); andreiWork.join(); andrewWork.join(); davidWork.join(); }
There is not much to add to this simplified workflow. The call workDone.arrive_and_wait(1)
(1) is equivalent to the calls count_down(upd); wait();
. This means the workers coordinate themself and the boss is no longer necessary such as in the previous program bossWorkers.cpp
.
What’s next?
A std::barrier
is quite similar to a std::latch
. std::barrier
‘s strength is it to perform a job more than once. In my next post, I will have a closer look at barriers.
Thanks a lot to my Patreon Supporters: Matt Braun, Roman Postanciuc, Tobias Zindl, G Prvulovic, Reinhold Dröge, Abernitzke, Frank Grimm, Sakib, Broeserl, António Pina, Sergey Agafyin, Андрей Бурмистров, Jake, GS, Lawton Shoemake, Jozo Leko, John Breland, Venkat Nandam, Jose Francisco, Douglas Tinkham, Kuchlong Kuchlong, Robert Blanch, Truels Wissneth, Mario Luoni, Friedrich Huber, lennonli, Pramod Tikare Muralidhara, Peter Ware, Daniel Hufschläger, Alessandro Pezzato, Bob Perry, Satish Vangipuram, Andi Ireland, Richard Ohnemus, Michael Dunsky, Leo Goodstadt, John Wiederhirn, Yacob Cohen-Arazi, Florian Tischler, Robin Furness, Michael Young, Holger Detering, Bernd Mühlhaus, Stephen Kelley, Kyle Dean, Tusar Palauri, Juan Dent, George Liao, Daniel Ceperley, Jon T Hess, Stephen Totten, Wolfgang Fütterer, Matthias Grün, Phillip Diekmann, Ben Atakora, Ann Shatoff, Rob North, Bhavith C Achar, Marco Parri Empoli, Philipp Lenk, Charles-Jianye Chen, Keith Jeffery,and Matt Godbolt.
Thanks, in particular, to Jon Hess, Lakshman, Christian Wittenhorst, Sherhy Pyton, Dendi Suhubdy, Sudhakar Belagurusamy, Richard Sargeant, Rusty Fleming, John Nebel, Mipko, Alicja Kaminska, Slavko Radman, and David Poole.
My special thanks to Embarcadero | |
My special thanks to PVS-Studio | |
My special thanks to Tipi.build | |
My special thanks to Take Up Code | |
My special thanks to SHAVEDYAKS |
Seminars
I’m happy to give online seminars or face-to-face seminars worldwide. Please call me if you have any questions.
Standard Seminars (English/German)
Here is a compilation of my standard seminars. These seminars are only meant to give you a first orientation.
- C++ – The Core Language
- C++ – The Standard Library
- C++ – Compact
- C++11 and C++14
- Concurrency with Modern C++
- Design Pattern and Architectural Pattern with C++
- Embedded Programming with Modern C++
- Generic Programming (Templates) with C++
- Clean Code with Modern C++
- C++20
Contact Me
- Mobil: +49 176 5506 5086
- Mail: schulung@ModernesCpp.de
- German Seminar Page: www.ModernesCpp.de
- Mentoring Page: www.ModernesCpp.org
Modernes C++ Mentoring,
Hi,
I have question regarding the lines in thr 2nd code example:
goHome.count_down();
std::cout << "BOSS: GO HOME!" << '\n';
I think when the latch goHome counter is changed from 1 to 0, there is no guarantee that the output
std::cout << "BOSS: GO HOME!" << '\n';
will execute before one of the worker threads. I think that if any of the thread detects that the latch counter is 0, it starts executing immediately and eventually could lock the mutex before the main thread does it.
Am I missing something?