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

  1. Why use a predicate with condition_variable::wait?
  2. When would you choose std::async over std::thread?
  3. What does std::lock_guard provide vs std::unique_lock?
Show answers
  1. To handle spurious wakeups and re-check the condition under the lock safely.
  2. When you want a simple task-return pattern with automatic joining via future and no manual thread management.
  3. lock_guard is a minimal RAII lock; unique_lock supports deferred locking, try_lock, unlock/relock.

Exercises

  1. Implement a thread-safe queue with push/pop using mutex + condition_variable; write producer/consumer demo.
  2. Use std::async to parallelize computing partial sums across chunks, then combine results.