Skip to content

Threads

benliao1 edited this page Aug 15, 2020 · 12 revisions

Threads

Threads are a mechanism to seemingly make a process run more than one section of code simultaneously. Normally, in a simple program, a process will simply step through its code, one line at a time. However, if a process spawns a bunch of threads, each thread is stepping through the code as well, meaning that one process with many threads behaves like it is executing code in multiple places at once.

Splitting code up into multiple threads is an extremely powerful tool. Here are some ways that threads are useful:

Parallel Computation

Suppose a process needs to run the same computational process on multiple sets of independent data (and the computation on one set of data does not affect computation on another set of data). Instead of one process chunking through the data one set of a time, have the process spawn several identical threads, one thread per set of data that you have, and have them all computing at the same time.

Event Handling

Suppose there is some process that is supposed to respond to inputs from multiple sources in different ways. This could be multiple buttons on a user interface, or reading from multiple pipes or sockets, etc. These actions to "respond to inputs" are often called event handlers. Even handlers often have the same structure:

  1. Wait for event to occur
  2. Handle event
  3. Repeat If you have to handle multiple events, there's no easy to way to structure your program to do the "wait for event to occur" in an efficient way. Think about it, if you have two events you need to wait for, writing code like:
while (1) {
    // wait for first event to occur
    // handle first event
    // wait for second event to occur
    // handle second event
}

won't work! Suppose you're waiting for the first event, and the second event occurs (or vice versa). You're not going to get to handling that event until the event that you're waiting on occurs! This is unacceptable. Now imagine if you created a thread, one for each event that needed to be handled. All of a sudden, you can wait for all events to happen at the same time, and handle them as they come in!

Collaboration

Threads are generally useful when reasoning about a very complex process and breaking it down into simpler tasks. Threads can "talk" to each other through shared data and communicate when to start and stop using other thread objects to synchronize their work.

Mutexes

Mutexes, short for "Mutual Exclusion Locks", are objects that are used to ensure that pieces of shared data are only accessed by one thread at a time.

Imagine a situation where there are two threads and a global variable x that is initialized to 0. Both threads do the same thing: loop 100 times a second, and each time they go through the loop, they each increment x by one. Logically, if both threads run at the same time for exactly 1 second (each thread runs 100 loops), you'd expect x = 200, right? However, this is not guaranteed without mutexes!

What can happen is that when you start the threads, each thread will read the current value of x (which is currently 0), increment it (both threads have x = 1), and then both threads write 1 to x. So after this series of events, both threads have looped once, but x = 1, not the x = 2 that expect! What we need is some way to ensure that each thread accesses x independently. To solve this, we need mutexes.

When we're writing these threads, we essentially enforce a rule that whenever we access x, we first need to "acquire the mutex" by locking it. Then we do our necessary reading and writing to x, modifying it. Lastly, we "release the mutex", or unlocking it. If a mutex is locked by another thread, other threads that are trying to acquire that lock will block when attempting to lock the mutex until the current user releases the mutex.

This ensures that each thread gains exclusive access to x (or any other shared data in a real-life situation), and that it always gets the latest data from x (hence the name Mutual Exclusion Locks).

Conditional Variables

Conditional variables are objects that are used to "put a thread to sleep" while another thread finishes doing work. They are really useful for blocking thread execution when they're not needed until some computation has been done.

Imagine a situation where you have four threads: one thread that waits for an event and three threads that process that event in different ways. This event is recurring, but doesn't happen that often (say, perhaps, approximately once per second). You'd need loops in each of the three worker threads to go back to waiting for the event to happen after processing an event, but you don't want those while loops to be looping as fast as possible, polling to see if the triggering event has occurred. The solution? Use a conditional variable!

All the threads know about a global conditional variable. The three worker threads wait on the conditional variable. The thread that is waiting for the triggering event also waits (it might be waiting for a signal to be sent, or for a child process to terminate, or for a computation to finish, etc.). When the triggering event occurs, the waiting thread signals (or broadcasts) on the conditional variable, which causes the three worker threads that were waiting for that conditional variable to wake up and process the event. Once each thread is done processing the event, the threads return to waiting on the conditional variable again.

Notice the advantage of using conditional variables. There is no extra looping required, and we can still respond immediately to events as they occur.

Functions Used When Working With Threads

The most commonly used thread functions are briefly listed here for reference; for a more detailed explanation, see their corresponding man pages and consult the Additional Resources section.

Thread Management

int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);

This function is used to create a thread. The first argument is a thread ID, so you basically declare a global variable of type pthread_t (ex. pthread_t thread_id;) and then you pass in the address of that variable as the first argument. You'll use this thread ID as an argument to some of the other functions in this section that require you to specify a thread. No need to initialize that variable before passing it in; the function will fill that variable with a value. The second argument specifies properties that our new thread should have on startup; for all of our purposes, this value is NULL. The third argument is the name of the function that will be our thread's "main" routine. It has to be declare to pointer to void (void *) and take one argument that is also a pointer to void (void *). Lastly, the last argument is a pointer to the argument that is passed to start_routine when it's initialized.

int pthread_join(pthread_t thread, void **value_ptr);

This function is called from one thread and blocks until the specified thread terminates and returns. The first argument specifies the thread to wait for / join with (it's a thread ID, which was set when the thread to be waited on / joined with was created with pthread_create()). The second argument is where any return value from the thread would be set so that the thread that is waiting or joining could access it. In Runtime, our threads never return anything that is not NULL, so this argument is always NULL.

int pthread_cancel(pthread_t thread);

This function cancels the thread specified by the single argument to the function. It does NOT block. This means that calling this function does not wait for the specified thread to terminate before returning; if you want the logic in the calling function to block until the specified thread is canceled before continuing, you must cancel it first with pthread_cancel(), and then call pthread_join() to wait for it to terminate. Moreover, there are many caveats to when and how a thread will behave when it is canceled. A thread can disable being canceled if it is performing some important operations that cannot be interrupted, or if canceling during a certain section of code would cause a memory leak, etc. A thread can only cancel on so-called "slow" I/O system calls (system calls that can block indefinitely such as read(), select(), recvfrom(), etc.) that are collectively known as "cancellation points". You may see or comment or two littered through our thread code talking about cancellation points, because they are very important in ensuring that threads and other system resources are cleaned up properly on exit.

int pthread_setcancelstate(int state, int *oldstate);

This function is used by a thread to enable or disable its cancellation. As noted in the description for pthread_cancel(), it is sometimes crucial for a thread to be able to disable its cancellation (receiving a cancellation request in the middle of some important memory-intensive operation would most definitely break something). However, a thread should not be disabling its cancellation by default, since then another thread may block indefinitely waiting for a canceled thread to terminate. Generally, it is a good idea to enable cancellation of threads around I/O operations that may block indefinitely. The first argument is one of PTHREAD_CANCEL_ENABLE or PTHREAD_CANCEL_DISABLE, which enable or disable cancellation of the calling thread, respectively. If you need to see the old cancellation state for some reason, you could pass in a pointer to int for the second argument and compare the value to one of the mentioned constants to determine the old cancellation state, but for our purposes, the second argument is always NULL.

void pthread_exit(void *value_ptr);

This function causes a thread to terminate with return value value_ptr. It basically does the same thing as return value_ptr; in the thread. We don't use this in our code, since all of our threads have return NULL as their return value, but you may see this in some online tutorials, so I thought I should include it here.

Mutexes

Mutexes are conceptually difficult to understand, but the functions that you need in order to use them are very straightforward. Here are the functions you'll need to know to understand the mutexes in our library.

int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);

This function initializes a mutex (which is of type pthread_mutex_t). This is kind of like the thread_t type from before, in that you create a variable (uninitialized) of type pthread_mutex_t, and then simply pass a pointer to that variable as the first argument to this function. The second argument is a specification of what attributes the mutex should have upon initialization, but for our intents and purposes, the second argument is just NULL.

int pthread_mutex_lock(pthread_mutex_t *mutex);

This function locks the specified mutex. If the mutex is locked when this function is called, it will block until this thread can lock the mutex (i.e. when this function returns, the calling thread will be guaranteed to have acquired the mutex).

int pthread_mutex_unlock(pthread_mutex_t *mutex);

This function unlocks the specified mutex; it does not block.

int pthread_mutex_destroy(pthread_mutex_t *mutex);

This function destroys the specified mutex. Be careful, and make sure not to destroy a mutex while you have it locked! This will result in either all of the threads that are trying to lock the mutex blocking forever (bad), or inconsistent / undefined behavior (worse). When a thread exits, you must ensure that any mutexes that it could be using are unlocked! Exiting a thread with a locked mutex will also result in other threads blocking forever and/or inconsistent / undefined behavior.

Conditional Variables

Conditional variables are also conceptually difficult to understand and the functions (especially pthread_cond_wait() have some caveats). They are briefly presented below. In the following discussion, the thread that is waiting on another thread to finish some work is called the waiting thread, and the thread that is doing the work is called the working thread.

int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);

This function initializes a conditional variable (which is of type pthread_cond_t). This is kind of like the thread_t and pthread_mutex_t types from before, in that you create a variable (uninitialized) of type pthread_cond_t, and then simply pass a pointer to that variable as the first argument to this function. The second argument is a specification of what attributes the conditional variable should have upon initialization, but for our intents and purposes, the second argument is just NULL.

int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);

This function, in one sentence, causes the waiting thread to block until some working thread signals on that conditional variable for the waiting thread to wake up and unblock. The mutex is there because it is often the case that the working thread and the waiting thread both access or modify some shared data. Whenever there is shared data between threads, you need to have a mutex over it to ensure the data doesn't get corrupted. So, the solution is that this function takes an associated mutex that must be locked by the waiting thread before the call to pthread_cond_wait(). This mutex is then immediately released by this function when called, so that the working thread can lock it and do work on the shared data. When the working thread is done, and signals to the blocking thread to wake up, the mutex is released by the working thread and, before pthread_cond_wait() returns in the blocking thread, the mutex is re-acquired by the blocking thread, so that the call to pthread_cond_wait() returns with the mutex locked. This is very important for dealing with canceling the waiting thread during a call to pthread_cond_wait() (you need to unlock the mutex before exiting the thread) and also when multiple waiting threads are waiting on the same working thread (you need to unlock the mutex after the call to pthread_cond_wait() in each of the blocking threads, otherwise the other waiting threads will never return from their calls to pthread_cond_wait() because they are waiting forever to reacquire the mutex).

int pthread_cond_signal(pthread_cond_t *cond);

This function is called from the working thread and signals on the given conditional variable. Calling this function will cause a single thread that is waiting on the given conditional variable to wake up / unblock and continue its work. If there are multiple threads waiting on the given conditional variable, only one will be woken up.

int pthread_cond_broadcast(pthread_cond_t *cond);

This function is called from the working thread and signals on the given conditional variable. Calling this function will cause all threads that are waiting on the given conditional variable to be woken up / unblocked and continue their work. Note that if the same mutex is supplied to all of the waiting threads in their calls to pthread_cond_wait(), the effect is that the waiting threads will wake up but then resume their work one by one, in an undetermined order (assuming that each waiting thread takes care of releasing that mutex when it is done modifying or accessing any shared data). If different mutexes are supplied to each waiting thread, then the effect is to wake up every waiting thread simultaneously.

int pthread_cond_destroy(pthread_cond_t *cond);

This function destroys the given conditional variable. Like mutexes, you need to make sure that you do not destroy a mutex while it is waiting (results in undefined behavior). You need to make sure that there all threads that use the conditional variable are unblocked and doing uninterrupted work before destroying your conditional variable.

Use in Runtime

Threads are used in pretty much every part of Runtime, because it helps break up our tasks into simpler jobs. For a more in-depth look at how these threads are used, please read the corresponding wiki pages.

net_handler

Threads are used here to manage the incoming and outgoing data streams. There is a thread for handling each of the following:

  • Incoming and outgoing TCP data to Dawn
  • Incoming and outgoing TCP data to Shepherd
  • Incoming UDP data from Dawn
  • Outgoing UDP data to Dawn These threads are created upon connection with Dawn / Shepherd and canceled upon disconnection from Dawn/Shepherd.

dev_handler

Upon a device connecting to the Raspberry Pi, dev_handler spawns three threads:

  • sender: a thread that reads commands to the device from shared memory, converts them to a serialized lowcar protocol message, and sends them to the device via serial port
  • receiver: a thread that reads data coming back from the device, converts it into a format understood by Runtime, and writes the data to shared memory
  • relayer: a thread that oversees the connection on this device, and handles the cleanup of resources allocated / opened for this device's connection upon disconnection of the device or shutdown of Runtime.

executor

To implement the Robot.run() function, we decided to use Python Thread objects to do this. Each time the student calls Robot.run(), we create a new Python Thread object to run the student's functions in Python, without dealing with them in C.

net_handler_client

The net_handler_client uses a thread to "catch" all of the output that net_handler is producing, unpack it, and dump it to the terminal screen so that the user can see what net_handler is producing. net_handler_client does this while at the same time receiving CLI inputs from the user; this would be not be possible without a thread.

test.c

The testing utility uses a thread to "catch" all of the output the various clients are producing and printing to standard out and route it to both the terminal and an internal storage buffer for use in output comparisons. This thread runs in parallel with the tests, which you can imagine would be very hard to do without the thread.

Additional Resources

Clone this wiki locally