CS170: Project 3 - Thread Synchronization (20% of project score)

Project Goals


The goals of this project are:

  • to enable multiple threads to safely coexist
  • to extend our basic thread library with important synchronization primitives

Administrative Information


The project is an individual project. It is due on Tuesday, May 14, 2019, 23:59:59 PST (no deadline extensions or late turn ins).

Adding synchronization to your Linux thread library


In the previous project, we have seen how we can start threads, let them perform computations, and then exit. However, we have not considered (intentional or unintentional) interactions between threads. In this project, we want to address this issue and add support so that multiple threads can safely co-exist.

The project consists of three parts:

  1. First, you have to add a basic locking mechanism to your thread library. For this, you have to implement the functions void lock() and void unlock(). Whenever a thread calls lock, it can no longer be interrupted by any other thread. Once it calls unlock, the thread resumes its normal status and will be scheduled whenever an alarm signal is received. Having the lock and unlock functions available is useful to protect small pieces of code (critical sections) during which you do not want that the current thread is interrupted. For example, lock and unlock should be used whenever your thread library manipulates global data structures (such as the list of running threads). A thread is supposed to call unlock only when it has previously performed a lock operation. Moreover, the result of calling lock twice without invoking an intermediate unlock is undefined.

  2. Second, you have to implement the following POSIX thread function:

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

    The pthread_join() function shall suspend execution of the calling thread until the target thread terminates, unless the target thread has already terminated. On return from a successful pthread_join() call with a non-NULL value_ptr argument, the value passed to pthread_exit() by the terminating thread shall be made available in the location referenced by value_ptr. When a pthread_join() returns successfully, the target thread has been terminated. The results of multiple simultaneous calls to pthread_join() specifying the same target thread are undefined.

    As you can see, with pthread_join, there is now the need to correctly handle the exit code of threads that terminate.

  3. Third, you are supposed to add semaphore support to your library. As discussed in class, semaphores are a useful tool to allow multiple threads to coordinate their actions. We will implement the following functions (for which you should include semaphore.h):

          int sem_init(sem_t *sem, int pshared, unsigned int value);
        

    The sem_init() function shall initialize the unnamed semaphore referred to by sem. The value of the initialized semaphore shall be value. The value has to be less that SEM_VALUE_MAX, which is 65,536 for this project. The pshared argument has to be always zero, which means that the semaphore is shared between threads of the process. Attempting to initialize an already initialized semaphore results in undefined behavior.

           int sem_destroy(sem_t *sem);
        

    sem_destroy() destroys the unnamed semaphore at the address pointed to by sem. Only a semaphore that has been initialized by sem_init should be destroyed using sem_destroy. Destroying a semaphore that other threads are currently blocked on (in sem_wait) produces undefined behavior. Using a semaphore that has been destroyed produces undefined results, until the semaphore has been reinitialized using sem_init.

          int sem_wait(sem_t *sem);
        

    sem_wait decrements (locks) the semaphore pointed to by sem. If the semaphore’s value is greater than zero, then the decrement proceeds, and the function returns immediately. If the semaphore currently has the value zero, then the call blocks until it becomes possible to perform the decrement (i.e., the semaphore value rises above zero). Note that in this implementation, the value of the semaphore never falls below zero (unlike how it was shown in class).

         int sem_post(sem_t *sem);
        

    sem_post() increments (unlocks) the semaphore pointed to by sem. If the semaphore’s value consequently becomes greater than zero, then another thread blocked in a sem_wait call will be woken up and proceeds to lock the semaphore. Note that when a thread is woken up and takes the lock as part of sem_post, the value of the semaphore will remain zero.

Implementation


In this project, you will build upon the thread library that you developed for the previous project. Thus, as a first step, make sure that everything works well. Then, extend your library as outlined above.

Adding the lock and the unlock functions is straightforward. To lock, you just need a way to make sure that the currently running thread can no longer be interrupted by an alarm signal. For this, you can make use of the sigprocmask function. To unlock, simply re-enable (unblock) the alarm signal, again using sigprocmask. Once you have lock and unlock, use them to protect all accesses to global data structures. You will definitely need to call these functions when implementing the semaphore routines.

To implement the pthread_join function, you will probably need to introduce a BLOCKED status for your threads. Whenever a thread is blocked, it cannot be selected by the scheduler. A thread becomes blocked when it attempts to join a thread that is still running.

In addition to blocking threads that wait for (attempt to join) active processes, you might also need to modify pthread_exit. In particular, whenever a thread exits, you cannot immediately clean up everything that is related to it. Instead, you need to retain its return value, because other threads might later want to get this return value by calling pthread_join. That is, once a thread exits, it is not immediately gone, but becomes a "zombie" thread (very similar to the situation with processes). Once a thread's exit value is collected via a call to pthread_join, you can free all resources related to this thread.

One question that might arise is how you can obtain the return (exit) value of a thread that does not call pthread_exit explicitly. We know that, in this case, we have to use the return value of the thread's start function (man pthread_exit). This should be fairly easy if you have used a wrapper function for the previous project. Since you explicitly called the start function from the wrapper, you can simply obtain (and then process) its return value once the start function returns.

When implementing semaphores, recall that you should first include the appropriate header file semaphore.h. This file includes /usr/include/bits/semaphore.h, where you can find the definition of the type sem_t. You will notice that this struct/union is likely not sufficient to store all relevant information for your semaphores. Thus, you might want to create your own semaphore structure that stores the current value, a pointer to a queue for threats that are waiting, and a flag that indicates whether the semaphore is initialized. Then, you can use one of the fields of the sem_t struct (for example, __align) to hold a reference to (or index of) your own semaphore.

Once you have your semaphore data structure, just go ahead and implement the semaphore functions as described above. Make sure that you test a scenario where a semaphore is initialized with a value of 1, and multiple threads use this semaphore to manage access to a critical code section that requires mutual exclusion (i.e., they invoke sem_wait before entering the critical section). When using your semaphore, only a single thread should be in the critical section at any time. The other threads need to wait until the first thread exits and calls sem_post. At this point, the next thread can proceed.

Deliverables


Please follow the instructions below exactly!

  1. We use gradescope to manage your project submissions and to communicate the results back to you. You will submit all files that are part of your project via the gradescope web interface.
  2. All your files must be in a directory named sync. The name of the threads library that we will test must be threads.o, and the POSIX function implementation must be done in C/C++. Of course, you cannot leverage any of the existing pthread library code to implement your thread library.
  3. All files that you need to build your library must be included (sources, headers, makefile) in that folder. We will just call make and expect that the object file threads.o is built from your sources. Please do not include any object or executable files.
  4. Gradescope does support built-in autograding, but, currently, we do not intend to use it. Instead, we will test your projects in our own environment. So, do not worry if you don't get immediate feedback or if the system tells you that the autograder is not running.
  5. Your project must compile on a CSIL machine. If you worked on a Windows machine or your laptop at home, then make sure it still works on CSIL or modify it appropriately!
  6. Include a README with this project. Explain what you did in the README. If you had problems, tell us why and what.