Multithreading in C: A Guide to a Parallel universe
In the age of 96 core CPUs multithreading is a no-brainer. To harness even a fraction of the computing power available today we need to spread the work as well as we can across as many cores as possible.
Multithreading allows us to do exactly this, we can split the work load into multiple parts that multiple cores can deal with simultaneously, improving speed and/or responsiveness.
Types of multithreading
There are two main uses of multithreading, data parallelism and task parallelism. Data parallelism splits the data up and distributes it to worker threads whereas task parallelism splits the tasks up (i.e. user interface thread and a processing thread).
There are two often employed forms of mulithreading, one per X models and threadpools. Threadpools utilise a queue to distribute work to a fixed number of threads where as one per X models create new threads for each task.
Threads in C
In UNIX/Linux threading is handled by a library: pthread.h
. This is different on Windows and won’t be covered here. The pthread
library has a few functions that are helpful to us. Amongst these are pthread_create
and pthread_join
.
pthread_create
takes four arguments. A memory address to store the thread’s handle, thread attributes, the thread’s function and one argument to pass to the thread, in that order. This creates a thread that runs the function you pass to it.
pthread_join
takes two arguments. The first is the thread’s handle to join and the second is a pointer to where to store the exit status of the thread. Join waits for the child threads to finish, and clears it’s memory once it has done so. This stops the parent thread.
Thread attributes
Atrribute | Explanation |
---|---|
scope | The set of threads that are competing with this thread for resources |
detachstate | A nondetached thread will leak memory if not joined, whereas a detached thread doesn't need to be joined. |
Thread synchronisation
There are three common tools used to synchronise threads in modern operating systems: mutex locks
, semaphores
and condition variables
. Mutex locks
are effectively structures that store which thread can currently access a variable, preventing others who cannot. Semaphores
are more like global variables that store statuses of things, a mutex lock
is like a binary semaphore
with ownership data on top.
Mutex locks
You can think of a mutex as a landline phone. Only the person who is holding the phone can actually talk to the person on the other side, the other people who wish to talk must wait for their turn.
A mutex is very similar. Those people in the line can still think about who they’re going to call and what they’re going to say but they must wait for their turn.
A mutex is usually used in a similar fashion, a thread can do all of the compute it needs to do but must wait until it has acquired the mutex before it can change a variable or piece of memory.
Semaphores
A semaphore can be thought of as a way to enhance a mutex. Imagine our previous example of people queueing to use a phone. Previously there was only one phone that could be used, but imagine if there are multiple phones and we want to use them all.
If, as soon as a phone became free, everyone rushed toward it we would likely end up with multiple people at the same phone. What if we instead changed our process to have one person at the front of the queue and only they can choose to use the phone.
This is similar to the idea of a semaphore. A semaphore could represent the number of phones free, but if multiple threads finish at the same time (multiple people go for the same phone) we might end up with an error. This is why semaphores are usually combined with mutex variables to ensure only one thread can update the semaphore at once, much like our person at the front of the queue.
Condition variables
Condition variables can be thought of as someone with a megaphone. Imagine our phone example, a condition variables is like if the person who had just finished their phone call turned around and shouted that the phone was free. The people in the queue are waiting for their signal to go to the phone, in this case that would be the person shouting.
Condition variables are much like this, a thread can broadcast a signal to other threads, and those threads can wait for a specific signal. In the code snippets below this is used to signal to the main thread that data is ready for processing.
Examples
Creating and joining threads
1#include <pthread.h>
2#include <stdio.h>
3
4void *thread_func(void *arg) {
5 // Do some work in the thread
6 return NULL;
7}
8
9int main() {
10 pthread_t thread;
11 // Creates the thread, storing the handle into thread and executing thread_func
12 pthread_create(&thread, NULL, thread_func, NULL);
13
14 // Waits here until the child thread returns
15 pthread_join(thread, NULL);
16 return 0;
17}
Mutexes and condition variables
1#include <pthread.h>
2#include <stdio.h>
3#include <stdlib.h>
4
5pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
6pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
7
8int data_ready = 0;
9
10void* thread_function(void* arg) {
11 // Lock the mutex
12 pthread_mutex_lock(&mutex);
13
14 // Do some work
15
16 // Set the data_ready flag to 1
17 data_ready = 1;
18
19 // Signal the condition variable
20 pthread_cond_signal(&cond);
21
22 // Unlock the mutex
23 pthread_mutex_unlock(&mutex);
24 pthread_exit(NULL);
25}
26
27int main(int argc, char* argv[]) {
28 pthread_t thread;
29
30 // Create the thread
31 pthread_create(&thread, NULL, thread_function, NULL);
32
33 // Lock the mutex
34 pthread_mutex_lock(&mutex);
35
36 // Wait for the condition variable to be signaled
37 while (!data_ready) {
38 pthread_cond_wait(&cond, &mutex);
39 }
40
41 // The data is now ready, so we can do something with it.
42 printf("Data is ready.\n");
43
44 // Unlock the mutex
45 pthread_mutex_unlock(&mutex);
46
47 // Wait for the thread to finish
48 pthread_join(thread, NULL);
49
50 return 0;
51}
Implementing a threadpool
For this we use a standard queue implementation that is part of the variable v
.
1// While we haven't pressed ctrl-c
2while (!stop_threads) {
3 // Acquire lock and wait if the queue is empty
4 pthread_mutex_lock(&v->packet_queue_mutex);
5 while(isempty(v->packet_queue)) {
6 pthread_cond_wait(&v->packet_queue_cond, &v->packet_queue_mutex);
7
8 // If we finish waiting and we have now pressed ctrl-c, break the loop
9 if(stop_threads) {
10 break;
11 }
12}
13
14// If we have pressed ctrl-c, release the lock and break again
15if(stop_threads) {
16 pthread_mutex_unlock(&v->packet_queue_mutex);
17 break;
18}
19
20pthread_exit(NULL);
In the above code, we utilise a global variable stop_threads in order to end threads when the parent process has ended. This is set in the parent thread and then the packet_queue_cond is broadcast once ctrl-c is pressed, hence the checks. We acquire the lock for the queue, and check if the queue is empty. If it is, we wait for it to stop being empty using a condition variable packet_queue_cond.