요약·해설과 원문, 전문 번역을 서로 분리했습니다. API 이름, symbol, source path는 원문 표기를 사용합니다.
1. 요약·해설
원문의 핵심 논리와 kernel programming 관점의 보충 설명입니다. 아래의 전문 번역과는 별도로 작성했습니다.
Hardirq와 softirq usage state
lockdep-design.rst:38-113각 class는 hardirq와 softirq에 대해 IRQ context에서 보유했는지, read lock으로 보유했는지, 해당 IRQ가 enabled인 상태에서 보유했는지를 기록합니다. 경고의 {....} 표시는 이 history를 압축한 것입니다.
| 문자 | 의미 |
|---|---|
| . | IRQ disabled이고 해당 IRQ context 밖에서 획득 |
| - | 해당 IRQ context에서 획득 |
| + | IRQ enabled 상태에서 획득 |
| ? | IRQ context인데 IRQ enabled라는 비정상 조합이 관찰됨 |
1hardirq context에서 획득한 이력2hardirq read 상태, IRQ disabled인 외부 문맥3softirq context에서 획득한 이력4softirq read 상태, softirq disabled인 외부 문맥중괄호 안의 네 문자는 단순한 현재 IRQ flag가 아니라 그 lock class가 과거에 어떤 문맥에서 획득되었는지를 누적한 표식입니다. 왼쪽부터 hardirq write, hardirq read, softirq write, softirq read 계열 위치로 읽고, 각 자리의 문자로 context 내부 여부와 IRQ enable 여부를 해석합니다.
IRQ-safe class는 해당 interrupt context에서 한 번이라도 사용된 class이고, IRQ-unsafe class는 IRQ enabled 상태에서 획득된 class입니다. 같은 class가 safe와 unsafe를 동시에 만족하면 normal context가 lock을 보유한 채 interrupt되어 같은 lock을 재획득할 수 있으므로 self-deadlock 가능성이 생깁니다.
단일 lock과 여러 lock의 금지 규칙
lockdep-design.rst:114-191- 같은 non-recursive lock class를 한 context에서 두 번 획득하지 않는다.
- L1 -> L2가 관찰된 뒤 L2 -> L1 순서를 만들지 않는다.
- hardirq-safe lock에서 hardirq-unsafe lock으로 이어지는 dependency를 만들지 않는다.
- softirq-safe lock에서 softirq-unsafe lock으로 이어지는 dependency를 만들지 않는다.
Lockdep은 두 edge만 보는 것이 아니라 임의 길이 dependency path에서 cycle을 찾습니다. 새 class state가 발견될 때도 과거 graph를 다시 검사합니다. 아직 실제 interrupt timing으로 deadlock이 발생하지 않았어도 safe class가 unsafe lock을 간접적으로 획득한 기록만 있으면 위험을 증명할 수 있습니다.
같은 class를 계층적으로 잡는 nested subclass
lockdep-design.rst:192-233Whole disk와 partition처럼 동일 type object 사이에 자연스러운 hierarchy가 있으면 같은 class instance 둘을 정해진 순서로 잡는 것이 올바를 수 있습니다. Lockdep은 runtime object 관계를 자동으로 알 수 없으므로 _nested() API에 subclass level을 전달해야 합니다.
enum bdev_bd_mutex_lock_class {
BD_MUTEX_NORMAL,
BD_MUTEX_WHOLE,
BD_MUTEX_PARTITION,
};
mutex_lock_nested(&bdev->bd_contains->bd_mutex,
BD_MUTEX_PARTITION);
Subclass는 경고를 숨이는 escape hatch가 아닙니다. Object hierarchy와 전체 획득 순서가 실제로 고정되어 있는지 증명하지 않고 사용하면 false negative를 만들 수 있습니다.
보유 조건과 pin annotation
lockdep-design.rst:234-280void update_rq_clock(struct rq *rq)
{
lockdep_assert_held(&rq->lock);
/* rq clock 갱신 */
}
cookie = lockdep_pin_lock(&rq->lock);
callback_chain();
lockdep_unpin_lock(&rq->lock, cookie);
lockdep_assert_held*()는 함수의 locking precondition을 runtime에 검사합니다. lockdep_pin_lock()은 callback 아래 계층에서 lock을 잠시 풀었다 다시 잡아 상위 계층이 기대한 연속 보호를 깨뜨리는 경우를 cookie로 탐지합니다. 단순 comment보다 실행 가능한 계약에 가깝습니다.
관찰된 component chain으로 조합을 증명한다
lockdep-design.rst:281-335Lockdep은 복잡한 multi-CPU deadlock timing 자체를 재현할 필요가 없습니다. 각 task에서 발생 가능한 단순 lock chain을 한 번씩 관찰하면 dependency graph의 조합으로 아직 동시에 실행되지 않은 cycle도 찾습니다. QA의 목표는 가능한 code path와 lock order component를 넓게 실행하는 것입니다.
모든 획득 때 graph 전체를 O(N²)로 검사하면 사용할 수 없을 만큼 느립니다. Lockdep은 held-lock stack에서 64-bit chain hash를 만들고 처음 보는 chain만 완전 검증하여 hash table에 넣습니다. 이후 동일 chain은 lock-free lookup으로 이미 검증된 경로임을 확인합니다.
Lock class 고갈과 초기화 오류
lockdep-design.rst:336-398MAX_LOCKDEP_KEYS를 소진하는 흔한 원인은 반복 module load/unload에 따른 class leakage와 대규모 lock array를 runtime init하지 않은 경우입니다. 8192 bucket 각각에 compile-time initializer를 복제하면 서로 다른 key로 인식될 수 있지만 loop에서 spin_lock_init()을 호출하면 같은 class로 매핑됩니다.
grep "lock-classes" /proc/lockdep_stats
grep "BD" /proc/lockdep
Allocated class 수가 시간에 따라 계속 증가하는지 보고, /proc/lockdep snapshot을 비교해 새로 생기는 class를 찾습니다. Lock은 object 공개 전에 공식 runtime initializer로 명시적으로 초기화하는 것이 기본입니다.
Recursive reader와 strong dependency cycle
lockdep-design.rst:399-659| 표기 | 획득 종류 | Writer waiter가 막는가 |
|---|---|---|
| W 또는 E | exclusive writer | 해당 없음 |
| r | non-recursive reader | 막힐 수 있음 |
| R | recursive reader | 현재 writer holder만 막고 waiter는 막지 않음 |
Non-recursive reader는 자신이 read lock을 보유한 상태에서도 대기 중인 writer 때문에 두 번째 read가 막혀 self-deadlock이 될 수 있습니다. Recursive reader는 writer waiter를 통과할 수 있어 같은 상황이 deadlock이 아닙니다. 이 차이를 반영하지 않고 모든 read edge를 같게 보면 false positive가 생깁니다.
Lockdep은 dependency를 ER, EN, SR, SN 네 종류로 축약합니다. Closed path라도 recursive reader가 통과할 수 있는 약한 연결만으로 이루어졌다면 deadlock을 뜻하지 않습니다. 인접 edge가 xR 다음 Sx 조합을 만들지 않는 strong closed path는 실제 circular wait를 구성할 수 있고, 반대로 deadlock이 있다면 그런 strong cycle이 반드시 존재합니다.
각 Lx -> Lx+1 edge는 한 실행에서 관찰된 획득 순서입니다. 서로 다른 task가 각 왼쪽 lock을 먼저 보유하게 배치하면 모두 다음 lock의 owner를 기다리게 됩니다. 마지막 Ln -> L1까지 이어져 wait-for graph가 닫히면 아무 task도 release 지점에 도달하지 못하므로 strong cycle은 deadlock 가능성을 구성합니다.
2. 영어 원문 전체
번역 기준이 된 Linux v6.18.37 원문입니다. 줄 번호는 이 버전의 파일 좌표입니다.
원문 전체 펼치기
Runtime locking correctness validator
=====================================
started by Ingo Molnar <mingo@redhat.com>
additions by Arjan van de Ven <arjan@linux.intel.com>
Lock-class
----------
The basic object the validator operates upon is a 'class' of locks.
A class of locks is a group of locks that are logically the same with
respect to locking rules, even if the locks may have multiple (possibly
tens of thousands of) instantiations. For example a lock in the inode
struct is one class, while each inode has its own instantiation of that
lock class.
The validator tracks the 'usage state' of lock-classes, and it tracks
the dependencies between different lock-classes. Lock usage indicates
how a lock is used with regard to its IRQ contexts, while lock
dependency can be understood as lock order, where L1 -> L2 suggests that
a task is attempting to acquire L2 while holding L1. From lockdep's
perspective, the two locks (L1 and L2) are not necessarily related; that
dependency just means the order ever happened. The validator maintains a
continuing effort to prove lock usages and dependencies are correct or
the validator will shoot a splat if incorrect.
A lock-class's behavior is constructed by its instances collectively:
when the first instance of a lock-class is used after bootup the class
gets registered, then all (subsequent) instances will be mapped to the
class and hence their usages and dependencies will contribute to those of
the class. A lock-class does not go away when a lock instance does, but
it can be removed if the memory space of the lock class (static or
dynamic) is reclaimed, this happens for example when a module is
unloaded or a workqueue is destroyed.
State
-----
The validator tracks lock-class usage history and divides the usage into
(4 usages * n STATEs + 1) categories:
where the 4 usages can be:
- 'ever held in STATE context'
- 'ever held as readlock in STATE context'
- 'ever held with STATE enabled'
- 'ever held as readlock with STATE enabled'
where the n STATEs are coded in kernel/locking/lockdep_states.h and as of
now they include:
- hardirq
- softirq
where the last 1 category is:
- 'ever used' [ == !unused ]
When locking rules are violated, these usage bits are presented in the
locking error messages, inside curlies, with a total of 2 * n STATEs bits.
A contrived example::
modprobe/2287 is trying to acquire lock:
(&sio_locks[i].lock){-.-.}, at: [<c02867fd>] mutex_lock+0x21/0x24
but task is already holding lock:
(&sio_locks[i].lock){-.-.}, at: [<c02867fd>] mutex_lock+0x21/0x24
For a given lock, the bit positions from left to right indicate the usage
of the lock and readlock (if exists), for each of the n STATEs listed
above respectively, and the character displayed at each bit position
indicates:
=== ===================================================
'.' acquired while irqs disabled and not in irq context
'-' acquired in irq context
'+' acquired with irqs enabled
'?' acquired in irq context with irqs enabled.
=== ===================================================
The bits are illustrated with an example::
(&sio_locks[i].lock){-.-.}, at: [<c02867fd>] mutex_lock+0x21/0x24
||||
||| \-> softirq disabled and not in softirq context
|| \--> acquired in softirq context
| \---> hardirq disabled and not in hardirq context
\----> acquired in hardirq context
For a given STATE, whether the lock is ever acquired in that STATE
context and whether that STATE is enabled yields four possible cases as
shown in the table below. The bit character is able to indicate which
exact case is for the lock as of the reporting time.
+--------------+-------------+--------------+
| | irq enabled | irq disabled |
+--------------+-------------+--------------+
| ever in irq | '?' | '-' |
+--------------+-------------+--------------+
| never in irq | '+' | '.' |
+--------------+-------------+--------------+
The character '-' suggests irq is disabled because if otherwise the
character '?' would have been shown instead. Similar deduction can be
applied for '+' too.
Unused locks (e.g., mutexes) cannot be part of the cause of an error.
Single-lock state rules:
------------------------
A lock is irq-safe means it was ever used in an irq context, while a lock
is irq-unsafe means it was ever acquired with irq enabled.
A softirq-unsafe lock-class is automatically hardirq-unsafe as well. The
following states must be exclusive: only one of them is allowed to be set
for any lock-class based on its usage::
<hardirq-safe> or <hardirq-unsafe>
<softirq-safe> or <softirq-unsafe>
This is because if a lock can be used in irq context (irq-safe) then it
cannot be ever acquired with irq enabled (irq-unsafe). Otherwise, a
deadlock may happen. For example, in the scenario that after this lock
was acquired but before released, if the context is interrupted this
lock will be attempted to acquire twice, which creates a deadlock,
referred to as lock recursion deadlock.
The validator detects and reports lock usage that violates these
single-lock state rules.
Multi-lock dependency rules:
----------------------------
The same lock-class must not be acquired twice, because this could lead
to lock recursion deadlocks.
Furthermore, two locks can not be taken in inverse order::
<L1> -> <L2>
<L2> -> <L1>
because this could lead to a deadlock - referred to as lock inversion
deadlock - as attempts to acquire the two locks form a circle which
could lead to the two contexts waiting for each other permanently. The
validator will find such dependency circle in arbitrary complexity,
i.e., there can be any other locking sequence between the acquire-lock
operations; the validator will still find whether these locks can be
acquired in a circular fashion.
Furthermore, the following usage based lock dependencies are not allowed
between any two lock-classes::
<hardirq-safe> -> <hardirq-unsafe>
<softirq-safe> -> <softirq-unsafe>
The first rule comes from the fact that a hardirq-safe lock could be
taken by a hardirq context, interrupting a hardirq-unsafe lock - and
thus could result in a lock inversion deadlock. Likewise, a softirq-safe
lock could be taken by an softirq context, interrupting a softirq-unsafe
lock.
The above rules are enforced for any locking sequence that occurs in the
kernel: when acquiring a new lock, the validator checks whether there is
any rule violation between the new lock and any of the held locks.
When a lock-class changes its state, the following aspects of the above
dependency rules are enforced:
- if a new hardirq-safe lock is discovered, we check whether it
took any hardirq-unsafe lock in the past.
- if a new softirq-safe lock is discovered, we check whether it took
any softirq-unsafe lock in the past.
- if a new hardirq-unsafe lock is discovered, we check whether any
hardirq-safe lock took it in the past.
- if a new softirq-unsafe lock is discovered, we check whether any
softirq-safe lock took it in the past.
(Again, we do these checks too on the basis that an interrupt context
could interrupt _any_ of the irq-unsafe or hardirq-unsafe locks, which
could lead to a lock inversion deadlock - even if that lock scenario did
not trigger in practice yet.)
Exception: Nested data dependencies leading to nested locking
-------------------------------------------------------------
There are a few cases where the Linux kernel acquires more than one
instance of the same lock-class. Such cases typically happen when there
is some sort of hierarchy within objects of the same type. In these
cases there is an inherent "natural" ordering between the two objects
(defined by the properties of the hierarchy), and the kernel grabs the
locks in this fixed order on each of the objects.
An example of such an object hierarchy that results in "nested locking"
is that of a "whole disk" block-dev object and a "partition" block-dev
object; the partition is "part of" the whole device and as long as one
always takes the whole disk lock as a higher lock than the partition
lock, the lock ordering is fully correct. The validator does not
automatically detect this natural ordering, as the locking rule behind
the ordering is not static.
In order to teach the validator about this correct usage model, new
versions of the various locking primitives were added that allow you to
specify a "nesting level". An example call, for the block device mutex,
looks like this::
enum bdev_bd_mutex_lock_class
{
BD_MUTEX_NORMAL,
BD_MUTEX_WHOLE,
BD_MUTEX_PARTITION
};
mutex_lock_nested(&bdev->bd_contains->bd_mutex, BD_MUTEX_PARTITION);
In this case the locking is done on a bdev object that is known to be a
partition.
The validator treats a lock that is taken in such a nested fashion as a
separate (sub)class for the purposes of validation.
Note: When changing code to use the _nested() primitives, be careful and
check really thoroughly that the hierarchy is correctly mapped; otherwise
you can get false positives or false negatives.
Annotations
-----------
Two constructs can be used to annotate and check where and if certain locks
must be held: lockdep_assert_held*(&lock) and lockdep_*pin_lock(&lock).
As the name suggests, lockdep_assert_held* family of macros assert that a
particular lock is held at a certain time (and generate a WARN() otherwise).
This annotation is largely used all over the kernel, e.g. kernel/sched/
core.c::
void update_rq_clock(struct rq *rq)
{
s64 delta;
lockdep_assert_held(&rq->lock);
[...]
}
where holding rq->lock is required to safely update a rq's clock.
The other family of macros is lockdep_*pin_lock(), which is admittedly only
used for rq->lock ATM. Despite their limited adoption these annotations
generate a WARN() if the lock of interest is "accidentally" unlocked. This turns
out to be especially helpful to debug code with callbacks, where an upper
layer assumes a lock remains taken, but a lower layer thinks it can maybe drop
and reacquire the lock ("unwittingly" introducing races). lockdep_pin_lock()
returns a 'struct pin_cookie' that is then used by lockdep_unpin_lock() to check
that nobody tampered with the lock, e.g. kernel/sched/sched.h::
static inline void rq_pin_lock(struct rq *rq, struct rq_flags *rf)
{
rf->cookie = lockdep_pin_lock(&rq->lock);
[...]
}
static inline void rq_unpin_lock(struct rq *rq, struct rq_flags *rf)
{
[...]
lockdep_unpin_lock(&rq->lock, rf->cookie);
}
While comments about locking requirements might provide useful information,
the runtime checks performed by annotations are invaluable when debugging
locking problems and they carry the same level of details when inspecting
code. Always prefer annotations when in doubt!
Proof of 100% correctness:
--------------------------
The validator achieves perfect, mathematical 'closure' (proof of locking
correctness) in the sense that for every simple, standalone single-task
locking sequence that occurred at least once during the lifetime of the
kernel, the validator proves it with a 100% certainty that no
combination and timing of these locking sequences can cause any class of
lock related deadlock. [1]_
I.e. complex multi-CPU and multi-task locking scenarios do not have to
occur in practice to prove a deadlock: only the simple 'component'
locking chains have to occur at least once (anytime, in any
task/context) for the validator to be able to prove correctness. (For
example, complex deadlocks that would normally need more than 3 CPUs and
a very unlikely constellation of tasks, irq-contexts and timings to
occur, can be detected on a plain, lightly loaded single-CPU system as
well!)
This radically decreases the complexity of locking related QA of the
kernel: what has to be done during QA is to trigger as many "simple"
single-task locking dependencies in the kernel as possible, at least
once, to prove locking correctness - instead of having to trigger every
possible combination of locking interaction between CPUs, combined with
every possible hardirq and softirq nesting scenario (which is impossible
to do in practice).
.. [1]
assuming that the validator itself is 100% correct, and no other
part of the system corrupts the state of the validator in any way.
We also assume that all NMI/SMM paths [which could interrupt
even hardirq-disabled codepaths] are correct and do not interfere
with the validator. We also assume that the 64-bit 'chain hash'
value is unique for every lock-chain in the system. Also, lock
recursion must not be higher than 20.
Performance:
------------
The above rules require **massive** amounts of runtime checking. If we did
that for every lock taken and for every irqs-enable event, it would
render the system practically unusably slow. The complexity of checking
is O(N^2), so even with just a few hundred lock-classes we'd have to do
tens of thousands of checks for every event.
This problem is solved by checking any given 'locking scenario' (unique
sequence of locks taken after each other) only once. A simple stack of
held locks is maintained, and a lightweight 64-bit hash value is
calculated, which hash is unique for every lock chain. The hash value,
when the chain is validated for the first time, is then put into a hash
table, which hash-table can be checked in a lockfree manner. If the
locking chain occurs again later on, the hash table tells us that we
don't have to validate the chain again.
Troubleshooting:
----------------
The validator tracks a maximum of MAX_LOCKDEP_KEYS number of lock classes.
Exceeding this number will trigger the following lockdep warning::
(DEBUG_LOCKS_WARN_ON(id >= MAX_LOCKDEP_KEYS))
By default, MAX_LOCKDEP_KEYS is currently set to 8191, and typical
desktop systems have less than 1,000 lock classes, so this warning
normally results from lock-class leakage or failure to properly
initialize locks. These two problems are illustrated below:
1. Repeated module loading and unloading while running the validator
will result in lock-class leakage. The issue here is that each
load of the module will create a new set of lock classes for
that module's locks, but module unloading does not remove old
classes (see below discussion of reuse of lock classes for why).
Therefore, if that module is loaded and unloaded repeatedly,
the number of lock classes will eventually reach the maximum.
2. Using structures such as arrays that have large numbers of
locks that are not explicitly initialized. For example,
a hash table with 8192 buckets where each bucket has its own
spinlock_t will consume 8192 lock classes -unless- each spinlock
is explicitly initialized at runtime, for example, using the
run-time spin_lock_init() as opposed to compile-time initializers
such as __SPIN_LOCK_UNLOCKED(). Failure to properly initialize
the per-bucket spinlocks would guarantee lock-class overflow.
In contrast, a loop that called spin_lock_init() on each lock
would place all 8192 locks into a single lock class.
The moral of this story is that you should always explicitly
initialize your locks.
One might argue that the validator should be modified to allow
lock classes to be reused. However, if you are tempted to make this
argument, first review the code and think through the changes that would
be required, keeping in mind that the lock classes to be removed are
likely to be linked into the lock-dependency graph. This turns out to
be harder to do than to say.
Of course, if you do run out of lock classes, the next thing to do is
to find the offending lock classes. First, the following command gives
you the number of lock classes currently in use along with the maximum::
grep "lock-classes" /proc/lockdep_stats
This command produces the following output on a modest system::
lock-classes: 748 [max: 8191]
If the number allocated (748 above) increases continually over time,
then there is likely a leak. The following command can be used to
identify the leaking lock classes::
grep "BD" /proc/lockdep
Run the command and save the output, then compare against the output from
a later run of this command to identify the leakers. This same output
can also help you find situations where runtime lock initialization has
been omitted.
Recursive read locks:
---------------------
The whole of the rest document tries to prove a certain type of cycle is equivalent
to deadlock possibility.
There are three types of lockers: writers (i.e. exclusive lockers, like
spin_lock() or write_lock()), non-recursive readers (i.e. shared lockers, like
down_read()) and recursive readers (recursive shared lockers, like rcu_read_lock()).
And we use the following notations of those lockers in the rest of the document:
W or E: stands for writers (exclusive lockers).
r: stands for non-recursive readers.
R: stands for recursive readers.
S: stands for all readers (non-recursive + recursive), as both are shared lockers.
N: stands for writers and non-recursive readers, as both are not recursive.
Obviously, N is "r or W" and S is "r or R".
Recursive readers, as their name indicates, are the lockers allowed to acquire
even inside the critical section of another reader of the same lock instance,
in other words, allowing nested read-side critical sections of one lock instance.
While non-recursive readers will cause a self deadlock if trying to acquire inside
the critical section of another reader of the same lock instance.
The difference between recursive readers and non-recursive readers is because:
recursive readers get blocked only by a write lock *holder*, while non-recursive
readers could get blocked by a write lock *waiter*. Considering the follow
example::
TASK A: TASK B:
read_lock(X);
write_lock(X);
read_lock_2(X);
Task A gets the reader (no matter whether recursive or non-recursive) on X via
read_lock() first. And when task B tries to acquire writer on X, it will block
and become a waiter for writer on X. Now if read_lock_2() is recursive readers,
task A will make progress, because writer waiters don't block recursive readers,
and there is no deadlock. However, if read_lock_2() is non-recursive readers,
it will get blocked by writer waiter B, and cause a self deadlock.
Block conditions on readers/writers of the same lock instance:
--------------------------------------------------------------
There are simply four block conditions:
1. Writers block other writers.
2. Readers block writers.
3. Writers block both recursive readers and non-recursive readers.
4. And readers (recursive or not) don't block other recursive readers but
may block non-recursive readers (because of the potential co-existing
writer waiters)
Block condition matrix, Y means the row blocks the column, and N means otherwise.
+---+---+---+---+
| | W | r | R |
+---+---+---+---+
| W | Y | Y | Y |
+---+---+---+---+
| r | Y | Y | N |
+---+---+---+---+
| R | Y | Y | N |
+---+---+---+---+
(W: writers, r: non-recursive readers, R: recursive readers)
acquired recursively. Unlike non-recursive read locks, recursive read locks
only get blocked by current write lock *holders* other than write lock
*waiters*, for example::
TASK A: TASK B:
read_lock(X);
write_lock(X);
read_lock(X);
is not a deadlock for recursive read locks, as while the task B is waiting for
the lock X, the second read_lock() doesn't need to wait because it's a recursive
read lock. However if the read_lock() is non-recursive read lock, then the above
case is a deadlock, because even if the write_lock() in TASK B cannot get the
lock, but it can block the second read_lock() in TASK A.
Note that a lock can be a write lock (exclusive lock), a non-recursive read
lock (non-recursive shared lock) or a recursive read lock (recursive shared
lock), depending on the lock operations used to acquire it (more specifically,
the value of the 'read' parameter for lock_acquire()). In other words, a single
lock instance has three types of acquisition depending on the acquisition
functions: exclusive, non-recursive read, and recursive read.
To be concise, we call that write locks and non-recursive read locks as
"non-recursive" locks and recursive read locks as "recursive" locks.
Recursive locks don't block each other, while non-recursive locks do (this is
even true for two non-recursive read locks). A non-recursive lock can block the
corresponding recursive lock, and vice versa.
A deadlock case with recursive locks involved is as follow::
TASK A: TASK B:
read_lock(X);
read_lock(Y);
write_lock(Y);
write_lock(X);
Task A is waiting for task B to read_unlock() Y and task B is waiting for task
A to read_unlock() X.
Dependency types and strong dependency paths:
---------------------------------------------
Lock dependencies record the orders of the acquisitions of a pair of locks, and
because there are 3 types for lockers, there are, in theory, 9 types of lock
dependencies, but we can show that 4 types of lock dependencies are enough for
deadlock detection.
For each lock dependency::
L1 -> L2
, which means lockdep has seen L1 held before L2 held in the same context at runtime.
And in deadlock detection, we care whether we could get blocked on L2 with L1 held,
IOW, whether there is a locker L3 that L1 blocks L3 and L2 gets blocked by L3. So
we only care about 1) what L1 blocks and 2) what blocks L2. As a result, we can combine
recursive readers and non-recursive readers for L1 (as they block the same types) and
we can combine writers and non-recursive readers for L2 (as they get blocked by the
same types).
With the above combination for simplification, there are 4 types of dependency edges
in the lockdep graph:
1) -(ER)->:
exclusive writer to recursive reader dependency, "X -(ER)-> Y" means
X -> Y and X is a writer and Y is a recursive reader.
2) -(EN)->:
exclusive writer to non-recursive locker dependency, "X -(EN)-> Y" means
X -> Y and X is a writer and Y is either a writer or non-recursive reader.
3) -(SR)->:
shared reader to recursive reader dependency, "X -(SR)-> Y" means
X -> Y and X is a reader (recursive or not) and Y is a recursive reader.
4) -(SN)->:
shared reader to non-recursive locker dependency, "X -(SN)-> Y" means
X -> Y and X is a reader (recursive or not) and Y is either a writer or
non-recursive reader.
Note that given two locks, they may have multiple dependencies between them,
for example::
TASK A:
read_lock(X);
write_lock(Y);
...
TASK B:
write_lock(X);
write_lock(Y);
, we have both X -(SN)-> Y and X -(EN)-> Y in the dependency graph.
We use -(xN)-> to represent edges that are either -(EN)-> or -(SN)->, the
similar for -(Ex)->, -(xR)-> and -(Sx)->
A "path" is a series of conjunct dependency edges in the graph. And we define a
"strong" path, which indicates the strong dependency throughout each dependency
in the path, as the path that doesn't have two conjunct edges (dependencies) as
-(xR)-> and -(Sx)->. In other words, a "strong" path is a path from a lock
walking to another through the lock dependencies, and if X -> Y -> Z is in the
path (where X, Y, Z are locks), and the walk from X to Y is through a -(SR)-> or
-(ER)-> dependency, the walk from Y to Z must not be through a -(SN)-> or
-(SR)-> dependency.
We will see why the path is called "strong" in next section.
Recursive Read Deadlock Detection:
----------------------------------
We now prove two things:
Lemma 1:
If there is a closed strong path (i.e. a strong circle), then there is a
combination of locking sequences that causes deadlock. I.e. a strong circle is
sufficient for deadlock detection.
Lemma 2:
If there is no closed strong path (i.e. strong circle), then there is no
combination of locking sequences that could cause deadlock. I.e. strong
circles are necessary for deadlock detection.
With these two Lemmas, we can easily say a closed strong path is both sufficient
and necessary for deadlocks, therefore a closed strong path is equivalent to
deadlock possibility. As a closed strong path stands for a dependency chain that
could cause deadlocks, so we call it "strong", considering there are dependency
circles that won't cause deadlocks.
Proof for sufficiency (Lemma 1):
Let's say we have a strong circle::
L1 -> L2 ... -> Ln -> L1
, which means we have dependencies::
L1 -> L2
L2 -> L3
...
Ln-1 -> Ln
Ln -> L1
We now can construct a combination of locking sequences that cause deadlock:
Firstly let's make one CPU/task get the L1 in L1 -> L2, and then another get
the L2 in L2 -> L3, and so on. After this, all of the Lx in Lx -> Lx+1 are
held by different CPU/tasks.
And then because we have L1 -> L2, so the holder of L1 is going to acquire L2
in L1 -> L2, however since L2 is already held by another CPU/task, plus L1 ->
L2 and L2 -> L3 are not -(xR)-> and -(Sx)-> (the definition of strong), which
means either L2 in L1 -> L2 is a non-recursive locker (blocked by anyone) or
the L2 in L2 -> L3, is writer (blocking anyone), therefore the holder of L1
cannot get L2, it has to wait L2's holder to release.
Moreover, we can have a similar conclusion for L2's holder: it has to wait L3's
holder to release, and so on. We now can prove that Lx's holder has to wait for
Lx+1's holder to release, and note that Ln+1 is L1, so we have a circular
waiting scenario and nobody can get progress, therefore a deadlock.
Proof for necessary (Lemma 2):
Lemma 2 is equivalent to: If there is a deadlock scenario, then there must be a
strong circle in the dependency graph.
According to Wikipedia[1], if there is a deadlock, then there must be a circular
waiting scenario, means there are N CPU/tasks, where CPU/task P1 is waiting for
a lock held by P2, and P2 is waiting for a lock held by P3, ... and Pn is waiting
for a lock held by P1. Let's name the lock Px is waiting as Lx, so since P1 is waiting
for L1 and holding Ln, so we will have Ln -> L1 in the dependency graph. Similarly,
we have L1 -> L2, L2 -> L3, ..., Ln-1 -> Ln in the dependency graph, which means we
have a circle::
Ln -> L1 -> L2 -> ... -> Ln
, and now let's prove the circle is strong:
For a lock Lx, Px contributes the dependency Lx-1 -> Lx and Px+1 contributes
the dependency Lx -> Lx+1, and since Px is waiting for Px+1 to release Lx,
so it's impossible that Lx on Px+1 is a reader and Lx on Px is a recursive
reader, because readers (no matter recursive or not) don't block recursive
readers, therefore Lx-1 -> Lx and Lx -> Lx+1 cannot be a -(xR)-> -(Sx)-> pair,
and this is true for any lock in the circle, therefore, the circle is strong.
References:
-----------
[1]: https://en.wikipedia.org/wiki/Deadlock
[2]: Shibu, K. (2009). Intro To Embedded Systems (1st ed.). Tata McGraw-Hill
3. 한국어 전문 번역
영어 원문의 문단 순서와 의미를 유지한 전체 번역입니다. 코드, 함수명, symbol과 URL은 원문 표기를 유지합니다.
Lockdep validator와 lock class
1-36Runtime locking correctness validator는 Ingo Molnar가 시작했고 Arjan van de Ven이 내용을 추가했다.
Validator가 다루는 기본 object는 lock의 class다. Lock class는 실제 instance가 여러 개, 많게는 수만 개 존재하더라도 locking rule 관점에서 논리적으로 같은 lock의 집합이다. 예를 들어 inode struct 안의 lock은 하나의 class이고 각 inode는 그 class에 속한 자기 lock instance를 가진다.
Validator는 lock class의 usage state와 서로 다른 lock class 사이 dependency를 추적한다. Lock usage는 IRQ context와 관련해 lock이 어떻게 쓰였는지를 나타낸다. Lock dependency는 lock order로 이해할 수 있다. L1 -> L2는 task가 L1을 보유한 채 L2 획득을 시도했음을 뜻한다.
Lockdep 관점에서 L1과 L2가 원래부터 관련된 lock일 필요는 없다. Dependency는 그 순서가 한 번이라도 실제로 나타났다는 뜻일 뿐이다. Validator는 usage와 dependency가 올바름을 계속 증명하며, 잘못됐다고 판단하면 splat을 출력한다.
Lock class의 behavior는 모든 instance의 사용 이력을 합쳐 구성한다. Boot 뒤 어떤 class의 첫 instance가 사용되면 class를 등록하고, 이후 instance를 그 class에 mapping한다. 따라서 모든 instance의 usage와 dependency가 class의 이력에 기여한다.
Lock instance가 사라져도 lock class는 곧바로 사라지지 않는다. 다만 static 또는 dynamic lock class의 memory space가 회수되면 class를 제거할 수 있다. Module unload나 workqueue destroy가 그 예다.
Usage state와 오류 문자열 읽기
38-112Validator는 lock class의 사용 이력을 (4 usages × n STATEs + 1) category로 나눈다. 각 STATE에 대해 다음 네 종류를 추적한다.
- STATE context에서 한 번이라도 보유됨
- STATE context에서 readlock으로 한 번이라도 보유됨
- STATE가 enabled인 상태에서 한 번이라도 보유됨
- STATE가 enabled인 상태에서 readlock으로 한 번이라도 보유됨
n개의 STATE는 kernel/locking/lockdep_states.h에 정의되며 현재 hardirq와 softirq를 포함한다. 마지막 한 category는 lock이 한 번이라도 사용됐는지를 나타낸다.
Locking rule 위반 때 usage bit는 오류 message의 중괄호 안에 2 × n STATEs개의 문자로 표시된다. 다음 인위적인 예에서는 같은 lock을 이미 보유한 task가 다시 획득하려 한다.
modprobe/2287 is trying to acquire lock:
(&sio_locks[i].lock){-.-.}, at: [<c02867fd>] mutex_lock+0x21/0x24
but task is already holding lock:
(&sio_locks[i].lock){-.-.}, at: [<c02867fd>] mutex_lock+0x21/0x24
각 STATE마다 왼쪽부터 lock usage와 readlock usage를 차례로 나타낸다. 각 위치의 문자는 report 시점까지 관측된 정확한 조합을 요약한다.
| 문자 | 의미 |
|---|---|
| . | IRQ가 disabled이고 해당 IRQ context 밖에서 획득함 |
| - | IRQ context에서 획득함 |
| + | IRQ가 enabled인 상태에서 획득함 |
| ? | IRQ가 enabled인 IRQ context에서 획득함 |
hardirq와 softirq 각각에 대해 일반 lock과 readlock의 사용 이력이 한 자리씩 배치된다.
주어진 STATE에서 IRQ context 획득 이력과 STATE enabled 상태의 획득 이력을 조합하면 네 경우가 나온다.
| 사용 이력 | IRQ enabled | IRQ disabled |
|---|---|---|
| IRQ context에서 획득한 적 있음 | ? | - |
| IRQ context에서 획득한 적 없음 | + | . |
'-'가 표시되면 IRQ enabled 이력은 없었다고 추론할 수 있다. 있었다면 '?'가 표시됐을 것이기 때문이다. '+'에도 비슷한 추론을 적용할 수 있다. 아직 사용되지 않은 lock, 예를 들어 한 번도 사용되지 않은 mutex는 오류 원인의 일부가 될 수 없다.
Single-lock state rule
114-135Lock이 irq-safe라는 말은 IRQ context에서 한 번이라도 사용됐다는 뜻이다. irq-unsafe는 IRQ enabled 상태에서 한 번이라도 획득됐다는 뜻이다. Softirq-unsafe lock class는 자동으로 hardirq-unsafe이기도 하다.
각 lock class의 usage에서 hardirq-safe와 hardirq-unsafe는 상호 배타적이고, softirq-safe와 softirq-unsafe도 상호 배타적이다. 각 pair에서는 하나만 설정될 수 있다.
<hardirq-safe> or <hardirq-unsafe>
<softirq-safe> or <softirq-unsafe>
IRQ context에서 사용할 수 있는 irq-safe lock은 IRQ enabled 상태에서 획득된 적이 있어서는 안 된다. 그렇게 사용하면 lock을 획득한 뒤 release하기 전에 interrupt가 들어왔을 때 같은 lock을 두 번째로 획득하려 하므로 deadlock이 생길 수 있다. 이를 lock recursion deadlock이라 한다.
Validator는 이 single-lock state rule을 어긴 lock usage를 발견해 report한다.
Multi-lock dependency rule
137-190같은 lock class를 두 번 획득하면 lock recursion deadlock이 생길 수 있으므로 허용되지 않는다.
두 lock을 서로 반대 순서로 획득하는 것도 허용되지 않는다. L1을 보유한 채 L2를 기다리는 context와 L2를 보유한 채 L1을 기다리는 context가 원을 이루어 영원히 서로를 기다릴 수 있다. 이를 lock inversion deadlock이라 한다.
<L1> -> <L2>
<L2> -> <L1>
Task A는 L1을 잡고 L2를 기다리며 Task B는 L2를 잡고 L1을 기다린다. 어느 쪽도 먼저 release 지점까지 진행할 수 없다.
Validator는 획득 operation 사이에 다른 locking sequence가 얼마든지 들어 있어도 임의 복잡도의 dependency circle을 찾아낸다.
Usage에 기반한 다음 lock dependency도 어떤 두 lock class 사이에서든 허용되지 않는다.
<hardirq-safe> -> <hardirq-unsafe>
<softirq-safe> -> <softirq-unsafe>
Hardirq-safe lock은 hardirq context에서 획득될 수 있고, 그 context가 hardirq-unsafe lock을 보유한 실행을 interrupt할 수 있다. 그러면 lock inversion deadlock이 생길 수 있다. Softirq-safe lock과 softirq-unsafe lock도 같은 원리다.
Kernel에서 어떤 locking sequence가 관측되든 새 lock을 획득할 때 validator는 새 lock과 현재 보유한 모든 lock 사이에 rule 위반이 있는지 검사한다.
Lock class state가 바뀌면 과거 dependency도 다시 검사한다. 새 hardirq-safe lock이 발견되면 과거에 hardirq-unsafe lock을 획득했는지, 새 softirq-safe lock이면 softirq-unsafe lock을 획득했는지 확인한다. 새 hardirq-unsafe lock이 발견되면 과거에 hardirq-safe lock이 이를 획득했는지, 새 softirq-unsafe lock이면 softirq-safe lock이 이를 획득했는지 확인한다.
실제로 그 interrupt timing이 아직 발생하지 않았어도 검사한다. Interrupt context는 어떤 irq-unsafe 또는 hardirq-unsafe lock 보유 구간도 interrupt할 수 있고, 그 가능성만으로 inversion deadlock이 성립할 수 있기 때문이다.
예외: 계층적 data와 nested locking
192-232Linux kernel에는 같은 lock class의 instance를 둘 이상 획득하는 경우가 조금 있다. 같은 type의 object 사이에 hierarchy가 있을 때 주로 나타난다. Hierarchy 속성이 두 object 사이의 자연스러운 순서를 정의하며 kernel은 각 object의 lock을 항상 그 고정 순서로 잡는다.
Whole-disk block device object와 partition block device object가 nested locking의 예다. Partition은 whole device의 일부이므로 whole disk lock을 partition lock보다 항상 상위에서 획득하면 ordering은 올바르다. 다만 이 ordering rule은 static하지 않으므로 validator가 자연스러운 계층을 자동으로 알아내지는 못한다.
이 올바른 usage model을 validator에 알려 주기 위해 여러 locking primitive에 nesting level을 지정하는 _nested() version이 추가됐다. Block device mutex 예시는 다음과 같다.
enum bdev_bd_mutex_lock_class
{
BD_MUTEX_NORMAL,
BD_MUTEX_WHOLE,
BD_MUTEX_PARTITION
};
mutex_lock_nested(&bdev->bd_contains->bd_mutex,
BD_MUTEX_PARTITION);
이 호출은 대상 bdev object가 partition임을 알고 lock을 획득한다. Validator는 nested 방식으로 획득한 lock을 validation 목적상 별도 subclass로 취급한다.
Code를 _nested() primitive로 바꿀 때는 hierarchy가 올바르게 mapping됐는지 매우 철저히 확인해야 한다. 잘못 지정하면 false positive뿐 아니라 false negative도 만들 수 있다.
Locking requirement annotation
234-279특정 지점에서 어떤 lock을 보유해야 하는지 annotate하고 검사하는 두 construct는 lockdep_assert_held*(&lock)과 lockdep_*pin_lock(&lock)이다.
lockdep_assert_held* macro family는 특정 시점에 지정한 lock을 보유했는지 assert하고, 그렇지 않으면 WARN()을 만든다. Kernel 전반에서 널리 쓰인다. kernel/sched/core.c의 update_rq_clock()은 rq clock을 안전하게 갱신하려면 rq->lock을 보유해야 함을 다음처럼 검사한다.
void update_rq_clock(struct rq *rq)
{
s64 delta;
lockdep_assert_held(&rq->lock);
[...]
}
lockdep_*pin_lock() family는 현재 rq->lock에만 쓰일 정도로 채택 범위가 좁지만 관심 lock이 실수로 unlock되면 WARN()을 만든다. Upper layer는 lock이 계속 잡혀 있다고 가정하지만 callback 아래쪽 layer가 lock을 잠깐 놓았다 다시 잡아도 된다고 생각해 race를 만드는 code를 debug할 때 특히 유용하다.
lockdep_pin_lock()은 struct pin_cookie를 반환하고 lockdep_unpin_lock()은 이 cookie로 중간에 누군가 lock 상태를 건드리지 않았는지 검사한다. kernel/sched/sched.h의 wrapper는 다음과 같다.
static inline void rq_pin_lock(struct rq *rq,
struct rq_flags *rf)
{
rf->cookie = lockdep_pin_lock(&rq->lock);
[...]
}
static inline void rq_unpin_lock(struct rq *rq,
struct rq_flags *rf)
{
[...]
lockdep_unpin_lock(&rq->lock, rf->cookie);
}
Locking requirement를 comment로 남기는 것도 정보가 되지만 annotation의 runtime check는 locking problem debug에 매우 값지고 code를 읽을 때도 같은 수준의 detail을 전달한다. 어느 쪽을 쓸지 망설여진다면 annotation을 우선한다.
Locking correctness의 수학적 closure와 성능
281-334Validator는 kernel lifetime 동안 한 번이라도 발생한 단순하고 독립적인 single-task locking sequence 각각에 대해 수학적 closure를 만든다. 이 component sequence들을 어떤 조합과 timing으로 실행해도 어떤 종류의 lock-related deadlock도 만들 수 없음을 100% 확실하게 증명한다.
복잡한 multi-CPU, multi-task locking scenario가 실제로 발생할 필요는 없다. 단순 component locking chain이 어느 task나 context에서든 한 번씩만 나타나면 validator가 correctness를 증명할 수 있다. 원래라면 CPU 세 개 이상과 task, IRQ context, timing의 매우 희귀한 조합이 필요한 deadlock도 부하가 적은 single-CPU system에서 검출할 수 있다.
따라서 locking QA의 복잡성이 크게 낮아진다. 현실적으로 불가능한 모든 CPU interaction과 모든 hardirq·softirq nesting 조합을 일으키는 대신, kernel의 단순 single-task dependency를 가능한 한 많이 한 번씩 실행하면 된다.
이 증명은 validator 자체가 완전히 올바르고 다른 system component가 validator state를 훼손하지 않는다고 가정한다. Hardirq-disabled code도 interrupt할 수 있는 모든 NMI/SMM path가 올바르고 validator를 방해하지 않아야 한다. 모든 lock chain의 64-bit chain hash가 unique하고 lock recursion depth가 20을 넘지 않는다는 가정도 필요하다.
O(N²) 검사를 줄이는 chain cache
위 rule을 lock 획득과 IRQ-enable event마다 모두 검사하면 runtime overhead가 너무 커져 system을 사실상 쓸 수 없을 정도로 느리게 만든다. 검사 complexity는 O(N²)이므로 lock class가 수백 개만 있어도 event마다 수만 번 검사해야 한다.
Lockdep은 주어진 locking scenario, 즉 lock을 차례로 획득한 unique sequence를 한 번만 검사해 해결한다. Held lock의 단순 stack을 유지하고 lock chain마다 unique한 가벼운 64-bit hash를 계산한다.
Chain을 처음 validate하면 hash를 lock-free 방식으로 조회할 수 있는 hash table에 넣는다. 나중에 같은 chain이 다시 발생하면 hash table을 보고 다시 validate하지 않는다.
MAX_LOCKDEP_KEYS 고갈 진단
336-397Validator가 추적할 수 있는 lock class는 최대 MAX_LOCKDEP_KEYS개다. 이 수를 넘으면 다음 warning이 발생한다.
DEBUG_LOCKS_WARN_ON(id >= MAX_LOCKDEP_KEYS)
현재 기본 MAX_LOCKDEP_KEYS는 8191이고 일반 desktop system의 lock class는 1,000개보다 적다. 따라서 이 warning은 보통 lock class leak이나 lock initialization 누락을 뜻한다.
반복되는 module load와 unload
Validator를 실행한 채 module을 반복 load·unload하면 lock class leak이 생긴다. Load할 때마다 module lock의 새 class set을 만들지만 unload는 과거 class를 제거하지 않는다. Lock class 재사용이 어려운 이유는 아래 설명과 같다. 반복하면 class 수가 결국 maximum에 도달한다.
대규모 lock array의 초기화 누락
명시적으로 초기화하지 않은 lock을 많이 포함한 array 같은 structure도 원인이 된다. Bucket마다 spinlock_t가 있는 8192-bucket hash table에서 각 spinlock을 runtime에 명시적으로 초기화하지 않으면 lock class 8192개를 소비한다.
Compile-time initializer인 __SPIN_LOCK_UNLOCKED()만 쓰지 말고 loop에서 각 lock에 spin_lock_init()을 호출하면 8192개 lock이 모두 하나의 lock class에 들어간다. 교훈은 lock을 항상 명시적으로 초기화하라는 것이다.
Lock class를 재사용하도록 validator를 고치자는 주장이 나올 수 있다. 그러나 제거할 class가 lock-dependency graph에 연결돼 있을 가능성을 고려해 필요한 변경을 검토하면 말보다 구현이 훨씬 어렵다는 점을 알 수 있다.
Leak 찾기
현재 사용 중인 lock class 수와 maximum은 다음 명령으로 확인한다.
grep "lock-classes" /proc/lockdep_stats
lock-classes: 748 [max: 8191]
할당 수가 시간에 따라 계속 늘어나면 leak일 가능성이 높다. Leaking lock class는 다음 명령으로 찾을 수 있다.
grep "BD" /proc/lockdep
명령 결과를 저장하고 나중 결과와 비교해 새로 늘어난 class를 찾는다. 같은 output은 runtime lock initialization을 빠뜨린 위치를 찾는 데도 도움이 된다.
Recursive read lock의 의미와 blocking matrix
399-498이후 문서는 특정 종류의 dependency cycle과 deadlock 가능성이 동치임을 증명한다. Locker는 writer, non-recursive reader, recursive reader 세 종류다.
| 표기 | 의미 |
|---|---|
| W 또는 E | Writer, 즉 spin_lock()이나 write_lock() 같은 exclusive locker |
| r | down_read() 같은 non-recursive shared reader |
| R | rcu_read_lock() 같은 recursive shared reader |
| S | 모든 shared reader: non-recursive + recursive |
| N | 재귀적이지 않은 locker: writer + non-recursive reader |
따라서 N은 r 또는 W이고 S는 r 또는 R이다. Recursive reader는 같은 lock instance의 다른 reader critical section 안에서도 획득할 수 있어 한 lock의 read-side critical section을 중첩할 수 있다. Non-recursive reader는 같은 상황에서 획득을 시도하면 self-deadlock을 일으킨다.
차이는 recursive reader가 현재 write lock holder에게만 block되지만 non-recursive reader는 write lock waiter에게도 block될 수 있다는 점에서 생긴다.
Task A가 X의 reader를 먼저 잡고 Task B가 writer로 대기한다. 두 번째 reader가 recursive면 진행하지만 non-recursive면 waiter B에 막혀 self-deadlock이 된다.
같은 lock instance의 reader와 writer 사이에는 네 가지 blocking condition이 있다. Writer는 다른 writer를 block하고, reader는 writer를 block한다. Writer는 recursive와 non-recursive reader를 모두 block한다. Reader는 다른 recursive reader를 block하지 않지만 함께 존재할 수 있는 writer waiter 때문에 non-recursive reader는 block할 수 있다.
| Holder \ Requester | W | r | R |
|---|---|---|---|
| W | Y | Y | Y |
| r | Y | Y | N |
| R | Y | Y | N |
표에서 Y는 row의 locker가 column의 locker를 block함을 뜻하고 N은 block하지 않음을 뜻한다.
Recursive read lock은 current write lock holder에게는 block되지만 write lock waiter만으로는 block되지 않는다. Task A가 read_lock(X)을 가진 상태에서 Task B가 write_lock(X)을 기다려도 Task A의 두 번째 recursive read_lock(X)은 기다릴 필요가 없다. 같은 operation이 non-recursive라면 B가 실제 lock을 얻지 못했더라도 waiter가 두 번째 read를 block해 deadlock이 된다.
하나의 lock instance도 어떤 acquisition function을 사용했는지, 더 정확히는 lock_acquire()의 read parameter 값에 따라 exclusive write lock, non-recursive read lock, recursive read lock 세 형태로 획득될 수 있다.
이후 설명에서는 write lock과 non-recursive read lock을 합쳐 non-recursive lock, recursive read lock을 recursive lock이라 부른다. Recursive lock끼리는 서로 block하지 않는다. Non-recursive lock끼리는 두 non-recursive read lock인 경우까지 서로 block한다. Non-recursive lock과 대응 recursive lock은 서로를 block할 수 있다.
Recursive lock이 포함된 실제 deadlock 형태
500-510Task A는 X의 reader이고 Y writer를 기다린다. Task B는 Y의 reader이고 X writer를 기다려 원형 대기가 된다.
Task A는 Task B가 Y를 read_unlock()하기를 기다리고 Task B는 Task A가 X를 read_unlock()하기를 기다리므로 둘 다 진행할 수 없다.
Dependency edge 네 종류와 strong path
512-579Lock dependency는 두 lock의 획득 순서를 기록한다. Locker가 세 종류이므로 이론상 dependency는 아홉 종류지만 deadlock detection에는 네 종류면 충분함을 보일 수 있다.
L1 -> L2는 같은 runtime context에서 L1을 보유한 뒤 L2를 획득한 이력을 lockdep이 봤다는 뜻이다. Deadlock detection에서 필요한 것은 L1을 보유한 상태로 L2에서 block될 수 있는지다. 즉 L1이 무엇을 block하는지와 무엇이 L2를 block하는지만 중요하다.
따라서 L1 쪽에서는 recursive reader와 non-recursive reader가 같은 type을 block하므로 합칠 수 있다. L2 쪽에서는 writer와 non-recursive reader가 같은 type에게 block되므로 합칠 수 있다.
| Edge | 의미 |
|---|---|
| -(ER)-> | Exclusive writer에서 recursive reader로 향함. X -(ER)-> Y는 X가 writer이고 Y가 recursive reader인 X -> Y |
| -(EN)-> | Exclusive writer에서 non-recursive locker로 향함. X는 writer이고 Y는 writer 또는 non-recursive reader |
| -(SR)-> | Shared reader에서 recursive reader로 향함. X는 recursive 여부와 무관한 reader이고 Y는 recursive reader |
| -(SN)-> | Shared reader에서 non-recursive locker로 향함. X는 reader이고 Y는 writer 또는 non-recursive reader |
두 lock 사이에는 dependency가 여러 개 있을 수 있다. Task A가 read_lock(X) 뒤 write_lock(Y)을 획득하고 Task B가 write_lock(X) 뒤 write_lock(Y)을 획득했다면 graph에는 X -(SN)-> Y와 X -(EN)-> Y가 모두 존재한다.
TASK A:
read_lock(X);
write_lock(Y);
TASK B:
write_lock(X);
write_lock(Y);
-(xN)->은 -(EN)-> 또는 -(SN)-> edge를 뜻한다. 같은 방식으로 -(Ex)->, -(xR)->, -(Sx)-> 표기를 사용한다.
Path는 graph에서 연속으로 이어진 dependency edge의 series다. Strong path는 path의 어떤 인접한 두 edge도 -(xR)-> 다음 -(Sx)-> 조합이 아닌 path로 정의한다.
다르게 말하면 X -> Y -> Z가 path에 있고 X에서 Y로 이동한 edge가 -(SR)-> 또는 -(ER)->라면 Y에서 Z로 이동하는 edge는 -(SN)-> 또는 -(SR)->일 수 없다. 다음 절에서 이 조건이 왜 strong이라 불리는지 증명한다.
Strong circle과 deadlock 가능성의 동치
581-658두 lemma를 증명한다. Lemma 1은 closed strong path, 즉 strong circle이 있으면 deadlock을 만드는 locking sequence 조합이 존재한다는 명제다. Strong circle은 deadlock detection의 충분조건이다.
Lemma 2는 closed strong path가 없으면 deadlock을 만들 수 있는 locking sequence 조합도 없다는 명제다. Strong circle은 deadlock detection의 필요조건이다.
두 lemma를 합치면 closed strong path는 deadlock의 필요충분조건이고 deadlock 가능성과 동치다. Deadlock을 만들지 않는 dependency circle도 있으므로, deadlock 가능한 chain을 구별해 strong이라 부른다.
각 Lx holder가 다음 lock Lx+1의 holder를 기다리고 마지막 Ln holder가 다시 L1 holder를 기다린다.
충분조건 증명: strong circle이면 deadlock을 구성할 수 있다
L1 -> L2 -> ... -> Ln -> L1인 strong circle이 있다고 하자. 이는 L1 -> L2, L2 -> L3, ..., Ln-1 -> Ln, Ln -> L1 dependency가 있다는 뜻이다.
먼저 한 CPU 또는 task가 L1 -> L2에서 L1을 획득하게 하고, 다른 CPU 또는 task가 L2 -> L3에서 L2를 획득하게 하는 식으로 배치한다. 그러면 Lx -> Lx+1의 모든 Lx를 서로 다른 CPU 또는 task가 보유한다.
L1 holder가 L2 획득을 시도할 때 L2는 이미 다른 CPU 또는 task가 보유한다. Strong 정의 때문에 L1 -> L2와 L2 -> L3는 -(xR)-> 뒤 -(Sx)-> 조합이 아니다. 즉 앞 dependency의 L2가 누구에게나 block되는 non-recursive locker이거나 뒤 dependency의 L2가 누구든 block하는 writer다. 따라서 L1 holder는 L2를 얻지 못하고 L2 holder의 release를 기다린다.
같은 결론을 이어가면 L2 holder는 L3 holder를 기다리고 모든 Lx holder는 Lx+1 holder를 기다린다. Ln+1은 L1이므로 circular waiting이 완성되고 누구도 진행하지 못해 deadlock이 된다.
필요조건 증명: deadlock이면 strong circle이 존재한다
Lemma 2는 deadlock scenario가 있다면 dependency graph에 반드시 strong circle이 있다는 명제와 동치다.
Deadlock에는 circular waiting이 존재한다. N개의 CPU 또는 task P1...Pn이 있고 P1은 P2가 보유한 lock을, P2는 P3가 보유한 lock을 기다리며, 마지막 Pn은 P1이 보유한 lock을 기다린다.
Px가 기다리는 lock을 Lx라 하자. P1은 Ln을 보유하면서 L1을 기다리므로 dependency graph에 Ln -> L1이 있다. 같은 방식으로 L1 -> L2, L2 -> L3, ..., Ln-1 -> Ln이 있어 Ln -> L1 -> L2 -> ... -> Ln circle이 만들어진다.
각 Lx에서 Px는 Lx-1 -> Lx dependency를 만들고 Px+1은 Lx -> Lx+1 dependency를 만든다. Px가 Px+1의 Lx release를 실제로 기다리므로 Px+1 쪽 Lx가 reader이고 Px 쪽 Lx가 recursive reader인 조합은 불가능하다. Recursive 여부와 상관없이 reader는 recursive reader를 block하지 않기 때문이다.
따라서 Lx-1 -> Lx와 Lx -> Lx+1은 -(xR)-> 뒤 -(Sx)-> pair일 수 없다. Circle의 모든 lock에 같은 사실이 성립하므로 이 circle은 strong이다.
참고 자료
660-663Shibu, K. (2009). Intro To Embedded Systems (1st ed.). Tata McGraw-Hill.
Instance가 아니라 lock class를 추적한다
lockdep-design.rst:1-37Lockdep의 기본 단위는 개별 lock instance가 아니라 논리적으로 같은 규칙을 따르는 lock class입니다. 수만 개 inode가 각각 i_lock instance를 가져도 동일한 초기화 key에 매핑되면 하나의 class로 취급됩니다. 모든 instance에서 관찰된 usage와 dependency가 class에 누적됩니다.
L1을 보유한 채 L2를 획득하면 lockdep graph에 L1 -> L2 edge가 기록됩니다. 두 lock이 설계상 관련 있는지와 무관하게 실제 실행에서 이 순서가 한 번 관찰되었다는 뜻입니다. 이후 반대 방향 경로나 IRQ usage 변화가 추가될 때 cycle 가능성을 검사합니다.
Lock instance가 해제되어도 class가 즉시 사라지는 것은 아닙니다. Static 또는 dynamic key의 memory가 회수되는 module unload, workqueue destroy 같은 시점에는 class 제거가 가능하지만 dependency graph와 key 수명은 별도로 관리됩니다.