↓ Skip to main content

Concurrency for the Bold: A Modern C++ Guide

·1772 words·9 mins
🌀 Aashish Thoughts
Author
🌀 Aashish Thoughts
Fragments of thinking, experiments, and ideas that refused to stay in my head.

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:

  1. Heavy Computation: Offload CPU-intensive tasks to prevent blocking the main thread
  2. 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 only x by value
  • [&x] → captures only x by 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!
100

Solution 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:

  1. A boolean flag (notify)
  2. A std::unique_lock (allows deferred locking and use with condition variables)
  3. A std::condition_variable
  4. 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.out

TSan checks shadow memory during execution and reports any detected race conditions.


Resources
#


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