As C# includes classes like Thread and Mutex, the C++ Standard Library also provides support for multi-threading. Classes like std::thread and std::mutex are very similar, but there are larger differences when it comes to C#’s lock, async, and await keywords. Read on to learn how to write multi-threaded C++!

Table of Contents

Thread

Like the C# Thread class in System.Threading, C++ has std::thread in the <thread> header. It’s the most basic way to create another thread:

#include <thread>
#include <chrono>
 
void PrintLoop(const char* threadName)
{
    for (int i = 0; i < 10; ++i)
    {
        DebugLog(threadName, i);
 
        // this_thread provides functions that operate on the current thread
        // sleep_for takes a std::chrono::duration
        std::this_thread::sleep_for(std::chrono::milliseconds{100});
    }
}
 
void MyThread()
{
    PrintLoop("MyThread");
}
 
// Create a thread and immediately start executing MyThread in it
std::thread t{MyThread};
 
// This happens on the main thread
PrintLoop("Main Thread");
 
// Block until the thread terminates
t.join();

This prints something like this, depending on OS thread scheduling:

Main Thread, 0
MyThread, 0
MyThread, 1
Main Thread, 1
Main Thread, 2
MyThread, 2
MyThread, 3
Main Thread, 3
Main Thread, 4
MyThread, 4
MyThread, 5
Main Thread, 5
MyThread, 6
Main Thread, 6
MyThread, 7
Main Thread, 7
Main Thread, 8
MyThread, 8
Main Thread, 9
MyThread, 9

std::this_thread has a few other functions:

#include <thread>
 
// Sleep until a specific time
std::this_thread::sleep_until(std::chrono::system_clock::now() + 1500ms);
 
// Tell the OS to schedule other threads
std::this_thread::yield();
 
// Get the current thread's ID
// Has overloaded comparison operators and works with std::hash
std::thread::id i = std::this_thread::get_id();
std::thread t{
    [&] {
        DebugLog(i == std::this_thread::get_id()); // false
    }
};
t.join();

std::thread itself has just a little more functionality. For starters, we can pass parameters to threads:

#include <thread>
 
void Thread(int param)
{
    DebugLog(param); // 123 then 456 or visa versa
}
 
std::thread t1{ Thread, 123 };
std::thread t2{ Thread, 456 };
t1.join();
t2.join();

We can get some information about the thread, including its std::thread::id:

#include <thread>
 
std::thread t{ [] {} };
 
// Get the ID from outside the thread
std::thread::id id = t.get_id();
 
// Get an platform-dependent handle to the thread
std::thread::native_handle_type handle = t.native_handle();
 
// Check how many threads the CPU can run at once
// Depends on number of processors, cores, Hyper-threading, etc.
unsigned int hc = std::thread::hardware_concurrency();
DebugLog(hc); // Maybe 8
 
// Check if the thread is active, i.e. we can join() it
DebugLog(t.joinable()); // true
 
t.join();
 
DebugLog(t.joinable()); // false

The last function is detach, which releases the OS thread from the std::thread:

#include <thread>
#include <chrono>
 
std::thread t{ [] {
    DebugLog("thread start");
    std::this_thread::sleep_for(std::chrono::milliseconds{500});
    DebugLog("thread done");
} };
 
// Release the OS thread
t.detach();
 
DebugLog(t.joinable()); // false
 
DebugLog("main thread done");
 
// Can't join() the thread anymore, so sleep longer than it runs
std::this_thread::sleep_for(std::chrono::milliseconds{ 1000 });

This might print:

false
main thread done
thread start
thread done

The reason this is necessary is that the std::thread destructor throws an exception if the thread is joinable. Calling detach makes it non-joinable:

#include <thread>
#include <chrono>
 
void Foo()
{
    std::thread t{ [] {
        std::this_thread::sleep_for(std::chrono::milliseconds{500});
    } };
} // destructor throws

Next in <thread>, which debuted in C++20, is std::jthread. This is like std::thread but with support for cancelation and automatic joining. The std::jthread destructor calls join for us if the thread is joinable. As C# lacks destructors, there’s no equivalent to this:

#include <thread>
#include <chrono>
 
void Foo()
{
    std::jthread t{ [] {
        std::this_thread::sleep_for(std::chrono::milliseconds{500});
    } };
} // destructor calls join()

To support cancelation, the thread function can take a std::stop_token defined in <stop_token> used to check if another thread has requested that the thread stop executing. Using this “stop” functionality allows us to avoid some tricky inter-thread communication. Unfortunately, there’s no analog to this in C#:

#include <thread>
#include <chrono>
 
void Foo()
{
    // Thread function takes a stop_token
    std::jthread t{ [] (std::stop_token st) {
        // Check if a stop is requested
        while (!st.stop_requested())
        {
            std::this_thread::sleep_for(std::chrono::milliseconds{100});
            DebugLog("Thread still running");
        }
 
        DebugLog("Stop requested");
        std::this_thread::sleep_for(std::chrono::milliseconds{ 500 });
    } };
 
    std::this_thread::sleep_for(std::chrono::seconds{ 1 });
 
    // Request that the thread stop executing
    // This does not block like join() would
    t.request_stop();
    DebugLog("After requesting stop");
} // jthread destructor calls join(). About 500 milliseconds passes here...
Stop Token

Besides defining std::stop_token, the <stop_token> header has a couple other features related to std::jthread. First, there is std::stop_source which issues std::stop_token objects:

#include <stop_token>
 
// Create a source of tokens
std::stop_source source{};
 
// Issue tokens from the source
std::stop_token t1 = source.get_token();
std::stop_token t2 = source.get_token();
 
// No stop is initially requested
DebugLog(t1.stop_requested(), t2.stop_requested()); // false, false
 
// Request a stop on all tokens issued by the source
source.request_stop();
DebugLog(t1.stop_requested(), t2.stop_requested()); // true, true

A std::stop_callback allows for a function to be called when a stop is requested:

#include <stop_token>
 
std::stop_source source{};
std::stop_token t = source.get_token();
 
// Call a lambda when a token's source is stopped
std::stop_callback sc(
    t,
    [] { DebugLog("stop requested"); });
 
source.request_stop(); // stop requested

The callback is called on the thread that requests the stop:

#include <thread>
#include <stop_token>
#include <chrono>
 
// Thread that sleeps for 1 second
std::jthread t1{
    []
    {
        std::this_thread::sleep_for(std::chrono::milliseconds{1000});
    }
};
std::stop_source source1 = t1.get_stop_source();
std::stop_token token1 = t1.get_stop_token();
 
// Thread that sleeps for 0.5 seconds then stops thread 1's source
std::jthread t2{
    [](std::stop_source ss)
    {
        std::this_thread::sleep_for(std::chrono::milliseconds{500});
 
        // Calls the below callback on this thread
        ss.request_stop();
    },
    source1
};
std::thread::id id2 = t2.get_id();
 
// Register a callback for when thread 1's token is stopped
std::stop_callback sc{
    token1,
    [&]
    {
        // Print which thread the callback was called on
        DebugLog(id2 == std::this_thread::get_id()); // true
    }
};
 
// Wait 2 seconds for the threads to do their work
std::this_thread::sleep_for(std::chrono::milliseconds{ 2000 });
Mutex

Proper synchronization between threads is essential to prevent data corruption and logic errors. To this end, C++ provides numerous facilities starting with std::mutex, the equivalent of C#’s Mutex class.

#include <thread>
#include <mutex>
 
// An array to fill up with integers
constexpr int size = 10;
int integers[size];
int index = 0;
 
// A mutex to control access to the array
std::mutex m{};
 
auto writer = [&]
{
    while (true)
    {
        // Lock the mutex before accessing shared state: index and integers
        m.lock();
 
        // Access shared state by reading index
        if (index >= size)
        {
            // Unlock the mutex when done with the shared state
            m.unlock();
            break;
        }
 
        // Access shared state by reading and writing index and writing integers
        integers[index] = index;
        index++;
 
        // Unlock the mutex when done with the shared state
        m.unlock();
    }
};
 
std::thread t1{writer};
std::thread t2{writer};
 
t1.join();
t2.join();
 
for (int i = 0; i < size; ++i)
{
    DebugLog(integers[i]); // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
}

More mutex classes are available besides the basic std::mutex. The std::timed_mutex class allows us to attempt to unlock a mutex for a certain amount of time:

#include <mutex>
 
std::timed_mutex m{};
 
// Try to get a lock for up to 1 millisecond then give up
bool didLock = m.try_lock_for(std::chrono::milliseconds{ 1 });

Because std::mutex and std::timed_mutex can’t be locked when already locked by the same thread, there’s also std::recursive_mutex that allows for this:

#include <mutex>
 
std::recursive_mutex m{};
 
// First lock
m.lock();
 
// Second lock: OK with recursive_mutex but not regular mutex
m.lock();
 
// Unlock second lock
m.unlock();
 
// Unlock first lock
m.unlock();

The std::recursive_timed_mutex class combines std::recursive_mutex and std::timed_mutex to provide both their feature sets.

When multiple mutexes need to be locked, a std::lock function avoids deadlocks due to the ABA problem:

std::mutex m1{};
std::mutex m2{};
 
// Lock both mutexes
std::lock(m1, m2);
 
m1.unlock();
m2.unlock();

When we need to call a function exactly once from multiple threads, we can use std::call_once and its helper class std::once_flag:

#include <thread>
#include <mutex>
 
// Keeps track of whether the function has been called
std::once_flag of{};
 
// Function to call once
auto print = [](int x) { DebugLog("called once", x); };
 
// Two threads racing to call the function
auto threadFunc = [&](int x) { std::call_once(of, print, x); };
std::thread t1{ threadFunc, 123 };
std::thread t2{ threadFunc, 456 };
 
t1.join();
t2.join();

In C#, we rarely use Mutex directly. Instead, we usually prefer to use a lock statement which takes care of unlocking the mutex even when an exception is thrown. The same is true in C++, except that we use a lock class and its destructor unlocks the mutex even when an exception is thrown:

#include <mutex>
 
void Foo()
{
    // Mutex to lock
    std::mutex m{};
 
    // Create a lock object for the mutex
    // Constructor locks the mutex
    std::lock_guard g{ m };
} // lock_guard's destructor unlocks the mutex

std::unique_lock is the same as std::lock_guard but it supports “moving” the lock object and not “copying” it. Regardless of the lock class chosen, we can use std::lock to lock multiple if we first defer taking the lock:

#include <mutex>
 
void Foo()
{
    std::mutex m1{};
    std::mutex m2{};
 
    // Make the locks, but don't lock the mutexes yet
    std::unique_lock g1{ m1, std::defer_lock };
    std::unique_lock g2{ m2, std::defer_lock };
 
    // Lock them both, avoiding deadlocks
    std::lock(g1, g2);
} // unique_lock's destructor unlocks both mutexes

In C++17, a more convenient std::scoped_lock was added to deal with locking multiple mutexes:

#include <mutex>
 
void Foo()
{
    std::mutex m1{};
    std::mutex m2{};
 
    // Lock both mutexes
    std::scoped_lock g{ m1, m2 };
} // scoped_lock's destructor unlocks both mutexes
Shared Mutex

C++17 adds another mutex type, std::shared_mutex, in the <shared_mutex> header. There are two ways to lock this mutex: “exclusive” and “shared.” An “exclusive” lock can only be taken by one thread at a time and prevents any threads from taking a “shared” lock. A “shared” lock allows other threads to take a “shared” lock but not an “exclusive” lock. Regardless of the kind of lock, any given thread can only lock once.

To take these two kinds of locks, we use the std::unique_lock class we’ve already seen in <mutex> and the std::shared_lock or std::shared_timed_lock classes provided by <shared_mutex>:

#include <mutex>
#include <shared_mutex>
 
class SharedInt
{
    int Value = 0;
 
    // Mutex that protects the value
    mutable std::shared_mutex Mutex;
 
public:
 
    int GetValue() const
    {
        // Multiple threads can read at once, so use take a "shared" lock
        std::shared_lock lock{ Mutex };
 
        return Value;
    }
 
    void SetValue(int value)
    {
        // Only one thread can write at once, so take an "exclusive" lock
        std::unique_lock lock{ Mutex };
        Value = value;
    }
};
Semaphore

C++20 introduces more synchronization mechanisms than just mutexes, starting with std::counting_semaphore in <semaphore>. This is the analog of C#’s Semaphore class and it allows more than one access at a time:

#include <semaphore>
 
// Allow up to 3 accesses with the counter starting at 3
std::counting_semaphore<3> cs{ 3 };
 
// Block while the counter is 0 then decrement it by 1
cs.acquire();
// Counter is now 2
 
cs.acquire();
// Counter is now 1
 
cs.acquire();
// Counter is now 0
 
// Try to acquire, but fail because the counter is at 0
bool didAcquire = cs.try_acquire();
DebugLog(didAcquire); // false
 
// Increment the counter
cs.release();
// Counter is now 1
 
DebugLog(cs.try_acquire()); // true
// Counter is now 0

A std::binary_semaphore is provided as an alias of std::counting_semaphore<1>.

Barrier

The next synchronization mechanism provided by C++20 is std::barrier in the <barrier> header. It’s equivalent to the Barrier class in C#. Like a semaphore, a barrier has a count of threads. In contrast, these indicate threads that have “arrived” at the barrier and should block until the barrier is “completed”:

#include <chrono>
#include <thread>
#include <barrier>
 
void Foo()
{
    // Define a function to call when the barrier is completed
    auto complete = []() noexcept {};
 
    // Allow up to three threads to block until the barrier is completed
    std::barrier b{ 3, complete };
 
    auto threadFunc = [&](int id)
    {
        // Do something before arriving at the barrier
        DebugLog("before arrival", id);
 
        // Arrive at the barrier and get a token
        auto arrivalToken = b.arrive();
 
        // Do something after arriving at the barrier
        DebugLog("after arrival", id);
 
        // Wait for the barrier to complete
        b.wait(std::move(arrivalToken));
 
        // Do something after the barrier completes
        DebugLog("after waiting", id);
    };
    std::jthread t1{ threadFunc, 1 };
    std::jthread t2{ threadFunc, 2 };
    std::jthread t3{ threadFunc, 3 };
 
    std::this_thread::sleep_for(std::chrono::seconds{ 3 });
 
    // Complete the barrier
    complete();
}

This prints:

before arrival, 3
before arrival, 1
before arrival, 2
after arrival, 3
after arrival, 11
after arrival, 2

Then three seconds later…

after waiting, 2
after waiting, 1
after waiting, 3
Latch

The std::latch class in C++20’s <latch> header provides a single-use version of std::barrier. This class is flexible in different ways than std::barrier. One is that any given thread can decrement the counter more than once. Another is that decrementing can be by more than one step. There’s no completion function though. Instead, threads blocking on the latch are resumed when the counter hits zero.

#include <chrono>
#include <thread>
#include <latch>
 
// Allow up to three threads to block
std::latch latch{ 3 };
 
auto threadFunc = [&](int id)
{
    // Do something before
    DebugLog("before", id);
 
    // Decrement the counter by one
    latch.count_down();
 
    // Do something after
    DebugLog("after", id);
 
    // Wait for the counter to hit zero
    latch.wait();
 
    // Do something after the counter hits zero
    DebugLog("after zero", id);
};
std::jthread t1{ threadFunc, 1 };
std::this_thread::sleep_for(std::chrono::seconds{ 2 });
std::jthread t2{ threadFunc, 2 };
std::this_thread::sleep_for(std::chrono::seconds{ 2 });
std::jthread t3{ threadFunc, 3 };

This prints:

before, 1
after, 1

Then two seconds later…

before, 2
after, 2

And two more seconds later, thread 3 reduces the latch to zero…

before, 3
after, 3
after zero, 3
after zero, 1
after zero, 2

There’s no direct equivalent in C#, but Barrier and CountdownEvent are rather close.

Condition Variable

Another thread synchronization option is std::condition_variable in <condition_variable>. This is similar to the ManualResetEvent and AutoResetEvent classes in C# in that it’s used by a thread that needs to wait for some condition to be satisfied before proceeding:

#include <thread>
#include <mutex>
#include <condition_variable>
 
// Mutex and condition variable to coordinate the threads
std::mutex m;
std::condition_variable cv;
 
// Flags to indicate that work is ready and the result of work is ready
bool workReady;
bool resultReady;
 
// The result of work
int result;
 
// Thread that does the work
// First waits for the condition to be set indicating that work is ready
void WorkThread()
{
    // Lock the mutex
    DebugLog("Work thread locking mutex");
    std::unique_lock<std::mutex> lock(m);
 
    // Wait for the workReady flag to be set to true
    DebugLog("Work thread waiting for workReady flag");
    cv.wait(lock, [] { return workReady; });
 
    // Now we have the mutex locked
    // Do "work" by setting the shared value to 123
    DebugLog("Work thread doing work");
    result = 123;
 
    // Set the resultReady flag to tell the other thread our work is done
    DebugLog("Work thread setting resultReady flag");
    resultReady = true;
 
    // Unlock the mutex
    DebugLog("Work thread unlocking mutex");
    lock.unlock();
 
    // Notify the condition variable
    DebugLog("Work thread notifying CV");
    cv.notify_one();
 
    DebugLog("Work thread done");
}
 
// Initially nothing is ready
result = 0;
workReady = false;
resultReady = false;
 
// Start the thread
// It'll start waiting for the condition variable
std::thread worker{ WorkThread };
 
{
    // Lock the mutex
    DebugLog("Main thread locking mutex");
    std::lock_guard lg(m);
 
    // Set the flag to indicate that work is ready
    DebugLog("Main thread setting workReady flag");
    workReady = true;
} // Third, unlock the mutex (via lock_guard destructor)
 
// Fourth, notify the condition variable
DebugLog("Main thread notifying CV");
cv.notify_one();
 
{
    // Lock the mutex
    DebugLog("Main thread locking mutex to get result");
    std::unique_lock ul(m);
 
    // Wait for the resultReady flag to be set to true
    DebugLog("Main thread waiting for resultReady flag");
    cv.wait(ul, [] { return resultReady; });
}
 
// Use the result
DebugLog("Main thread got result", result);
 
worker.join();

This prints:

Main thread locking mutex
Main thread setting workReady flag
Main thread notifying CV
Main thread locking mutex to get result
Main thread waiting for resultReady flag
Work thread locking mutex
Work thread waiting for workReady flag
Work thread doing work
Work thread setting resultReady flag
Work thread unlocking mutex
Work thread notifying CV
Work thread done
Main thread got result, 123

std::condition_variable requires us to use exactly std::mutex as our mutex type. If we’d rather use another type, we can replace it with std::condition_variable_any.

Atomic

The final synchronization mechanism we’ll look at today is std::atomic in the <atomic> header. A std::atomic<T> class acts like the T type but all operators are implemented atomically. This can be somewhat approximated in C# with the static functions of the Interlocked class, but there’s no generic type that behaves quite like std::atomic.

#include <atomic>
#include <thread>
 
// Make an atomic int starting at zero
std::atomic<int> val{ 0 };
 
// Run three threads that each use the atomic int
auto threadFunc = [&]
{
    for (int i = 0; i < 1000; ++i)
    {
        // Call the overloaded ++ operator
        // Atomically adds one
        val++;
    }
};
std::jthread t1{ threadFunc };
std::jthread t2{ threadFunc };
std::jthread t3{ threadFunc };
t1.join();
t2.join();
t3.join();
 
DebugLog(val); // 3000

There are a lot of type aliases for specializations of the std::atomic class template. Here are a few:

std::atomic_bool ab; // atomic<bool>
std::atomic_int ai; // atomic<int>
std::atomic_int32_t ai32; // atomic<int32_t>
std::atomic_int64_t ai64; // atomic<int64_t>
std::atomic_size_t as; // atomic<size_t>
 
// C++20: the most efficient lock-free types
std::atomic_signed_lock_free aslf; // signed
std::atomic_unsigned_lock_free aulf; // unsigned

Any trivially-copyable, copy-constructible, and copy-assignable type can be used but hardware support for atomic operations may not be available and locks may be required to ensure atomic access:

struct Player
{
    const char* Name;
    int32_t Score;
    int32_t Health;
};
std::atomic<Player> ap;

We can test that with is_lock_free:

std::atomic<int> val{ 0 };
DebugLog(val.is_lock_free()); // true
DebugLog(ap.is_lock_free()); // false

Besides overloaded operators like ++, there are a few member functions to take more control over the atomic operations. The store and load functions allow customization of how memory is affected so we can control memory reordering:

#include <atomic>
 
std::atomic<int> val{ 0 };
 
// Write and customize how memory ordering is affected
val.store(1, std::memory_order_relaxed); // No synchronization
val.store(2, std::memory_order_release); // No writes reordered after this
val.store(3, std::memory_order_seq_cst); // Sequentially consistent
 
// Read and customize how memory ordering is affected
int i;
i = val.load(std::memory_order_relaxed); // No synchronization
i = val.load(std::memory_order_consume); // No writes reordered before this
i = val.load(std::memory_order_acquire); // No reads or writes before this
i = val.load(std::memory_order_seq_cst); // Sequentially consistent

The exchange, compare_exchange_weak, and compare_exchange_strong functions are very similar to functions in C#’s Interlocked class:

#include <atomic>
 
std::atomic<int> v{ 123 };
 
// Set a new value and return the old value
int old = v.exchange(456);
DebugLog(old); // 123
 
// Set a new value if the current value is an expected value
int expected = 456;
bool exchanged = v.compare_exchange_strong(expected, 789);
DebugLog(exchanged); // true
DebugLog(v); // 789
exchanged = v.compare_exchange_strong(expected, 1000);
DebugLog(exchanged); // false
DebugLog(v); // 789
 
// A "weak" version that might set even if the expected value differs
exchanged = v.compare_exchange_strong(expected, 1000);
Future

Lastly, we have <future> with its future and async functionality. The async function works conceptually similarly to Task in C# in that the platform takes care of running a function, presumably on another thread in a thread pool. A future is returned as a placeholder for the eventual return value of that function:

#include <chrono>
#include <thread>
#include <future>
 
DebugLog("Calling async");
std::future<int> f {
    std::async(
        [] {
            std::this_thread::sleep_for(std::chrono::seconds{2});
            return 123;
        }
    )
};
 
DebugLog("Waiting");
f.wait();
 
DebugLog("Getting return value");
int retVal = f.get();
DebugLog("Got return value", retVal);

This prints:

Calling async
Waiting

Then two seconds later…

Getting return value
Got return value, 123

The std::launch enumeration provides options for how to execute the function. By default, it’s run asynchronously as though we passed std::launch::async. We can instead use std::launch::deferred to instead run the function on the first thread that calls get on the future:

#include <chrono>
#include <thread>
#include <future>
 
DebugLog("Calling async");
std::future<int> f{
    std::async(std::launch::deferred, [] {
        std::this_thread::sleep_for(std::chrono::seconds{2});
        return 123; }) };
 
DebugLog("Doing something else");
std::this_thread::sleep_for(std::chrono::seconds{ 5 });
 
DebugLog("Getting return value");
int retVal = f.get();
DebugLog("Got return value", retVal);

This prints:

Calling async
Doing something else

Then five seconds later calling get runs the function…

Getting return value

Then two more seconds later the function finishes…

Got return value, 123

std::promise is another way to create a std::future besides std::async. It can also hold an exception in the case that no value can be produced. We can use std::promise with a wide variety of asynchronous programming techniques. For example, it easily combines with a simple std::jthread:

#include <chrono>
#include <thread>
#include <future>
 
// Make a promise to produce an int
std::promise<int> p{};
 
// Get a future for that int
std::future<int> f{ p.get_future() };
 
// Produce the value in another thread
std::jthread{
    [&]
    {
        std::this_thread::sleep_for(std::chrono::seconds{2});
        p.set_value(123);
    }
};
 
// Block until the value is ready
DebugLog("Getting return value");
int retVal = f.get();
DebugLog(retVal);

This prints:

Getting return value

Then two seconds later…

123

The last class to look at in <future> is std::packaged_task. This is an adapter that wraps functions in a class with an overloaded function call operator like is done for us by the compiler with lambdas. We can then get a std::future that’s ready when the std::packaged_task is called. Like std::promise, we can use std::packaged_task with plain threads or other asynchronous programming paradigms besides std::async:

#include <chrono>
#include <thread>
#include <future>
 
int DoWork()
{
    std::this_thread::sleep_for(std::chrono::seconds{ 2 });
    return 123;
}
 
// Wrap DoWork in a class object
std::packaged_task pt{ DoWork };
 
// Get a future for when the packaged task is executed
std::future<int> f{ pt.get_future() };
 
// Call the packaged task on another thread
std::jthread t{ [&] { pt(); } };
 
// Block (for about 2 seconds) until DoWork returns
DebugLog("Getting return value");
int retVal = f.get();
DebugLog(retVal);

This prints:

Getting return value

Then two seconds later…

123
Conclusion

The C++ Standard Library provides us with quite a few multi-threading tools. At the most basic, we have thread and jthread to create our own threads. Once we’ve created these, we have a huge variety of synchronization mechanisms: mutexes, latches, barriers, semaphores, condition variables, and atomics. The <future> header provides future, promise, packaged_task to wrap up work that’ll be done asynchronously and either complete or throw an exception in the future. These generic tools allow us to avoid implementing extremely complex and error-prone thread synchronization strategies ourselves.

The Standard Library even provides async as a high-level mechanism for letting the platform take care of scheduling threads in a manner similar to C#’s Task. A future version of the Standard Library will combine this with coroutines for a very similar experience to C#’s async functions. In the meantime, we can make use of community libraries to accomplish this.