One of the most difficult problems in concurrent program is that of coordinating cooperation between the processes. If two processes are totally unrelated to one another, then they may execute concurrently without the possibility of mutual interference. But if they share any data in common, then access to that data must be controlled to prevent inconsistencies.
Example: in our sorting race program, each process sorted a copy of the test data using a particular method. Each time it compared two items or stored a value somewhere, it updated its region of the screen to reflect the operation just performed.
Consider what updating the screen entails: first we output an escape sequence to put the cursor at the right position; then we write the new data. (The cursor positioning is handled by the procedure PUT_CURSOR.)
Now suppose two processes both tried to update the screen at the same time. Suppose the following sequence of events occurs:
Process A executes its call to PUT_CURSOR.
Process B executes its call to PUT_CURSOR, then writes its new character.
Process A writes its new character.
What is the result?
Process B's character is updated correctly.
But process A's character is written in the middle of process B's data - next to the character process B just wrote!
Another example: earlier we considered an example of concurrency in which a card-reader process (producer) generates data for a disk-writer process (consumer). To allow IO overlap, we have the processes communicate by means of a buffer, implemented as a linked queue:
send inserts a new character at rear of queue
receive removes a character from front of queue
type queueitem = record ch: char; next: ^queueitem end; var front, rear: ^queueitem; (front = nil => queue is empty) procedure send(ch: char); var p: ^queueitem; begin new(p); p^.ch := ch; p^.next := nil; if front = nil then front := p; else rear^.next := p; rear := p end; procedure receive(var ch: char); var p: ^queueitem; begin while front = nil do ; {wait for an item} p := front; front := p^.next; ch := p^.ch; dispose(p) end;Now consider the following execution sequence:
queue initially contains one item:
_____ front --> |A | rear --> |nil| -----producer starts to add a new character B at the same time consumer starts to remove A.
the producer checks to see if front = nil, and finds it is not, so B is linked in after the node containing A. But before this occurs, the consumer sets front := front^.next. The resultant state of the queue is as follows:
_____ _____ front (nil) |A | |B | rear ------->| --|--->|nil| ----- -----further, when dispose(p) is called to recycle the node that contained A, all link to B is lost. The result is that:
Examples like the above may seem contrived because they depend on a very specific (and perhaps improbable) sequence of events. But it is the very rarity of these situations that makes them so insidious. They will probably arise in practice on rare occasions, but can never be fixed because they cannot be reproduced at will.
These situations are called race conditions. A race condition occurs whenever the outcome of the execution depends on the particular order that instructions are executed.
The above is a simple illustration of the critical section problem: When two or more processes share data in common, the code that updates this data is called a critical section. To ensure integrity of the shared data, we require that at most one process is executing in its critical section for a given data structure at one time.
In the first case, the screen is the critical section. Only one process may be updating the screen at any one time.
In the second case, we require that if one process is executing send then the other may not start receive until the first has finished, and vice versa.
To deal with critical sections, we need a methodology that guarantees:
Mutual exclusion: Under no circumstances can two processes be in their critical sections (for the same data structure) at the same time.
Progress: At no time do we have a situation where a process is forced to wait forever for an event that will never occur. (This is also known as the no deadlock requirement.)
No starvation: No process waiting for a given resource can be forced to wait forever while other processes repeatedly lock and unlock the same resource. (This is also called the bounded wait or the fairness requirement.)
Solutions to the critical section problem are of two general types:
Solutions depending on special hardware facilities.
Solutions that are strictly software based - in the sense that the only characteristic of the hardware they rely on is that if two processes attempt to store a value in the same memory cell, then the hardware will guarantee that one completes the operation before the other starts - though nothing is guaranteed regarding the order and hence the final outcome.
Software solutions: two processes, one critical section each. We require that each process execute special entry code before starting its critical section, and exit code upon leaving. The problem is to specify what the entry and exit code must be:
Assume each process looks like this:
loop non-critical section; entry protocol; critical section; exit protocol; non-critical section forever
Some inadequate solutions:
TEXT (5th ed, page 159) algorithm 1. If turn = 0 and process 0 has reached a point in its computation where it never again needs to enter its critical section, then process 1 will be unable to make progress.
TEXT - (5th ed, page 160) algorithm 2. Can deadlock. (Each process sets it's flag true before either examines the other's flag.)
From alternate edition page 86 algorithm 2.
repeat while flag[j] do no-op; flag[i] := true; Critical section flag[i] := false; Remainder section until false;Does not guarantee mutual exclusion. (Each process could see the other's flag false before either sets its flag true.)
An algorithm from a former edition of the text [Algorithm 4 from 1st ed.], though inadequate, is worth considering because it shows how subtle concurrency problems can be:
repeat flag[i] := true; while flag[j] do begin flag[i] := false; while flag[j] do skip; flag[i] := true end; critical section flag[i] := false; non-critical section until false;This apparently-correct algorithm can lead to starvation for both processes if they alternate in a certain pattern:
Process 0 | Process 1 |
---|---|
sets flag[0] true | |
sets flag[1] true | |
first while - sees flag[1] true | |
first while - sees flag[0] true | |
sets flag[0] false | |
sets flag[1] false | |
second while - sees flag[1] false | |
second while - sees flag[0] false | |
sets flag[0] true | |
sets flag[1] true | |
first while - sees flag[1] true | |
first while - sees flag[0] true | |
... | |
... |
Dekker's algorithm. This algorithm was the first totally correct solution to the critical section problem, and so is of interest historically.
repeat flag[i] := true; while flag[j] do if turn = j then begin flag[i] := false; while turn = j do skip; flag[i] := true end; critical section turn := j; flag[i] := false; remainder section until false
Guarantees mutual exclusion. Proof: once a given process is in its critical region, the other process is blocked from entering until the first leaves. A violation of mutual exclusion could only occur if both processes enter their critical regions at the nearly the same time. Suppose, then, that a violation of mutual exclusion occurs because both processes execute their entry code in parallel. For process 0, we must have the sequence:
Wall clock time: flag[0] := true; t1 flag[1] is seen to be false t2 (and t1 < t2)and for process 1, we must have:
flag[1] := true; t3 flag[0] is seen to be false t4 (and t3 < t4)but process 0 sees flag[1] false which implies t2 <= t3; and process 1 sees flag[0] false which implies t4 < = t1. Since t3 < t4, this implies:
t2 <= t3 < t4 <= t1 -- therefore t2 < t1but we have already required t1 < t2 - therefore this is a contradiction, and our assumption that a violation of mutual exclusion can occur cannot be true.
Is deadlock free: Proof: deadlock could only occur if both processes decide to enter their critical section at about the same time - otherwise one process would find the other process's flag false, would proceed into it critical section, and would then clear its own flag. Assume, then that both processes have proceeded past their initial flag setting, and are now apparently deadlocked.
Assume without loss of generality that turn = 0.
Process 0 will find the condition in the if statement false, and so will do nothing while it loops in the outer while loop.
Process 1 will find the condition in the if statement true, and so will clear its flag and loop in the inner while.
But now process 0 can proceed past the outer while into its critical section. Upon completion of the critical section, it will set turn = 1, allowing process 1 to exit its inner while, and will clear its own flag, eliminating (for the moment at least) the potential deadlock.
Is starvation free: Proof: Suppose that one process is starving while the other is in its critical section. (Assume without loss of generality that process 0 is the one starving.)
When process 1 exits its critical section, it sets turn = 0 and flag[1] := false. To keep process 0 starving, it would have to immediately try to re-enter its critical section by setting flag[1] := true. But we have already shown above that this apparent deadlock would be resolved in favor of process 0 - hence process 0 would be guaranteed access to the critical section next after process 1 completes its access.
Though Dekker's algorithm satisfies all our criteria, it has some limitations:
Complexity: three variables needed to control one critical section. If a program has several critical sections, the code could easily become burdensome.
Limited as it stands to two processes, though it can be extended to any number at the price of added complexity.
Relies on busy-waiting - hence a processor can be tied up for long times doing nothing while waiting to enter a critical section.
Not suited to distributed environments. For such an environment we want an algorithm that calls for each variable only to be altered by a single process, though it may be inspected by any. In Dekker's algorithm, each process must update turn when it leaves its critical section.
Peterson's algorithm - Algorithm 3 in TEXT - (5th ed, page 161) is another correct solution that is also simpler than Dekker's algorithm.
Lamport's Bakery Algorithm (page 1 and page 2). Works for any number of processes and is suitable for a distributed environment.
Whenever a process wishes to enter its critical section, it chooses a number (as in a bakery). The algorithm guarantees that the number chosen is >= the number held by any other process waiting for or in its critical section. (It may be = if two processes choose at the same time, but this is resolved by using the process id to break the tie.)
A process may only enter its critical section if its number is the smallest among all the waiting processes - hence mutual exclusion is guaranteed. Furthermore, once a process arrives at this point, its entry is guaranteed, so deadlock is not possible. Finally, a process leaving the critical section and turning around to re-enter must get a number greater than than of any waiting process that has already chosen, so fairness is ensured.
Again, however, the code and data structures involve undesirable complexity: two arrays per critical data structure. Further, this algorithm also uses busy waiting.
Hardware-based solutions.
The complexity of software solutions arises because we cannot, in general, guarantee that a variable will not be altered between the time that a given process looks at it and the time it itself tries to change it. This is because inspecting a value and altering a value normally require two or more machine instructions, with the possibility of an interrupt (on a uniprocessor) or an access from another processor intervening.
Special hardware provisions of a fairly simple sort can greatly simplify the mutual exclusion algorithms.
On a uniprocessor, we can generally inhibit interrupts for a brief time. Many operating systems rely on this method totally for their own internal critical sections; and the operating system can use this method to furnish a system service to processes for their own use. (We will say more about what form this service might take shortly.) This will not work with multiprocessors, however.
IBM 370 series has a test and set instruction that records the current state of a variable while at the same time setting it to a new value - in such a way as to prohibit any intervening operations.
VAX has a pair of instructions Branch on Bit Clear and Clear, Interlocked (BBCCI) and Branch on Bit Set and Set, Interlocked (BBSSI). The former operates as follows:
The specified bit is tested.
The bit is then cleared.
If the bit was already clear, then a branch is taken to the specified address - else no branch is taken.
Many microprocessors, such as the Intel 8086/8088 family, have a LOCK instruction that can lock up the memory bus during the execution of the next instruction. With this, the effect of test and set, add interlocked, or swap (as in the book) can be achieved.
How do we use these hardware facilities in software? We will consider several alternate approaches.
$Id: concurrency5.html,v 1.5 1998/03/03 23:42:04 senning Exp $
These notes were written by Prof. R. Bjork of Gordon College. In February 1998 they were edited and converted into HTML by J. Senning of Gordon College.