Common Pitfalls in Multithreading: Navigating the Threaded Terrain
Common Pitfalls in Multithreading: Navigating the Threaded Terrain
October 18, 2023
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:
#include<stdio.h>#include<pthread.h>intcookies=0;void*steal_cookies(void*arg){for(inti=0;i<100000;++i){cookies++;}returnNULL;}intmain(){pthread_tkid1,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!
}
#include<iostream>#include<thread>intcookies=0;voidsteal_cookies(){for(inti=0;i<100000;++i){cookies++;}}intmain(){std::threadkid1(steal_cookies);std::threadkid2(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.
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:
#include<stdio.h>#include<pthread.h>pthread_mutex_tpiano1=PTHREAD_MUTEX_INITIALIZER;pthread_mutex_tpiano2=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);returnNULL;}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);returnNULL;}intmain(){pthread_tplayer1,player2;pthread_create(&player1,NULL,pianist_one,NULL);pthread_create(&player2,NULL,pianist_two,NULL);pthread_join(player1,NULL);pthread_join(player2,NULL);}
#include<mutex>std::mutexpiano1;std::mutexpiano2;voidpianist_one(){piano1.lock();piano2.lock();std::cout<<"Pianist One is playing!"<<std::endl;piano2.unlock();piano1.unlock();}voidpianist_two(){piano2.lock();piano1.lock();std::cout<<"Pianist Two is playing!"<<std::endl;piano1.unlock();piano2.unlock();}intmain(){std::threadplayer1(pianist_one);std::threadplayer2(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:
#include<stdio.h>#include<pthread.h>pthread_mutex_tpiano1=PTHREAD_MUTEX_INITIALIZER;pthread_mutex_tpiano2=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);returnNULL;}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);returnNULL;}
#include<mutex>std::mutexpiano1;std::mutexpiano2;voidpianist_one(){piano1.lock();piano2.lock();std::cout<<"Pianist One is playing!"<<std::endl;piano2.unlock();piano1.unlock();}voidpianist_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:
#include<stdio.h>#include<pthread.h>#include<unistd.h>pthread_mutex_tfood_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);}returnNULL;}void*small_bird_eat(void*arg){if(!pthread_mutex_trylock(&food_bowl)){printf("Little bird got some food!\n");pthread_mutex_unlock(&food_bowl);}returnNULL;}intmain(){pthread_tbig_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);}
#include<mutex>#include<chrono>std::mutexfood_bowl;voidbig_bird_eat(){while(true){food_bowl.lock();std::this_thread::sleep_for(std::chrono::milliseconds(10));food_bowl.unlock();}}voidsmall_bird_eat(){if(food_bowl.try_lock()){std::cout<<"Little bird got some food!"<<std::endl;food_bowl.unlock();}}intmain(){std::threadbig_bird(big_bird_eat);std::threadlittle_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:
#include<stdio.h>#include<pthread.h>#include<unistd.h>pthread_mutex_tfood_bowl=PTHREAD_MUTEX_INITIALIZER;pthread_cond_tcondition=PTHREAD_COND_INITIALIZER;intturn=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);}returnNULL;}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);}returnNULL;}
#include<iostream>#include<thread>#include<mutex>#include<condition_variable>std::mutexfood_bowl;std::condition_variablecv;boolfood_available=true;voidbig_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();}voidsmall_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();}intmain(){std::threadbig_bird(big_bird_eat);std::threadlittle_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.
voiddance(){while(true){/* Just keep dancing */}}intmain(){constintNUM_DANCERS=1000;std::threaddancers[NUM_DANCERS];for(inti=0;i<NUM_DANCERS;++i){dancers[i]=std::thread(dance);}for(inti=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.
#include<stdio.h>#include<pthread.h>#include<semaphore.h>sem_tdance_floor;constintMAX_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
returnNULL;}intmain(){sem_init(&dance_floor,0,MAX_DANCERS);constintNUM_DANCERS=1000;pthread_tdancers[NUM_DANCERS];for(inti=0;i<NUM_DANCERS;++i){pthread_create(&dancers[i],NULL,dance,NULL);}for(inti=0;i<NUM_DANCERS;++i){pthread_join(dancers[i],NULL);}sem_destroy(&dance_floor);}
#include<iostream>#include<thread>#include<semaphore>constintMAX_DANCE_FLOOR_CAPACITY=10;// Only allow 10 dancers for simplicity.
std::counting_semaphore<MAX_DANCE_FLOOR_CAPACITY>dance_floor(MAX_DANCE_FLOOR_CAPACITY);voiddance(intdancer_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();}intmain(){constintTOTAL_DANCERS=20;// We have 20 dancers, but only 10 can dance at once.
std::threaddancers[TOTAL_DANCERS];for(inti=0;i<TOTAL_DANCERS;++i){dancers[i]=std::thread(dance,i+1);}for(inti=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.
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:
void*responsible_artist(void*arg){while(1){int*paint=(int*)malloc(1000*sizeof(int));// Use the paint...
free(paint);}returnNULL;}
voidresponsible_artist(){while(true){int*paint=newint[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.