Skip to content

C++ Multithreading

C++ Multithreading using the Standard Library

Section titled “C++ Multithreading using the Standard Library”

Multithreading allows a program to perform multiple tasks concurrently, improving performance and responsiveness, especially on multi-core processors. Modern C++ provides robust support for multithreading directly within the Standard Library (since C++11), primarily through headers like <thread>, <mutex>, <condition_variable>, and <future>.

While operating system-specific APIs like POSIX Threads (pthreads) or Windows threads exist and are sometimes used for platform-specific features, the C++ Standard Library offers a portable and type-safe way to manage threads and synchronization.

Key Concepts:

  • Thread: An independent path of execution within a process.
  • Concurrency: Multiple threads making progress over time (may involve switching on a single core).
  • Parallelism: Multiple threads executing simultaneously on different cores.
  • Synchronization: Mechanisms (like mutexes, condition variables) to coordinate access to shared resources between threads and prevent data races.

The <thread> header provides the std::thread class to create and manage threads. You create a thread by constructing a std::thread object, passing a callable entity (function, lambda expression, functor) and its arguments.

#include <iostream>
#include <thread> // Required for std::thread
#include <vector>
#include <string>
// Function to be executed by the thread
void print_message(int id, const std::string& message) {
// std::this_thread::get_id() returns the unique ID of the current thread
std::cout << "Thread [" << std::this_thread::get_id()
<< "] ID: " << id << " says: " << message << std::endl;
}
int main() {
std::cout << "Main thread ID: " << std::this_thread::get_id() << std::endl;
// Create thread t1 executing print_message(1, "Hello from thread 1")
std::thread t1(print_message, 1, "Hello from thread 1");
// Create thread t2 using a lambda expression
int thread_id_2 = 2;
std::thread t2([thread_id_2]() { // Capture thread_id_2 by value
std::cout << "Thread [" << std::this_thread::get_id()
<< "] ID: " << thread_id_2 << " says: Hi from lambda!" << std::endl;
});
// Create multiple threads
std::vector<std::thread> threads;
for (int i = 3; i <= 5; ++i) {
// Use emplace_back to construct threads directly in the vector
threads.emplace_back(print_message, i, "Greetings from thread " + std::to_string(i));
}
std::cout << "Main thread continues execution..." << std::endl;
// Wait for threads to complete (Join)
std::cout << "Waiting for threads to finish..." << std::endl;
t1.join();
t2.join();
for (auto& t : threads) {
t.join();
}
std::cout << "All threads have completed." << std::endl;
return 0;
}

Compile using a C++11 (or later) compliant compiler, linking the thread library if necessary (often -pthread or -lpthread flag for GCC/Clang):

g++ your_file.cpp -o thread_example -std=c++11 -pthread

The output order of messages from different threads is non-deterministic due to concurrent execution.

A std::thread object must be either joined or detached before it goes out of scope or is destroyed. Failure to do so will result in std::terminate being called.

  • join(): Causes the calling thread (e.g., the main thread) to block and wait until the associated thread (t1, t2, etc.) finishes execution. This is the most common way to manage thread lifetime.
  • detach(): Separates the thread of execution from the std::thread object. The thread continues execution independently in the background. The original std::thread object no longer represents the running thread and cannot be joined. Detached threads must manage their own lifetime carefully; if main exits, detached threads are typically terminated abruptly. Use detach() with caution.
// Example of detaching (use carefully!)
// std::thread t3(background_task);
// t3.detach(); // t3 object no longer manages the thread
// // Main thread continues without waiting for background_task

Arguments are passed to the thread function by copying or moving them during std::thread construction. Be mindful of argument types:

  • By Value: Arguments are copied. Changes inside the thread function do not affect original variables.
  • By Reference: To pass arguments by reference, wrap them in std::ref() or std::cref() (for const reference).
  • By Pointer: Pointers can be passed directly, but ensure the pointed-to object remains valid for the thread’s lifetime (beware of dangling pointers).
  • Move Semantics: Objects supporting move semantics can be moved into a thread, transferring ownership.
#include <iostream>
#include <thread>
#include <string>
#include <functional> // for std::ref, std::cref
void modify_value(int& value) { // Takes int by reference
value *= 2;
std::cout << "Thread modified value to: " << value << std::endl;
}
void read_value(const int& value) { // Takes int by const reference
std::cout << "Thread read value: " << value << std::endl;
}
int main() {
int shared_data = 10;
// Pass by reference using std::ref
std::thread t_modify(modify_value, std::ref(shared_data));
t_modify.join(); // Wait for modification
std::cout << "Main thread sees shared_data as: " << shared_data << std::endl; // Should be 20
// Pass by const reference using std::cref or std::ref
std::thread t_read(read_value, std::cref(shared_data));
t_read.join();
return 0;
}

When multiple threads access shared data, and at least one thread modifies it, you need synchronization to prevent data races (undefined behavior). A mutex (Mutual Exclusion) is the most basic synchronization primitive.

The <mutex> header provides std::mutex. A thread locks the mutex before accessing shared data and unlocks it afterward. Only one thread can hold the lock at any given time.

Using RAII wrappers like std::lock_guard or std::scoped_lock (C++17) is highly recommended. They automatically lock the mutex on construction and unlock it on destruction (when they go out of scope), ensuring the mutex is always released, even if exceptions occur.

#include <iostream>
#include <thread>
#include <vector>
#include <mutex> // Required for std::mutex and std::lock_guard
std::mutex cout_mutex; // Mutex to protect access to std::cout
int shared_counter = 0;
std::mutex counter_mutex; // Mutex to protect access to shared_counter
void increment_counter(int id) {
for (int i = 0; i < 5; ++i) {
// Lock the counter mutex before accessing shared_counter
// std::lock_guard automatically unlocks when 'lock' goes out of scope
std::lock_guard<std::mutex> lock(counter_mutex);
shared_counter++;
// Lock the cout mutex before printing
{
std::lock_guard<std::mutex> cout_lock(cout_mutex);
std::cout << "Thread " << id << " incremented counter to " << shared_counter << std::endl;
} // cout_lock is released here
// Simulate some work
std::this_thread::sleep_for(std::chrono::milliseconds(10));
} // lock (for counter_mutex) is released here
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 3; ++i) {
threads.emplace_back(increment_counter, i + 1);
}
for (auto& t : threads) {
t.join();
}
std::cout << "Final counter value: " << shared_counter << std::endl; // Should be 15
return 0;
}

The C++ Standard Library provides other tools for more complex synchronization scenarios:

  • std::condition_variable: Allows threads to wait efficiently for a specific condition to become true.
  • std::atomic: Provides atomic operations (indivisible, uninterruptible) on basic types, often more efficient than mutexes for simple counters or flags.
  • std::future and std::promise / std::async: Enable retrieving results from asynchronous operations (tasks running potentially in other threads).

Multithreading introduces complexity (debugging, potential deadlocks, livelocks). Start simple, use RAII for resource management (locks), and carefully design interactions between threads.