Multithreading in C++
Before C++11, C++ did not have standardized support for multithreading, which meant developers had to rely on platform-specific libraries to implement multithreading. This approach was cumbersome and lacked cross-platform compatibility.
For example:
On Windows, you could use WinAPI, which required including the <windows.h>
header file. This approach did not require additional libraries but was only suitable for Windows systems.
On POSIX systems (such as Linux and macOS), you could use POSIX Threads (pthread), which required including the <pthread.h>
header file. POSIX Threads provided cross-platform capabilities but required installing the relevant libraries.
C++11 introduced new standard libraries to enhance multithreading support, making it easier and safer to write multithreaded programs.
Multithreading Example in C++11
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
#include <thread>
void print_message(const std::string& message) {
std::cout << message << std::endl;
}
int main() {
std::thread t1(print_message, "Hello from thread 1!");
std::thread t2(print_message, "Hello from thread 2!");
t1.join(); // Wait for t1 to finish
t2.join(); // Wait for t2 to finish
return 0;
}
Result:
Since the execution order of threads cannot be controlled, the output of the above example may vary with each run.
code with lock_guard
When multiple threads access and modify shared data, data races can occur, leading to unpredictable behavior and data corruption.
To prevent this, we use std::mutex
and std::lock_guard
to ensure that only one thread can access the critical section at a time.
Here is an example demonstrating the use of std::mutex and std::lock_guard:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <iostream>
#include <thread>
#include <mutex>
#include <vector>
std::mutex mtx;
void print_message(int id) {
std::cout << "Thread " << id << " is waiting." << std::endl;
std::lock_guard<std::mutex> lock(mtx);
std::cout << "Thread " << id << " is running." << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
std::cout << "Thread " << id << " has finished." << std::endl;
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 5; ++i) {
threads.push_back(std::thread(print_message, i));
}
for (auto& t : threads) {
t.join();
}
return 0;
}
Result:
Execution Order with std::lock_guard
:
- t0 (thread 0) waits.
- t0 acquires the lock and executes.
- t3 waits.
- t2 & t4 wait.
- t1 waits.
- t0 completes and releases the lock.
- t3 acquires the lock and executes.
- t3 completes and releases the lock.
- t4 acquires the lock and executes.
- t4 completes and releases the lock.
- t2 acquires the lock and executes.
- t2 completes and releases the lock.
- t1 acquires the lock and executes.
- t1 completes and releases the lock.
code with unique_lock
std::unique_lock
provides more flexibility than std::lock_guard, allowing manual lock and unlock operations, which can reduce thread waiting times and improve overall efficiency.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <iostream>
#include <thread>
#include <mutex>
#include <vector>
std::mutex mtx;
void print_message(int id) {
std::cout << "Thread " << id << " is waiting." << std::endl;
std::unique_lock<std::mutex> lock(mtx);
std::cout << "Thread " << id << " is running." << std::endl;
// unlock by user
lock.unlock();
// no needed locked tasks
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
// lock again
lock.lock();
std::cout << "Thread " << id << " has finished." << std::endl;
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 5; ++i) {
threads.push_back(std::thread(print_message, i));
}
for (auto& t : threads) {
t.join();
}
return 0;
}
Result:
Execution Order with std::unique_lock
:
- t0 waits.
- t1 waits.
- t1 acquires the lock and executes.
- t3 waits.
- t4 waits. t2 waits. t0 acquires the lock and executes. t3 acquires the lock and executes. t4 acquires the lock and executes. t2 acquires the lock and executes. t3 completes and releases the lock. t0 completes and releases the lock. t2 completes and releases the lock. t1 completes and releases the lock. t4 completes and releases the lock.
Scope of Locks
The scope of a lock is determined by the lifetime of the std::unique_lock
or std::lock_guard
variable. When a std::unique_lock
or std::lock_guard
variable enters scope, it attempts to lock the given std::mutex, and it automatically unlocks the mutex when the variable goes out of scope.
Why Locks Are Automatically Released When Going Out of Scope This automatic unlocking is due to the RAII (Resource Acquisition Is Initialization) principle. RAII is a common C++ programming practice that ensures resources (such as locks, file handles, memory, etc.) are correctly allocated and released during their lifetime.
RAII Principle: With RAII, resource management is tied to object lifetime. When an object is created, it acquires resources, and when it is destroyed, it releases those resources.
Destructor Behavior: std::unique_lock and std::lock_guard call the unlock() method in their destructors to release the lock. Thus, when the std::unique_lock or std::lock_guard object goes out of scope (e.g., when a function returns or the scope ends), the destructor is called, and the lock is automatically released.
When to Use Multithreading Tools
std::thread: Use std::thread to create new threads when you need to perform multiple tasks concurrently. This is especially useful for improving program performance and making full use of multi-core processors.
std::mutex and std::lock_guard/std::unique_lock: Use std::mutex to protect shared resources when multiple threads need to access them, preventing data races.