C++ - Concurrency (Threads, async, futures)
Overview
Estimated time: 70–90 minutes
Run work in parallel with threads and high-level async APIs. Learn safe synchronization with mutexes, condition variables, and atomics, and how to return results via futures/promises.
Learning Objectives
- Create and manage threads with std::thread (launch, join, detach).
- Protect shared data using std::mutex, std::lock_guard, std::unique_lock.
- Coordinate threads using std::condition_variable.
- Use std::async, std::future, std::promise to return results safely.
- Understand when to use std::atomic and common concurrency pitfalls.
Prerequisites
Launch and join a thread
#include <thread>
#include <iostream>
void work(int id){ std::cout << "worker " << id << "\n"; }
int main(){
std::thread t(work, 1);
// do other things...
t.join(); // wait for completion
}
Expected Output (example): worker 1
Protect shared data with a mutex
#include <thread>
#include <mutex>
#include <vector>
#include <iostream>
int main(){
std::mutex m;
int counter = 0;
auto task = [&]{
for (int i=0;i<10000;++i){
std::lock_guard<std::mutex> lk(m);
++counter; // protected
}
};
std::thread a(task), b(task);
a.join(); b.join();
std::cout << counter << "\n"; // 20000
}
Condition variable for coordination
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <iostream>
std::mutex m;
std::condition_variable cv;
std::queue<int> q;
bool done = false;
void producer(){
for (int i=1;i<=3;++i){
{
std::lock_guard<std::mutex> lk(m);
q.push(i);
}
cv.notify_one();
}
{
std::lock_guard<std::mutex> lk(m);
done = true;
}
cv.notify_all();
}
void consumer(){
for(;;){
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, []{ return !q.empty() || done; });
if (!q.empty()) { std::cout << q.front() << "\n"; q.pop(); }
else if (done) break;
}
}
int main(){
std::thread p(producer), c(consumer);
p.join(); c.join();
}
Expected Output:
1
2
3
High-level async with futures
#include <future>
#include <iostream>
int compute(){ return 21*2; }
int main(){
std::future<int> f = std::async(std::launch::async, compute);
// do other work...
std::cout << f.get() << "\n"; // 42
}
Promises and packaged_task
#include <future>
#include <thread>
#include <iostream>
int main(){
std::promise<int> p;
std::future<int> f = p.get_future();
std::thread t([pr = std::move(p)]() mutable {
pr.set_value(7);
});
std::cout << f.get() << "\n"; // 7
t.join();
}
Atomics (when you need lock-free primitives)
#include <atomic>
#include <thread>
#include <iostream>
int main(){
std::atomic<int> counter{0};
auto task = [&]{ for (int i=0;i<10000;++i) counter.fetch_add(1, std::memory_order_relaxed); };
std::thread a(task), b(task); a.join(); b.join();
std::cout << counter.load() << "\n"; // 20000
}
Common Pitfalls
- Forgetting to join threads (leaks or terminate on destruction). Prefer RAII wrappers for threads.
- Data races when writing shared state without synchronization (undefined behavior).
- Deadlocks from inconsistent lock ordering; prefer scoped locks and a single lock order.
- Using condition_variable without a predicate in wait; always use a predicate to handle spurious wakeups.
- Detaching threads that access objects which may go out of scope; avoid detach unless you truly manage lifetime.
Checks for Understanding
- Why use a predicate with condition_variable::wait?
- When would you choose std::async over std::thread?
- What does std::lock_guard provide vs std::unique_lock?
Show answers
- To handle spurious wakeups and re-check the condition under the lock safely.
- When you want a simple task-return pattern with automatic joining via future and no manual thread management.
- lock_guard is a minimal RAII lock; unique_lock supports deferred locking, try_lock, unlock/relock.
Exercises
- Implement a thread-safe queue with push/pop using mutex + condition_variable; write producer/consumer demo.
- Use std::async to parallelize computing partial sums across chunks, then combine results.