xv6-cs450/web/l-lock.html
2008-09-03 04:50:04 +00:00

322 lines
11 KiB
HTML

<title>L7</title>
<html>
<head>
</head>
<body>
<h1>Locking</h1>
<p>Required reading: spinlock.c
<h2>Why coordinate?</h2>
<p>Mutual-exclusion coordination is an important topic in operating
systems, because many operating systems run on
multiprocessors. Coordination techniques protect variables that are
shared among multiple threads and updated concurrently. These
techniques allow programmers to implement atomic sections so that one
thread can safely update the shared variables without having to worry
that another thread intervening. For example, processes in xv6 may
run concurrently on different processors and in kernel-mode share
kernel data structures. We must ensure that these updates happen
correctly.
<p>List and insert example:
<pre>
struct List {
int data;
struct List *next;
};
List *list = 0;
insert(int data) {
List *l = new List;
l->data = data;
l->next = list; // A
list = l; // B
}
</pre>
<p>What needs to be atomic? The two statements labeled A and B should
always be executed together, as an indivisible fragment of code. If
two processors execute A and B interleaved, then we end up with an
incorrect list. To see that this is the case, draw out the list after
the sequence A1 (statement executed A by processor 1), A2 (statement A
executed by processor 2), B2, and B1.
<p>How could this erroneous sequence happen? The varilable <i>list</i>
lives in physical memory shared among multiple processors, connected
by a bus. The accesses to the shared memory will be ordered in some
total order by the bus/memory system. If the programmer doesn't
coordinate the execution of the statements A and B, any order can
happen, including the erroneous one.
<p>The erroneous case is called a race condition. The problem with
races is that they are difficult to reproduce. For example, if you
put print statements in to debug the incorrect behavior, you might
change the time and the race might not happen anymore.
<h2>Atomic instructions</h2>
<p>The programmer must be able express that A and B should be executed
as single atomic instruction. We generally use a concept like locks
to mark an atomic region, acquiring the lock at the beginning of the
section and releasing it at the end:
<pre>
void acquire(int *lock) {
while (TSL(lock) != 0) ;
}
void release (int *lock) {
*lock = 0;
}
</pre>
<p>Acquire and release, of course, need to be atomic too, which can,
for example, be done with a hardware atomic TSL (try-set-lock)
instruction:
<p>The semantics of TSL are:
<pre>
R <- [mem] // load content of mem into register R
[mem] <- 1 // store 1 in mem.
</pre>
<p>In a harware implementation, the bus arbiter guarantees that both
the load and store are executed without any other load/stores coming
in between.
<p>We can use locks to implement an atomic insert, or we can use
TSL directly:
<pre>
int insert_lock = 0;
insert(int data) {
/* acquire the lock: */
while(TSL(&insert_lock) != 0)
;
/* critical section: */
List *l = new List;
l->data = data;
l->next = list;
list = l;
/* release the lock: */
insert_lock = 0;
}
</pre>
<p>It is the programmer's job to make sure that locks are respected. If
a programmer writes another function that manipulates the list, the
programmer must must make sure that the new functions acquires and
releases the appropriate locks. If the programmer doesn't, race
conditions occur.
<p>This code assumes that stores commit to memory in program order and
that all stores by other processors started before insert got the lock
are observable by this processor. That is, after the other processor
released a lock, all the previous stores are committed to memory. If
a processor executes instructions out of order, this assumption won't
hold and we must, for example, a barrier instruction that makes the
assumption true.
<h2>Example: Locking on x86</h2>
<p>Here is one way we can implement acquire and release using the x86
xchgl instruction:
<pre>
struct Lock {
unsigned int locked;
};
acquire(Lock *lck) {
while(TSL(&(lck->locked)) != 0)
;
}
release(Lock *lck) {
lck->locked = 0;
}
int
TSL(int *addr)
{
register int content = 1;
// xchgl content, *addr
// xchgl exchanges the values of its two operands, while
// locking the memory bus to exclude other operations.
asm volatile ("xchgl %0,%1" :
"=r" (content),
"=m" (*addr) :
"0" (content),
"m" (*addr));
return(content);
}
</pre>
<p>the instruction "XCHG %eax, (content)" works as follows:
<ol>
<li> freeze other CPUs' memory activity
<li> temp := content
<li> content := %eax
<li> %eax := temp
<li> un-freeze other CPUs
</ol>
<p>steps 1 and 5 make XCHG special: it is "locked" special signal
lines on the inter-CPU bus, bus arbitration
<p>This implementation doesn't scale to a large number of processors;
in a later lecture we will see how we could do better.
<h2>Lock granularity</h2>
<p>Release/acquire is ideal for short atomic sections: increment a
counter, search in i-node cache, allocate a free buffer.
<p>What are spin locks not so great for? Long atomic sections may
waste waiters' CPU time and it is to sleep while holding locks. In
xv6 we try to avoid long atomic sections by carefully coding (can
you find an example?). xv6 doesn't release the processor when
holding a lock, but has an additional set of coordination primitives
(sleep and wakeup), which we will study later.
<p>My list_lock protects all lists; inserts to different lists are
blocked. A lock per list would waste less time spinning so you might
want "fine-grained" locks, one for every object BUT acquire/release
are expensive (500 cycles on my 3 ghz machine) because they need to
talk off-chip.
<p>Also, "correctness" is not that simple with fine-grained locks if
need to maintain global invariants; e.g., "every buffer must be on
exactly one of free list and device list". Per-list locks are
irrelevant for this invariant. So you might want "large-grained",
which reduces overhead but reduces concurrency.
<p>This tension is hard to get right. One often starts out with
"large-grained locks" and measures the performance of the system on
some workloads. When more concurrency is desired (to get better
performance), an implementor may switch to a more fine-grained
scheme. Operating system designers fiddle with this all the time.
<h2>Recursive locks and modularity</h2>
<p>When designing a system we desire clean abstractions and good
modularity. We like a caller not have to know about how a callee
implements a particul functions. Locks make achieving modularity
more complicated. For example, what to do when the caller holds a
lock, then calls a function, which also needs to the lock to perform
its job.
<p>There are no transparent solutions that allow the caller and callee
to be unaware of which lokcs they use. One transparent, but
unsatisfactory option is recursive locks: If a callee asks for a
lock that its caller has, then we allow the callee to proceed.
Unfortunately, this solution is not ideal either.
<p>Consider the following. If lock x protects the internals of some
struct foo, then if the caller acquires lock x, it know that the
internals of foo are in a sane state and it can fiddle with them.
And then the caller must restore them to a sane state before release
lock x, but until then anything goes.
<p>This assumption doesn't hold with recursive locking. After
acquiring lock x, the acquirer knows that either it is the first to
get this lock, in which case the internals are in a sane state, or
maybe some caller holds the lock and has messed up the internals and
didn't realize when calling the callee that it was going to try to
look at them too. So the fact that a function acquired the lock x
doesn't guarantee anything at all. In short, locks protect against
callers and callees just as much as they protect against other
threads.
<p>Since transparent solutions aren't ideal, it is better to consider
locks part of the function specification. The programmer must
arrange that a caller doesn't invoke another function while holding
a lock that the callee also needs.
<h2>Locking in xv6</h2>
<p>xv6 runs on a multiprocessor and is programmed to allow multiple
threads of computation to run concurrently. In xv6 an interrupt might
run on one processor and a process in kernel mode may run on another
processor, sharing a kernel data structure with the interrupt routing.
xv6 uses locks, implemented using an atomic instruction, to coordinate
concurrent activities.
<p>Let's check out why xv6 needs locks by following what happens when
we start a second processor:
<ul>
<li>1516: mp_init (called from main0)
<li>1606: mp_startthem (called from main0)
<li>1302: mpmain
<li>2208: scheduler.
<br>Now we have several processors invoking the scheduler
function. xv6 better ensure that multiple processors don't run the
same process! does it?
<br>Yes, if multiple schedulers run concurrently, only one will
acquire proc_table_lock, and proceed looking for a runnable
process. if it finds a process, it will mark it running, longjmps to
it, and the process will release proc_table_lock. the next instance
of scheduler will skip this entry, because it is marked running, and
look for another runnable process.
</ul>
<p>Why hold proc_table_lock during a context switch? It protects
p->state; the process has to hold some lock to avoid a race with
wakeup() and yield(), as we will see in the next lectures.
<p>Why not a lock per proc entry? It might be expensive in in whole
table scans (in wait, wakeup, scheduler). proc_table_lock also
protects some larger invariants, for example it might be hard to get
proc_wait() right with just per entry locks. Right now the check to
see if there are any exited children and the sleep are atomic -- but
that would be hard with per entry locks. One could have both, but
that would probably be neither clean nor fast.
<p>Of course, there is only processor searching the proc table if
acquire is implemented correctly. Let's check out acquire in
spinlock.c:
<ul>
<li>1807: no recursive locks!
<li>1811: why disable interrupts on the current processor? (if
interrupt code itself tries to take a held lock, xv6 will deadlock;
the panic will fire on 1808.)
<ul>
<li>can a process on a processor hold multiple locks?
</ul>
<li>1814: the (hopefully) atomic instruction.
<ul>
<li>see sheet 4, line 0468.
</ul>
<li>1819: make sure that stores issued on other processors before we
got the lock are observed by this processor. these may be stores to
the shared data structure that is protected by the lock.
</ul>
<p>
<h2>Locking in JOS</h2>
<p>JOS is meant to run on single-CPU machines, and the plan can be
simple. The simple plan is disabling/enabling interrupts in the
kernel (IF flags in the EFLAGS register). Thus, in the kernel,
threads release the processors only when they want to and can ensure
that they don't release the processor during a critical section.
<p>In user mode, JOS runs with interrupts enabled, but Unix user
applications don't share data structures. The data structures that
must be protected, however, are the ones shared in the library
operating system (e.g., pipes). In JOS we will use special-case
solutions, as you will find out in lab 6. For example, to implement
pipe we will assume there is one reader and one writer. The reader
and writer never update each other's variables; they only read each
other's variables. Carefully programming using this rule we can avoid
races.