We have seen that while semaphores do provide a solution to the critical section problem, they are not an ideal solution because of the possibility of programmer error. Therefore, considerable attention has been given to better ways of handling the shared resource problem - ways that allow the compiler to enforce mutual exclusion. We will look at two of these:
Critical regions
Monitors
The first major attempt to improve on the semaphore as a synchronization primitive was the critical region, proposed by Brinch Hansen and Hoare (1972). Its essential features are the following:
Shared variables can be explicitly declared as such by using the keyword shared. This means that the compiler can enforce certain constraints regarding access to such a variable. For example
The programmer is required to precede any portion of code accessing a shared variable v with a construct of the form region v do. (The syntax is similar to that of a Pascal with statement.)
The compiler can now enforce mutual exclusion on the shared variable as follows:
The compiler can guarantee that a shared variable is only accessed from within the scope of a region statement. Thus, if v is declared shared, then the compiler would allow:
region v do writeln( v )but would disallow
writeln( v ) -- no region statementor
region w do writeln( v ) -- wrong region
The compiler can use a lower-level synchronization, such as a semaphore, to achieve the required mutual exclusion.
For each variable declared shared, the compiler can create a semaphore (not visible to the user) - perhaps named something like v_mutex.
The construct region v do S is compiled as:
(* region v do *) P( v_mutex ); S; V( v_mutex );
Note that this could be implemented by a slight modification to an existing compiler, or by a pre-processor that converts a source program into a standard language lacking the critical region construct.
An improvement on critical regions is the concept of a conditional critical region. Sometimes, a program needs to inspect a shared variable before deciding whether to proceed with an operation involving it. If the process cannot proceed, then it must release its hold on the shared variable and await some future event.
For example, in the bounded buffer problem, a process wishing to insert into the buffer would proceed as follows:
Examine the buffer pointers to see if the buffer is currently full. (This requires access to shared variables, and so must be done in a critical region.)
If the buffer is not full, then store an item, update the pointers, and leave the critical region. Otherwise, wait until some other process comes along and removes an item. But note: this also requires the process to leave its critical region - else no other process could get in to remove an item and deadlock would occur.
This problem could be solved as follows:
repeat region buffer do if possible to store character then store character successful := true else successful := false until successful-- but this would result in busy waiting
The conditional critical region allows this problem to be solved as follows:
region buffer when possible to store character do store character
The implementation of this (by the compiler) could be done by using two semaphores:
One to provide mutual exclusion on shared variables.
One to allow a process to wait for an event.
A process having a conditional critical region would wait on the first semaphore to enter the critical code. Then, if the test showed it could not proceed, it would release the first semaphore and then wait on the second. Once it passed the second, it could once again wait on the first (to get back into the critical region), do its work, and leave. (The second wait on the original semaphore can be avoided, though, by proper coding.)
The critical region concept - as such - has not been adopted by any non-experimental language. But some of its key ideas show up in the next concept we discuss.
Another useful approach is the monitor.
At about the same time that pivotal work was being done on concurrency primitives, another area of research was developing the concept of data abstraction.
The key idea is that we define an abstract data type in terms of a set of visible procedures and a set of hidden implementation code. Many data abstraction languages provide for a two part definition of an abstract data type:
A definition part - contains information that can be relied on by programmers using the type
An implementation part - contains information that cannot be used anywhere else.
The monitor concept is a fusion of ideas from abstract data types and critical regions. A monitor provides the rest of the world with a limited set of mechanisms for accessing some protected data. The visible part of a monitor consists of the headers of various procedures, each of which can be called by any other process.
The monitor is implemented in such a way as to allow only one process to be executing any of its procedures at any time.
Example: the bounded buffer problem solved using a monitor
The visible part:
monitor buffer; procedure insert(c: char); procedure remove(var c: char);
The implementation of this monitor would involve an array of characters plus two pointers, plus some lower-level access control mechanism like a semaphore. The bodies of the monitor procedures might look like this:
procedure insert; begin P( mutex ); ... V( mutex ); end; procedure remove; begin P( mutex ); ... V( mutex ); end;
In a complete implementation, the monitor programmer would be responsible for declaring the buffer and pointer variables. But the semaphore would be provided implicitly by the compiler.
Likewise, the monitor programmer would be responsible for the logic of inserting a character into the buffer, updating the pointers etc. But the compiler would provide the P() and V() operations - these would not appear as such in the source code.
We noted in our discussion of critical regions that there is sometimes a need for testing the state of protected variables before proceeding with an operation. Therefore, a complete monitor implementation would include another type of variable called a condition, with operations wait and signal. The condition is superficially like the semaphore, but has some important differences:
A process executing a wait is always blocked.
A signal executed when the queue for the condition is empty has no effect; it is not remembered.
The queue of waiters on a given signal is assumed to be FIFO.
Example - the bounded buffers monitor:
monitor buffer; const size = 100; var buff: array[1..size] of char; in, out: 1..size; full: boolean; not_empty, not_full: condition; procedure insert( c: char ); begin if full then wait( not_full ); buff[in] := c; in := in mod size + 1; full := in = out; signal( not_empty ); end; procedure remove(var c: char); begin if ( in = out ) and not full then wait( not_empty ); c := buff[out]; out := out mod size + 1; full := false; signal( not_full ); end; begin (* initialization *) in := 1; out := 1; full := false; end;
In the simplest sort of monitor, we impose the restriction that a signal operation - if it appears in a given procedure - must appear at the very end. When a signal is executed, the calling process leaves the monitor, and the first process on the queue awaiting that condition is admitted. We can picture such a monitor as follows:
P is currently in the monitor. When P leaves, one other process will be admitted.If P exits by signalling condition C1, the first Y process will be admitted
If P exits by signalling condition C3, the Z process will be admitted
If P exits by signalling condition C2, or simply exits without signalling, the first X process will be admitted.
As a further extension, we may remove the restriction that a process can only signal as its last act. (This would also allow a given call to generate more than one signal.) This raises a problem -- when a signal awakes a procedure, and the signaler wishes to remain in the monitor, one must yield to the other. One way to handle this is as follows: if a procedure contains a signal other than as its last statement, the calling process is suspended while the awakened process completes its work. The calling process has priority to re-enter the monitor over any processes awaiting at the main gate. This can be pictured as follows:
P is currently in the monitor. When P leaves, one other process will be admitted.If P exits by signalling condition C1, the first Y process will be admitted
If P exits by signalling condition C3, the Z process will be admitted
If P exits by signalling condition C2, or simply exits without signalling, the A process will be re-admitted.
$Id: concurrency7.html,v 1.3 1998/03/03 23:42:05 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.