trapMice

C++ Core Guidelines: Be Aware of the Traps of Condition Variables

Today, I am writing a scary post about condition variables. You should be aware of these issues of condition variables. The C++ core guideline CP 42 states: “Don’t wait without a condition”.

 trapMice

 

Wait! Condition variables support a pretty simple concept. One thread prepares something and sends a notification another thread is waiting for. Why can’t this be so dangerous? Okay, let’s start with the only rule for today.

CP.42: Don’t wait without a condition

Here is the rationale for the rule: “A wait without a condition can miss a wakeup or wake up simply to find that there is no work to do.” What does that mean? Condition variables can be victims of two severe issues: lost wakeup and spurious wakeup. The key concern about condition variables is that they have no memory.

Before I present this issue, let me first do it right. Here is the pattern of how to use condition variables.

 

// conditionVariables.cpp

#include <condition_variable>
#include <iostream>
#include <thread>

std::mutex mutex_;
std::condition_variable condVar; 

bool dataReady{false};

void waitingForWork(){
    std::cout << "Waiting " << std::endl;
    std::unique_lock<std::mutex> lck(mutex_);
    condVar.wait(lck, []{ return dataReady; });   // (4)
    std::cout << "Running " << std::endl;
}

void setDataReady(){
    {
        std::lock_guard<std::mutex> lck(mutex_);
        dataReady = true;
    }
    std::cout << "Data prepared" << std::endl;
    condVar.notify_one();                        // (3)
}

int main(){
    
  std::cout << std::endl;

  std::thread t1(waitingForWork);               // (1)
  std::thread t2(setDataReady);                 // (2)

  t1.join();
  t2.join();
  
  std::cout << std::endl;
  
}

 

How does the synchronization work? The program has two child threads: t1 and t2. They get their work package waitingForWork and setDataRead in lines (1 and 2). setDataReady notifies – using the condition variable condVar – that it is done with preparing the work: condVar.notify_one()(line 3). While holding the lock, thread t1 waits for its notification: condVar.wait(lck, []{ return dataReady; })( line 4). The sender and receiver need a lock. In the case of the sender, a std::lock_guard is sufficient because it calls to lock and unlock only once. In the case of the receiver, a std::unique_lock is necessary because it usually frequently locks and unlocks its mutex.

Here is the output of the program.

 

Rainer D 6 P2 500x500Modernes C++ Mentoring

  • "Fundamentals for C++ Professionals" (open)
  • "Design Patterns and Architectural Patterns with C++" (open)
  • "C++20: Get the Details" (open)
  • "Concurrency with Modern C++" (open)
  • "Generic Programming (Templates) with C++": October 2024
  • "Embedded Programming with Modern C++": October 2024
  • "Clean Code: Best Practices for Modern C++": March 2025
  • Do you want to stay informed: Subscribe.

     

     conditionVariable

     

     

     

     

     

     

     

    Maybe you are wondering why you need a predicate for the wait call because you can invoke wait without a predicate. This workflow seems quite too complicated for such a simple synchronization of threads. 

    Now we are back to the missing memory and the two phenomena called lost wakeup and spurious wakeup.

    Lost Wakeup and Spurious Wakeup

    • Lost wakeup: The phenomenon of the lost wakeup is that the sender sends its notification before the receiver gets to its wait state. The consequence is that the notification is lost. The C++ standard describes condition variables as a simultaneous synchronization mechanism: “The condition_variable class is a synchronization primitive that can be used to block a thread, or multiple threads at the same time, …”. So the notification gets lost. The receiver is waiting and waiting and…
    • Spurious wakeup: The receiver may wake up although no notification has happened. At a minimum POSIX Threads and the Windows API can be victims of these phenomena.

    To become not the victim of these two issues, you have to use an additional predicate as memory or, as a rule, state it as an additional condition. If you don’t believe it, here is the wait workflow.

    The wait workflow 

    In the initial wait processing, the thread locks the mutex and then checks the predicate []{ return dataReady; }.

    • If the call of the predicated evaluates to
      • true: the thread continues its work.
      • false: condVar.wait() unlocks the mutex and puts the thread in a waiting (blocking) state

    If the condition_variable condVar is in the waiting state and gets a notification or a spurious wakeup, the following steps happen.

    • The thread is unblocked, and will reacquire the lock on the mutex. 
    • The thread checks the predicate.
    • If the call of the predicated evaluates to
      • true: the thread continues its work.
      • false: condVar.wait() unlocks the mutex and puts the thread in a waiting (blocking) state.

    Complicated! Right? Don’t you believe me?

    Without a predicate

    What will happen if I remove the predicate from the last example?  

    // conditionVariableWithoutPredicate.cpp
    
    #include <condition_variable>
    #include <iostream>
    #include <thread>
    
    std::mutex mutex_;
    std::condition_variable condVar;
    
    void waitingForWork(){
        std::cout << "Waiting " << std::endl;
        std::unique_lock<std::mutex> lck(mutex_);
        condVar.wait(lck);                       // (1)
        std::cout << "Running " << std::endl;
    }
    
    void setDataReady(){
        std::cout << "Data prepared" << std::endl;
        condVar.notify_one();                   // (2)
    }
    
    int main(){
        
      std::cout << std::endl;
    
      std::thread t1(waitingForWork);
      std::thread t2(setDataReady);
    
      t1.join();
      t2.join();
      
      std::cout << std::endl;
      
    }
    

     

    Now, the wait call in line (1) does not use a predicate, and the synchronization looks relatively easy. Sad to say, but the program now has a race condition which you can see in the very first execution. The screenshot shows the deadlock.

    conditionVariableWithoutPredicate

     

     

    The sender sends in line (1)  (condVar.notify_one()) its notification before the receiver is capable of receiving it; therefore, the receiver will sleep forever. 

    Okay, lesson learned the hard way. The predicate is necessary, but there must be a way to simplify the program conditionVariables.cpp?

    An atomic predicate 

    Maybe, you saw it. The variable dataReady is just a boolean. We should make it an atomic boolean and, therefore, eliminate the mutex on the sender.

    Here we are:

    // conditionVariableAtomic.cpp
    
    #include <atomic>
    #include <condition_variable>
    #include <iostream>
    #include <thread>
    
    std::mutex mutex_;
    std::condition_variable condVar;
    
    std::atomic<bool> dataReady{false};
    
    void waitingForWork(){
        std::cout << "Waiting " << std::endl;
        std::unique_lock<std::mutex> lck(mutex_);
        condVar.wait(lck, []{ return dataReady.load(); });   // (1)
        std::cout << "Running " << std::endl;
    }
    
    void setDataReady(){
        dataReady = true;
        std::cout << "Data prepared" << std::endl;
        condVar.notify_one();
    }
    
    int main(){
        
      std::cout << std::endl;
    
      std::thread t1(waitingForWork);
      std::thread t2(setDataReady);
    
      t1.join();
      t2.join();
      
      std::cout << std::endl;
      
    }
    

     

    The program is relatively straightforward compared to the first version because dataReady has not to be protected by a mutex. Once more, the program has a race condition that can cause a deadlock. Why? dataReady is atomic! Right, but the wait expression (condVar.wait(lck, []{ return dataReady.load(); });) in line (1) is way more complicated than it seems.

    The wait expression is equivalent to the following four lines:

    std::unique_lock<std::mutex> lck(mutex_);
    while ( ![]{ return dataReady.load(); }() { // time window (1) condVar.wait(lck); }

    Even if you make dataReady an atomic, it must be modified under the mutex; if not, the modification to the waiting thread may be published but not correctly synchronized. This race condition may cause a deadlock. What does that mean: published but not correctly synchronized? Let’s look closer at the previous code snippet and assume that data is atomic and is not protected by the mutex mutex.

    Let me assume the notification is sent while the condition variable condVar is in the wait expression but not the waiting state. This means the execution of the thread is in the source snippet, in line with the comment time window ( line 1). The result is that the notification is lost. Afterward, the thread returns to the waiting state and presumably sleeps forever. 

    This wouldn’t have happened if dataReady had been protected by a mutex. Because of the synchronization with the mutex, the notification would only be sent if the condition variable and, therefore, the receiver thread is in the waiting state. 

    What a scary story! Is there no possibility of making the initial program conditionVariables.cpp more straightforward? No, not with a condition variable, but you can use a promise and future pair to complete the job. For the details, read the post Thread Synchronisation with Condition Variables or Tasks.

    What’s next?

    Now, I’m nearly done with the rules of concurrency. The rules for parallelism, message passing, and vectorization have no content; therefore, I will skip them and write in my next post mainly about lock-free programming.

     

     

     

    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

    Modernes C++ GmbH

    Modernes C++ Mentoring (English)

    Do you want to stay informed about my mentoring programs? Subscribe Here

    Rainer Grimm
    Yalovastraße 20
    72108 Rottenburg

    Mobil: +49 176 5506 5086
    Mail: schulung@ModernesCpp.de
    Mentoring: www.ModernesCpp.org

    Modernes C++ Mentoring,

     

     

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