Introduction to concurrency in C++
Table of Contents
Using std::lock_guard for Thread Safety
In C++, managing mutexes manually using std::mutex, lock(), and unlock() can lead to potential issues. For instance, if an exception occurs before unlocking the mutex, the resource may remain locked indefinitely, causing deadlocks or other synchronization problems. To avoid such scenarios, it is considered a best practice to use std::lock_guard.
std::lock_guard is a RAII (Resource Acquisition Is Initialization) wrapper that automatically locks a mutex upon construction and unlocks it when it goes out of scope. This ensures that the mutex is always properly released, even if an exception is thrown. By encapsulating the mutex and the shared resource it protects within a class, you can guarantee thread-safe access to the resource.
Additionally, when working with containers like std::vector, you can use emplace_back to construct elements directly within the container. This avoids unnecessary copies or moves. In the example below, emplace_back is used to create std::thread objects that execute a lambda function calling counter.increment().
1#include <iostream>
2#include <thread>
3#include <mutex>
4#include <vector>
5
6class ThreadSafeCounter {
7private:
8 int count;
9 std::mutex mtx;
10
11public:
12 ThreadSafeCounter() : count(0) {}
13
14 void increment() {
15 std::lock_guard<std::mutex> lock(mtx);
16 ++count;
17 }
18
19 int get() {
20 std::lock_guard<std::mutex> lock(mtx);
21 return count;
22 }
23};
24
25int main() {
26 ThreadSafeCounter counter;
27
28 std::vector<std::thread> threads;
29 for (int i = 0; i < 10; ++i) {
30 threads.emplace_back([&counter]() {
31 for (int j = 0; j < 1000; ++j) {
32 counter.increment();
33 }
34 });
35 }
36 for (auto& t : threads) {
37 t.join();
38 }
39 std::cout << "Final counter value: " << counter.get() << std::endl;
40 return 0;
41}It is crucial to avoid passing references to protected data outside the scope of a lock. This includes returning references from functions, storing them in externally visible memory, or passing them as arguments to user-supplied functions. Doing so can lead to unsafe access to shared data, bypassing the mutex protection entirely. Below is an example demonstrating how a malicious function can exploit such a scenario, allowing unauthorized access to the shared data without acquiring the lock.
1#include <iostream>
2#include <thread>
3#include <mutex>
4#include <functional>
5
6class SensitiveData {
7public:
8 void performOperation() {
9 std::cout << "Performing operation on sensitive data." << std::endl;
10 }
11};
12
13class DataProtector {
14private:
15 SensitiveData data;
16 std::mutex mtx;
17
18public:
19 template<typename Function>
20 void secureOperation(Function func) {
21 std::lock_guard<std::mutex> lock(mtx);
22 func(data);
23 }
24};
25
26SensitiveData* exposedData = nullptr;
27
28void maliciousFunction(SensitiveData& protectedData) {
29 exposedData = &protectedData;
30}
31
32void unsafeFunction() {
33 DataProtector protector;
34 protector.secureOperation(maliciousFunction);
35 if (exposedData) {
36 exposedData->performOperation(); // Bypasses the mutex protection
37 }
38}
39
40int main() {
41 unsafeFunction();
42 return 0;
43}In this example, a malicious function is supplied with a reference to protectedData. This reference is then assigned to an externally visible variable, exposedData. As a result, exposedData->performOperation() can be called without acquiring the lock, effectively bypassing the mutex protection and violating thread safety.
Hand over hand locking
Hand-over-hand locking is a technique used in concurrent programming to manage multiple locks in a way that reduces contention and avoids deadlocks. It is particularly useful when traversing or modifying data structures (e.g., linked lists, trees) where multiple nodes or elements need to be locked simultaneously.
Instead of locking the entire data structure at once (which can lead to high contention and reduced parallelism), hand-over-hand locking locks one node (or a small portion of the data structure) at a time. As the program moves from one node to the next, it releases the lock on the previous node and acquires the lock on the next node. This ensures that only a small portion of the data structure is locked at any given time, allowing other threads to access other parts of the data structure concurrently.
1#include <iostream>
2#include <thread>
3#include <mutex>
4
5class Node {
6public:
7 int data;
8 Node* next;
9 std::mutex mtx;
10
11 Node(int value): data(value), next(nullptr) {}
12};
13
14class LinkedList {
15private:
16 Node* head;
17 std::mutex head_mtx;
18public:
19 LinkedList(): head(nullptr) {}
20
21 void insert(int val) {
22 Node* newNode = new Node(val);
23
24 // Add small random delay to increase chance of interleaving
25 std::this_thread::sleep_for(std::chrono::milliseconds(rand() % 50));
26
27 std::lock_guard<std::mutex> headLock(head_mtx);
28 if(!head) { //if list is empty, insert new node as head
29 head = newNode;
30 return;
31 }
32 std::unique_lock<std::mutex> currentLock(head->mtx); // Lock the first node
33 headLock.unlock();
34
35 Node* current = head;
36 Node* next = head->next;
37 while(next) {
38 std::unique_lock<std::mutex> nextLock(next->mtx);
39 currentLock.unlock();
40 current=next;
41 next = current->next;
42 currentLock = std::move(nextLock);
43 }
44 current->next = newNode;
45 }
46
47 void print() {
48 Node* current = head;
49 while(current) {
50 std::cout << current->data << "->";
51 current = current->next;
52 }
53 std::cout << "null" << std::endl;
54 }
55};
56
57int main() {
58 LinkedList list;
59 std::thread t1([&list]() {
60 for(int i = 0; i < 10; i++) {
61 list.insert(i);
62 }
63 });
64 std::thread t2([&list]() {
65 for(int i = 0; i < 10; i++) {
66 list.insert(i);
67 }
68 });
69 t1.join();
70 t2.join();
71 list.print();
72 return 0;
73}Avoiding Deadlocks using std::lock
In concurrent programming, deadlocks are a common issue, especially when multiple mutexes are involved. A deadlock occurs when two or more threads each hold a mutex and are waiting to acquire another mutex held by the other thread. As a result, neither thread can proceed, causing the program to hang indefinitely.
One effective way to avoid deadlocks is to ensure that locks are acquired together in a single operation, rather than acquiring them one at a time. This prevents situations where a thread holds one lock while waiting for another. In C++, the std::lock function can be used to acquire multiple locks simultaneously without risking a deadlock. Below, we demonstrate this technique with an example.
1#include <iostream>
2#include <thread>
3#include <mutex>
4
5class BankAccount {
6
7private:
8 double balance;
9 std::mutex mtx;
10
11public:
12 BankAccount(double initialBalance): balance(initialBalance) {}
13
14void deposit(double amount) {
15 balance += amount;
16}
17
18void withdraw(double amount) {
19 if(balance >= amount) {
20 balance -= amount;
21 } else {
22 std::cout << "Insufficient funds to withdraw" << std::endl;
23 }
24}
25
26double getBalance() {
27 return balance;
28}
29
30std::mutex& getMutex() {
31 return mtx;
32}
33
34};
35
36void transfer(BankAccount& from, BankAccount& to, double amount) {
37 //we have to first lock both the mutexes of a and b, followed by separating them into differnet locks
38 std::lock(from.getMutex(), to.getMutex());
39 std::lock_guard<std::mutex> fromLock(from.getMutex(), std::adopt_lock);
40 std::lock_guard<std::mutex> toLock(to.getMutex(), std::adopt_lock);
41 if(from.getBalance() >= amount) {
42 from.withdraw(amount);
43 to.deposit(amount);
44 std::cout << "Transfer successful: " << amount << " transferred." << std::endl;
45 } else {
46 std::cout << "Unsuccessful transaction due to insufficient funds" << std::endl;
47 }
48}
49
50int main() {
51 BankAccount acc1(1000.0);
52 BankAccount acc2(500.0);
53 std::cout << "Initial balances:" << std::endl;
54 std::cout << "Account 1: " << acc1.getBalance() << std::endl;
55 std::cout << "Account 2: " << acc2.getBalance() << std::endl;
56
57 std::thread thread1(transfer, std::ref(acc1), std::ref(acc2), 200);
58 std::thread thread2(transfer, std::ref(acc2), std::ref(acc1), 300);
59 thread1.join();
60 thread2.join();
61 std::cout << "Final balances:" << std::endl;
62 std::cout << "Account 1: " << acc1.getBalance() << std::endl;
63 std::cout << "Account 2: " << acc2.getBalance() << std::endl;
64 return 0;
65}In the example below, we simulate a banking application where money is transferred between two accounts. Each account has its own mutex to protect its balance. To ensure thread safety during the transfer, we use std::lock to acquire the mutexes of both the source and destination accounts simultaneously. Once the locks are acquired, we use std::lock_guard with std::adopt_lock to manage the locks' lifetimes automatically.
Avoiding Deadlocks using std::scoped_lock
std::scoped_lock is a C++ class template that provides a convenient RAII-style mechanism for managing one or more mutexes within a scoped block. When a std::scoped_lock object is created, it immediately attempts to acquire ownership of the given mutexes. Once the scope ends, the std::scoped_lock is automatically destroyed and releases all associated locks, ensuring exception-safe unlocking. If multiple mutexes are passed, std::scoped_lockuses a deadlock-avoidance strategy internally, similar to std::lock.
1#include <iostream>
2#include <thread>
3#include <mutex>
4#include <chrono>
5
6std::mutex mutexA;
7std::mutex mutexB;
8
9void task1() {
10 for (int i = 0; i < 5; ++i) {
11 {
12 std::scoped_lock lock(mutexA, mutexB); // Safely locks both mutexes
13 std::cout << "Task 1 acquired both mutexes (iteration " << i << ")
14";
15 } // Automatically releases both locks here
16 std::this_thread::sleep_for(std::chrono::milliseconds(100));
17 }
18}
19
20void task2() {
21 for (int i = 0; i < 5; ++i) {
22 {
23 std::scoped_lock lock(mutexB, mutexA); // Even if order is reversed, no deadlock
24 std::cout << "Task 2 acquired both mutexes (iteration " << i << ")
25";
26 }
27 std::this_thread::sleep_for(std::chrono::milliseconds(100));
28 }
29}
30
31int main() {
32 std::thread t1(task1);
33 std::thread t2(task2);
34
35 t1.join();
36 t2.join();
37
38 std::cout << "Both tasks completed successfully without deadlock.
39";
40 return 0;
41}By using std::scoped_lock, we can safely lock multiple mutexes at once with built-in deadlock prevention. It combines the flexibility of std::lock with the safety of RAII, ensuring that mutexes are always properly released, even in the event of exceptions. This makes code simpler, safer, and easier to reason about.
Transferring Ownership of Locks using unique_lock
In C++, std::unique_lock provides a powerful mechanism for transferring ownership of locks. Unlike std::lock_guard, which is non-transferable, std::unique_lock allows you to move lock ownership between different parts of your code, such as between functions or threads. This flexibility is particularly useful when you need to modularize locking logic or avoid unnecessary lock acquisition and release in different code sections.
The ability to transfer ownership of locks enables you to "hand off" a lock to another part of your program that needs to access the shared resource. Below, we demonstrate this functionality with an example.
1#include <iostream>
2#include <thread>
3#include <mutex>
4
5std::mutex mtx;
6
7void function1(std::unique_lock<std::mutex> lock) {
8 std::cout << "Function 1 has the lock" << std::endl;
9 std::this_thread::sleep_for(std::chrono::seconds(1));
10 std::cout << "Function 1 is releasing the lock" << std::endl;
11}
12
13void function2() {
14 std::unique_lock<std::mutex> lock(mtx);
15 std::cout << "Function 2 has the lock" << std::endl;
16 function1(std::move(lock));
17 std::cout << "Lock no longer belongs to function2";
18}
19
20int main() {
21 std::thread t1(function2);
22 t1.join();
23 return 0;
24}Waiting for Conditions
In C++, std::condition_variable is used together with std::mutex to synchronize threads. It allows one or more threads to wait until another thread modifies a shared variable and signals the condition variable. This mechanism is essential for coordinating access to shared resources efficiently.
Before modifying the shared variable, a thread must first acquire a std::mutex, typically using std::lock_guard or std::unique_lock. Once the modification is complete, the thread can call notify_one or notify_all on the std::condition_variable to wake up waiting threads.
A thread waiting on a std::condition_variable must acquire a std::unique_lock<std::mutex> before calling wait, wait_for, or wait_until. These functions atomically release the mutex and put the thread in a blocked state until the condition variable is notified, a timeout expires, or a spurious wakeup occurs.
When the thread wakes up, it re-acquires the mutex and checks whether the condition is satisfied. If it is, we proceed with the execution. If not, it releases the lock and resumes waiting.
1#include <iostream>
2#include <queue>
3#include <thread>
4#include <mutex>
5#include <condition_variable>
6#include <chrono>
7
8std::queue<int> queue;
9std::mutex mtx;
10std::condition_variable condition;
11bool finished = false;
12
13//producer function that takes in num_items, sleep the thread to simulate work lock the mutex, add the element into the queue, unlock the mutex, and notify a thread using condition.notify_one()
14void produce(int num) {
15 for(int i = 0; i < num; i++) {
16 std::this_thread::sleep_for(std::chrono::milliseconds(10));
17 std::unique_lock<std::mutex> lock(mtx);
18 queue.push(i);
19 std::cout << "Produced: " << i << std::endl;
20 lock.unlock();
21 condition.notify_one();
22 }
23 std::unique_lock<std::mutex> lock(mtx); // need to acquire lock to modify shared variable finished
24 finished = true;
25 condition.notify_all();
26}
27
28//consumer function, waiting indefinitely in a while loop, but if finished is true, break.
29void consume() {
30 while(true) {
31 std::unique_lock<std::mutex> lock(mtx);
32 condition.wait(lock, []{return !queue.empty() || finished; }); // either one of these conditions are true, it will continue execution
33 if(finished && queue.empty()) {
34 break;
35 }
36 int item = queue.front();
37 queue.pop();
38 std::cout << "consuming " << item << std::endl;
39 lock.unlock();
40 }
41}
42
43int main() {
44 std::thread t1(produce, 10);
45 std::thread t2(consume);
46 t1.join();
47 t2.join();
48 return 0;
49}Barrier
std::barrier provides a thread-coordination mechanism that blocks a group of threads until all members have reached a synchronization point. Unlike std::latch, a barrier is reusable: once all threads arrive, the barrier resets automatically for the next phase.
Barriers allow threads to synchronize at specific stages, known as phases. Each thread can signal its arrival and either wait for others or continue, depending on the method used.
arrive(): A thread signals that it has reached the barrier, reducing the internal counter, but continues execution without waiting.
wait(): A thread blocks until all other threads have arrived at the barrier. It can be used after arrive() or independently through arrive_and_wait().
arrive_and_wait(): A convenience function that combines arrive() and wait() in a single call.
arrive_and_drop(): A thread signals arrival but opts out of future barrier phases, useful when a thread exits a loop or leaves synchronization.
Optionally, a completion function can be provided when creating the barrier. This function is executed once per phase after all threads have arrived, but before they are released. It is run by one of the participating threads (which one is not specified).
1#include <iostream>
2#include <barrier>
3#include <thread>
4#include <vector>
5
6const int num_threads = 3;
7
8void worker(std::barrier<>& sync_point, int id) {
9 std::cout << "Thread " << id << " doing phase 1 work...
10";
11 std::this_thread::sleep_for(std::chrono::milliseconds(100 * id));
12
13 sync_point.arrive_and_wait(); // Wait for others
14 std::cout << "Thread " << id << " passed barrier to phase 2
15";
16
17 std::cout << "Thread " << id << " doing phase 2 work...
18";
19}
20
21int main() {
22 std::barrier sync_point(num_threads, [] {
23 std::cout << "All threads reached the barrier. Running completion.
24";
25 });
26
27 std::vector<std::thread> threads;
28 for (int i = 0; i < num_threads; ++i)
29 threads.emplace_back(worker, std::ref(sync_point), i);
30
31 for (auto& t : threads)
32 t.join();
33
34 return 0;
35}