Home > database >  Can std::atomic_flag be safely destroyed after calling notify_all?
Can std::atomic_flag be safely destroyed after calling notify_all?

Time:12-27

In my code, I want to use a std::atomic_flag to synchronize two threads. Specifically, I would like to use the new wait and notify_all features that are introduced in C 20.

In a nutshell: one thread is waiting for the flag to become ready while another thread will set the flag and issue the notification. The catch, however, is that the atomic_flag lives on the stack and will be destroyed after the notification, while the first thread may still be in the call to wait.

Basically, I have something equivalent to the following snippet:

#include <atomic>
#include <thread>

int main(int, char**)
{
    auto t = std::thread{};

    {
    auto f = std::atomic_flag{};
    t = std::thread{[&f] { f.wait(false); }};

    // Ensures that 't' is waiting on 'f' (not 100% guarantee, but you get the point)
    std::this_thread::sleep_for(std::chrono::milliseconds{50});

    f.test_and_set();
    f.notify_all();
    } // <--- 'f' is destroyed here but 't' may still be in the wait call

    t.join();
    return 0;
}

In the past, I have used boost::latch for situations like this and I know from experience that this pattern will nearly always crash or assert. However, replacing boost::latch with std::atomic_flag has not resulted in any crashes, asserts, or deadlocks.

My question: is it safe to destroy a std::atomic_flag after a call to notify_all (i.e. with waking threads potentially still in the wait method)?

CodePudding user response:

No, it is not safe.

From the standard

In the standard ([atomics.flag]), the effects of atomic_flag_wait are described as follows:

Effects: Repeatedly performs the following steps, in order:

  • Evaluates flag->test(order) != old.
  • If the result of that evaluation is true, returns.
  • Blocks until it is unblocked by an atomic notifying operation or is unblocked spuriously.

This implies that, after unblocking, the std::atomic_flag is accessed to read the new value. And thus, it is a race with destructing the atomic flag from the other thread.

In practice

Probably, the code snippet works fine because the destructor of std::atomic_flag is trivial. So the memory is left intact on the stack and the waiting thread can still continue to use those bytes as if they were the atomic flag.

By modifying the code a bit to explicitly zero the memory where the std::atomic_flag lived, the snippet now deadlocks (at least on my system).

#include <atomic>
#include <cstddef>
#include <cstring>
#include <thread>

int main(int, char**)
{
    auto t = std::thread{};

    // Some memory to construct the std::atomic_flag in
    std::byte memory[sizeof(std::atomic_flag)];

    {
    auto f = new (reinterpret_cast<std::atomic_flag *>(&memory)) std::atomic_flag{};

    t = std::thread{[&f] { f->wait(false); }};
    std::this_thread::sleep_for(std::chrono::milliseconds{50});
    f->test_and_set();
    f->notify_all();
    
    f->~atomic_flag(); // Trivial, but it doesn't hurt

    // Set the memory where the std::atomic_flag lives to all zeroes
    std::memset(&memory, 0, sizeof(std::atomic_flag));
    }

    t.join();
    return 0;
}

Which will deadlock the waiting thread if it happens to read the atomic flag's value after the memory has been set to all zeroes (probably because it now interprets those zeroes as 'false' for the atomic flag's value).

  • Related