Synchronization
If sharing of resources between threads is not handled in a careful, controlled fashion, the result is usually a big mess.
This is especially the case in operating system kernels, where faulty sharing can crash the entire machine.
Pintos provides several synchronization primitives to help out.
Disabling Interrupts
The crudest way to do synchronization is to disable interrupts, that is, to temporarily prevent the CPU from responding to interrupts.
If interrupts are off, no other thread will preempt the running thread, because thread preemption is driven by the timer interrupt.
If interrupts are on, as they normally are, then the running thread may be preempted by another at any time, whether between two C statements or even within the execution of one.
Incidentally, this means that Pintos is a "preemptible kernel," that is, kernel threads can be preempted at any time. Traditional Unix systems are "nonpreemptible," that is, kernel threads can only be preempted at points where they explicitly call into the scheduler. (User programs can be preempted at any time in both models.) As you might imagine, preemptible kernels require more explicit synchronization.
Types and Functions
Types and functions for disabling and enabling interrupts are in threads/interrupt.h
.
Notes
You should have little need to set the interrupt state directly. Most of the time you should use the other synchronization primitives described in the following sections.
The main reason to disable interrupts is to synchronize kernel threads with external interrupt handlers, which cannot sleep and thus cannot use most other forms of synchronization (see section External Interrupt Handling).
Some external interrupts cannot be postponed, even by disabling interrupts. These interrupts, called non-maskable interrupts (NMIs), are supposed to be used only in emergencies, e.g. when the computer is on fire. Pintos does not handle non-maskable interrupts.
Semaphores
Semaphores were invented by Edsger Dijkstra and first used in the THE operating system
. A semaphore is a nonnegative integer together with two operators that manipulate it atomically, which are:
"Down" or "P"
wait for the value to become positive, then decrement it.
"Up" or "V"
increment the value (and wake up one waiting thread, if any).
A semaphore initialized to 0 may be used to wait for an event that will happen exactly once.
For example, suppose thread A starts another thread B and wants to wait for B to signal that some activity is complete.
A can create a semaphore initialized to 0, pass it to B as it starts it, and then "down" the semaphore. When B finishes its activity, it "ups" the semaphore. This works regardless of whether A "downs" the semaphore or B "ups" it first.
A semaphore initialized to 1 is typically used for controlling access to a resource.
Before a block of code starts using the resource, it "downs" the semaphore, then after it is done with the resource it "ups" the resource.
In such a case a lock, described below, may be more appropriate.
Semaphores can also be initialized to values larger than 1. These are rarely used.
Types and Functions
Pintos' semaphore type and operations are declared in threads/synch.h
.
Notes
Semaphores are internally built out of disabling interrupt (see section Disabling Interrupts) and thread blocking and unblocking (
thread_block()
andthread_unblock()
).Each semaphore maintains a list of waiting threads, using the linked list implementation in
lib/kernel/list.c
.
Locks
A lock is like a semaphore with an initial value of (see section Semaphores). A lock's equivalent of "up" is called "release", and the "down" operation is called "acquire".
Types and Functions
Lock types and functions are declared in threads/synch.h
.
Notes
Compared to a semaphore, a lock has one added restriction: only the thread that acquires a lock, called the lock's "owner", is allowed to release it. If this restriction is a problem, it's a good sign that a semaphore should be used, instead of a lock.
Locks in Pintos are not "recursive," that is, it is an error for the thread currently holding a lock to try to acquire that lock.
Monitors
A monitor is a higher-level form of synchronization than a semaphore or a lock.
A monitor consists of data being synchronized, plus a lock, called the monitor lock, and one or more condition variables.
Before it accesses the protected data, a thread first acquires the monitor lock. It is then said to be "in the monitor".
While in the monitor, the thread has control over all the protected data, which it may freely examine or modify. When access to the protected data is complete, it releases the monitor lock.
Condition variables allow code in the monitor to wait for a condition to become true. Each condition variable is associated with an abstract condition, e.g. "some data has arrived for processing" or "over 10 seconds has passed since the user's last keystroke".
When code in the monitor needs to wait for a condition to become true, it "waits" on the associated condition variable, which releases the lock and waits for the condition to be signaled.
If, on the other hand, it has caused one of these conditions to become true, it "signals" the condition to wake up one waiter, or "broadcasts" the condition to wake all of them.
Types and Functions
Condition variable types and functions are declared in threads/synch.h
.
Notes
The theoretical framework for monitors was laid out by
C. A. R. Hoare
.Their practical usage was later elaborated in a paper on the
Mesa
operating system.
Monitor example
The classical example of a monitor is handling a buffer into which one or more "producer" threads write characters and out of which one or more "consumer" threads read characters.
To implement this we need, besides the monitor lock, two condition variables which we will call not_full and not_empty:
Note that
BUF_SIZE
must divide evenly intoSIZE_MAX + 1
for the above code to be completely correct. Otherwise, it will fail the first timehead
wraps around to 0.In practice,
BUF_SIZE
would ordinarily be a power of 2.
Optimization Barrier
An optimization barrier is a special statement that prevents the compiler from making assumptions about the state of memory across the barrier.
The compiler will not reorder reads or writes of variables across the barrier or assume that a variable's value is unmodified across the barrier, except for local variables whose address is never taken.
In Pintos,
threads/synch.h
defines thebarrier()
macro as an optimization barrier.
One reason to use an optimization barrier is when data can change asynchronously, without the compiler's knowledge, e.g. by another thread or an interrupt handler.
The
too_many_loops()
function indevices/timer.c
is an example. This function starts out by busy-waiting in a loop until a timer tick occurs:
Without an optimization barrier in the loop, the compiler could conclude that the loop would never terminate, because
start
andticks
start out equal and the loop itself never changes them. It could then "optimize" the function into an infinite loop, which would definitely be undesirable.
Optimization barriers can be used to avoid other compiler optimizations.
The
busy_wait()
function, also indevices/timer.c
, is an example. It contains this loop:
The goal of this loop is to busy-wait by counting
loops
down from its original value to 0. Without the barrier, the compiler could delete the loop entirely, because it produces no useful output and has no side effects. The barrier forces the compiler to pretend that the loop body has an important effect.
Finally, optimization barriers can be used to force the ordering of memory reads or writes.
For example, suppose we add a "feature" that, whenever a timer interrupt occurs, the character in the global variable
timer_put_char
is printed on the console, but only if the global Boolean variabletimer_do_put
is true. The best way to set up x to be printed is then to use an optimization barrier, like this:
Without the barrier, the code is buggy because the compiler is free to reorder operations when it doesn't see a reason to keep them in the same order. In this case, the compiler doesn't know that the order of assignments is important, so its optimizer is permitted to exchange their order. There's no telling whether it will actually do this, and it is possible that passing the compiler different optimization flags or using a different version of the compiler will produce different behavior.
Another solution is to disable interrupts around the assignments. This does not prevent reordering, but it prevents the interrupt handler from intervening between the assignments. It also has the extra runtime cost of disabling and re-enabling interrupts:
A second solution is to mark the declarations of
timer_put_char
andtimer_do_put
as volatile. This keyword tells the compiler that the variables are externally observable and restricts its latitude for optimization. However, the semantics of volatile are not well-defined, so it is not a good general solution. The base Pintos code does not use volatile at all.The following is not a solution, because locks neither prevent interrupts nor prevent the compiler from reordering the code within the region where the lock is held:
The compiler treats invocation of any function defined externally, that is, in another source file, as a limited form of optimization barrier. Specifically, the compiler assumes that any externally defined function may access any statically or dynamically allocated data and any local variable whose address is taken. This often means that explicit barriers can be omitted. It is one reason that Pintos contains few explicit barriers.
A function defined in the same source file, or in a header included by the source file, cannot be relied upon as an optimization barrier. This applies even to the invocation of a function before its definition, because the compiler may read and parse the entire source file before performing optimization.
Last updated