6.5 Re-entrant locks
It might appear that some deadlocks and lock-ordering challenges could be avoided by using re-entrant locks, which are also called recursive locks. The idea is that if the lock is held by a process and if that process attempts to acquire the lock again, then the kernel could just allow this (since the process already has the lock), instead of calling panic, as the xv6 kernel does.
It turns out, however, that re-entrant locks make it harder to reason
about concurrency: re-entrant locks break the intuition that locks
cause critical sections to be atomic with respect to other critical
sections. Consider the following functions f
and g
, and a hypothetical function h
:
1 struct spinlock lock;
2 int data = 0; // protected by lock
3
4 f() {
5 acquire(&lock);
6 if(data == 0){
7 call_once();
8 h();
9 data = 1;
10 }
11 release(&lock);
12 }
13
14 g() {
15 aquire(&lock);
16 if(data == 0){
17 call_once();
18 data = 1;
19 }
20 release(&lock);
21 }
22
23 h() {
24 ...
25 }
Looking at this code fragment, the intuition is that
call_once
will be called
only once: either by f
, or by g
, but not by
both.
But if re-entrant locks are allowed, and h
happens to call
g
, call_once
will be called twice.
If re-entrant locks aren’t allowed, then h
calling
g
results in a deadlock, which is not great either. But,
assuming it would be a serious error to call
call_once
, a deadlock is
preferable. The kernel developer will observe the deadlock (the
kernel panics) and can fix the code to avoid it, while calling
call_once
twice may
silently result in an error that is difficult to track down.
For this reason, xv6 uses the simpler to understand non-re-entrant
locks. As long as programmers keep the locking rules in mind,
however, either approach can be made to work. If xv6 were to use
re-entrant locks, one would have to modify acquire
to
notice that the lock is currently held by the calling thread. One
would also have to add a count of nested acquires to struct spinlock,
in similar style to push_off
, which is discussed next.