When writing multi-threading code, one often need the thread to wait for some condition being met. A naive approach would look like this:
std::atomic_bool is_lifted;
...
//waiting for it being lifted
while (false == is_lifted.load())
std::this_thread::yield();
This code is suboptimal, as the busy waiting loop wastes CPU power. Nonetheless, it has several benefits:
- It is simple and easy to comprehend.
- The writer side does not need to do any extra job to notify the reader.
- It has very small memory footprint.
Edit1[[----------------
Since C++20, the code can be easily improved using the std::atomic::wait/notify_*
functions:
std::atomic_bool is_lifted;
...
//writer
is_lifted.store(true);
is_lifted.notify_all();
...
//reader
//waiting for it being lifted
is_lifted.wait(false);
Usually std::atomic::notify_*
implementations are smart to avoid syscall'ing to the kernel if there are no waiters,
however the cost is not negligible.
In Windows10+VS2022, one notify function call (no waiter) costs ~50 instructions.
If you use Windows7, since there is no WaitOnAddress
/WakeByAddressXxx
, it costs even more.
A common solution to cut this cost off is to store waiting status alongside the condition var, like this one: https://github.com/microsoft/STL/blob/2261f7edb760eb3fe0726187c818b796dc7ea798/stl/inc/semaphore#L207
As a practice to this approach,
----------------]]End Edit1
As a practice for the C++20 atomic::wait/notify_*
functions, I wrote a small class to address the waiting performance issue of the busy waiting version, while keep its benefits as much as possible:
- easy to use
- writer do notify only when necessary
- small memory footprint
Here's the code:
class condition_notifier
{
using uchar = unsigned char;
static constexpr uchar set_bit = 1;
static constexpr uchar set_unmask = (uchar)~(unsigned)set_bit;
static constexpr uchar wait_bit = 2;
static constexpr uchar wait_unmask = (uchar)~(unsigned)wait_bit;
//Possible states:
//unset and no waiter
static constexpr uchar unset_nowait = 0;
//set and no waiter
static constexpr uchar set_nowait = set_bit;
//unset and waiting
static constexpr uchar unset_wait = wait_bit;
//set and canceled wait, should be treated like `set_nowait`
static constexpr uchar set_cwait = (uchar)((unsigned)set_bit) | ((unsigned)wait_bit);
std::atomic_uchar atomic_state{ unset_nowait };
public:
bool is_set(std::memory_order morder = std::memory_order_relaxed) noexcept
{
return atomic_state.load(morder) & set_bit;
}
void reset(std::memory_order morder = std::memory_order_relaxed) noexcept
{
atomic_state.fetch_and(set_unmask, morder);
//0 -> 0: still unset
//1 -> 0: resetted
//2 -> 2: still waiting
//3 -> 2: set_cwait -> unset_wait, unfortunate but rare and acceptable, see the comment in `wait()`
}
void set(std::memory_order morder = std::memory_order_release) noexcept
{
uchar old_state = atomic_state.exchange(set_nowait, morder);
if (old_state == unset_wait)
{
atomic_state.notify_all();
}
}
void wait(std::memory_order morder = std::memory_order_acquire) noexcept
{
for (;;)
{
uchar state = atomic_state.load(morder);
if (state & (unsigned)set_bit)
return;
assert(state == unset_nowait || state == unset_wait);
if (state == unset_nowait)
{
state = atomic_state.fetch_or(wait_bit, morder);
//0 -> 2: unset, waiting registered
//1 -> 3: set, waiting canceled
//2 -> 2: unset, mutiple waiters
//3 -> 3: set, waiting (multi-)canceled
if (state == set_nowait)
{
//reset the wait bit
//This should prevent bogus `unset_wait` state
// and save a `notify_all()` call, most of the time.
uchar bad_state = set_cwait;
atomic_state.compare_exchange_strong(bad_state, set_nowait);
return;
}
if (state == set_cwait)
{
//the one who set the wait bit would reset it
return;
}
}
atomic_state.wait(unset_wait, std::memory_order_relaxed);
}
}
};
int main()
{
condition_notifier cond_noti;
auto wait_func = [&](int thread_id)
{
std::printf("thread %d: spin and check\n", thread_id);
for (int i = 0; i < 256; ++i)
{
if (cond_noti.is_set())
return;
_mm_pause();
}
std::printf("thread %d: yield and check\n", thread_id);
for (int i = 0; i < 4; ++i)
{
if (cond_noti.is_set())
return;
std::this_thread::yield();
}
std::printf("thread %d: block and waiting for notification\n", thread_id);
cond_noti.wait();
std::printf("thread %d: wait success\n", thread_id);
};
std::thread thread1{ wait_func, 1 };
std::thread thread2{ wait_func, 2 };
using namespace std::chrono_literals;
std::this_thread::sleep_for(5s);
cond_noti.set();
thread1.join();
thread2.join();
std::puts("threads returned");
return 0;
}
I want to know is there any bug in it, and anything to be improved.
std::atomic<>
's notify functions cannot avoid syscalling, as there is no state in the variable itself that tracks if there are waiters. This is in contrast withstd:condition_variable
. \$\endgroup\$std::atomic<>
types. This does have issues though if you have atomic variables in memory shared between processes, or (very unlikely) even between parts of the program that link with different standard libraries. \$\endgroup\$