1. The Treacherous Terrain of Multithreading
While multithreading offers a lot of advantages, it isn't without its challenges. As developers, we need to be aware of these pitfalls to ensure our applications remain robust, efficient, and predictable.
In the last post, we explored the basics of multithreading in Python using asyncio and threading. In this post,
we'll dive into some of the most
common pitfalls in multithreading, and how to avoid them, but this time using C and C++.
2. The Most Common Pitfalls
2.1 Race Connditions
The Great Cookie Jar Heist: 🫙🍪
Imagine two kids are trying to steal cookies from a jar at the same time. Without coordination, they might end up with more cookies than expected!
Let's see this in action: C:
#include <stdio.h>
#include <pthread.h>
int cookies = 0;
void* steal_cookies(void* arg) {
for (int i = 0; i < 100000; ++i) {
cookies++;
}
return NULL;
}
int main() {
pthread_t kid1, kid2;
pthread_create(&kid1, NULL, steal_cookies, NULL);
pthread_create(&kid2, NULL, steal_cookies, NULL);
pthread_join(kid1, NULL);
pthread_join(kid2, NULL);
printf("Total cookies stolen: %d\n", cookies); // Expected 200000, might get different!
}
C++:
#include <iostream>
#include <thread>
int cookies = 0;
void steal_cookies() {
for (int i = 0; i < 100000; ++i) {
cookies++;
}
}
int main() {
std::thread kid1(steal_cookies);
std::thread kid2(steal_cookies);
kid1.join();
kid2.join();
std::cout << "Total cookies stolen: " << cookies << std::endl; // Expected 200000, might get different!
}
Here, we have two threads, kid1 and kid2, trying to steal cookies from the cookie jar. Each thread runs a loop to
steal 100000 cookies. We expect the total number of cookies stolen to be 200000, but we might get a different result
each time we run the program. Why? Because the threads are racing to increment the cookies variable. If kid1 reads
the value of cookies as 0, and then kid2 reads it as 0, and then both increment it to 1, we've lost a cookie!
Now the question is, how do we prevent this? We need to ensure that only one thread can access the cookies variable at
a time. We can do this by using a mutex (mutual exclusion lock):
What is a mutex?
A mutex is a variable that can be locked and unlocked. When a thread locks a mutex, it gains exclusive access to the
variable. This prevents other threads from accessing the variable. Once the thread is done, it unlocks the mutex. This
allows other threads to access the variable. If we use a mutex to protect the cookies variable, we can be sure that
the
total number of cookies stolen will always be 200000.
C:
#include <pthread.h>
pthread_mutex_t cookie_jar = PTHREAD_MUTEX_INITIALIZER;
void* safe_steal_cookies(void* arg) {
for (int i = 0; i < 100000; ++i) {
pthread_mutex_lock(&cookie_jar);
cookies++;
pthread_mutex_unlock(&cookie_jar);
}
return NULL;
}
C++:
#include <mutex>
std::mutex cookie_jar;
void safe_steal_cookies() {
for (int i = 0; i < 100000; ++i) {
cookie_jar.lock();
cookies++;
cookie_jar.unlock();
}
}
Here, we use the pthread_mutex_lock function to lock the mutex before incrementing the cookies variable. This
ensures that only one thread can access the variable at a time.In other words, if kid1 locks the mutex, kid2 has to
wait for kid1 to unlock the mutex before it can access the cookie jar. Once the thread is done, it unlocks the mutex
using the
pthread_mutex_unlock function. This allows other threads to access the variable. Now, we can be sure that the total
number of cookies stolen will always be 200000.
2.2 Deadlocks
The Dueling Piano Players: 🎹
Two pianists want to play on two pianos. However, they booth need both pianos to play their duet. if they can't agree on who gets to play which piano, they might just end up waiting forever! This is called a deadlock.
A deadlock is a situation where two or more threads are waiting for each other to finish, and none of them can proceed.
Let's see this in action:
C:
#include <stdio.h>
#include <pthread.h>
pthread_mutex_t piano1 = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t piano2 = PTHREAD_MUTEX_INITIALIZER;
void* pianist_one(void* arg) {
pthread_mutex_lock(&piano1);
pthread_mutex_lock(&piano2);
printf("Pianist One is playing!\n");
pthread_mutex_unlock(&piano2);
pthread_mutex_unlock(&piano1);
return NULL;
}
void* pianist_two(void* arg) {
pthread_mutex_lock(&piano2);
pthread_mutex_lock(&piano1);
printf("Pianist Two is playing!\n");
pthread_mutex_unlock(&piano1);
pthread_mutex_unlock(&piano2);
return NULL;
}
int main() {
pthread_t player1, player2;
pthread_create(&player1, NULL, pianist_one, NULL);
pthread_create(&player2, NULL, pianist_two, NULL);
pthread_join(player1, NULL);
pthread_join(player2, NULL);
}
C++:
#include <mutex>
std::mutex piano1;
std::mutex piano2;
void pianist_one() {
piano1.lock();
piano2.lock();
std::cout << "Pianist One is playing!" << std::endl;
piano2.unlock();
piano1.unlock();
}
void pianist_two() {
piano2.lock();
piano1.lock();
std::cout << "Pianist Two is playing!" << std::endl;
piano1.unlock();
piano2.unlock();
}
int main() {
std::thread player1(pianist_one);
std::thread player2(pianist_two);
player1.join();
player2.join();
}
Here, we have two threads, player1 and player2, trying to play the pianos. Each thread locks both pianos before
playing. This leads to a deadlock. player1 locks piano1 and then tries to lock piano2. However, player2 has
already locked piano2. So, player1 waits for player2 to unlock piano2. Similarly, player2 waits for player1
to unlock piano1. This leads to a deadlock. Neither thread can proceed, and the program hangs.
Now the question is, how do we prevent this? We need to ensure that both threads lock the pianos in the same order. We can do this by using a mutex:
C:
#include <stdio.h>
#include <pthread.h>
pthread_mutex_t piano1 = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t piano2 = PTHREAD_MUTEX_INITIALIZER;
void* pianist_one(void* arg) {
pthread_mutex_lock(&piano1);
pthread_mutex_lock(&piano2);
printf("Pianist One is playing!\n");
pthread_mutex_unlock(&piano2);
pthread_mutex_unlock(&piano1);
return NULL;
}
void* pianist_two(void* arg) {
pthread_mutex_lock(&piano1); // Lock piano1 first
pthread_mutex_lock(&piano2);
printf("Pianist Two is playing!\n");
pthread_mutex_unlock(&piano2);
pthread_mutex_unlock(&piano1);
return NULL;
}
C++:
#include <mutex>
std::mutex piano1;
std::mutex piano2;
void pianist_one() {
piano1.lock();
piano2.lock();
std::cout << "Pianist One is playing!" << std::endl;
piano2.unlock();
piano1.unlock();
}
void pianist_two() {
piano1.lock(); // Lock piano1 first
piano2.lock();
std::cout << "Pianist Two is playing!" << std::endl;
piano2.unlock();
piano1.unlock();
}
Here, we ensure that both threads lock piano1 first. This prevents a deadlock. Now, player1 locks piano1 and then
piano2. player2 waits for player1 to unlock piano1. Once player1 unlocks piano1, player2 locks piano1
and then piano2. This allows both threads to proceed. Now, we can be sure that the program will not hang.
2.3 Starvation
The Hungry Birds: 🐓🦆🦜
A big bird keeps eating all the food, leaving the little birds hungry. This is starvation.
I'll keep it simple,but in real world senarios, this can lead to performance issues. A more popular example of starvation is the Dining Philosophers Problem where a group of philosophers are seated around a table with a bowl of rice in the center. Each philosopher needs two forks to eat. However, there are only five forks on the table. So, if all philosophers pick up the fork on their left, no one can eat. This is called starvation.
Let's see this in action: C:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
pthread_mutex_t food_bowl = PTHREAD_MUTEX_INITIALIZER;
void* big_bird_eat(void* arg) {
while (1) {
pthread_mutex_lock(&food_bowl);
usleep(10000); // simulating eating
pthread_mutex_unlock(&food_bowl);
}
return NULL;
}
void* small_bird_eat(void* arg) {
if (!pthread_mutex_trylock(&food_bowl)) {
printf("Little bird got some food!\n");
pthread_mutex_unlock(&food_bowl);
}
return NULL;
}
int main() {
pthread_t big_bird, little_bird;
pthread_create(&big_bird, NULL, big_bird_eat, NULL);
pthread_create(&little_bird, NULL, small_bird_eat, NULL);
pthread_join(big_bird, NULL);
pthread_join(little_bird, NULL);
}
C++:
#include <mutex>
#include <chrono>
std::mutex food_bowl;
void big_bird_eat() {
while (true) {
food_bowl.lock();
std::this_thread::sleep_for(std::chrono::milliseconds(10));
food_bowl.unlock();
}
}
void small_bird_eat() {
if (food_bowl.try_lock()) {
std::cout << "Little bird got some food!" << std::endl;
food_bowl.unlock();
}
}
int main() {
std::thread big_bird(big_bird_eat);
std::thread little_bird(small_bird_eat);
big_bird.join();
little_bird.join();
}
Here, we have two threads, big_bird and little_bird, trying to eat from the food bowl. The big_bird thread keeps
eating from the food bowl. The little_bird thread tries to eat from the food bowl, but it can't because the big_bird
thread is hogging the food bowl. This leads to starvation. The little_bird thread never gets to eat.
Now the question is, how do we prevent this? We need to ensure that the little_bird thread gets a chance to eat. We
can solve this by using a mutex:
C:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
pthread_mutex_t food_bowl = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t condition = PTHREAD_COND_INITIALIZER;
int turn = 1; // 1 for big bird, 2 for small bird
void* big_bird_eat(void* arg) {
while (1) {
pthread_mutex_lock(&food_bowl);
while (turn != 1) {
pthread_cond_wait(&condition, &food_bowl);
}
usleep(10000); // simulating eating
turn = 2;
pthread_cond_signal(&condition);
pthread_mutex_unlock(&food_bowl);
}
return NULL;
}
void* small_bird_eat(void* arg) {
while (1) {
pthread_mutex_lock(&food_bowl);
while (turn != 2) {
pthread_cond_wait(&condition, &food_bowl);
}
printf("Little bird got some food!\n");
turn = 1;
pthread_cond_signal(&condition);
pthread_mutex_unlock(&food_bowl);
}
return NULL;
}
C++:
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
std::mutex food_bowl;
std::condition_variable cv;
bool food_available = true;
void big_bird_eat() {
std::unique_lock<std::mutex> lock(food_bowl);
while (!food_available) {
cv.wait(lock);
}
std::cout << "Big bird is eating!" << std::endl;
food_available= false;
cv.notify_one();
lock.unlock();
}
void small_bird_eat() {
std::unique_lock<std::mutex> lock(food_bowl);
while (food_available) {
cv.wait(lock);
}
std::cout << "Little bird is eating!" << std::endl;
food_available= true;
cv.notify_one();
lock.unlock();
}
int main() {
std::thread big_bird(big_bird_eat);
std::thread little_bird(small_bird_eat);
big_bird.join();
little_bird.join();
}
Here, we use a condition variable to ensure that the little_bird thread gets a chance to eat. The big_bird thread
eats from the food bowl, and then signals the condition variable. This allows the little_bird thread to eat. The
little_bird thread eats from the food bowl, and then signals the condition variable. This allows the big_bird thread
to eat. This ensures that both threads get a chance to eat. Now, we can be sure that the little_bird thread will not
starve.
2.4 Thread Thrashing
The Overbooked Dance Floor: 💃🪩🕺
It's friday, you're at the club and everyone wants to dance. However, the dance floor is too small to accommodate everyone. So, people keep bumping into each other, and no one can dance properly. That's a problem! 🚨
But now seriously, this is called Thread Thrashing.
Let's see this in action: C:
#include <stdio.h>
#include <pthread.h>
void* dance(void* arg) {
while (1) {
// Dance!
}
return NULL;
}
int main() {
const int NUM_DANCERS = 1000;
pthread_t dancers[NUM_DANCERS];
for (int i = 0; i < NUM_DANCERS; ++i) {
pthread_create(&dancers[i], NULL, dance, NULL);
}
for (int i= 0; i < NUM_DANCERS; ++i) {
pthread_join(dancers[i], NULL);
}
}
C++:
void dance() {
while (true) { /* Just keep dancing */ }
}
int main() {
const int NUM_DANCERS = 1000;
std::thread dancers[NUM_DANCERS];
for (int i = 0; i < NUM_DANCERS; ++i) {
dancers[i] = std::thread(dance);
}
for (int i = 0; i < NUM_DANCERS; ++i) {
dancers[i].join();
}
}
Here, we have 1000 dancers (threads) trying to dance. However, the dance floor is too small to accommodate all of them. So, the dancers keep bumping into each other, and no one can dance properly. This leads to thread thrashing. In other words, the threads keep switching between each other, and no thread can make any progress.
Now the question is, how do we prevent this? We need to ensure that the threads get a chance to dance. We can do this by
using a semaphore.
What is a semaphore?
A semaphore is a variable that keeps track of the number of resources available. In this case, the semaphore keeps track of the number of dancers that can dance at a time. We can use a semaphore to ensure that only a certain number of threads can run at a time. This prevents thread thrashing.
C:
#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>
sem_t dance_floor;
const int MAX_DANCERS = 10;
void* dance(void* arg) {
sem_wait(&dance_floor); // Wait for space on the dance floor
// Dancing...
sem_post(&dance_floor); // Leave the dance floor
return NULL;
}
int main() {
sem_init(&dance_floor, 0, MAX_DANCERS);
const int NUM_DANCERS = 1000;
pthread_t dancers[NUM_DANCERS];
for (int i = 0; i < NUM_DANCERS; ++i) {
pthread_create(&dancers[i], NULL, dance, NULL);
}
for (int i= 0; i < NUM_DANCERS; ++i) {
pthread_join(dancers[i], NULL);
}
sem_destroy(&dance_floor);
}
C++:
#include <iostream>
#include <thread>
#include <semaphore>
const int MAX_DANCE_FLOOR_CAPACITY = 10; // Only allow 10 dancers for simplicity.
std::counting_semaphore<MAX_DANCE_FLOOR_CAPACITY> dance_floor(MAX_DANCE_FLOOR_CAPACITY);
void dance(int dancer_id) {
dance_floor.acquire();
std::cout << "Dancer " << dancer_id << " is dancing!" << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // Simulate dancing
std::cout << "Dancer " << dancer_id << " is leaving the dance floor!" << std::endl;
dance_floor.release();
}
int main() {
const int TOTAL_DANCERS= 20; // We have 20 dancers, but only 10 can dance at once.
std::thread dancers[TOTAL_DANCERS];
for (int i= 0; i < TOTAL_DANCERS; ++i) {
dancers[i]= std::thread(dance, i+1);
}
for (int i= 0; i < TOTAL_DANCERS; ++i) {
dancers[i].join();
}
}
Here, we use a semaphore, to ensure that only 10 dancers can dance at a time. The other dancers wait for space on the dance floor. This prevents thread thrashing. Now, we can be sure that the dancers will get a chance to dance.
2.5 Memory Leaks
The Forgetful Artist: 👨🎨🎨
Imagine an artist who keeps buying new paint but never uses it or throws any away. This is a memory leak. and security issues.
Let's see this in action: C:
#include <stdio.h>
#include <pthread.h>
void* buy_paint(void* arg) {
while (1) {
int* paint = (int*)malloc(1000 * sizeof(int));
}
return NULL;
}
int main() {
pthread_t artist;
pthread_create(&artist, NULL, buy_paint, NULL);
pthread_join(artist, NULL);
}
C++:
void buy_paint() {
while (true) {
int* paint = new int[1000];
}
}
int main() {
std::thread artist(buy_paint);
artist.join();
}
Here, we have an artist (thread) who keeps buying paint (allocating memory) but never uses it or throws any away. This leads to a memory leak. In other words, the artist keeps allocating memory but never frees it. This can lead to performance and security issues. For example, if the artist keeps allocating memory, the system might run out of memory and crash. From the security perspective, if the artist keeps allocating memory, a malicious user might be able to exploit this vulnerability to gain access to the system. This is called a buffer overflow attack which I might discuss in a future post. To prevent memory leaks, we need to ensure that the artist frees the paint after using it. Some programming languages like Python and Java have automatic garbage collection. However, in C, we need to manually free the memory.
Let's see how we can do this:
C:
void* responsible_artist(void* arg) {
while (1) {
int* paint = (int*)malloc(1000 * sizeof(int));
// Use the paint...
free(paint);
}
return NULL;
}
C++:
void responsible_artist() {
while (true) {
int* paint = new int[1000];
// Use the paint...
delete[] paint;
}
}
Here, we ensure that the artist frees the paint after using it. This prevents memory leaks. Now, we can be sure that the artist will not run out of memory or any malicious user will not be able to exploit this vulnerability.
3. In Conclusion
In C++, you have two main options for working with threads: <thread> and <pthread.h>. <thread> is part of the C++
Standard
Library, offering a user-friendly, object-oriented interface for creating and managing threads in a portable and
standard way. On the other hand, <pthread.h> is a lower-level C library available on POSIX-compliant systems,
providing
more
control but requiring manual resource management. <thread> is the preferred choice for most C++ projects,
while <pthread.h>is used in POSIX-specific scenarios where fine-grained control is necessary or when you work with RT
applications.
Multithreading is a powerful tool that can help us build robust, efficient, and predictable applications. However, it comes with its own set of challenges. As developers, we need to be cognizant of these pitfalls to ensure our applications remain robust, efficient, and predictable.