C++ For C# Developers: Part 43 – Threading Library
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
- Part 1: Introduction
- Part 2: Primitive Types and Literals
- Part 3: Variables and Initialization
- Part 4: Functions
- Part 5: Build Model
- Part 6: Control Flow
- Part 7: Pointers, Arrays, and Strings
- Part 8: References
- Part 9: Enumerations
- Part 10: Struct Basics
- Part 11: Struct Functions
- Part 12: Constructors and Destructors
- Part 13: Initialization
- Part 14: Inheritance
- Part 15: Struct and Class Permissions
- Part 16: Struct and Class Wrap-up
- Part 17: Namespaces
- Part 18: Exceptions
- Part 19: Dynamic Allocation
- Part 20: Implicit Type Conversion
- Part 21: Casting and RTTI
- Part 22: Lambdas
- Part 23: Compile-Time Programming
- Part 24: Preprocessor
- Part 25: Intro to Templates
- Part 26: Template Parameters
- Part 27: Template Deduction and Specialization
- Part 28: Variadic Templates
- Part 29: Template Constraints
- Part 30: Type Aliases
- Part 31: Deconstructing and Attributes
- Part 32: Thread-Local Storage and Volatile
- Part 33: Alignment, Assembly, and Language Linkage
- Part 34: Fold Expressions and Elaborated Type Specifiers
- Part 35: Modules, The New Build Model
- Part 36: Coroutines
- Part 37: Missing Language Features
- Part 38: C Standard Library
- Part 39: Language Support Library
- Part 40: Utilities Library
- Part 41: System Integration Library
- Part 42: Numbers Library
- Part 43: Threading Library
- Part 44: Strings Library
- Part 45: Array Containers Library
- Part 46: Other Containers Library
- Part 47: Containers Library Wrapup
- Part 48: Algorithms Library
- Part 49: Ranges and Parallel Algorithms
- Part 50: I/O Library
- Part 51: Missing Library Features
- Part 52: Idioms and Best Practices
- Part 53: Conclusion
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.
#1 by typoman on March 29th, 2021 ·
typo: “latches, barriers, latches”
#2 by jackson on April 4th, 2021 ·
Fixed. Thanks for letting me know!