Thread Lifecycle Management
Prior to the C++11 standard, native threading capabilities were not part of the core language. Modern C++ introduced std::thread within the <thread> header to facilitate parallel execution.
Creating a thread involves instantiating a std::thread objeect and passing a callable along with its arguments:
#include <iostream>
#include <thread>
#include <chrono>
void worker_loop(int id) {
while (true) {
std::cout << "Worker " << id << " running\n";
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
int main() {
int task_id = 1;
std::thread executor(worker_loop, task_id);
// Main thread logic continues here...
return 0;
}
Threads begin execution immediately upon construction; no explicit start method is required. However, terminating the primary thread while child threads are still active results in program termination via std::terminate, often causing errors.
To manage the lifecycle properly, use join() or detach() on the std::thread object:
Joining Threads
The join() method suspends the calling thread until the target thread completes. This ensures orderly shutdown.
// Inside main()
std::thread executor(worker_loop, 1);
executor.join(); // Blocks until 'executor' finishes
Detaching Threads
Using detach() separates the parent and child threads completely. The detached thread runs independently as a background process. The main thread exits without waiting.
std::thread executor(worker_loop, 1);
executor.detach();
// Main thread proceeds immediately
Warning: When detaching threads, be cautious about data scope. If a pointer or reference passed to the detached thread points to local stack memory in the main function, that memory becomes invalid once
mainreturns, leading to undefined behavior.
Thread entry points can be free functions, static member functions, lambda expressions, or functors.
Synchronization Utilities
Inside any thread context, you can query properties using std::this_thread:
#include <thread>
void introspection_func() {
auto tid = std::this_thread::get_id();
auto concurrency = std::this_thread::hardware_concurrency();
std::cout << "ID: " << tid << "\n";
}
Other useful utilities include yield() to voluntarily give up CPU time, and sleep functions (sleep_for, sleep_until).
Mutex and Mutual Exclusion
When multiple threads access shared state simultaneously, race conditions may occur. To protect critical sections, std::mutex provides exclusive ownership semantics.
Consider a scenario where two threads increment and decrement a shared variable:
#include <iostream>
#include <thread>
#include <mutex>
int shared_counter = 0;
std::mutex mtx;
void unsafe_access() {
for (int i = 0; i < 50000; ++i) {
std::lock_guard<std::mutex> lock(mtx);
shared_counter++;
shared_counter--;
}
}
int main() {
std::thread t1(unsafe_access);
std::thread t2(unsafe_access);
t1.join();
t2.join();
std::cout << "Final value: " << shared_counter << "\n";
}
Without synchronization, the final result is non-deterministic. Locking mechanisms ensure serial access.
Locking Strategies
Manual locking requires careful handling to prevent deadlocks from exceptions or early returns. RAII (Resource Acquisition Is Initialization) patterns simplify this.
std::lock_guard
This template class locks automatically upon construction and unlocks upon destruction. It does not support manual unlocking.
void task() {
std::lock_guard<std::mutex> guard(mtx);
// Critical section: resource modification
}
std::unique_lock
More flexible than lock_guard, allowing deferred locking, manual unlocking, or changing lock ownership. Useful when unlock timing needs specific control within the function scope.
void task() {
std::unique_lock<std::mutex> ulock(mtx);
// Modify resource
ulock.unlock(); // Release early if needed
// Continue with unlocked operations
}
Deadlock Prevention with Multiple Mutexes
Acquiring multiple locks in inconsistent orders leads to deadlocks. Use std::scoped_lock (C++17) to acquire them safely atomcially.
std::mutex m1, m2;
void safe_task() {
// Acquires both safely, preventing circular wait
std::scoped_lock lock(m1, m2);
// Update resources protected by m1 and m2
}
Alternatively, std::lock() attempts to lock all provided mutexes without blocking indefinitely.
Atomic Operations
For simple counters or flags, full mutex overhead is unnecessary. std::atomic<T> guarantees atomic read-modify-write operations without external synchronization primitives.
#include <atomic>
std::atomic<int> atomic_val{0};
void increment() {
for (int i = 0; i < 1000; ++i) {
atomic_val.fetch_add(1, std::memory_order_seq_cst);
atomic_val.fetch_sub(1, std::memory_order_seq_cst);
}
}
Atomic types rely on hardware instructions where available, ensuring thread safety for specific operations like load/store/add. Note that complex structs are generally not supported directly as atomics unless specialized.
Condition Variables
Busy-waiting consumes excessive CPU. std::condition_variable allows threads to block until a notification arrives.
A classic producer-consumer pattern uses a mutex and condition variable to coordinate a queue:
#include <condition_variable>
#include <deque>
std::mutex mtx;
std::deque<int> queue;
std::condition_variable cv;
void producer() {
while (true) {
{
std::unique_lock<std::mutex> lock(mtx);
queue.push_back(10);
cv.notify_one(); // Signal one waiting consumer
}
// Sleep briefly
}
}
void consumer() {
while (true) {
std::unique_lock<std::mutex> lock(mtx);
// Wait must be inside a loop to handle spurious wakeups
cv.wait(lock, []{ return !queue.empty(); });
int data = queue.front();
queue.pop_front();
std::cout << "Consumed: " << data << "\n";
}
}
Spurious wakeups (where a thread wakes up but the predicate is false) are common. Therefore, always wrap wait() calls in a while loop checking the condition predicate rather than an if statement.
Semaphores (C++20)
C++20 introduces std::semaphore in <semaphore>. It offers counting-based synchronization which can outperform condition variables in certain scenarios.
#include <semaphore>
std::counting_semaphore<3> limit(0); // Allow 3 concurrent accesses
void worker() {
limit.acquire();
// Execute restricted task
limit.release();
}
Binary semaphores (binary_semaphore) act similarly to mutexes but allow re-entrancy in some contexts depending on implementation, though primarily used for coordination signals.
Async Execution and Futures
Retrieving values from background tasks requires communication channels between threads.
Promise and Future
std::future holds the result of an asynchronous operation, managed by std::promise.
#include <future>
int compute_value() {
return 42;
}
auto result = std::async(compute_value); // Starts async task
int val = result.get(); // Blocks until result is ready
Explicit std::promise usage allows passing objects between threads:
void task_handler(std::promise<int>& promise_ref) {
promise_ref.set_value(100);
}
int main() {
std::promise<int> p;
std::future<int> f = p.get_future();
std::thread t(task_handler, std::ref(p));
t.join();
std::cout << "Result: " << f.get() << "\n";
}
Note that get() can only be called once on a future object. For sharing state across multiple consumers, use std::shared_future:
std::shared_future<int> sf = f.share();
t1.execute(sf);
t2.execute(sf);
std::packaged_task
This wrapper allows treating a callable object like a future-producing entity explicitly.
#include <future>
int calc(int x, int y) { return x + y; }
int main() {
std::packaged_task<int(int, int)> task(calc);
std::future<int> res = task.get_future();
task(5, 10);
std::cout << "Task Result: " << res.get() << "\n";
}
These high-level abstractions streamline concurrent programming by abstracting manual synchronization boilerplate.