Latches in C++20

Contents[Show]

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.

 TimelineCpp20

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

 addMoney

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

  1. What is the difference between these two mechanisms to coordinate threads? You can use a std::latch only once, but you can use a std::barrier more than once. A std::latch is useful for managing one task by multiple threads; a std::barrier is helpful for managing repeated tasks by multiple threads. Additionally, a std::barrier enables you to execute a function in the so-called completion step. The completion step is the state when the counter becomes zero.
  2. 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.

 memberFunctionsLatch

The default value for upd is 1. When upd is greater than the counter or negative, the behavior 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 using 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.

bossWorkers

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.

workers

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, Marko, G Prvulovic, Reinhold Dröge, Abernitzke, Frank Grimm, Sakib, Broeserl, António Pina, Darshan Mody, Sergey Agafyin, Андрей Бурмистров, Jake, GS, Lawton Shoemake, Animus24, Jozo Leko, John Breland, espkk, Wolfgang Gärtner,  Louis St-Amour, Stephan Roslen, Venkat Nandam, Jose Francisco, Douglas Tinkham, Kuchlong Kuchlong, Avi Kohn, Robert Blanch, Truels Wissneth, Kris Kafka, Mario Luoni, Neil Wang, Friedrich Huber, lennonli, Pramod Tikare Muralidhara, Peter Ware, and Tobi Heideman.

 

Thanks in particular to Jon Hess, Lakshman, Christian Wittenhorst, Sherhy Pyton, Dendi Suhubdy, Sudhakar Belagurusamy, and Richard Sargeant.

My special thanks to Embarcadero CBUIDER STUDIO FINAL ICONS 1024 Small

 

Seminars

I'm happy to give online-seminars or face-to-face seminars world-wide. Please call me if you have any questions.

Bookable (Online)

Deutsch

English

Standard Seminars 

Here is a compilation of my standard seminars. These seminars are only meant to give you a first orientation.

New

Contact Me

Modernes C++,

RainerGrimmSmall

 

Comments   

0 #1 Michael 2021-01-27 10:57
Similar to sync.WaitGroup in golang: https://gobyexample.com/waitgroups

In Go, the number of waiting process are defined while events define the problem : much more better approach - from my personal point of view.

We should emulate the same behaviour in C++.
Quote

My Newest E-Books

Course: Modern C++ Concurrency in Practice

Course: C++ Standard Library including C++14 & C++17

Course: Embedded Programming with Modern C++

Course: Generic Programming (Templates)

Course: C++ Fundamentals for Professionals

Subscribe to the newsletter (+ pdf bundle)

Blog archive

Source Code

Visitors

Today 5547

Yesterday 8077

Week 22196

Month 206732

All 5759075

Currently are 581 guests and no members online

Kubik-Rubik Joomla! Extensions

Latest comments