Race conditions occur when multiple threads access the same data, non-atomically modifying it, and the result depends on the order in which their operations are interleaved.
Recall that atomic means indivisible; a sequence of operations is atomic with respect to some data if that data cannot be accessed (read or written) by another thread during that sequence.
We'll discuss two concepts that are independent parts of synchronization:
General points about synchronization:
sthread_mutex_lock()
and sthread_mutex_unlock()
.sthread_mutex_lock
with
the same lock twice in a row?pthread_exit(3)
or by returning
from the thread's initial function.A
creates thread B
and sets B
off computing some value. A
then computes on it's own, but eventually needs the value
B
was computing. So it joins with B
,
to make sure B
was actually finished.pthread_exit(3)
and pthread_join(3)
sem_wait(3)
and sem_post(3)
.sthread_cond_wait()
: Atomically do [release lock,
add to condition variable queue, sleep]. Reacquire lock before returning.sthread_cond_signal()
: Wake a single waiter, if any
currently waiting.sthread_cond_broadcast()
: Wake all currently waiting waiters.So, why do we need these condition variable things anyway? We'll use a common example, passing data through an unlimited size buffer between two threads, and attempt to solve it without the condition variable. Like simplethreads, we are using Mesa-style condition variables.
We'll assume the following code is used to initialize the system appropriatly:
Initialization |
---|
sthread_mutex_t lock = sthread_mutex_init();
|
The correct version, with condition variables. We assume that two threads are using the code, one calling AddToBuffer and another calling RemoveFromBuffer.
AddToBuffer | RemoveFromBuffer |
---|---|
sthread_mutex_lock(lock);
|
sthread_mutex_lock(lock);
|
First, lets try just removing condition variable:
AddToBuffer | RemoveFromBuffer |
---|---|
sthread_mutex_lock(lock);
|
sthread_mutex_lock(lock);
|
Obviously no good - the while loop holds the lock, meaning no way to run AddToBuffer, so while will run forever....
OK, so release the lock in the while loop (but remember to reacquire it before checking if anything is in the buffer):
AddToBuffer | RemoveFromBuffer |
---|---|
sthread_mutex_lock(lock);
|
sthread_mutex_lock(lock);
|
This code will work (surprisingly), but very slowly - if one thread enters RemoveFromBuffer (with an empty buffer) and then another thread enters AddToBuffer, the only way to make progress is to context switch out of RemoveFromBuffer in-between the two lock statements. That's no good.
What if we add a sthread_yield()
to speed things up a bit
- that way, at least the scheduler will have a better chance of
context switching us there. (That the algorithm is correct, but very
inefficient, is a good sign we have an inter-thread scheduling problem
and not a mutual exclusion problem).
AddToBuffer | RemoveFromBuffer |
---|---|
sthread_mutex_lock(lock);
|
sthread_mutex_lock(lock);
|
That is looking a little better, but a yield isn't quite right - it is quite likely we will have to go around the while loop many, many times before some thread actually adds anything to the buffer. What we really want is to sleep until there is data available, and have AddToBuffer wake us up.
Lets try and do that by adding a queue to hold waiting threads
(call it waiters
).
AddToBuffer will take the first thread off of the queue and wake it up.
We can then change the sthread_yield()
to a sleep(3)
,
since AddToBuffer should wake us up...
AddToBuffer | RemoveFromBuffer |
---|---|
sthread_mutex_lock(lock);
|
sthread_mutex_lock(lock);
|
OK, that looks good. Where is the problem? Consider a context switch from thread A between lines 1 and 2 in RemoveFromBuffer to thread B just entering AddToBuffer. Thread B would successfully add the item to the buffer, and then check if any waiters were available. Since thread A has not yet added itself to the waiters queue, it wouldn't find any, and it would return. At some point, thread A would run again, and would sleep - even though there is actually data available in the buffer.
What we need is an atomic version of lines 1 through 4. Hmm, that happens to be exactly what a condition variable does. Note that we were able to solve the locking/consistency part of the problem without condition variables, but to get the scheduling part right, we needed them.
You may also be wondering: When would we ever need more than 1
condition variable? Here is a quick example where we have a
finite-size buffer, and add another CV, space_available
,
to handle the inter-thread scheduling constraints imposed by a buffer
being full:
AddToBuffer | RemoveFromBuffer |
---|---|
sthread_mutex_lock(lock);
|
sthread_mutex_lock(lock);
|
Note that both CVs use the same lock; the lock is really to provide mutual exclusion to the buffer data structure, and since both conditions are related to that data structure, both CVs use the same lock.
Basically, anytime you have multiple different conditions, use another condition variable.