Concurrency
Concurrency refers to the ability of a program to make progress on multiple tasks during the same period of time.
- On a single-core CPU, concurrency is typically achieved through
context switching - On a multi-core CPU, concurrent tasks may execute in
parallel - It’s used to improve the
program performanceandresponse time
Concurrency in C++ can be implemented using several approach:
- STD Thread
(C++11) - Async/Future
(C++11) - Coroutines
(C++20)
1. Thread-Based Concurrency
1.1. Threads
A thread is the basic unit of execution within a process (multitasking.)
- Each thread has its own call stack, while all threads in the same process share the heap and other process resources.
- Use threads when running long-lived or complex tasks that need independent execution.
There are many errors and risks associated with concurrency, including:
Deadlocksrefers to the situation where two or more threads are blocked, waiting for each other indefinitelyRace conditionrefers to the situation where two or more threads access shared data concurrently, leading to theundefined behavior.Starvationrefers to the situation where a thread is unable to gain regular access to the shared resources. => We can avoid these problems byproper synchronizationbetween the threads.
1.2. Thread Management
A thread is an OS-level thread managed by the kernel. Each thread has it own call stack, but all thread` share the heap.
thread objectis a C++ instance associated with an active thread of execution in hardware level.std::thread(callable)requests the kernel OS to create a new thread.std::this_threadrefers to the current thread
Lifecycle control methods:
join()blocks the current thread until this* thread (a.ka. object thread) finishes its execution. If the exception is throw beforejoin,std::terminatemight be called, and will kill the entire program process, not just the thread.detach()separates thethread of the executionfrom the itsthread object, letting it continue running independently in the background.yield()voluntarily pauses the current thread to give other threads a chance to run.returnuse to to kill a thread’s by returning from its callable
Always ensure a thread is either
join()ordetach()before the std::thread object is destroyed. otherwise std::terminate() is called.
1.3. Sharing Data
Global/Static Variable: can be accessed by all threads.Pass By Reference: we need to explicitly wrap the args instd::refto pass by reference and is the only way to properly get data out of a threadthread_localcreates a separate instance of a static variable per thread, each thread gets its own copy with independent lifetime.
2. Synchronization
Synchronization ensures safe access to shared resources across threads. It can be done by using the following components:
- Mutex/Lock
<mutex>they are used to protect the shared resources, ensure that only one thread can accessthe critical sectionsat a time. - Semaphore
<semaphore>(C++20) control access by limiting concurrent thread count - Futures & Promises
<future>/<promise>pass results/exceptions between threads asynchronously - Condition Variable
<condition_variable>block threads until a condition is signaled
2.1. Mutexes and Locks /
A mutex (mutual exclusion object) is owned by the thread that acquires it. Only the owning thread may access the protected critical section.
Raw Mutex Usage
#include <mutex>
// Create your mutex here
std::mutex my_mutex;
thread_function()
{
my_mutex.lock(); // Acquire lock
// Do some non-thread safe stuff...
my_mutex.unlock(); // Release lock
}
Mutex Types:
std::mutexBasic, non-recursive mutexstd::timed_mutexAdds try_lock_for() / try_lock_until()std::recursive_mutexSame thread can lock multiple times without deadlockingstd::recursive_timed_mutexRecursive + timedstd::shared_mutex(C++17)Supports shared (read) and exclusive (write) ownershipstd::shared_timed_mutexShared + timed
Lock Guard Types (RAII Wrappers)
Lock Guard Type is a wrapper mutex that provides a convenient RAII-style mechanism. (lock on construction and automatically unlock on destruction, even if an exception is thrown.)
std::lock_guard<mutex>: The simples lock. Locks immediately, unlocks on scope exit. No manual control.std::scoped_lock<mutex1,mutex2,...>: lock for multiple mutexes simultaneously. Avoids deadlock via a deadlock-avoidance algorithm.std::unique_lock<mutex>: lock for a single mutex. Supports deferred locking, manuallock()/unlock(), and is required for use withstd::condition_variablestd::shared_lock<shared_mutex>: lock for shared (read) access. Multiple threads can hold a shared lock concurrently; exclusive (write) locks still block.
2.2. Condition Variable / <condition_variable>
A std::condition_variable is a synchronization primitive that works alongside a std::mutex to block one or more threads until another thread both modifies a shared variable (the condition) and notifies the std::condition_variable.
Methods:
wait(lock, predicate)releases the lock and blocks the thread until the predicate returns true. Re-acquires the lock before returning.notify_one()wakes one waiting threadnotify_all()wakes all waiting threads.
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <string>
#include <thread>
std::mutex m;
std::condition_variable cv;
std::string data;
bool ready = false;
bool processed = false;
void worker_thread() {
// 1. wait until main() sends data
std::unique_lock lk(m);
cv.wait(lk, []{ return ready; });
// after the wait, we own the lock
std::cout << "Worker thread is processing data\n";
data += " after processing";
// send data back to main()
processed = true;
std::cout << "Worker thread signals data processing completed\n";
// manual unlocking is done before notifying, to avoid waking up
// the waiting thread only to block again (see notify_one for details)
lk.unlock();
cv.notify_one();
}
void main() {
std::thread worker(worker_thread);
data = "Example data";
// send data to the worker thread
{
std::lock_guard lk(m);
ready = true;
std::cout << "main() signals data ready for processing\n";
}
cv.notify_one();
// wait for the worker
{
std::unique_lock lk(m);
cv.wait(lk, []{ return processed; });
}
std::cout << "Back in main(), data = " << data << '\n';
worker.join();
}
// Output:
// main() signals data ready for processing
// Worker thread is processing data
// Worker thread signals data processing completed
// Back in main(), data = Example data after processing
2.3. Atomic Operation /
An atomic type is a type that implements atomic operations. It’s used to guarantee no race conditions will occur. (e.g. std::atomic<bool> - std::atomic_bool)
#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
std::atomic_int acnt;
int cnt;
void f() {
for (auto n{10000}; n; --n) {
++acnt;
++cnt;
// Note: for this example, relaxed memory order is sufficient,
// e.g. acnt.fetch_add(1, std::memory_order_relaxed);
}
}
void main() {
{
std::vector<std::jthread> pool;
for (int n = 0; n < 10; ++n)
pool.emplace_back(f);
}
std::cout << "The atomic counter is " << acnt << '\n'
<< "The non-atomic counter is " << cnt << '\n';
}
3. Task
A task is a unit of asynchronous work scheduled for execution. It is a higher-level abstraction than a thread. Tasks are generally preferred over raw threads because:
- The runtime can reuse threads across tasks via a thread pool, avoiding the overhead of creating and destroying OS threads.
- Scheduling is managed automatically by the runtime, rather than manually by the programmer.
- Less boilerplate, no need to manage join(), detach(), or shared state explicitly.
3.1. Promises & Futures
/// PROMISE / FUTURE
[Producer Thread] [Consumer Thread]
std::promise<T> std::future<T>
| |
| get_future() // main bind |
+---------------------------------->+
set_value() | |
set_exception() v v get()/wait()
Shared State
+---------------+
| Value / |
| Exception |
+---------------+
std::future
A class template that holds a value to be assigned at some point in the future, providing a way to retrieve it once ready.
- blocks the calling thread if
.get()is called before the value is assigned. - returned by async operations:
std::async,std::promise, andstd::packaged_task - move-only - cannot be copied; only one thread can retrieve the value.
std::shared_future
Works the same way as std::future, but copyable , so multiple threads are allowed to wait for the same shared state.
std::promise
The write end (write side) of a future/promise pair. Stores a value or exception that will be retrieved asynchronously by its associated std::future.
- It is commonly used to pass results between threads.
- It creates a
std::futureusingget_future(). - The
std::promiseand thestd::futureshare a common shared state. - The
std::promisesets the value of the shared state usingset_value(). - The associated
std::futureretrieves the value usingget().
Methods:
get_future()creates and returns the associated std::futureset_value(v)writes the value into the shared state, unblocking the futureset_exception(e)stores an exception instead of a value
Example
#include <future>
#include <thread>
void producer(std::promise<int> p) {
p.set_value(42); // write result into shared state
}
void main() {
std::promise<int> p;
std::future<int> f = p.get_future(); // bind future to promise
std::thread t(producer, std::move(p)); // promise is move-only
int result = f.get(); // blocks until set_value() is called
// result == 42
t.join();
}
3.2. Async & Future
/// ASYNC / FUTURE
[Worker Thread] [Main Thread]
Callable std::future<T>
| ^
| return value |
| throw exception |
+-------------------------------------+
|
v
Shared State
+---------------+
| Value / Ex |
+---------------+
Async is a function template that spawns asynchronous work, returns a std::future to retrieve the result once it’s ready, without manually managing threads or promises.
auto future = std::async(some_function, arg1, arg2);
// ... do other work ...
auto result = future.get(); // blocks until result is ready
- A call to
std::asyncreturns astd::futureobject. - The future can later be used to retrieve the result of the asynchronous computation.
std::asyncaccepts an optional launch policy as its first argument, controlling when and how the function runs:std::launch::async: Runs in a new thread, immediatelystd::launch::deferred: Lazy, runs on the calling thread only when.get()or.wait()is called- Default: defer to system
// Pass in function pointer auto future = std::async(std::launch::async, some_function, arg_1, arg_2); // Pass in function reference auto future = std::async(std::launch::async, &some_function, arg_1, arg_2); // Pass in function object struct SomeFunctionObject { void operator() (int arg_1){} }; auto future = std::async(std::launch::async, SomeFunctionObject(), arg_1); // Lambda function auto future = std::async(std::launch::async, [](){});
Example
#include <future>
int producer() {
return 42;
}
void main() {
std::future<int> f = std::async(std::launch::async, producer);
int result = f.get(); // blocks until producer() returns
// result == 42
}
4. Coroutine
TBD