Sharing data between processes requires synchronization to avoid e.g. race conditions. Standard example is the producer-consumer program with shared memory (see Process communication).
A race condition occurs when more than one process reads/writes shared data in a way such that the correctness of the computation depends on the order of execution.
The producer-consumer problem is an instance of the critical-section problem:
Process structure:
repeat
[entry section] - ask for permission to enter CS
[Critical Section]
[exit section] - clean up, let others know this proc. exited CS
[remainder section] - non-critical code
Requirements on a solution of the CS problem:
1 is the foundation. 2 makes sure, e.g., that a solution is not to simply alternate/take turns. 3 makes sure, e.g., that a solution is not to allow only one process.
Assumptions for solutions:
Relatively simple solutions exist for the case of exactly 2 processes. Classic algorithm: Peterson's Solution, which requires load/store/test instructions to be atomic.
Notation: j = 1-i (i.e. Pi and Pj "the process" and "the other process")
shared bool flag[2], int turn;
repeat
flag[i] = TRUE; - process i wants to enter CS
turn = j; - allow the other to run
while (flag[j] == TRUE and turn == j)
do nothing; - wait for the other process to finish
[Critical Section]
flag[i] = FALSE; - we're not interested anymore
[remainder section]
Exercise: understand the proof in the book.
Easiest way: use a "sledgehammer" - disable interrupts, preventing preemption.
Works in single-CPU systems with non-preemptive kernel: while in kernel/supervisor mode (executing system calls), no preemption is done. However, preemptive kernels are better for real-time programming, and may be more responsive.
Disabling interrupts on multiprocessors is time consuming (all processors must effectively synchronise!). In general also "overkill" and only useful for very short pieces of code.
Standard solution: provide special atomic instructions for supporting CS problem solution, which cannot be interrupted.
Example use:
shared lock = FALSE;
repeat
while (Test-and-Set(lock)) - lock it
do nothing; - already locked
[Critical Section]
lock = FALSE; - unlock
[remainder section]
Doesn't satisfy Bounded Waiting - slightly more complex version does (fig 6.8).
Above solutions use busy waiting (tight loops testing the lock/flags etc), which is wasteful since it keeps the process in the RQ although it "should" be waiting.
Semaphores (invented by Edsger W. Dijkstra) provide a general and easy-to-use solution. Typically implemented by the operating system, avoiding busy waiting.
Structure:
Value of S is "the number of wait operations that can be done without blocking".
Simple example implementation of operations:
wait(semaphore S)
{
while (S <= 0) do nothing; - test is ATOMIC
S--; - ATOMIC
}
signal(semaphore S)
{
S++; - ATOMIC
}
"Real" implementations later.
Typical uses of semaphores: mutual exclusion, synchronisation, and counting.
Critical-section solution: use a semaphore mutex (for mutual exclusion) with initial value 1.
repeat wait(mutex); [Critical Section] signal(mutex); [remainder section]
Suppose the code S1 in process P1 must complete before the code S2 in P2 may start: use a semaphore synch with initial value 0.
P1: S1;
signal(synch);
P2: wait(synch);
S2;
Use three semaphores: one mutex=1, one for counting occupied slots (full=0), one for counting free slots (empty=N).
Producer:
int in = 0; while (<more data produced>) wait(empty); - wait for an empty slot wait(mutex); - lock the buffer buf[in] = next data; in = (in + 1) mod N; signal(mutex); - unlock buffer signal(full); - one more occupied
int out = 0; do wait(full); - wait for an occupied slot wait(mutex); - lock buffer next data = buf[out] out = (out + 1) mod N; signal(mutex); - unlock buffer signal(empty); - one more free <consume data>
To avoid busy waiting, let OS block processes using waiting lists.
wait(semaphore S) - ATOMIC
{
S--;
if (S < 0) {
add process to S->list; - end of atomic
block(process); - move process to semaphore waiting list
}
}
signal(semaphore S) - ATOMIC
{
S++;
if (S <= 0) {
pick a process from S->list; - end of atomic
wakeup(process); - move it to ready list
}
}
Still, need atomic instructions or busy wait for the critical sections of the semaphore implementation! But this is OK:
Main problem: the programmer must use the semaphores correctly!!
Example: mutex semaphores S and Q
P1: P2: wait(S) wait(Q) wait(Q) wait(S) ... .... signal(Q) signal(Q) signal(S) signal(S)
Leads to deadlock: both P1 and P2 are waiting for eachother! (More on this later.)
Of course all (write/test) accesses to data structures must use the same locking, but programmers tend to forget this, which leads to race conditions.
(Additionally, starvation may occur if semaphores are incorrectly implemented.)
Solution: abstraction (as usual)
Abstract data type/class which ensures only one process at a time is executing a method, and in addition different but similar wait/signal operations.
Declare methods synchronized locks all synchronized methods in the object when one is called - no other process/thread may enter a synchronized method until this one returns. Also works on blocks/objects. (Note that locks are reentrant - if you are already executing a synchronized method, you can call another synchronized method.)
Use wait() to wait until notify() (or notifyAll()) is called.
In addition, has explicit locks, and condition variables
similar to semaphores (not counting ones).