TimelineCpp20

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.

 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.

 

Rainer D 6 P2 500x500Modernes C++ Mentoring

Be part of my mentoring programs:

  • "Fundamentals for C++ Professionals" (open)
  • "Design Patterns and Architectural Patterns with C++" (open)
  • "C++20: Get the Details" (open)
  • "Concurrency with Modern C++" (starts March 2024)
  • Do you want to stay informed: Subscribe.

     

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

    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, 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, Kris Kafka, 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, Dmitry Farberov, 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, moon, Philipp Lenk, Hobsbawm, and Charles-Jianye Chen.

    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

    Online Seminars (German)

    Contact Me

    Modernes C++ Mentoring,

     

     

    1 reply
    1. Lubomir Kordos
      Lubomir Kordos says:

      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?

      Reply

    Leave a Reply

    Want to join the discussion?
    Feel free to contribute!

    Leave a Reply

    Your email address will not be published. Required fields are marked *