Introduction to C++ Multithreaded Concurrent Programming

Introduction: Why Do We Need Concurrency?

In today’s world, where Moore’s Law is slowing down, the improvement of single-core performance has reached a bottleneck. CPU manufacturers have turned to multi-core architectures, which means modern computers generally have multiple cores capable of executing multiple computational tasks simultaneously. To fully utilize these hardware resources, concurrent programming has emerged.

  • Concurrency: Refers to the overlapping execution of multiple tasks in time, where they can execute alternately, appearing to run simultaneously. This is logically simultaneous execution.
  • Parallelism: Refers to the actual simultaneous execution of multiple tasks at the same time, which requires support from multi-core processors. This is physically simultaneous execution.

The release of the C++11 standard was a milestone in the history of C++ development, as it provided cross-platform support for concurrent programming at the language level for the first time, liberating developers from the obscure and platform-dependent Pthreads (POSIX) or Windows API. This article will systematically explore C++ multithreaded programming based on the modern features of C++11/14/17/20.

Chapter 1: Basics of Threads – <span>std::thread</span>

<span>std::thread</span> is the cornerstone of the C++ thread library, allowing us to create and manage a new execution thread.

1.1 Creating and Starting Threads

By creating a <span>std::thread</span> object and passing a callable object (function, lambda expression, function object, etc.), the thread will immediately start executing.

#include <iostream>
#include <thread>
#include <chrono>
void task() {
    std::cout << "Task Thread ID: " << std::this_thread::get_id() << std::endl;
    std::this_thread::sleep_for(std::chrono::seconds(1));
}
int main() {
    std::cout << "Main Thread ID: " << std::this_thread::get_id() << std::endl;
    // Create and start thread
    std::thread my_thread(task);
    std::cout << "Main Thread continues executing..." << std::endl;
    // Wait for the child thread to finish
    my_thread.join();
    std::cout << "Main Thread ends." << std::endl;
    return 0;
}

1.2 Thread Lifecycle: <span>join()</span> and <span>detach()</span>

Each <span>std::thread</span> object must have a defined state before it is destroyed: either wait for it to finish using <span>join()</span>, or separate it from the <span>std::thread</span> object using <span>detach()</span>.

  • <span>join()</span>: Blocks the current thread (usually the main thread) until the called thread finishes executing. This is a common method to ensure the visibility of the results of the child thread’s operations. If you do not <span>join()</span>, the main thread may finish before the child thread, leading to abnormal program termination or forced interruption of the child thread.
  • <span>detach()</span>: Separates the child thread from the <span>std::thread</span> object, making the child thread a “daemon thread” that runs independently in the background. The main thread can no longer interact with it, nor can it <span>join()</span>. Once detached, the lifecycle of the child thread is managed by the C++ runtime library. This is suitable for “fire and forget” tasks.

Warning: Forgetting to <span>join()</span> or <span>detach()</span> will cause the program to call <span>std::terminate()</span> when the <span>std::thread</span> object is destructed, causing the program to crash.

UML – Thread Lifecycle Sequence Diagram

Introduction to C++ Multithreaded Concurrent Programming

1.3 Passing Parameters to Threads

When passing parameters to a thread function, by default, the parameters are copied or moved into the thread’s internal storage.

void print_message(const std::string& msg) {
    std::cout << "Thread Message: " << msg << std::endl;
}
int main() {
    std::string message = "Hello, Thread!";
    // message is copied into the thread
    std::thread t(print_message, message);
    t.join();
}

If you need to pass a reference, you must use <span>std::ref()</span> or <span>std::cref()</span> wrappers; otherwise, the parameters will still be copied.

#include <functional> // For std::ref
void update_value(int& val) {
    val = 100;
}
int main() {
    int my_value = 10;
    // Use std::ref to pass by reference
    std::thread t(update_value, std::ref(my_value));
    t.join();
    std::cout << "Updated value: " << my_value << std::endl; // Outputs 100
    return 0;
}

Chapter 2: The Nightmare of Shared Data – Data Races and Race Conditions

Problems arise when multiple threads access the same shared resource, and at least one thread modifies that resource.

  • Race Condition: The correctness of the program depends on the relative execution order of operations by multiple threads.
  • Data Race: Specifically refers to concurrent access to the same memory location by two or more threads performing non-atomic operations without synchronization, with at least one being a write operation. This is undefined behavior in C++.

Example: A Faulty Counter

#include <iostream>
#include <thread>
#include <vector>
int counter = 0;
void increment() {
    for (int i = 0; i < 100000; ++i) {
        // The problem: read-modify-write operation is not atomic
        counter++;
    }
}
int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i) {
        threads.push_back(std::thread(increment));
    }
    for (auto& t : threads) {
        t.join();
    }
    // The result is almost certainly not 1,000,000
    std::cout << "Final count: " << counter << std::endl;
    return 0;
}

<span>counter++</span> consists of at least three steps: 1. Read the value of <span>counter</span> into a register; 2. Increment the value in the register; 3. Write the register value back to <span>counter</span>. Two threads may read the old value simultaneously, leading to a lost increment.

Chapter 3: Synchronization Primitives (I) – Mutexes and Locks

To solve data races, we need synchronization mechanisms to protect shared data. A mutex is the most basic synchronization primitive.

3.1 <span>std::mutex</span>

<span>std::mutex</span> (mutex lock) ensures that only one thread can acquire the lock and access the protected resource at any given time.

#include <iostream>
#include <thread>
#include <vector>
#include <mutex>
int counter = 0;
std::mutex mtx; // Declare a mutex
void safe_increment() {
    for (int i = 0; i < 100000; ++i) {
        mtx.lock();   // Acquire lock
        counter++;
        mtx.unlock(); // Release lock
    }}// ... main function similar to before, but calls safe_increment ...

This code is now thread-safe, but manually calling <span>lock()</span> and <span>unlock()</span> is dangerous: if an exception occurs between locking and unlocking, the lock will never be released, leading to deadlock.

3.2 RAII-style Lock Management: <span>std::lock_guard</span> and <span>std::unique_lock</span>

To address the above issue, C++ provides RAII (Resource Acquisition Is Initialization) style lock wrappers.

  • <span>std::lock_guard</span>: A lightweight RAII wrapper. It acquires the lock in its constructor and releases it in its destructor. It is simple and efficient but has limited functionality.
void safe_increment_guard() {
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> guard(mtx); // Lock on construction, automatically unlocks on scope exit
        counter++;
    } // guard destructs here, lock is released
}
  • <span>std::unique_lock</span>: A more flexible but slightly more expensive RAII wrapper. It supports deferred locking, try-locking, timed locking, and transfer of lock ownership, and is essential for using condition variables.
std::unique_lock<std::mutex> lock(mtx, std::defer_lock); // Create without locking
lock.lock(); // Manually lock
// ...
lock.unlock(); // Manually unlock

UML – Mutex State Diagram

Introduction to C++ Multithreaded Concurrent Programming

3.3 Deadlock

Deadlock occurs when two or more threads are waiting for each other to release resources. A classic example is two threads acquiring two locks in different orders.

std::mutex mtx1, mtx2;
void func1() {
    std::lock_guard<std::mutex> guard1(mtx1);
    std::this_thread::sleep_for(std::chrono::milliseconds(10)); // Create deadlock opportunity
    std::lock_guard<std::mutex> guard2(mtx2);
    // ...}
void func2() {
    std::lock_guard<std::mutex> guard2(mtx2);
    std::this_thread::sleep_for(std::chrono::milliseconds(10));
    std::lock_guard<std::mutex> guard1(mtx1); // Opposite order to func1
    // ...}

Solutions:

  1. Ensure Locking Order: All threads acquire locks in the same order.
  2. Use <span>std::lock</span> (C++11) or <span>std::scoped_lock</span> (C++17): They can atomically lock multiple mutexes, using an algorithm that avoids deadlock. <span>std::scoped_lock</span> is a more modern and recommended choice.
void safe_func() {
    // C++17: RAII style, automatically manage locks
    std::scoped_lock lock(mtx1, mtx2);
    // ... safely operate on shared resources ...
}

Chapter 4: Synchronization Primitives (II) – Condition Variables

<span>std::condition_variable</span> allows one or more threads to wait for a specific condition to be met. It is typically used in conjunction with <span>std::mutex</span> and <span>std::unique_lock</span> to implement complex inter-thread communication, such as the classic “producer-consumer” model.

4.1 Producer-Consumer Model

One or more producer threads add data to a queue, while one or more consumer threads take data from the queue for processing.

  • When the queue is empty, consumers must wait.
  • When the queue is full, producers must wait.
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <chrono>
std::mutex mtx;
std::condition_variable cv;
std::queue<int> data_queue;
const unsigned int MAX_QUEUE_SIZE = 10;
void producer() {
    for (int i = 0; i < 20; ++i) {
        std::unique_lock<std::mutex> lock(mtx);
        // Wait until the queue is not full
        cv.wait(lock, []{ return data_queue.size() < MAX_QUEUE_SIZE; });
        std::cout << "Producing: " << i << std::endl;
        data_queue.push(i);
        lock.unlock(); // Unlock early to let consumers work as soon as possible
        cv.notify_one(); // Wake up one waiting consumer
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    }}
void consumer() {
    while (true) {
        std::unique_lock<std::mutex> lock(mtx);
        // Wait until the queue is not empty
        cv.wait(lock, []{ return !data_queue.empty(); });
        int data = data_queue.front();
        data_queue.pop();
        std::cout << "Consuming: " << data << std::endl;
        lock.unlock(); // Unlock early
        cv.notify_one(); // Wake up a producer that may be waiting for the queue to be not full
        if (data == 19) break; // Exit after consuming the last one
        std::this_thread::sleep_for(std::chrono::milliseconds(200));
    }}
int main() {
    std::thread p(producer);
    std::thread c(consumer);
    p.join();
    c.join();
    return 0;
}

Key Points:

  • <span>cv.wait(lock, predicate)</span>: Atomically unlocks <span>lock</span> and puts the thread into a waiting state. When woken up, it re-locks and checks the <span>predicate</span> (a lambda returning a boolean). If <span>predicate</span> is <span>true</span>, <span>wait</span> returns; if <span>false</span> (spurious wakeup), it continues waiting.Always use the wait version with a predicate.
  • <span>cv.notify_one()</span>: Wakes up one waiting thread.
  • <span>cv.notify_all()</span>: Wakes up all waiting threads.

UML – Producer-Consumer Sequence Diagram

Introduction to C++ Multithreaded Concurrent Programming

Chapter 5: Advanced Abstractions – <span>future</span>, <span>promise</span> and <span>async</span>

C++ provides higher-level concurrency abstractions for handling task-based concurrency, rather than directly manipulating threads and locks.

5.1 <span>std::async</span>, <span>std::future</span>

<span>std::async</span> is a function template that starts a callable object asynchronously (possibly in a new thread) and returns a <span>std::future</span> object.<span>std::future</span> represents the eventual result of that asynchronous operation.

#include <iostream>
#include <future>
#include <chrono>
int long_computation() {
    std::this_thread::sleep_for(std::chrono::seconds(2));
    return 42;
}
int main() {
    std::cout << "Starting asynchronous computation..." << std::endl;
    // Start task, std::launch::async policy forces execution in a new thread
    std::future<int> result_future = std::async(std::launch::async, long_computation);
    std::cout << "Main thread can do other things..." << std::endl;
    // ... do other work ...
    // When the result is needed, call get()
    // get() will block until the result is ready
    int result = result_future.get();
    std::cout << "Computation result: " << result << std::endl;
    return 0;
}

<span>std::async</span> launch policies:

  • <span>std::launch::async</span>: Guarantees asynchronous execution on a new thread.
  • <span>std::launch::deferred</span>: Deferred execution. The task will only execute on the current thread when <span>future</span> is called with <span>get()</span> or <span>wait()</span>.
  • Default policy (<span>std::launch::async | std::launch::deferred</span>): Implementation decides whether to create a new thread immediately or defer execution.

5.2 <span>std::promise</span> and <span>std::packaged_task</span>

<span>std::async</span> is backed by the mechanisms of <span>std::promise</span> and <span>std::packaged_task</span>.

  • <span>std::promise</span>: Allows you to set a value in one thread and retrieve that value in another thread through the associated <span>std::future</span>. It establishes a one-time communication channel between producer and consumer.
  • <span>std::packaged_task</span>: Wraps a callable object so it can be called asynchronously. Its return value or exception is stored in a <span>std::future</span>. This is very useful for building task queues.

<span>std::promise</span> Example:

void set_value(std::promise<int> p) {
    try {
        // ... computation ...
        p.set_value(101);
    } catch (...) {
        p.set_exception(std::current_exception());
    }
}
int main() {
    std::promise<int> p;
    std::future<int> f = p.get_future();
    std::thread t(set_value, std::move(p));
    std::cout << "Waiting for result..." << std::endl;
    std::cout << "Result: " << f.get() << std::endl;
    t.join();
}

UML – Future/Promise Model

Introduction to C++ Multithreaded Concurrent Programming

Chapter 6: Lock-Free Programming – Atomic Operations Library <span><atomic></span>

For very simple operations (like incrementing a counter, reading and writing pointers), the overhead of using mutexes may be too high.<span>std::atomic</span> library provides the ability to perform atomic operations on built-in types, which are indivisible and can avoid data races.

#include <atomic>
std::atomic<int> atomic_counter = 0;
void atomic_increment() {
    for (int i = 0; i < 100000; ++i) {
        // fetch_add is an atomic "read-modify-write" operation
        atomic_counter.fetch_add(1, std::memory_order_relaxed);
    }}

<span>std::atomic</span> is centered around memory order, which determines how atomic operations synchronize with other memory operations (including non-atomic and atomic operations).

  • <span>memory_order_relaxed</span>: The loosest, only guarantees the atomicity of the operation itself, without providing any synchronization or ordering constraints. Highest performance.
  • <span>memory_order_acquire</span>: Acquire semantics. No read or write operations in the current thread can be reordered to before this operation.
  • <span>memory_order_release</span>: Release semantics. No read or write operations in the current thread can be reordered to after this operation.
  • <span>memory_order_acq_rel</span>: Both acquire and release semantics.
  • <span>memory_order_seq_cst</span>: Sequential consistency. The strictest, guarantees that all threads see the order of all atomic operations consistently. This is the default for all atomic operations.

Memory order is a very complex topic that requires a deep understanding of compiler and hardware memory models. For beginners, sticking to the default <span>memory_order_seq_cst</span><span> is the safest choice.</span>

Chapter 7: C++20 and Future Concurrency Features

C++20 introduces more powerful concurrency tools:

  • <span>std::jthread</span>: A <span>std::thread</span> that automatically calls <span>join()</span>. Its destructor checks if the thread can be <span>joined</span>, and if so, calls <span>join()</span>. It also introduces a cooperative interruption mechanism (via <span>stop_token</span>). This is a significant improvement over <span>std::thread</span>, and is highly recommended.
#include <thread> // in C++20, jthread is here
void some_task(std::stop_token st) {
    while (!st.stop_requested()) {
        // ... work ...
    }}
int main() {
    std::jthread jt(some_task);
    // ...} // jt destructs here, automatically requests stop and joins()
  • Semaphores (<span>std::counting_semaphore</span>): A classic synchronization primitive that allows a certain number of threads to access resources simultaneously.
  • Latches (<span>std::latch</span>) and Barriers (<span>std::barrier</span>): Used to synchronize multiple threads at a certain execution point. <span>latch</span> is one-time, while <span>barrier</span> is reusable.
  • Coroutines: C++20 introduces coroutine support, a brand new concurrency model (cooperative multitasking) that differs from preemptive multithreading. It allows functions to suspend and resume, making it very suitable for I/O-intensive asynchronous tasks, allowing you to write asynchronous code that is as easy to understand as synchronous code.

Conclusion and Best Practices

Modern C++ provides a powerful and rich toolbox for concurrent programming.

  1. Prefer Higher-Level Abstractions: <span>std::async</span>, <span>std::packaged_task</span> are better than manually managing <span>std::thread</span>.
  2. Embrace RAII: Always use <span>std::lock_guard</span> or <span>std::unique_lock</span> to manage mutexes. In C++17, prefer using <span>std::scoped_lock</span> to lock multiple mutexes. In C++20, replace <span>std::thread</span> with <span>std::jthread</span>.
  3. Share Data Wisely: Reducing shared data is the best way to avoid concurrency issues. If sharing is necessary, strictly protect it with synchronization primitives.
  4. Be Careful of Deadlocks: Ensure consistent locking order, or use <span>std::scoped_lock</span>.
  5. Use Condition Variables Correctly: Always use them with mutexes and <span>std::unique_lock</span>, and use the version of <span>wait</span> with a predicate to prevent spurious wakeups.
  6. Use Atomic Operations Only When Necessary: Consider <span>std::atomic</span> when performance is critical and operations are simple. But be cautious of the complexities introduced by memory order.
  7. Understand Your Tools: Deeply understand the applicable scenarios, advantages, and overhead of each concurrency tool.

Concurrent programming is a double-edged sword; it can greatly enhance program performance but also introduces new complexities and sources of bugs. By mastering these tools and paradigms provided by modern C++, you can confidently write robust, efficient, and maintainable concurrent programs.

Note: This article is sourced from AI’s PY.

  • Perspective from Painters and Programmers

  • New Discoveries in Rendering Details of Mihayou ZZZ Absolute Zone Zero
  • Netizens Discover that “Black Myth: Wukong” Mistakenly Scanned Rebar into the Game, How to Evaluate Such Practices of Scene Scanning in Games? How Was It Done?

  • Collect and Forward, GPU, CPU, Memory, and More than 150 Game Development Performance Analysis Optimization Tips Collection!
  • Unity3D Game Development Implementation and Source Code Collection of 100+ Effects – Definitely Useful to Keep!
  • Not an Advertisement! 6-Year-Old Account Benefits, Outline, Depth of Field, Bloom, Shadow, and More than 60 Game Post-Processing Effects Implementation Collection!
  • Collect and Forward! Research Collection of Rendering Effect Techniques for 80+ Games such as Genshin Impact, Sekiro, and God of War 4! Free!

The statement: This article comes from the public account: Game Development Technology Tutorial (GameDevLearning), please attach the original link and this statement when reprinting.

Follow 【Game Development Technology Tutorial

Game Development Technology, Skills, Tutorials, and Resources, Q&A, Internal PushInterviews

Introduction to C++ Multithreaded Concurrent Programming

Leave a Comment