HolyCowMan

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.

Reply to this post by email ↪