52. Multithreading & Concurrency
Definition
Multithreading allows a program to run several tasks concurrently within the same process. Each thread has its own stack but shares the memory space with other threads. This concept is very important for taking advantage of modern multi-core processors and for improving the performance of applications that need I/O-bound or CPU-bound operations.
C++11 introduced a standard threading library (<thread>, <mutex>, <atomic>) that replaces the dependency on external libraries such as pthreads.
std::thread — Creating Threads
#include <iostream>
#include <thread>
#include <string>
using namespace std;
void task1() {
for (int i = 0; i < 5; i++) {
cout << "[Thread 1] Iterasi " << i << endl;
this_thread::sleep_for(chrono::milliseconds(100));
}
}
void task2(const string& message) {
for (int i = 0; i < 5; i++) {
cout << "[Thread 2] " << message << " " << i << endl;
this_thread::sleep_for(chrono::milliseconds(150));
}
}
int main() {
thread t1(task1);
thread t2(task2, "Hello");
// join — wait for the threads to finish
t1.join();
t2.join();
cout << "All threads done" << endl;
return 0;
}
Code Explanation
thread t1(function)creates a new thread that runs the function.join()blocks the main thread until the target thread finishes.detach()detaches the thread — it runs independently and cannot be joined.this_thread::sleep_for()sleeps for a given duration.
Join vs Detach
#include <iostream>
#include <thread>
using namespace std;
void background() {
this_thread::sleep_for(chrono::seconds(2));
cout << "Background done" << endl;
}
int main() {
// Detach — the thread runs independently
thread t(background);
t.detach();
cout << "Main thread continues..." << endl;
// Make sure the detached thread finishes before the program ends
this_thread::sleep_for(chrono::seconds(3));
return 0;
}
Case Study: Parallel Sum
#include <iostream>
#include <thread>
#include <vector>
#include <numeric>
using namespace std;
void calculatePartial(const vector<int>& data, size_t start, size_t end, long long& result) {
result = 0;
for (size_t i = start; i < end; i++) {
result += data[i];
}
}
int main() {
const size_t N = 100000000;
vector<int> data(N);
iota(data.begin(), data.end(), 1); // fill with 1, 2, 3, ...
int numThreads = 4;
size_t chunkSize = N / numThreads;
vector<long long> result(numThreads, 0);
vector<thread> threads;
for (int i = 0; i < numThreads; i++) {
size_t start = i * chunkSize;
size_t end = (i == numThreads - 1) ? N : start + chunkSize;
threads.emplace_back(calculatePartial, cref(data), start, end, ref(result[i]));
}
for (auto& t : threads) {
t.join();
}
long long total = accumulate(result.begin(), result.end(), 0LL);
cout << "Total: " << total << endl;
return 0;
}
Code Explanation
- The data is split into chunks, each processed by a different thread.
cref()wraps a reference as a const reference for the thread.ref()wraps a reference for pass-by-reference to the thread.
Race Conditions
A race condition occurs when two or more threads access shared data simultaneously without synchronization, producing non-deterministic results.
Problem Example
#include <iostream>
#include <thread>
#include <vector>
using namespace std;
int counter = 0; // shared data — RACE CONDITION!
void increment() {
for (int i = 0; i < 100000; i++) {
counter++; // not atomic — interleaving can occur
}
}
int main() {
vector<thread> threads;
for (int i = 0; i < 10; i++) {
threads.emplace_back(increment);
}
for (auto& t : threads) t.join();
// Result INACCURATE — should be 1000000
cout << "Counter: " << counter << endl; // somewhere between 0 - 1000000
return 0;
}
Code Explanation
counter++is actually three operations: read, increment, write.- Another thread can read counter in between these operations.
- The final result is unpredictable — it can be lower than expected.
std::mutex — Mutual Exclusion
A mutex ensures that only one thread can access shared data at a time.
Code Example
#include <iostream>
#include <thread>
#include <mutex>
#include <vector>
using namespace std;
int counter = 0;
mutex mtx; // global mutex
void increment() {
for (int i = 0; i < 100000; i++) {
mtx.lock();
counter++;
mtx.unlock();
}
}
int main() {
vector<thread> threads;
for (int i = 0; i < 10; i++) {
threads.emplace_back(increment);
}
for (auto& t : threads) t.join();
cout << "Counter: " << counter << endl; // always 1000000
return 0;
}
Code Explanation
mtx.lock()blocks the thread until the mutex is available.mtx.unlock()releases the mutex.- Only one thread can hold the lock at a time.
std::lock_guard — RAII Locking
lock_guard uses RAII to manage the lock automatically — it locks on construction and unlocks on destruction.
Code Example
#include <iostream>
#include <thread>
#include <mutex>
#include <vector>
using namespace std;
int counter = 0;
mutex mtx;
void increment() {
for (int i = 0; i < 100000; i++) {
lock_guard<mutex> lock(mtx); // locks automatically
counter++;
// unlocks automatically when lock leaves scope
}
}
int main() {
vector<thread> threads;
for (int i = 0; i < 10; i++) {
threads.emplace_back(increment);
}
for (auto& t : threads) t.join();
cout << "Counter: " << counter << endl;
return 0;
}
Code Explanation
lock_guard<mutex> lock(mtx)locks when created and unlocks when it leaves scope.- Even if an exception occurs, the lock is still released — it's safe.
std::unique_lock — Flexible Locking
unique_lock is more flexible than lock_guard — it can be locked/unlocked manually, moved, and used with condition variables.
Code Example
#include <iostream>
#include <thread>
#include <mutex>
#include <vector>
using namespace std;
int counter = 0;
mutex mtx;
void increment() {
for (int i = 0; i < 100000; i++) {
unique_lock<mutex> lock(mtx);
counter++;
lock.unlock(); // unlock manually
// can do other work without the lock
lock.lock(); // lock again
counter++;
}
}
int main() {
vector<thread> threads;
for (int i = 0; i < 10; i++) {
threads.emplace_back(increment);
}
for (auto& t : threads) t.join();
cout << "Counter: " << counter << endl;
return 0;
}
Code Explanation
unique_lockcan be locked/unlocked manually.- It can be moved (lock_guard cannot).
- It is required for
condition_variable::wait().
std::condition_variable — Signaling Between Threads
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
using namespace std;
mutex mtx;
condition_variable cv;
queue<int> dataQueue;
bool finished = false;
// Producer
void producer() {
for (int i = 1; i <= 10; i++) {
{
lock_guard<mutex> lock(mtx);
dataQueue.push(i);
cout << "[Producer] Production: " << i << endl;
}
cv.notify_one(); // notify the consumer
this_thread::sleep_for(chrono::milliseconds(100));
}
{
lock_guard<mutex> lock(mtx);
finished = true;
}
cv.notify_all();
}
// Consumer
void consumer() {
while (true) {
unique_lock<mutex> lock(mtx);
cv.wait(lock, [] { return !dataQueue.empty() || finished; });
while (!dataQueue.empty()) {
int val = dataQueue.front();
dataQueue.pop();
lock.unlock();
cout << " [Consumer] Konsumsi: " << val << endl;
lock.lock();
}
if (finished && dataQueue.empty()) break;
}
}
int main() {
thread prod(producer);
thread cons(consumer);
prod.join();
cons.join();
cout << "Done" << endl;
return 0;
}
Code Explanation
cv.wait(lock, pred)waits until the predicate becomes true.cv.notify_one()wakes up one waiting thread.cv.notify_all()wakes up all waiting threads.- Shared data must be accessed under a lock.
std::atomic — Atomic Operations
atomic provides thread-safe operations without a mutex for simple data types.
Code Example
#include <iostream>
#include <thread>
#include <atomic>
#include <vector>
using namespace std;
atomic<int> counter(0); // atomic counter
void increment() {
for (int i = 0; i < 100000; i++) {
counter++; // atomic — no mutex needed
}
}
int main() {
vector<thread> threads;
for (int i = 0; i < 10; i++) {
threads.emplace_back(increment);
}
for (auto& t : threads) t.join();
cout << "Counter: " << counter.load() << endl;
return 0;
}
Code Explanation
atomic<int>guarantees read/write operations happen atomically.- More efficient than a mutex for simple data.
- Supports operations:
++,--,+=,-=,load(),store(),exchange().
Deadlock Prevention
A deadlock occurs when two or more threads wait for each other's locks.
Deadlock Example
// DANGER — POTENTIAL DEADLOCK!
mutex mtx1, mtx2;
void thread1() {
lock_guard<mutex> lock1(mtx1);
lock_guard<mutex> lock2(mtx2); // waits for mtx2 held by thread2
// ...
}
void thread2() {
lock_guard<mutex> lock1(mtx2);
lock_guard<mutex> lock2(mtx1); // waits for mtx1 held by thread1
// ...
}
Solution: std::lock
#include <iostream>
#include <thread>
#include <mutex>
using namespace std;
mutex mtx1, mtx2;
void thread1() {
lock(mtx1, mtx2); // lock both atomically
lock_guard<mutex> lock1(mtx1, adopt_lock);
lock_guard<mutex> lock2(mtx2, adopt_lock);
cout << "Thread 1: " << endl;
}
void thread2() {
lock(mtx1, mtx2);
lock_guard<mutex> lock1(mtx1, adopt_lock);
lock_guard<mutex> lock2(mtx2, adopt_lock);
cout << "Thread 2: " << endl;
}
int main() {
thread t1(thread1);
thread t2(thread2);
t1.join();
t2.join();
return 0;
}
Code Explanation
lock(mtx1, mtx2)locks both mutexes atomically — no interleaving.adopt_locktells lock_guard that the mutex is already locked.- Golden rule: always lock mutexes in the same order.
Case Study: Producer-Consumer with a Buffer
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <chrono>
using namespace std;
class BoundedBuffer {
queue<int> buffer;
size_t maxSize;
mutex mtx;
condition_variable notFull, notEmpty;
public:
BoundedBuffer(size_t size) : maxSize(size) {}
void produce(int item) {
unique_lock<mutex> lock(mtx);
notFull.wait(lock, [this] { return buffer.size() < maxSize; });
buffer.push(item);
cout << "[Producer] Add: " << item
<< " "(content: " << buffer.size() << ")" << endl;
notEmpty.notify_one();
}
int consume() {
unique_lock<mutex> lock(mtx);
notEmpty.wait(lock, [this] { return !buffer.empty(); });
int item = buffer.front();
buffer.pop();
cout << " [Consumer] Take: " << item
<< " "(content: " << buffer.size() << ")" << endl;
notFull.notify_one();
return item;
}
};
int main() {
BoundedBuffer buffer(5);
thread producer([&buffer]() {
for (int i = 1; i <= 15; i++) {
buffer.produce(i);
this_thread::sleep_for(chrono::milliseconds(50));
}
});
thread consumer([&buffer]() {
for (int i = 0; i < 15; i++) {
buffer.consume();
this_thread::sleep_for(chrono::milliseconds(150));
}
});
producer.join();
consumer.join();
return 0;
}
Code Explanation
- The buffer is bounded — the producer waits if the buffer is full.
- The consumer waits if the buffer is empty.
condition_variablesynchronizes between producer and consumer.
Multithreading Best Practices
- Use
lock_guardorunique_lock— don't lock/unlock manually - Minimize shared state — use thread-local storage where possible
- Use
atomicfor simple data,mutexfor complex data - Avoid deadlocks — use
std::lock()or a consistent lock order - Use
join()unless the thread must run independently - Be careful with reference parameters to threads — make sure the data lives long enough
Summary
Multithreading in C++ is powerful but complex. std::thread for creating threads, mutex and lock_guard for synchronization, condition_variable for coordination, and atomic for lightweight operations. Race conditions and deadlocks are the two main problems to watch out for. Always use RAII patterns to manage resources safely.