What is multithreaded programming in C++?

Multithreaded programming in C++ allows multiple threads to execute concurrently, which improves the performance and responsiveness of applications. It exploits the power of modern multi-core processors, enabling tasks to run in parallel and enhancing efficiency. In C++, threads are created using the <thread> library, and there are several ways to launch them, each with its own advantages and use cases.

Multithreading in C++ consists of threads, synchronization primitives for shared data, thread-local data, and tasks.

Threads

A std::thread represents an independent unit of program execution. The executable unit, which is started immediately, receives its work package as a callable unit. A callable unit can be a named function, a function object, or a lambda function.

The creator of a thread is responsible for its lifecycle. The executable unit of the new thread ends with the end of the callable. Either the creator waits until the created thread t is done (t.join()), or the creator detaches itself from the created thread: (t.detach()). A thread t is joinable if no operation t.join() or t.detach() was performed on it. A joinable thread calls std::terminate in its destructor and the program terminates.

A thread that is detached from its creator is typically called a daemon thread because it runs in the background. A std::thread is a variadic template. This means that it can receive an arbitrary number of arguments by copy or reference; either the callable or the thread can get the arguments.

Shared Data

You have to coordinate access to a shared variable if more than one thread is using it at the same time and the variable is mutable (non-const). Reading and writing a shared variable at the same time is a data race, and therefore, undefined behavior. Coordinating access to a shared variable is achieved with mutexes and locks in C++.

Mutexes

A mutex (mutual exclusion) guarantees that only one thread can access a shared variable at any given time. A mutex locks and unlocks the critical section that the shared variable belongs to. C++ has five different mutexes; they can lock recursively, tentatively, and with or without time constraints. Even mutexes can share a lock at the same time.

Locks

You should encapsulate a mutex in a lock to release the mutex automatically. A lock implements the RAII idiom by binding a mutex’s lifetime to its own. C++ has a std::lock_guard for the simple cases, and a std::unique_lock / std::shared_lock for the advanced use-cases, such as the explicit locking or unlocking of the mutex respectively.

Thread-safe Initialization of Data

If shared data is read-only, it’s sufficient to initialize it in a thread-safe way. C++ offers various ways to achieve this including using constant expression, a static variable with block scope, or using the function std::call_once in combination with the flag std::once_flag.

Thread Local Data

Declaring a variable as thread-local ensures that each thread gets its own copy; therefore, there is no shared variable. The lifetime of a thread local data is bound to the lifetime of its thread.

Condition Variables

Condition variables enable threads to be synchronized via messages. One thread acts as the sender while another one acts as the receiver of the message, where the receiver blocks wait for the message from the sender. Typical use cases for condition variables are producer-consumer workflows. A condition variable can be either the sender or the receiver of the message. Using condition variables correctly is quite challenging; therefore, tasks are often the easier solution.

Tasks

Tasks have a lot in common with threads. While you explicitly create a thread, a task is simply a job you start. The C++ runtime will automatically handle, in the simple case of std::async, the lifetime of the task.

Tasks are like data channels between two communication endpoints. They enable thread-safe communication between threads: the promise at one endpoint puts data into the data channel, and the future at the other endpoint picks the value up. The data can be a value, an exception, or simply a notification. In addition to std::async, C++ has the class templates std::promise and std::future that give you more control over the task.

Methods to launch threads in C++

There are multiple ways to create and manage threads in C++ depending upon the specific requirements of the application, such as the complexity of the task, the need for state management, or the desired encapsulation of thread logic. Below are the various methods to launch threads in C++.

Launching thread using function pointer

A function pointer can be passed to a thread to execute a specific function in parallel. This approach is straightforward: a thread is created by providing a pointer to a function that will be executed concurrently with the main thread. This method is ideal when you have a simple function that performs a specific task.

Press + to interact
void task() {
// Code for the task
}
std::thread thread1(task);

Launching thread using lambda expression

Lambda expressions provide a more flexible way to create threads. They allow you to define an anonymous function directly within the thread creation call. This is particularly useful when you need a small, self-contained function that doesn’t require a separate function definition. It enables you to keep the thread logic close to where it is needed in the code.

Press + to interact
std::thread thread2([]() {
// Code for the task
});

Launching thread using function objects

Function objects (functors) offer another way to launch threads. A functor is a class that overloads the operator(), allowing an instance of the class to be called like a function. This method is beneficial when the thread requires state or configuration, as the function object can maintain internal data members.

Press + to interact
class Task {
public:
void operator()() {
// Code for the task
}
};
Task task;
std::thread thread3(task);

Launching thread using non-static member function

To launch a thread using a non-static member function, you need to provide both the function and an instance of the class to which it belongs. This method allows the thread to access the non-static members of the class, enabling more complex behavior by maintaining state and accessing class methods.

Press + to interact
class Task {
public:
void run() {
// Code for the task
}
};
Task task;
std::thread thread4(&Task::run, &task);

Launching thread using static member function

Static member functions can also be used to create threads. Unlike non-static member functions, static functions do not require an instance of the class and can be invoked directly using the class name. This approach is useful when you don't need to access any non-static members and want to keep the thread function encapsulated within the class.

Press + to interact
class Task {
public:
static void run() {
// Code for the task
}
};
std::thread thread5(&Task::run);