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.

Press + to interact
#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 character
std::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.

Press + to interact
#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 character
std::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 is true if std::atomic<T> for the given T is always lock-free. This is a static constexpr member, which can be checked at compile-time.

  • The is_lock_free() is true if a particular instance of std::atomic<T> is lock-free and can be true even if is_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.

Press + to interact
#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-free
static_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-free
assert(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 character
std::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.

Press + to interact
#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 character
std::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 ...)
Output of the ThreadSanitizer for this puzzle

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: The std::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.