Introduction#
Concurrency is one of the most powerful tools in modern programming, enabling applications to perform multiple tasks simultaneously and make efficient use of multi-core processors. This guide explores C++ concurrency from the ground up, covering everything from basic threading to advanced synchronization primitives.
Understanding Threads#
What is a Thread?#
A thread is a lightweight process that allows your program to perform multiple operations concurrently. Think of it as a separate execution path within your program that can run independently of the main thread.
When Should You Use Threads?#
Threads are particularly useful in two scenarios:
- Heavy Computation: Offload CPU-intensive tasks to prevent blocking the main thread
- Task Separation: Isolate different types of work (e.g., UI updates, background processing, network requests)
Modern C++ Threading#
Basic Thread Creation with std::thread#
The simplest way to create a thread in modern C++ is using std::thread:
#include <iostream>
#include <thread>
void test(int x) {
std::cout << "Hello from test\n";
std::cout << "Arguments: " << x << "\n";
}
int main() {
std::thread myThread(&test, 100);
std::cout << "Hello from main\n";
myThread.join(); // Wait for thread to complete
return 0;
}Using Lambda Functions#
Lambda functions provide a concise way to define thread behavior inline:
#include <iostream>
#include <thread>
int main() {
auto lambda = [](int x, int y) {
std::cout << "Hello from test\n";
std::cout << "Arguments: " << x << ", " << y << "\n";
};
std::cout << "Hello from main\n";
std::thread myThread(lambda, 100, 20);
myThread.join();
return 0;
}Lambda Capture Quick Reference:
[]→ captures nothing[=]→ captures all outer variables by value (copy)[&]→ captures all outer variables by reference[x]→ captures onlyxby value[&x]→ captures onlyxby reference
Managing Multiple Threads#
For scenarios requiring multiple concurrent operations, store threads in a container:
#include <iostream>
#include <thread>
#include <vector>
int main() {
auto lambda = [](int x) {
std::cout << "Hello from test " << std::this_thread::get_id() << "\n";
std::cout << "Index: " << x << "\n";
};
std::vector<std::thread> threads;
// Create threads
for (int i = 0; i < 5; ++i) {
threads.push_back(std::thread(lambda, i));
}
// Wait for all threads to complete
for (int i = 0; i < 5; ++i) {
threads[i].join();
}
std::cout << "Hello from main\n";
return 0;
}C++20: std::jthread (Joining Thread)#
C++20 introduced std::jthread, which automatically joins when it goes out of scope:
#include <iostream>
#include <thread>
#include <vector>
int main() {
auto lambda = [](int x) {
std::cout << "Hello from test " << std::this_thread::get_id() << "\n";
std::cout << "Index: " << x << "\n";
};
std::vector<std::jthread> jthreads;
for (int i = 0; i < 5; ++i) {
jthreads.push_back(std::jthread(lambda, i));
}
std::cout << "Hello from main\n";
// No need to call join() - happens automatically!
return 0;
}Thread Safety and Synchronization#
The Race Condition Problem#
When multiple threads access shared data without synchronization, race conditions occur due to context switching:
#include <iostream>
#include <thread>
#include <vector>
static int shared_value = 0;
auto increment = []() {
shared_value += 1;
};
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 5000; ++i) {
threads.push_back(std::thread(increment));
}
for (int i = 0; i < 5000; ++i) {
threads[i].join();
}
std::cout << shared_value << "\n"; // May not be 5000!
return 0;
}Output (inconsistent):
100
100
99 ← Race condition!
100Solution 1: Mutex (Mutual Exclusion)#
A mutex (also called a binary semaphore) ensures only one thread accesses critical sections at a time:
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
std::mutex gLock;
static int shared_value = 0;
auto increment = []() {
gLock.lock();
shared_value += 1;
gLock.unlock();
};
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 100; ++i) {
threads.push_back(std::thread(increment));
}
for (int i = 0; i < 100; ++i) {
threads[i].join();
}
std::cout << shared_value << "\n"; // Always 100
return 0;
}The Deadlock Problem#
If a lock is never released (due to exceptions or early returns), your program will deadlock:
std::mutex gLock;
static int shared_value = 0;
auto increment = []() {
gLock.lock();
try {
shared_value += 1;
throw "danger";
} catch (...) {
std::cout << "Exception caught\n";
return; // Lock never released! Deadlock!
}
gLock.unlock();
};Solution 2: std::lock_guard (RAII)#
std::lock_guard provides RAII-style (Resource Acquisition Is Initialization) automatic lock management:
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
std::mutex gLock;
static int shared_value = 0;
auto increment = []() {
std::lock_guard<std::mutex> lockGuard(gLock);
shared_value += 1;
// Lock automatically released when lockGuard goes out of scope
};
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 100; ++i) {
threads.push_back(std::thread(increment));
}
for (int i = 0; i < 100; ++i) {
threads[i].join();
}
std::cout << shared_value << "\n";
return 0;
}Solution 3: Atomic Operations#
For simple types, atomic operations provide lock-free thread safety:
#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
static std::atomic<int> shared_value = 0;
auto increment = []() {
shared_value++; // Thread-safe increment
};
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 100; ++i) {
threads.push_back(std::thread(increment));
}
for (int i = 0; i < 100; ++i) {
threads[i].join();
}
std::cout << shared_value << "\n";
return 0;
}Note: Mutexes are necessary when data types are larger than the CPU’s word size, as atomic operations can’t be implemented for them.
Modern C++ Parallelism#
When data accessed by different threads is independent, no locking is needed. Here’s a parallel sum computation:
#include <iostream>
#include <thread>
#include <vector>
void partial_sum(const std::vector<int>& arr, int start, int end, long long& result) {
long long sum = 0;
for (int i = start; i < end; i++) {
sum += arr[i];
}
result = sum;
}
int main() {
std::vector<int> arr(1000000, 1); // 1 million elements, all = 1
long long result1 = 0, result2 = 0;
std::thread t1(partial_sum, std::cref(arr), 0, arr.size() / 2, std::ref(result1));
std::thread t2(partial_sum, std::cref(arr), arr.size() / 2, arr.size(), std::ref(result2));
t1.join();
t2.join();
long long total = result1 + result2;
std::cout << "Total sum = " << total << std::endl;
return 0;
}Advanced Synchronization#
Condition Variables#
Problem: When using a mutex, waiting threads constantly check if the lock is available (spin lock), wasting CPU cycles.
Solution: Condition variables allow threads to sleep until notified:
#include <chrono>
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <thread>
std::mutex gLock;
std::condition_variable gCV;
int main() {
int result = 0;
bool notify = false;
// Reporter thread - waits for work to complete
std::thread reporter([&] {
std::unique_lock<std::mutex> lock(gLock);
if (!notify) {
gCV.wait(lock); // Sleep until notified
}
std::cout << "Reported, result is: " << result << "\n";
});
// Worker thread - performs computation
std::thread worker([&] {
std::unique_lock<std::mutex> lock(gLock);
// Do work
result = 43 + 1;
notify = true;
std::this_thread::sleep_for(std::chrono::seconds(5));
std::cout << "Work done!\n";
gCV.notify_one(); // Wake up reporter
});
worker.join();
reporter.join();
return 0;
}Condition Variables Require:
- A boolean flag (
notify) - A
std::unique_lock(allows deferred locking and use with condition variables) - A
std::condition_variable - Worker and reporter threads
Try Lock: Non-Blocking Acquisition#
try_lock() attempts to acquire a lock without blocking. If unsuccessful, the thread can do other useful work:
#include <chrono>
#include <iostream>
#include <mutex>
#include <thread>
std::mutex gLock;
void Job1() {
if (gLock.try_lock()) {
std::cout << "Job1 executed\n";
gLock.unlock();
}
}
void Job2() {
if (gLock.try_lock()) {
std::cout << "Job2 executed\n";
gLock.unlock();
} else {
// Do something useful instead of waiting
std::this_thread::sleep_for(std::chrono::milliseconds(200));
if (gLock.try_lock()) {
std::cout << "Job2 executed on 2nd try\n";
gLock.unlock();
}
}
}
int main() {
std::thread thread1(&Job1);
std::thread thread2(&Job2);
thread1.join();
thread2.join();
return 0;
}Use Case: Producer threads generating data can produce additional items while waiting for buffer access, rather than blocking.
Semaphores (C++20)#
A counting semaphore allows multiple threads (up to a specified limit) to access a shared resource simultaneously:
#include <iostream>
#include <semaphore>
#include <thread>
#include <vector>
std::counting_semaphore<2> gSl(2); // Allow 2 concurrent accesses
void Work(int id) {
std::cout << "Thread " << id << " waiting...\n";
gSl.acquire();
std::cout << "Thread " << id << " acquired!\n";
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "Thread " << id << " releasing.\n";
gSl.release();
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 21; ++i) {
threads.push_back(std::thread(Work, i));
}
for (int i = 0; i < 21; ++i) {
threads[i].join();
}
return 0;
}Asynchronous Programming#
std::async and std::future#
Functions run with std::async execute on separate threads. The main thread doesn’t wait for completion until get() is called:
#include <future>
#include <iostream>
int square(int x) {
return x * x;
}
int main() {
std::future<int> asyncFunc = std::async(&square, 9);
// Do other work while async function runs
for (int i = 0; i < 10; ++i) {
std::cout << square(i) << "\n";
}
// Wait for and retrieve async result
std::cout << asyncFunc.get() << "\n";
return 0;
}Use Case: Loading data in the background while performing other operations (e.g., YouTube buffering video while playing).
Thread Management Guidelines#
How Many Threads Can You Create?#
Operating System Limits:
- Linux: Limited by virtual memory and
ulimit -u. Each thread typically uses ~8 MB of stack space - Windows: Usually supports thousands of threads, limited by available memory
Memory Constraints:
- Linux (glibc pthreads): 8 MB per thread (default stack size)
- Windows: ~1 MB per thread (default stack size)
- Example: On an 8 GB system with 1 MB stacks, theoretically ~8000 threads (less in practice due to overhead)
CPU Considerations:
- CPU-bound work: Number of threads ≈ number of CPU cores
- I/O-bound work: Can have many more threads since most will be waiting
Practical Guidelines:
- Lightweight workloads: Tens of thousands of threads possible (but performance degrades)
- Real-world usage: Typically a few hundred threads maximum
- For larger scales: Use thread pools or async/event-driven models
Development Tools#
Thread Sanitizer (TSan)#
Detect data races using Thread Sanitizer, which uses shadow memory to track metadata about each byte/word:
g++ -fsanitize=thread racecondition.cpp
./a.outTSan checks shadow memory during execution and reports any detected race conditions.
Resources#
- Modern Concurrency by Mike Shah - Comprehensive video series
- C++ Reference - Official documentation
- Example Code Repository - Complete code examples
Conclusion#
Modern C++ provides powerful concurrency primitives that make writing efficient multi-threaded applications more accessible than ever. From basic threading with std::thread to advanced synchronization with condition variables and semaphores, the C++ Standard Library offers the tools needed to build robust concurrent systems.
Remember the key principles:
- Use RAII-style wrappers (
lock_guard,unique_lock) to prevent deadlocks - Prefer atomic operations for simple types
- Use condition variables to avoid CPU-wasting spin locks
- Match your threading strategy to your workload (CPU-bound vs I/O-bound)
- Always test with Thread Sanitizer to catch race conditions
