Discussion: Back from the Future
Execute the code to understand the output and gain insights into data races and the importance of thread-safe operations.
Run the code
Now, it’s time to execute the code and observe the output.
#include <future>#include <iostream>int main(){char counter = 0;auto future1 = std::async(std::launch::async, [&](){counter++;});auto future2 = std::async(std::launch::async, [&](){return counter;});future1.wait();// Cast to int to print it as a numerical value rather than a characterstd::cout << (int)future2.get();}
Understanding the output
When two unsynchronized threads access the same memory location and at least one of these accesses is a modification, we have a data race. This program launches two threads, one that modifies the counter
and one that reads it. Since we have made no effort to synchronize the two threads, the program has a data race.
Undefined behavior
If we run this program on our machine, we’re very likely to see either 0
or 1
, depending on which thread gets to counter
first. The counter
is a single byte, so we’re not likely to see, for example, any half-written values. Either the counter++
operation happens entirely before the second thread reads counter
or it happens entirely after. But that doesn’t mean we can rely on this for some sort of eventually consistent logic! A data race results in undefined behavior, so anything can happen (remember what we talked about in the preface—demons could come flying out of our noses). Don’t try to reason about or test what happens under undefined behavior. Our program might seem to work, but it breaks on another system when the compiler is upgraded or every one-millionth time it runs.
The need for synchronization
For these threads to safely access the counter
, some sort of synchronization is needed. The classical solution is to protect the counter
with a mutex, but this is cumbersome and also introduces locks. Locks can be slow, and where there are locks, there can be deadlocks. A better approach is to use an atomic, like so:
std::atomic<char> counter = 0;auto future1 = std::async(std::launch::async, [&]() {counter++;});auto future2 = std::async(std::launch::async, [&]() {return counter.load();});
Using std::atomic
instead of a mutex, makes the code simpler, avoids introducing an extra variable for the mutex, ensures we can’t forget to lock the mutex, and probably makes the code lock-free.
#include <future>#include <iostream>#include <atomic>int main(){std::atomic<char> counter = 0;auto future1 = std::async(std::launch::async, [&]() {counter++;});auto future2 = std::async(std::launch::async, [&]() {return counter.load();});future1.wait();// Cast to int to print it as a numerical value rather than a characterstd::cout << static_cast<int>(future2.get());}
Lock-free atomics
Whether or not std::atomic<char>
is lock-free is implementation-defined, but it typically is. We can check this on our system with the is_lock_free
and/or is_always_lock_free
members of the std::atomic
class template:
static_assert(counter.is_always_lock_free);assert(counter.is_lock_free());
The
is_always_lock_free
istrue
ifstd::atomic<T>
for the givenT
is always lock-free. This is astatic constexpr
member, which can be checked at compile-time.The
is_lock_free()
istrue
if a particular instance ofstd::atomic<T>
is lock-free and can betrue
even ifis_always_lock_free
is not. Some atomics could, for instance, be lock-free only if they’re aligned. This can only be checked at runtime.
#include <future>#include <iostream>#include <atomic>#include <cassert>int main(){std::atomic<char> counter = 0;// Compile-time check if the atomic operations are always lock-freestatic_assert(std::atomic<char>::is_always_lock_free, "Atomic operations on char are not always lock-free.");// Run-time check if the atomic operations are lock-freeassert(counter.is_lock_free() && "Atomic operations on this instance are not lock-free.");auto future1 = std::async(std::launch::async, [&]() {counter++;});auto future2 = std::async(std::launch::async, [&]() {return counter.load();});future1.wait();// Cast to int to print it as a numerical value rather than a characterstd::cout << static_cast<int>(future2.get());}
Both on an M1 Macbook and x86_64 Linux machine, std::atomic
of int
, long
, and double
are all always lock-free.
ThreadSanitizer
A great tool to detect data races is the free ThreadSanitizer. It’s one of many sanitizers that ship with LLVM, and it’s enabled by simply passing the -fsanitize=thread
compiler option and then running the program.
#include <future>#include <iostream>int main(){char counter = 0;auto future1 = std::async(std::launch::async, [&](){counter++;});auto future2 = std::async(std::launch::async, [&](){return counter;});future1.wait();// Cast to int to print it as a numerical value rather than a characterstd::cout << (int)future2.get();}
It will tell us something like this for this puzzle:
WARNING: ThreadSanitizer: data race (pid=15)Read of size 1 at 0x7ffdcc19e017 by thread T2:(... stack trace ...)Previous write of size 1 at 0x7ffdcc19e017 by thread T1:(... stack trace ...)Location is stack of main thread.Location is global '<null>' at 0x000000000000 ([stack]+0x000000020017)Thread T2 (tid=18, running) created by main thread at:(... stack trace ...)Thread T1 (tid=17, finished) created by main thread at:(... stack trace ...)
As we can see, it not only tells us that there was a data race but also tells us where the variable is, the stack traces to the code that read and wrote it, and the stack traces to where the threads were created. Pretty useful stuff!
Recommendations
Here are some recommendations to ensure thread safety.
Synchronize accesses to shared variables: Always synchronize accesses to shared variables, especially if at least one of the accesses is a write, to prevent data races.
Consider
std::atomic
for simple synchronization: Thestd::atomic
is often a great choice for simple synchronization of values, providing a straightforward way to manage shared data safely.Use sanitizers: Utilize thread sanitizers to detect race conditions and other threading issues during development.
Level up your interview prep. Join Educative to access 70+ hands-on prep courses.