3

On the reference documentation for std::condition_variable there's this example:

#include <condition_variable>
#include <iostream>
#include <mutex>
#include <string>
#include <thread>
 
std::mutex m;
std::condition_variable cv;
std::string data;
bool ready = false;
bool processed = false;
 
void worker_thread()
{
    // Wait until main() sends data
    std::unique_lock lk(m);
    cv.wait(lk, []{ return ready; });
 
    // after the wait, we own the lock.
    std::cout << "Worker thread is processing data\n";
    data += " after processing";
 
    // Send data back to main()
    processed = true;
    std::cout << "Worker thread signals data processing completed\n";
 
    // Manual unlocking is done before notifying, to avoid waking up
    // the waiting thread only to block again (see notify_one for details)
    lk.unlock();
    cv.notify_one();
}
 
int main()
{
    std::thread worker(worker_thread);
 
    data = "Example data";
    // send data to the worker thread
    {
        std::lock_guard lk(m);
        ready = true;
        std::cout << "main() signals data ready for processing\n";
    }
    cv.notify_one();
 
    // wait for the worker
    {
        std::unique_lock lk(m);
        cv.wait(lk, []{ return processed; });
    }
    std::cout << "Back in main(), data = " << data << '\n';
 
    worker.join();
}

My question is regarding very specifically this section:

    {
        std::lock_guard lk(m);
        ready = true;
        std::cout << "main() signals data ready for processing\n";
    }
    cv.notify_one();

Why does ready need to have a lock guard? If the other thread is waiting shouldn't it be guaranteed that ready = true happens before that other thread is woken up by notify_one?

I'm asking this to get insight into a problem I'm seeing a private code base that I cannot show here.

I'm even more confused if ready was an std::atomic then is the lock still needed? In the private code I observed that it still is otherwise the bool isn't changed before the notification and wakeup happens

1
  • 2
    Regarding the tags: C++11 and C++20 are hardly necessary. Before C++11, there was no official threads and the synchronizing part you ask about has not changed since C++11. Commented Feb 15, 2024 at 19:58

3 Answers 3

5

Without the lock in question, ready = true; is not synchronized with worker_thread reading ready. That's a data race and has undefined behavior.

    {
        // std::lock_guard lk(m); // removed
        ready = true;             // race condition
    }

With the removed sychronization primitive, changes to ready in one thread doesn't require the compiler to produce any code that makes sense to you as a programmer.

The waiting thread may never see the change in ready and the compiler may let the waiting thread observe a "cached" value of false - since, again, you didn't tell the compiler that there's some synchronization needed. If the two threads do not synchronize, why then should the compiler assume that the value of the variable will ever change in the thread that reads it? If you don't tell it that there's a writer it may simply replace the reading of variable with a hard value, be it true or false.

The compiler could also lay out the final assembly as if ready was set all along. It's allowed to go any way it wants when you don't synchronize events.

You also don't know when the waiting thread wakes up. It may not only be woken up by notify_one since there's also spurious wakeups, which is why the condition must be checked after waking up. If it's not true, go back to waiting.

Sign up to request clarification or add additional context in comments.

7 Comments

I don't understand your first statement. How is ready not synchronized? Especially in the case when an atomic is used for ready
@Tyler Re: "How is ready not synchronized?" - It's written to while another thread may read it. ready = true; is unsynchronized wrt return ready; without the lock. If it had been atomic, the required synchronization would be guaranteed.
@Tyler If you think of it this way: Without the lock, what would prevent the main thread from writing to ready at the same time as the thread reads from ready?
Hmm, my confusion is coming from the case where ready is an atomic. When I did the testing a few months ago I still observed a deadlock occurring after that change and removing the std::lock_guard lk(m); line from before ready = true; I'll try re-observing that
yeah in the regular case I understand how the mutex lock stop the deadlock. I'll go ahead and accept this answer
|
1

Why a condition variable doesn't work with an atomic boolean (not std::atomic_flag as you pass what state is going to put the thread to wait) is the race condition:

  • receiving thread is not actually waiting (or had a spurious wake-up) and reads the old state of the atomic boolean.
  • sending thread sets the boolean and sends the signal
  • receiving thread waits for the signal that it has missed and is not woken up until the next signal is potentially sent.

This is why the lock has to be passed to the waiting call, so the receiver knows the signal is not sent before it had time to wait for it. The sending thread can send the signal while holding the lock or after releasing it because by then the receiving thread has either read the new state of the boolean or is waiting for the signal.

Comments

1

Anytime you use a condition variable, you also (unavoidably) use a mutex. There's simply no getting away from that. You can't use a cv without a mutex, because they're simply not designed to work that way.

You could, for one possibility, make ready an atomic<bool>, which would guarantee that the change in its value would propagate to the other thread correctly. You'd still need the mutex for the cv though, so you might as well use the mutex to protect ready as well (in which case, you don't need to make ready atomic, because the mutex guarantees propagation, just like making it atomic does).

Since you've tagged C++20, I'll also mention an alternative: you could consider an atomic_flag instead of a condition variable. For example, we could rewrite your program a bit like this:

#include <atomic>
#include <iostream>
#include <mutex>
#include <string>
#include <thread>

std::atomic_flag ready {};
std::atomic_flag processed {};

std::string data;
 
void worker_thread()
{
    // Wait until main() sends data
    ready.wait(false);
    ready.clear();

    std::cout << "Worker thread is processing data\n";
    data += " after processing";

    processed.test_and_set();
    processed.notify_one();
    std::cout << "Worker thread signals data processing completed\n";
}
 
int main()
{
    std::thread worker(worker_thread);
 
    data = "Example data";
    ready.test_and_set();
    ready.notify_one();
    std::cout << "main() signals data ready for processing\n";
 
    processed.wait(false);
    std::cout << "Back in main(), data = " << data << '\n';
 
    worker.join();
}

[As a side note: std::atomic_flag has existed since C++11, but the notify_one and wait were added in C++20.]

This can often reduce overhead compared to using a condition variable.

Also note that we could use only one atomic_flag, if we really wanted. main would set it, and notify one, then wait for it to be cleared, before doing the final processing. The worker thread would wait for it to be set, modify the data, then clear it and notify one to let main know it was done:

#include <atomic>
#include <iostream>
#include <mutex>
#include <string>
#include <thread>

std::atomic_flag ready {};

std::string data;
 
void worker_thread()
{
    // Wait until main() sends data
    ready.wait(false);

    std::cout << "Worker thread is processing data\n";
    data += " after processing";

    ready.clear();
    ready.notify_one();
    std::cout << "Worker thread signals data processing completed\n";
}
 
int main()
{
    std::thread worker(worker_thread);
 
    data = "Example data";
    ready.test_and_set();
    ready.notify_one();
    std::cout << "main() signals data ready for processing\n";
 
    ready.wait(true);
    std::cout << "Back in main(), data = " << data << '\n';
 
    worker.join();
}

Race Conditions

In the comments it has been asserted that my second paragraph is incorrect, and making ready an std::atomic<bool> would result in a race condition. A long string of comments has resulted in a great deal of heat, but little or no light, so I'm going to go into a ridiculous level of detail here in the hope that changing that.

Let's start by reviewing the original code at a very basic level:

The main thread:

  1. initializes ready and processed to false
  2. spawns a worker thread
  3. generates some data for the worker thread to process
  4. sets ready to true
  5. waits for processed to become true
  6. prints out the result
  7. exits the process

The worker thread:

  1. waits for ready to become true
  2. processes the data
  3. sets processed to true
  4. exits the thread

In particular, note that the program:

  • processes exactly one (1) batch of data one (1) time.
  • initializes ready and processed to false
  • ready is only ever written to by one thread, one time
  • processed is only ever written to by one thread, one time

So, after ready becomes true, it remains true until the process exits. Likewise, one processed becomes true, it remains true until the process exits. I'll repeat for emphasis, because this is important: there is no circumstance under which either ready or processed will transition from true to false. Both start out false, transition to true, then remain true until the process exits.

Second major point: there is only ever one main thread and one worker thread.

The first claim of a race condition was that:

Your second paragraph is incorrect though. Making ready atomic is not enough to ensure its value propagates correctly to the other thread for the reason I explained in my comment above.

At least as they're defined in C++, this is false. A variable that's not atomic may not propagate from one thread to another--for example, thread A can change a bool from false to true, and thread B can later read that variable, but still see the old value instead of the updated value.

If, however, the variable is atomic, that can't happen. It's guaranteed that if one thread updates a value of an atomic variable, any subsequent read by any thread will always see the updated value.

That's what propagation means, and (in C++) atomic variables guarantee propagation.

Although that mistake was never admitted, that led to another attempt at another, different claim of a race condition:

Using an atomic by itself does not force propagation because it won't unblock a thread that is waiting for the value to change. That's why we're talking about using it in conjunction with a condition variable. Read my first comment again, particularly the "unlock and wait" part. The code will not work because a thread observed the atomic, decides to sleep, another thread modifies the atomic, the first thread then goes to sleep to wait for something that has already happened. You need an atomic "unlock and wait" operation and that's what the mutex is for -- to have something to unlock.

First of all, this completely misuses "propagation" to mean something almost, but not quite, completely different from what it actually means.

Second, it seems to be based on using some threading primitive that would wake a thread when a write is done to an atomic, but with no knowledge of the value contained in that atomic. At least, that's the only way I can make any sense of: "[...] the first thread then goes to sleep to wait for something that has already happened."

That leads to yet another problem though: the "goes to sleep to wait" part seems to presuppose that there's some primitive to sleep until there's a write to the atomic variable. Since this entire line of reasoning starts off by asserting that no such capability exists, I'm baffled at how this can possibly make any sense.

In reality, you could (for only one possibility) basically do something like a spin-lock:

while (!ready)
    std::this_thread::yield();

Depending on the situation (especially if you expect that to take very long) there are other ways to do the job that can and will be better, but that's sufficient to illustrate the basic point that it's entirely possible for a thread to wait until an atomic variable contains a specific value, without creating any race condition, or using an argument that depends on your using a primitive that it starts out by asserting doesn't exist.

Since that argument didn't make much sense, I tried to guess at something he might be trying to get at that wasn't self-contradictory. The possibility that occurred to me was what's called the ABA problem. This can occur with code like the loop above, where we get a sequence like this this:

  • ready is set to true
  • ready is set back to false
  • thread C checks whether ready is true, and misses the fact that it was true for a short time

If the intent is for thread C to react to every change in ready, then this can be a real problem.

As a bare minimum, however, this requires that there is some circumstance under which ready can/will be changed from true to false--which (as already noted) simply can't happen in any of the code considered here. Further, since we only process a single batch of data going from the main thread to the worker thread, we'd have to postulate some rather different program that did some rather different thing before it made any sense to consider doing that at all.

Summary

Changing ready from bool to std::atomic<bool> would ensure that writes to ready propagated between threads, even without a mutex. And if you did so, you'd no longer need to use a mutex to protect ready from changes by multiple threads simultaneously, because atomic variables guarantee defined behavior when modified from multiple threads without other synchronization.

But when you're using a condition variable, you need to use a mutex. So when you're doing that, there's no real point in making ready atomic--a condition variable requires you to use a mutex, regardless, and synchronizing with that mutex assures propagation of its value, even without its being atomic.

22 Comments

The atomic doesn't work because the whole point of a condition variable is to provide an atomic "unlock and wait" operation. If you use an atomic with a condition variable, there's nothing to unlock and you run the risk of waiting for something that happened between when you decided to call wait and when you actually called wait.
@DavidSchwartz: I was careful to point out that all use of a cv requires a mutex. That's what the cv unlocks. If you change ready from bool to std::atomic<bool>, it'll continue to work fine.
Your second paragraph is incorrect though. Making ready atomic is not enough to ensure its value propagates correctly to the other thread for the reason I explained in my comment above.
@DavidSchwartz: Use of an atomic,all by itself, forces proper value propagation (unless you explicitly stop that from happening, such as by using memory_order_relaxed).
@DavidSchwartz: I suspect you're really trying to get at the ABA problem, but 1) that's unrelated to propagation, and 2) it only relates to code quite different from any of the above. The ABA problem doesn't stop the code from working, but can restrict your model somewhat (e.g., in this case it works because at any given time we have, in essence, a single producer and single consumer).
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.