Lesson 57
Multithreading (part 3)
Covers condition variables, atomic variables, mutex vs atomic, the producer-consumer pattern, performance considerations, common mistakes, and best practices for multithreading.
Lesson content
Condition variables
Sometimes a thread shouldn't continuously check (or poll) whether a condition has changed. Instead, it should sleep until another thread notifies it. A condition variable allows threads to wait efficiently for an event.
Think of it like waiting for a package delivery. Instead of checking your front door every minute, you wait until the doorbell rings. The doorbell is similar to a condition variable notification.
Components needed
- std::mutex – protect shared data
- std::condition_variable – put threads to sleep and wake them
- std::unique_lock – lock that can be temporarily released while waiting
Example 10: Waiting for a signal
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
std::mutex mtx;
std::condition_variable cv;
bool ready = false;
void worker()
{
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, []()
{
return ready;
});
std::cout << "Worker started!\n";
}
int main()
{
std::thread t(worker);
{
std::lock_guard<std::mutex> lock(mtx);
ready = true;
}
cv.notify_one();
t.join();
return 0;
}
// Output: Worker started!Using a predicate with wait(lock, predicate) protects against spurious wakeups, where a waiting thread may wake up even though the condition hasn't actually become true.
Atomic variables
Sometimes you only need to perform simple operations like incrementing a counter. Using a mutex works, but it can be slower due to locking overhead. C++ provides atomic variables for lightweight thread-safe operations.
#include <atomic>Example 11: Atomic counter
#include <iostream>
#include <thread>
#include <atomic>
std::atomic<int> counter(0);
void increase()
{
for (int i = 0; i < 100000; i++)
{
counter++;
}
}
int main()
{
std::thread t1(increase);
std::thread t2(increase);
t1.join();
t2.join();
std::cout << counter;
return 0;
}
// Output: 200000Mutex vs atomic
- std::mutex: protects complex critical sections, has higher overhead, is flexible, and can guard multiple variables
- std::atomic: best for simple operations, has lower overhead, is limited to atomic operations, and is usually used for individual variables
Producer-consumer pattern
One of the most common multithreading patterns is the Producer-Consumer model. The producer creates data, and the consumer processes data. A condition variable is often used so the consumer sleeps until new data is available, flowing from the producer through a shared queue to the consumer.
- Video streaming
- Download managers
- Task schedulers
- Job queues
- Print spoolers
Performance considerations
Multithreading is not always faster. Creating threads, synchronizing them, and switching between them all introduce overhead. You should consider the amount of work each thread performs, the number of CPU cores available, the cost of synchronization, and the possibility of contention (many threads waiting for the same resource).
Rule of thumb: measure performance before assuming multithreading will improve it.
Common mistakes and pitfalls
- Forgetting to join or detach a thread. Fix: every std::thread must be either joined or detached before it is destroyed
- Sharing data without synchronization. Fix: use std::lock_guard<std::mutex> or an std::atomic<int> for simple counters
- Holding locks too long. Fix: keep critical sections as short as possible—modify shared data inside the lock, then do lengthy work outside it—to reduce contention
- Using too many threads. Fix: creating one thread per tiny task can make a program slower rather than faster; prefer a thread pool for applications that execute many small tasks
- Ignoring deadlocks. Fix: always lock multiple mutexes in a consistent order, or use std::scoped_lock (C++17) when locking multiple mutexes simultaneously
Best practices
- Prefer std::lock_guard or std::unique_lock over manual lock()/unlock()
- Use std::atomic for simple shared counters and flags
- Minimize shared mutable state
- Keep critical sections as short as possible
- Avoid detached threads unless their lifetime is well understood
- Prefer passing data by value when practical to reduce sharing
- Use condition variables instead of busy waiting
- Profile your program before introducing multithreading
- Test multithreaded code thoroughly, as concurrency bugs can be difficult to reproduce
When not to use this
- The task is very small and thread creation overhead outweighs the benefits
- The work must be performed strictly in sequence
- Shared-state synchronization becomes too complex
- The application spends most of its time waiting on a single resource
- Simplicity and maintainability are more important than potential performance gains
Summary
- A thread is the smallest unit of execution within a process
- std::thread creates and starts a new thread
- Threads should be joined or detached before their std::thread objects are destroyed
- Shared memory enables communication but requires synchronization
- A race condition occurs when multiple threads access shared data without proper coordination
- A mutex protects critical sections
- std::lock_guard provides exception-safe mutex management
- Condition variables allow threads to wait efficiently for events
- Atomic variables are useful for simple thread-safe operations
- Deadlocks occur when threads wait indefinitely for resources held by one another
- Good multithreaded programs balance performance, correctness, and maintainability
Frequently asked questions
Is multithreading always faster?
No. Thread creation, synchronization, and context switching all have costs. Multithreading helps most when tasks can execute independently and there's enough work to keep multiple CPU cores busy.
What's the difference between a process and a thread?
A process is an independent program with its own memory space. A thread is a unit of execution within a process, sharing the process's memory with other threads.
When should I use std::atomic instead of std::mutex?
Use std::atomic for simple operations (like incrementing a counter or setting a flag). Use std::mutex when multiple operations or multiple shared variables must be protected together.
Why do race conditions happen?
Because multiple threads access the same data concurrently without proper synchronization, causing unpredictable execution order and potentially incorrect results.
What is a deadlock?
A deadlock occurs when two or more threads wait indefinitely for resources held by one another, preventing any of them from making progress.
Cheat sheet: thread management
- std::thread – create a thread
- join() – wait for a thread to finish
- detach() – allow a thread to run independently
- joinable() – check if a thread can be joined or detached
Cheat sheet: synchronization
- std::mutex – protect shared resources
- std::lock_guard – automatic mutex management
- std::unique_lock – flexible locking, required for condition variables
- std::condition_variable – wait for and notify events
- std::atomic – lock-free operations on simple shared variables
Cheat sheet: common problems
- Race condition – use mutexes or atomics
- Deadlock – lock resources consistently or use std::scoped_lock
- Busy waiting – use condition variables
- Forgotten join() – always join or detach threads
Conclusion
Multithreading Basics in C++ provide the foundation for writing responsive and high-performance applications. While concurrency introduces additional complexity, understanding threads, synchronization, mutexes, condition variables, and atomic operations equips you to build correct and efficient multithreaded programs.
As you continue your C++ journey, consider exploring advanced topics such as thread pools, std::future and std::async, promises, parallel algorithms, and the C++ memory model to deepen your understanding of concurrent programming.