Your programming assignment 2 solutions will certainly need to create threads, and may need to make use of two types of synchronization primitives: mutexes and condition variables. The following notes explain these two types of synchronization.
We provide you basic examples of creating C++ threads, locking/unlocking mutexes, and using condition variables in the file tutorial/tutorial.cpp provided in the starter code.
Creating new threads in C++ is simple. To create threads, an application constructs new instances of the std::thread object. For example, in the code below, the main thread creates two threads that run the function my_func. (Observe that the function my_func is used as an argument to the std::thread constructor.) The main thread invokes join() to determine when the execution of a spawned thread has completed.
#include <thread>
#include <stdio.h>
void my_func(int thread_id, int num_threads) {
printf("Hello from spawned thread %d of %d\n", thread_id, num_threads);
}
int main(int argc, char** argv) {
std::thread t0 = std::thread(my_func, 0, 2);
std::thread t1 = std::thread(my_func, 1, 2);
printf("The main thread is running concurrently with spawned threads.\n");
t0.join();
t1.join();
printf("Spawned threads have terminated at this point.\n");
return 0;
}
Full documentation of std::thread can be found here: https://en.cppreference.com/w/cpp/thread/thread.
Useful tutorials on creating threads in C++ 11:
- https://www.geeksforgeeks.org/multithreading-in-cpp/
- https://thispointer.com/c-11-multithreading-part-1-three-different-ways-to-create-threads/
C++ standard library provides a mutex synchronization primitive, std::mutex, for protecting shared data from simultaneous access by multiple application threads. (Note: mutex is short for "mutual exclusion").
https://en.cppreference.com/w/cpp/thread/mutex
You have already encountered mutexes in prior courses like CS110. A thread locks the mutex using mutex::lock(). The calling thread will block until the mutex lock can be acquired. When lock() returns to the caller, the calling thread is guaranteed to have the lock. A thread unlocks the mutex using mutex::unlock().
For those interested, C++ provides a number of wrapper classes that are designed to reduce bugs when using locks (e.g., forgetting to unlock a mutex). You may wish to look at the definitions of std::unique_lock and std::lock_guard. For example lock_guard automatically locks a specified mutex on construction, and unlocks the mutex when it is goes out of scope.
We recommend that you take a look at the function mutex_example() in tutorial/tutorial.cpp for a simple example of using a mutex to protect updates to a shared counter. In this example, the mutex is used to ensure the read-modify-write to the counter is performed atomically.
A condition variable manages a list of threads waiting for a condition to hold (e.g., an event to occur), and allows other threads to notify the waiting threads that the event of interest has occurred. A condition variable, when used in conjunction with a mutex, provides an easy way to send notifications between threads.
There are two major operations on a condition variable: wait() and notify().
A thread calls wait(lock) to indicate it wishes to wait until a notfication from another thread. Notice that a mutex (wrapped in a std::unique_lock) is passed to the call to wait(). When the thread is notified, the condition variable will acquire the lock. This means that when a call to wait() returns, the calling thread is the current holder of the lock. Typically the lock is used to protect a shared variable that the thread now needs to check to ensure the condition it is waiting for is true.
For example, the code in tutorial/tutorial.cpp creates N threads. N-1 of the threads wait for notification from thread 0, and then when notified, atomically increment a counter that is protected by a shared mutex.
A thread calls notify() on a condition variable to notify exactly one thread waiting on the condition variable and notify_all() to notify all threads waiting on the condition variable. Notice how in tutorial/tutorial.cpp, thread 0 releases the lock protecting the counter prior to signaling all the waiting threads.
In your task execution system implementation, how might you use notify_all()? Consider a situation where all worker threads are currently waiting for a new bulk task launch, and the application makes a call to run(), providing new tasks to execute.
Additional references:
- https://thispointer.com/c11-multithreading-part-7-condition-variables-explained
- https://www.modernescpp.com/index.php/condition-variables
C++ also provides a simple way to make operations on a variable atomic---just create a variable of the type std::atomic<T>. For example to create an integer that supports atomic increment, just create a variable of type:
std::atomic<int> my_counter;
Now operations on my_counter, like my_counter++ are guaranteed to be performed atomically. For more detail see: https://en.cppreference.com/w/cpp/atomic/atomic.