C++: Multithreading Synchronization
Last updated: 2026-08-26
In lesson 43, we learned the basics of multithreading.
Now, we'll dive into multithreading synchronization — making multiple threads coordinate their work and avoiding races and deadlocks.
The challenge of multithreaded programming isn't creating threads — it's synchronization.
1. Deadlock
(1) 1.1 What Is a Deadlock?
A deadlock occurs when two or more threads wait for each other to release resources, so neither can continue.
Four necessary conditions:
- Mutual exclusion: Resources cannot be shared
- Hold and wait: A thread holding resources waits for other resources
- No preemption: Resources cannot be forcibly released
- Circular wait: A circular chain of waiting threads exists
(2) 1.2 Deadlock Example
Example: Deadlock (Difficulty ⭐⭐⭐)
▶ Example 2: Multithreading programming demo (Difficulty ⭐)
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx1, mtx2;
void thread1() {
std::lock_guard<std::mutex> lock1(mtx1);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::lock_guard<std::mutex> lock2(mtx2); // Waiting for mtx2
std::cout << "Thread 1 done" << std::endl;
}
void thread2() {
std::lock_guardstd::mutex lock2(mtx2);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::lock_guardstd::mutex lock1(mtx1); // Waiting for mtx1
std::cout << "Thread 2 done" << std::endl;
}
int main() {
std::thread t1(thread1);
std::thread t2(thread2);
t1.join();
t2.join();
return 0;
}
Output:
Thread 1 done
Thread 2 done
💡 Tip:
- This program may never finish running (deadlock)
(3) 1.3 Avoiding Deadlocks
Method 1: Fixed Locking Order
void thread1() {
std::lock_guardstd::mutex lock1(mtx1);
std::lock_guardstd::mutex lock2(mtx2); // Always mtx1 first, then mtx2
std::cout << "Thread 1 done" << std::endl;
}
void thread2() {
std::lock_guardstd::mutex lock1(mtx1); // Same order as thread1
std::lock_guardstd::mutex lock2(mtx2);
std::cout << "Thread 2 done" << std::endl;
}
Method 2: Use std::lock to lock multiple mutexes simultaneously
void safe_function() {
std::unique_lock<std::mutex> lock1(mtx1, std::defer_lock);
std::unique_lock<std::mutex> lock2(mtx2, std::defer_lock);
std::lock(lock1, lock2); // Lock simultaneously, avoids deadlock
// ...
}
Method 3: Use std::scoped_lock (C++17, recommended)
void safe_function() {
std::scoped_lock lock(mtx1, mtx2); // Automatically avoids deadlock
// ...
}
2. Read-Write Lock
(1) 2.1 Why Do We Need Read-Write Locks?
Problem: Mutexes are too "conservative" — even when multiple threads only read data, they must queue up.
Solution: Read-write lock (std::shared_mutex, C++17)
| Lock Type | Purpose |
|---|---|
| Exclusive lock | Write operation, other threads cannot read or write |
| Shared lock | Read operation, multiple threads can read simultaneously |
(2) 2.2 Example: Protecting a cache with read-write lock (Difficulty ⭐⭐⭐)
#include <iostream>
#include <thread>
#include <shared_mutex>
#include <unordered_map>
std::unordered_map<int, int> cache;
std::shared_mutex smtx;
int get(int key) {
std::shared_lock<std::shared_mutex> lock(smtx); // Shared lock (read)
auto it = cache.find(key);
if (it != cache.end()) {
return it->second;
}
return -1;
}
void set(int key, int value) {
std::unique_lock<std::shared_mutex> lock(smtx); // Exclusive lock (write)
cache[key] = value;
}
int main() {
// Multiple threads can read simultaneously; writes are exclusive
return 0;
}
Output:
(Program output)
3. Atomic Operations
(1) 3.1 What Is an Atomic Operation?
An atomic operation is an indivisible operation — it either executes completely or not at all.
Advantage: No locking needed, high performance.
(2) 3.2 std::atomic
C++11 provides the std::atomic template.
Example: Atomic counter (Difficulty ⭐⭐)
#include <iostream>
#include <thread>
#include <vector>
#include <atomic>
std::atomic<int> counter(0); // Atomic variable
void increment() {
for (int i = 0; i < 1000; i++) {
counter++; // Atomic operation, no lock needed
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 10; i++) {
threads.emplace_back(increment);
}
for (auto& t : threads) {
t.join();
}
std::cout << "Counter: " << counter << std::endl; // Always 10000
return 0;
}
(3) 3.3 Memory Order
std::atomic supports memory order to control the scope of synchronization.
Common memory orders:
std::memory_order_relaxed: Weakest, only guarantees atomicitystd::memory_order_acquire: Read operation, prevents reorderingstd::memory_order_release: Write operation, prevents reorderingstd::memory_order_seq_cst: Strongest, default (sequential consistency)
4. Thread Pool
(1) 4.1 Why Do We Need a Thread Pool?
Problem: Frequent thread creation/destruction has high overhead.
Solution: Thread pool — pre-create a set of threads and reuse them.
(2) 4.2 Simple Thread Pool Implementation
Example: Thread pool (Difficulty ⭐⭐⭐⭐)
#include <iostream>
#include <thread>
#include <vector>
#include <queue>
#include <functional>
#include <mutex>
#include <condition_variable>
class ThreadPool {
private:
std::vector<std::thread> workers;
std::queue<std::function<void()>> tasks;
std::mutex mtx;
std::condition_variable cv;
bool stop;
public:
ThreadPool(size_t numThreads) : stop(false) {
for (size_t i = 0; i < numThreads; i++) {
workers.emplace_back([this] {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [this] {
return stop || !tasks.empty();
});
if (stop && tasks.empty()) {
return;
}
task = std::move(tasks.front());
tasks.pop();
}
task();
}
});
}
}
~ThreadPool() {
{
std::lock_guard<std::mutex> lock(mtx);
stop = true;
}
cv.notify_all();
for (auto& t : workers) {
t.join();
}
}
template<class F>
void enqueue(F&& f) {
{
std::lock_guardstd::mutex lock(mtx);
tasks.emplace(std::forward<F>(f));
}
cv.notify_one();
}
};
int main() {
ThreadPool pool(4); // Create 4 threads
for (int i = 0; i < 10; i++) {
pool.enqueue([i] {
std::cout << "Task " << i << " running" << std::endl;
});
}
return 0;
}
5. Practice: Parallel Sorting
▶ Example 1: Accelerating sorting with multithreading (Difficulty ⭐⭐⭐⭐)
#include <iostream>
#include <vector>
#include <thread>
#include <algorithm>
void parallelSort(std::vector<int>& v, int left, int right) {
if (left >= right) return;
std::sort(v.begin() + left, v.begin() + right + 1);
}
int main() {
std::vector<int> v = {3, 1, 4, 1, 5, 9, 2, 6};
// Sort in parallel with 2 threads
std::thread t1(parallelSort, std::ref(v), 0, v.size()/2);
std::thread t2(parallelSort, std::ref(v), v.size()/2 + 1, v.size()-1);
t1.join();
t2.join();
// Merge the two sorted halves
std::inplace_merge(v.begin(), v.begin() + v.size()/2 + 1, v.end());
for (int x : v) {
std::cout << x << " ";
}
std::cout << std::endl;
return 0;
}
Output:
3 1 4 1 5 9 2 6
▶ Example 3: Protecting a shared counter with mutex (Difficulty ⭐)
#include <iostream>
#include <thread>
#include <vector>
#include <mutex>
int counter = 0;
std::mutex mtx;
void increment() {
for (int i = 0; i < 1000; i++) {
std::lock_guard<std::mutex> lock(mtx);
counter++;
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 10; i++) {
threads.emplace_back(increment);
}
for (auto& t : threads) {
t.join();
}
std::cout << "counter = " << counter << std::endl;
return 0;
}
Output:
counter = 10000
❓ FAQ
std::cout to print logs (but add a mutex) - Use Valgrind, Helgrind, etc. to detect data races - Use Thread Sanitizer (GCC/Clang)std::jthread (auto-join) - Semaphores (std::counting_semaphore) - Latches (std::latch) - Barriers (std::barrier)📖 Summary
| Key Point | Summary |
|---|---|
| Deadlock | Four conditions, three avoidance methods |
| Read-write lock | std::shared_mutex (C++17) |
| Atomic operations | std::atomic, no lock needed |
| Thread pool | Reuse threads, reduce overhead |
| Parallel algorithms | C++17 std::execution |
📝 Exercises
-
Basic (Difficulty ⭐): Create two threads that each increment a global variable 100,000 times. Observe if the result is correct. Then protect it with
std::mutexand test again. -
Intermediate (Difficulty ⭐⭐): Use
std::lock_guardto protect an "account balance" variable. Simulate two threads performing deposit/withdrawal operations simultaneously, ensuring the final balance is correct. -
Challenge (Difficulty ⭐⭐⭐): Use
std::condition_variableto implement a "producer-consumer" model. One thread produces data and puts it in a queue, another thread consumes data. The consumer waits when the queue is empty.
- Race conditions: multiple threads simultaneously access shared data
- std::mutex protects critical sections
- std::lock_guard automatically locks and unlocks (RAII)
- Deadlock: two threads wait for each other to release locks
- Condition variable std::condition_variable notifies threads
Phase 6 Complete! Next: Phase 7 (Advanced & Practice)