요약·해설과 원문, 전문 번역을 서로 분리했습니다. API 이름, symbol, source path는 원문 표기를 사용합니다.
1. 요약·해설
원문의 핵심 논리와 kernel programming 관점의 보충 설명입니다. 아래의 전문 번역과는 별도로 작성했습니다.
Wait-Die와 Wound-Wait
ww-mutex-design.rst:36-56| Algorithm | 요청자가 owner보다 오래됨 | 요청자가 owner보다 젊음 |
|---|---|---|
| Wait-Die | 젊은 owner가 끝날 때까지 기다림 | 요청자가 rollback하고 재시도 |
| Wound-Wait | 젊은 owner를 wound하여 다음 충돌에서 rollback시킴 | 오래된 owner가 끝날 때까지 기다림 |
두 알고리즘 모두 ticket을 유지하면 starvation 없이 결국 성공합니다. Wound-Wait는 rollback 횟수가 적은 경향이 있지만 running transaction에 wounded 상태를 전달하고 안전한 지점에서 rollback시키는 작업이 더 필요합니다. 여기서 preemption은 scheduler 선점이 아니라 -EDEADLK를 반환해 transaction을 중단하는 의미입니다.
Acquire context, class와 세 종류 획득
ww-mutex-design.rst:57-109ww_acquire_ctx는 transaction ticket과 현재 획득 수, wounded 상태와 debug 정보를 보유합니다. Retry할 때 새 context를 만들면 나이가 계속 젊어져 forward progress가 깨지므로 같은 context를 끝까지 유지해야 합니다. ww_class는 context와 모든 mutex가 같은 algorithm과 ticket domain을 사용하게 합니다.
- ww_mutex_lock(lock, ctx): transaction context를 사용한 일반 획득, 반환값 필수 확인
- ww_mutex_lock_slow(lock, ctx): -EDEADLK 뒤 모든 기존 lock을 푼 상태에서 contended lock을 blocking acquire
- ww_mutex_lock(lock, NULL): 하나의 object만 잡아 deadlock prevention이 필요 없는 일반 mutex semantics
_slow variant는 단순 재호출과 결과적으로 같은 lock을 얻지만 debug build에서 모든 이전 lock을 놓았는지, 정확한 contended lock을 기다리는지 검사하고 must_check 반환값이 없어 retry code의 계약을 명확히 합니다.
-EDEADLK rollback과 retry
ww-mutex-design.rst:110-233ww_acquire_init(ctx, &ww_class);
retry:
for_each_object(obj) {
ret = ww_mutex_lock(&obj->lock, ctx);
if (ret == -EDEADLK) {
unlock_all_objects();
ww_mutex_lock_slow(&obj->lock, ctx);
reserved = obj;
goto retry;
}
if (ret)
goto error;
}
ww_acquire_done(ctx);
/* objects 사용 */
unlock_all_objects();
ww_acquire_fini(ctx);
고정 list를 재정렬할 수 없으면 slow로 얻은 contended object를 reserved로 기억하고 retry loop에서 건너뜁니다. 재정렬 가능하면 그 object를 list 앞쪽으로 옮겨 다음 retry의 첫 lock으로 만들 수 있습니다. -EALREADY는 같은 object가 목록에 중복되었음을 알리며 user input 검증에도 사용할 수 있습니다.
-EDEADLK를 받은 뒤 하나라도 기존 ww_mutex를 보유한 채 slow acquire하면 deadlock prevention protocol을 위반합니다. 모든 lock을 먼저 풀고 정확히 실패한 lock 하나를 slow로 획득해야 합니다.
Graph를 따라 object를 동적으로 발견할 때
ww-mutex-design.rst:235-330Graph edge를 따라가며 새로운 node lock을 발견하는 경우에도 ww_mutex를 사용할 수 있습니다. 이미 보유한 node에서 -EALREADY가 오면 추가 bookkeeping 없이 cycle을 건너뜁니다. -EDEADLK이면 동적으로 만든 held list를 모두 풀고 실패한 node를 slow acquire한 뒤 graph walk 자체를 다시 시작합니다.
고정 시작 목록과 동적 발견을 결합할 수도 있습니다. 이때 dynamic 단계의 rollback은 시작 목록에서 잡은 lock까지 모두 놓아야 합니다. 하나만 획득할 때는 ctx에 NULL을 전달하여 ticket과 rollback machinery를 생략합니다.
Waiter 정렬과 lazy wound
ww-mutex-design.rst:332-363ww_mutex는 내부에 struct mutex를 포함하여 일반 NULL-context lock path의 overhead를 최소화합니다. Context가 있는 waiter는 ticket stamp 순서로 정렬하고 context 없는 waiter는 FIFO로 섞입니다. Wait-Die에서는 이미 다른 lock을 가진 waiter가 context waiter 중 맨 앞 하나만 존재하도록 invariant를 유지합니다.
Wound-Wait는 즉시 task를 강제로 멈추지 않고 lazy wound를 사용합니다. Wounded transaction이 다음 lock contention을 만났을 때 상태를 확인하고 rollback합니다. 이때 실제 contended lock을 알 수 있어 blind retry보다 다시 충돌할 가능성이 작습니다.
Lockdep이 잡는 protocol 위반
ww-mutex-design.rst:364-394- ww_acquire_init() 또는 fini()를 빠뜨리거나 같은 context에 두 번 호출
- ww_acquire_done() 뒤 새 mutex를 추가 획득
- -EDEADLK 뒤 기존 lock을 풀기 전에 slow acquire
- -EDEADLK를 준 lock이 아닌 다른 lock을 slow acquire
- -EDEADLK 없이 ww_mutex_lock_slow() 호출
- Context와 mutex에 서로 다른 ww_class 사용
- 잘못된 unlock 함수와 일반 lock-order deadlock
CONFIG_DEBUG_MUTEXES도 일부 misuse를 찾지만 CONFIG_PROVE_LOCKING을 켜야 acquire context lifecycle과 dependency를 폭넓게 검증할 수 있습니다.
2. 영어 원문 전체
번역 기준이 된 Linux v6.18.37 원문입니다. 줄 번호는 이 버전의 파일 좌표입니다.
원문 전체 펼치기
======================================
Wound/Wait Deadlock-Proof Mutex Design
======================================
Please read mutex-design.rst first, as it applies to wait/wound mutexes too.
Motivation for WW-Mutexes
-------------------------
GPU's do operations that commonly involve many buffers. Those buffers
can be shared across contexts/processes, exist in different memory
domains (for example VRAM vs system memory), and so on. And with
PRIME / dmabuf, they can even be shared across devices. So there are
a handful of situations where the driver needs to wait for buffers to
become ready. If you think about this in terms of waiting on a buffer
mutex for it to become available, this presents a problem because
there is no way to guarantee that buffers appear in a execbuf/batch in
the same order in all contexts. That is directly under control of
userspace, and a result of the sequence of GL calls that an application
makes. Which results in the potential for deadlock. The problem gets
more complex when you consider that the kernel may need to migrate the
buffer(s) into VRAM before the GPU operates on the buffer(s), which
may in turn require evicting some other buffers (and you don't want to
evict other buffers which are already queued up to the GPU), but for a
simplified understanding of the problem you can ignore this.
The algorithm that the TTM graphics subsystem came up with for dealing with
this problem is quite simple. For each group of buffers (execbuf) that need
to be locked, the caller would be assigned a unique reservation id/ticket,
from a global counter. In case of deadlock while locking all the buffers
associated with a execbuf, the one with the lowest reservation ticket (i.e.
the oldest task) wins, and the one with the higher reservation id (i.e. the
younger task) unlocks all of the buffers that it has already locked, and then
tries again.
In the RDBMS literature, a reservation ticket is associated with a transaction.
and the deadlock handling approach is called Wait-Die. The name is based on
the actions of a locking thread when it encounters an already locked mutex.
If the transaction holding the lock is younger, the locking transaction waits.
If the transaction holding the lock is older, the locking transaction backs off
and dies. Hence Wait-Die.
There is also another algorithm called Wound-Wait:
If the transaction holding the lock is younger, the locking transaction
wounds the transaction holding the lock, requesting it to die.
If the transaction holding the lock is older, it waits for the other
transaction. Hence Wound-Wait.
The two algorithms are both fair in that a transaction will eventually succeed.
However, the Wound-Wait algorithm is typically stated to generate fewer backoffs
compared to Wait-Die, but is, on the other hand, associated with more work than
Wait-Die when recovering from a backoff. Wound-Wait is also a preemptive
algorithm in that transactions are wounded by other transactions, and that
requires a reliable way to pick up the wounded condition and preempt the
running transaction. Note that this is not the same as process preemption. A
Wound-Wait transaction is considered preempted when it dies (returning
-EDEADLK) following a wound.
Concepts
--------
Compared to normal mutexes two additional concepts/objects show up in the lock
interface for w/w mutexes:
Acquire context: To ensure eventual forward progress it is important that a task
trying to acquire locks doesn't grab a new reservation id, but keeps the one it
acquired when starting the lock acquisition. This ticket is stored in the
acquire context. Furthermore the acquire context keeps track of debugging state
to catch w/w mutex interface abuse. An acquire context is representing a
transaction.
W/w class: In contrast to normal mutexes the lock class needs to be explicit for
w/w mutexes, since it is required to initialize the acquire context. The lock
class also specifies what algorithm to use, Wound-Wait or Wait-Die.
Furthermore there are three different class of w/w lock acquire functions:
* Normal lock acquisition with a context, using ww_mutex_lock.
* Slowpath lock acquisition on the contending lock, used by the task that just
killed its transaction after having dropped all already acquired locks.
These functions have the _slow postfix.
From a simple semantics point-of-view the _slow functions are not strictly
required, since simply calling the normal ww_mutex_lock functions on the
contending lock (after having dropped all other already acquired locks) will
work correctly. After all if no other ww mutex has been acquired yet there's
no deadlock potential and hence the ww_mutex_lock call will block and not
prematurely return -EDEADLK. The advantage of the _slow functions is in
interface safety:
- ww_mutex_lock has a __must_check int return type, whereas ww_mutex_lock_slow
has a void return type. Note that since ww mutex code needs loops/retries
anyway the __must_check doesn't result in spurious warnings, even though the
very first lock operation can never fail.
- When full debugging is enabled ww_mutex_lock_slow checks that all acquired
ww mutex have been released (preventing deadlocks) and makes sure that we
block on the contending lock (preventing spinning through the -EDEADLK
slowpath until the contended lock can be acquired).
* Functions to only acquire a single w/w mutex, which results in the exact same
semantics as a normal mutex. This is done by calling ww_mutex_lock with a NULL
context.
Again this is not strictly required. But often you only want to acquire a
single lock in which case it's pointless to set up an acquire context (and so
better to avoid grabbing a deadlock avoidance ticket).
Of course, all the usual variants for handling wake-ups due to signals are also
provided.
Usage
-----
The algorithm (Wait-Die vs Wound-Wait) is chosen by using either
DEFINE_WW_CLASS() (Wound-Wait) or DEFINE_WD_CLASS() (Wait-Die)
As a rough rule of thumb, use Wound-Wait iff you
expect the number of simultaneous competing transactions to be typically small,
and you want to reduce the number of rollbacks.
Three different ways to acquire locks within the same w/w class. Common
definitions for methods #1 and #2::
static DEFINE_WW_CLASS(ww_class);
struct obj {
struct ww_mutex lock;
/* obj data */
};
struct obj_entry {
struct list_head head;
struct obj *obj;
};
Method 1, using a list in execbuf->buffers that's not allowed to be reordered.
This is useful if a list of required objects is already tracked somewhere.
Furthermore the lock helper can use propagate the -EALREADY return code back to
the caller as a signal that an object is twice on the list. This is useful if
the list is constructed from userspace input and the ABI requires userspace to
not have duplicate entries (e.g. for a gpu commandbuffer submission ioctl)::
int lock_objs(struct list_head *list, struct ww_acquire_ctx *ctx)
{
struct obj *res_obj = NULL;
struct obj_entry *contended_entry = NULL;
struct obj_entry *entry;
ww_acquire_init(ctx, &ww_class);
retry:
list_for_each_entry (entry, list, head) {
if (entry->obj == res_obj) {
res_obj = NULL;
continue;
}
ret = ww_mutex_lock(&entry->obj->lock, ctx);
if (ret < 0) {
contended_entry = entry;
goto err;
}
}
ww_acquire_done(ctx);
return 0;
err:
list_for_each_entry_continue_reverse (entry, list, head)
ww_mutex_unlock(&entry->obj->lock);
if (res_obj)
ww_mutex_unlock(&res_obj->lock);
if (ret == -EDEADLK) {
/* we lost out in a seqno race, lock and retry.. */
ww_mutex_lock_slow(&contended_entry->obj->lock, ctx);
res_obj = contended_entry->obj;
goto retry;
}
ww_acquire_fini(ctx);
return ret;
}
Method 2, using a list in execbuf->buffers that can be reordered. Same semantics
of duplicate entry detection using -EALREADY as method 1 above. But the
list-reordering allows for a bit more idiomatic code::
int lock_objs(struct list_head *list, struct ww_acquire_ctx *ctx)
{
struct obj_entry *entry, *entry2;
ww_acquire_init(ctx, &ww_class);
list_for_each_entry (entry, list, head) {
ret = ww_mutex_lock(&entry->obj->lock, ctx);
if (ret < 0) {
entry2 = entry;
list_for_each_entry_continue_reverse (entry2, list, head)
ww_mutex_unlock(&entry2->obj->lock);
if (ret != -EDEADLK) {
ww_acquire_fini(ctx);
return ret;
}
/* we lost out in a seqno race, lock and retry.. */
ww_mutex_lock_slow(&entry->obj->lock, ctx);
/*
* Move buf to head of the list, this will point
* buf->next to the first unlocked entry,
* restarting the for loop.
*/
list_del(&entry->head);
list_add(&entry->head, list);
}
}
ww_acquire_done(ctx);
return 0;
}
Unlocking works the same way for both methods #1 and #2::
void unlock_objs(struct list_head *list, struct ww_acquire_ctx *ctx)
{
struct obj_entry *entry;
list_for_each_entry (entry, list, head)
ww_mutex_unlock(&entry->obj->lock);
ww_acquire_fini(ctx);
}
Method 3 is useful if the list of objects is constructed ad-hoc and not upfront,
e.g. when adjusting edges in a graph where each node has its own ww_mutex lock,
and edges can only be changed when holding the locks of all involved nodes. w/w
mutexes are a natural fit for such a case for two reasons:
- They can handle lock-acquisition in any order which allows us to start walking
a graph from a starting point and then iteratively discovering new edges and
locking down the nodes those edges connect to.
- Due to the -EALREADY return code signalling that a given objects is already
held there's no need for additional book-keeping to break cycles in the graph
or keep track off which looks are already held (when using more than one node
as a starting point).
Note that this approach differs in two important ways from the above methods:
- Since the list of objects is dynamically constructed (and might very well be
different when retrying due to hitting the -EDEADLK die condition) there's
no need to keep any object on a persistent list when it's not locked. We can
therefore move the list_head into the object itself.
- On the other hand the dynamic object list construction also means that the -EALREADY return
code can't be propagated.
Note also that methods #1 and #2 and method #3 can be combined, e.g. to first lock a
list of starting nodes (passed in from userspace) using one of the above
methods. And then lock any additional objects affected by the operations using
method #3 below. The backoff/retry procedure will be a bit more involved, since
when the dynamic locking step hits -EDEADLK we also need to unlock all the
objects acquired with the fixed list. But the w/w mutex debug checks will catch
any interface misuse for these cases.
Also, method 3 can't fail the lock acquisition step since it doesn't return
-EALREADY. Of course this would be different when using the _interruptible
variants, but that's outside of the scope of these examples here::
struct obj {
struct ww_mutex ww_mutex;
struct list_head locked_list;
};
static DEFINE_WW_CLASS(ww_class);
void __unlock_objs(struct list_head *list)
{
struct obj *entry, *temp;
list_for_each_entry_safe (entry, temp, list, locked_list) {
/* need to do that before unlocking, since only the current lock holder is
allowed to use object */
list_del(&entry->locked_list);
ww_mutex_unlock(entry->ww_mutex)
}
}
void lock_objs(struct list_head *list, struct ww_acquire_ctx *ctx)
{
struct obj *obj;
ww_acquire_init(ctx, &ww_class);
retry:
/* re-init loop start state */
loop {
/* magic code which walks over a graph and decides which objects
* to lock */
ret = ww_mutex_lock(obj->ww_mutex, ctx);
if (ret == -EALREADY) {
/* we have that one already, get to the next object */
continue;
}
if (ret == -EDEADLK) {
__unlock_objs(list);
ww_mutex_lock_slow(obj, ctx);
list_add(&entry->locked_list, list);
goto retry;
}
/* locked a new object, add it to the list */
list_add_tail(&entry->locked_list, list);
}
ww_acquire_done(ctx);
return 0;
}
void unlock_objs(struct list_head *list, struct ww_acquire_ctx *ctx)
{
__unlock_objs(list);
ww_acquire_fini(ctx);
}
Method 4: Only lock one single objects. In that case deadlock detection and
prevention is obviously overkill, since with grabbing just one lock you can't
produce a deadlock within just one class. To simplify this case the w/w mutex
api can be used with a NULL context.
Implementation Details
----------------------
Design:
^^^^^^^
ww_mutex currently encapsulates a struct mutex, this means no extra overhead for
normal mutex locks, which are far more common. As such there is only a small
increase in code size if wait/wound mutexes are not used.
We maintain the following invariants for the wait list:
(1) Waiters with an acquire context are sorted by stamp order; waiters
without an acquire context are interspersed in FIFO order.
(2) For Wait-Die, among waiters with contexts, only the first one can have
other locks acquired already (ctx->acquired > 0). Note that this waiter
may come after other waiters without contexts in the list.
The Wound-Wait preemption is implemented with a lazy-preemption scheme:
The wounded status of the transaction is checked only when there is
contention for a new lock and hence a true chance of deadlock. In that
situation, if the transaction is wounded, it backs off, clears the
wounded status and retries. A great benefit of implementing preemption in
this way is that the wounded transaction can identify a contending lock to
wait for before restarting the transaction. Just blindly restarting the
transaction would likely make the transaction end up in a situation where
it would have to back off again.
In general, not much contention is expected. The locks are typically used to
serialize access to resources for devices, and optimization focus should
therefore be directed towards the uncontended cases.
Lockdep:
^^^^^^^^
Special care has been taken to warn for as many cases of api abuse
as possible. Some common api abuses will be caught with
CONFIG_DEBUG_MUTEXES, but CONFIG_PROVE_LOCKING is recommended.
Some of the errors which will be warned about:
- Forgetting to call ww_acquire_fini or ww_acquire_init.
- Attempting to lock more mutexes after ww_acquire_done.
- Attempting to lock the wrong mutex after -EDEADLK and
unlocking all mutexes.
- Attempting to lock the right mutex after -EDEADLK,
before unlocking all mutexes.
- Calling ww_mutex_lock_slow before -EDEADLK was returned.
- Unlocking mutexes with the wrong unlock function.
- Calling one of the ww_acquire_* twice on the same context.
- Using a different ww_class for the mutex than for the ww_acquire_ctx.
- Normal lockdep errors that can result in deadlocks.
Some of the lockdep errors that can result in deadlocks:
- Calling ww_acquire_init to initialize a second ww_acquire_ctx before
having called ww_acquire_fini on the first.
- 'normal' deadlocks that can occur.
FIXME:
Update this section once we have the TASK_DEADLOCK task state flag magic
implemented.
3. 한국어 전문 번역
영어 원문의 문단 순서와 의미를 유지한 전체 번역입니다. 코드, 함수명, symbol과 URL은 원문 표기를 유지합니다.
여러 GPU buffer lock과 deadlock 문제
1-25Wait/wound mutex에도 일반 mutex 설계가 적용되므로 먼저 Documentation/locking/mutex-design.rst를 읽어야 한다.
GPU operation은 흔히 여러 buffer를 함께 사용한다. Buffer는 context와 process 사이에서 공유되고 VRAM과 system memory처럼 서로 다른 memory domain에 있을 수 있으며 PRIME/dmabuf를 통해 device 사이에서도 공유된다. Driver가 buffer 준비를 기다려야 하는 상황이 생긴다.
Buffer mutex가 풀리기를 기다린다고 생각하면 문제가 보인다. 모든 context에서 execbuf/batch의 buffer 순서가 같다고 보장할 수 없다. 순서는 userspace와 application의 GL call sequence가 결정하므로 deadlock 가능성이 있다.
Kernel이 GPU operation 전에 buffer를 VRAM으로 migration해야 하고 이를 위해 다른 buffer를 evict해야 하는 상황까지 고려하면 더 복잡하다. 이미 GPU에 queue된 buffer를 evict해서도 안 된다. 다만 기본 문제를 이해할 때에는 이 세부 사항을 생략할 수 있다.
Wait-Die와 Wound-Wait
27-55TTM graphics subsystem이 사용한 기본 algorithm은 단순하다. Lock해야 할 각 buffer group, 즉 execbuf에 global counter에서 고유 reservation id 또는 ticket을 부여한다. 모든 buffer를 lock하다 deadlock이 생기면 ticket이 가장 낮은 오래된 task가 이긴다. Reservation id가 높은 젊은 task는 이미 lock한 모든 buffer를 unlock하고 다시 시도한다.
RDBMS 문헌에서는 reservation ticket을 transaction과 연결하며 이 deadlock 처리 방식을 Wait-Die라고 한다. Lock을 잡은 transaction이 더 젊으면 lock을 요청한 오래된 transaction은 기다린다. Holder가 더 오래되었으면 젊은 requester가 물러나 죽는다.
Wound-Wait에서는 lock holder가 더 젊으면 오래된 requester가 holder를 wound하여 죽도록 요청한다. Holder가 더 오래되었으면 requester가 기다린다.
두 algorithm 모두 transaction이 결국 성공한다는 점에서 fair하다. Wound-Wait는 일반적으로 Wait-Die보다 backoff가 적지만 backoff recovery 작업은 더 많다. 다른 transaction이 wound하는 preemptive algorithm이므로 wounded 상태를 확실히 감지해 실행 transaction을 중단할 방법이 필요하다. 이는 process preemption과 다르다. Wound 뒤 -EDEADLK를 return하며 transaction이 죽을 때 Wound-Wait transaction이 preempt된 것으로 본다.
Acquire context, ww class, lock API
57-108일반 mutex와 비교하면 ww mutex interface에는 두 개념이 추가된다.
Acquire context는 transaction을 나타낸다. Forward progress를 보장하려면 task가 lock acquisition을 다시 시도할 때 새 reservation id를 받지 않고 처음 시작할 때 받은 ticket을 유지해야 한다. Context는 ticket과 ww mutex interface 오용을 찾기 위한 debug state를 보관한다.
WW class는 lock class와 사용할 algorithm을 명시한다. 일반 mutex와 달리 acquire context 초기화에 class가 필요하며 Wound-Wait 또는 Wait-Die 중 하나를 선택한다.
| acquisition 형태 | 의미 |
|---|---|
| ww_mutex_lock(..., ctx) | Context를 사용한 normal lock acquisition |
| ww_mutex_lock_slow(..., ctx) | -EDEADLK 뒤 이미 잡은 모든 lock을 놓은 task가 contended lock을 먼저 획득하는 slowpath |
| ww_mutex_lock(..., NULL) | WW mutex 하나만 획득하며 일반 mutex와 같은 의미 |
_slow function은 의미상 꼭 필요하지는 않다. 다른 ww mutex를 모두 놓은 뒤 normal ww_mutex_lock()을 contended lock에 다시 호출해도 deadlock 가능성이 없어 block하고 -EDEADLK를 조기 return하지 않는다. 하지만 _slow는 interface safety를 높인다.
- ww_mutex_lock은 __must_check int를 return하지만 ww_mutex_lock_slow는 void다. Retry loop가 필요하므로 첫 lock이 실패하지 않아도 __must_check가 불필요한 warning을 만들지는 않는다.
- Full debugging에서 ww_mutex_lock_slow는 획득했던 모든 ww mutex를 놓았는지 검사하여 deadlock을 막고 contended lock에서 실제로 block하는지 확인하여 -EDEADLK slowpath를 spin하는 일을 막는다.
- WW mutex 하나만 필요하면 acquire context와 deadlock avoidance ticket을 만들 필요가 없으므로 NULL context를 쓴다.
- Signal wakeup을 처리하는 일반 variant도 모두 제공한다.
Class 선택과 method 1: 순서를 바꿀 수 없는 list
110-181DEFINE_WW_CLASS()는 Wound-Wait, DEFINE_WD_CLASS()는 Wait-Die를 선택한다. 동시에 경쟁하는 transaction 수가 보통 적고 rollback 수를 줄이고 싶다면 대략적인 기준으로 Wound-Wait를 사용한다.
Method 1과 2에서 공통으로 쓰는 type은 다음과 같다.
static DEFINE_WW_CLASS(ww_class);
struct obj {
struct ww_mutex lock;
/* obj data */
};
struct obj_entry {
struct list_head head;
struct obj *obj;
};
Method 1은 execbuf->buffers list 순서를 바꿀 수 없을 때 사용한다. 필요한 object list를 이미 다른 곳에서 추적하는 경우에 유용하다. Duplicate entry가 있으면 lock helper가 -EALREADY를 caller에 전달할 수 있어 userspace input으로 만든 list에서 중복을 금지하는 ABI, 예를 들어 GPU command buffer submission ioctl에 적합하다.
int lock_objs(struct list_head *list, struct ww_acquire_ctx *ctx)
{
struct obj *res_obj = NULL;
struct obj_entry *contended_entry = NULL;
struct obj_entry *entry;
ww_acquire_init(ctx, &ww_class);
retry:
list_for_each_entry(entry, list, head) {
if (entry->obj == res_obj) {
res_obj = NULL;
continue;
}
ret = ww_mutex_lock(&entry->obj->lock, ctx);
if (ret < 0) {
contended_entry = entry;
goto err;
}
}
ww_acquire_done(ctx);
return 0;
err:
list_for_each_entry_continue_reverse(entry, list, head)
ww_mutex_unlock(&entry->obj->lock);
if (res_obj)
ww_mutex_unlock(&res_obj->lock);
if (ret == -EDEADLK) {
/* seqno race에서 패배: contended lock을 잡고 다시 시도 */
ww_mutex_lock_slow(&contended_entry->obj->lock, ctx);
res_obj = contended_entry->obj;
goto retry;
}
ww_acquire_fini(ctx);
return ret;
}
실패하면 지금까지 획득한 lock을 역순으로 놓는다. -EDEADLK이면 contended object만 slowpath로 먼저 lock하고 같은 acquire context와 ticket으로 list 전체를 다시 순회한다. Retry 중 res_obj를 만나면 이미 획득했으므로 건너뛴다.
Method 2: 재정렬할 수 있는 list
183-233Method 2는 execbuf->buffers list를 재정렬할 수 있을 때 사용한다. Method 1과 같이 -EALREADY로 duplicate를 감지하지만 contended entry를 list head로 옮겨 더 자연스러운 retry loop를 만든다.
int lock_objs(struct list_head *list, struct ww_acquire_ctx *ctx)
{
struct obj_entry *entry, *entry2;
ww_acquire_init(ctx, &ww_class);
list_for_each_entry(entry, list, head) {
ret = ww_mutex_lock(&entry->obj->lock, ctx);
if (ret < 0) {
entry2 = entry;
list_for_each_entry_continue_reverse(entry2, list, head)
ww_mutex_unlock(&entry2->obj->lock);
if (ret != -EDEADLK) {
ww_acquire_fini(ctx);
return ret;
}
/* seqno race에서 패배: contended lock을 잡고 다시 시도 */
ww_mutex_lock_slow(&entry->obj->lock, ctx);
/* contended entry를 head로 옮겨 다음 unlocked entry부터 재시작 */
list_del(&entry->head);
list_add(&entry->head, list);
}
}
ww_acquire_done(ctx);
return 0;
}
Method 1과 2의 unlock 방식은 같다. 모든 object lock을 놓은 뒤 acquire context를 fini한다.
void unlock_objs(struct list_head *list, struct ww_acquire_ctx *ctx)
{
struct obj_entry *entry;
list_for_each_entry(entry, list, head)
ww_mutex_unlock(&entry->obj->lock);
ww_acquire_fini(ctx);
}
Method 3: graph를 걸으며 동적으로 object 발견
235-267Method 3은 object list를 미리 만들지 않고 임시로 구성할 때 유용하다. 각 node가 ww_mutex를 가진 graph에서 관련 node의 lock을 모두 잡아야 edge를 바꿀 수 있는 경우가 예다.
- WW mutex는 임의 순서 acquisition을 처리하므로 시작 node에서 graph를 순회하면서 새 edge와 연결 node를 발견할 때마다 lock할 수 있다.
- -EALREADY가 이미 보유한 object를 알려 주므로 graph cycle을 끊거나 이미 잡은 lock을 따로 기록할 필요가 없다. 여러 starting node를 써도 같다.
Method 1·2와는 두 차이가 있다. 동적 object list는 -EDEADLK retry 때 달라질 수 있으므로 lock하지 않은 object를 persistent list에 둘 필요가 없고 list_head를 object 자체에 넣을 수 있다. 반면 dynamic construction에서는 -EALREADY를 caller에 전달할 수 없다.
Method 1 또는 2로 userspace가 전달한 시작 node list를 먼저 lock한 뒤 method 3으로 추가 affected object를 lock하는 결합도 가능하다. Dynamic 단계에서 -EDEADLK가 나면 fixed list에서 얻은 lock도 모두 놓아야 하므로 backoff/retry가 복잡해진다. WW mutex debug check는 이런 결합의 interface 오용도 찾는다.
Method 3은 -EALREADY를 error로 전달하지 않으므로 non-interruptible 예제의 lock acquisition step은 실패하지 않는다. _interruptible variant는 이 예제 범위 밖이다.
Method 3 code
269-325struct obj {
struct ww_mutex ww_mutex;
struct list_head locked_list;
};
static DEFINE_WW_CLASS(ww_class);
void __unlock_objs(struct list_head *list)
{
struct obj *entry, *temp;
list_for_each_entry_safe(entry, temp, list, locked_list) {
/* 현재 lock holder만 object를 쓸 수 있으므로 unlock 전에 제거 */
list_del(&entry->locked_list);
ww_mutex_unlock(entry->ww_mutex);
}
}
void lock_objs(struct list_head *list, struct ww_acquire_ctx *ctx)
{
struct obj *obj;
ww_acquire_init(ctx, &ww_class);
retry:
/* loop 시작 상태를 다시 초기화 */
loop {
/* graph를 순회하며 lock할 object를 고르는 code */
ret = ww_mutex_lock(obj->ww_mutex, ctx);
if (ret == -EALREADY) {
/* 이미 보유했으므로 다음 object로 진행 */
continue;
}
if (ret == -EDEADLK) {
__unlock_objs(list);
ww_mutex_lock_slow(obj, ctx);
list_add(&entry->locked_list, list);
goto retry;
}
/* 새 object를 lock했으므로 list에 추가 */
list_add_tail(&entry->locked_list, list);
}
ww_acquire_done(ctx);
return 0;
}
void unlock_objs(struct list_head *list, struct ww_acquire_ctx *ctx)
{
__unlock_objs(list);
ww_acquire_fini(ctx);
}
원문 예제는 algorithm 구조를 보여 주는 pseudo-code 성격이며 entry와 obj 사용 등 실제 code에 맞게 보완해야 할 부분이 있다. 핵심은 acquired object를 object 내부 locked_list로 추적하고 -EDEADLK 때 전부 unlock한 뒤 contended object를 slowpath로 선점해 graph walk를 다시 시작하는 것이다.
Method 4: object 하나만 lock
327-330Object 하나만 lock하면 같은 class 안에서 deadlock을 만들 수 없으므로 deadlock detection과 prevention은 불필요하다. 이 경우 NULL acquire context로 ww mutex API를 사용한다.
ww_mutex_lock(&obj->lock, NULL);
구현 invariant와 lazy wound preemption
332-362현재 ww_mutex는 struct mutex를 감싼다. 훨씬 흔한 normal mutex lock에는 추가 overhead가 없고 wait/wound mutex를 사용하지 않으면 code size만 조금 증가한다.
Wait list에는 다음 invariant를 유지한다.
- Acquire context가 있는 waiter는 stamp 순서로 정렬한다. Context가 없는 waiter는 FIFO 순서로 사이에 배치한다.
- Wait-Die에서는 context가 있는 waiter 중 첫 번째만 다른 lock을 이미 획득한 상태(ctx->acquired > 0)일 수 있다. 이 waiter 앞에 context 없는 waiter가 있을 수 있다.
Wound-Wait preemption은 lazy-preemption scheme으로 구현한다. 새 lock에 contention이 생겨 실제 deadlock 가능성이 있을 때만 transaction의 wounded 상태를 검사한다. Wounded transaction은 backoff하고 상태를 clear한 뒤 retry한다.
이 방식의 큰 장점은 transaction을 다시 시작하기 전에 기다릴 contended lock을 식별할 수 있다는 점이다. 아무 lock도 우선 확보하지 않고 무작정 다시 시작하면 같은 상황에서 또 backoff할 가능성이 크다.
일반적으로 contention은 많지 않을 것으로 예상한다. Lock은 주로 device resource access를 serialize하므로 optimization은 uncontended case에 집중해야 한다.
Lockdep이 찾는 API 오용
364-393가능한 많은 API 오용을 warning하도록 특별히 신경 썼다. 일부 흔한 오류는 CONFIG_DEBUG_MUTEXES로 찾지만 CONFIG_PROVE_LOCKING을 권장한다.
- ww_acquire_fini() 또는 ww_acquire_init() 호출 누락
- ww_acquire_done() 뒤 추가 mutex lock 시도
- -EDEADLK 뒤 모든 mutex를 놓고 잘못된 mutex lock 시도
- -EDEADLK 뒤 모든 mutex를 놓기 전에 올바른 contended mutex를 lock하려는 시도
- -EDEADLK를 받기 전에 ww_mutex_lock_slow() 호출
- 잘못된 unlock function으로 mutex unlock
- 같은 context에 동일한 ww_acquire_* function을 두 번 호출
- Mutex와 ww_acquire_ctx에 서로 다른 ww_class 사용
- Deadlock을 일으킬 수 있는 일반 lockdep error
- 첫 ww_acquire_ctx에 ww_acquire_fini()를 호출하기 전에 두 번째 context를 ww_acquire_init()으로 초기화
- 일반적인 deadlock
원문의 FIXME는 TASK_DEADLOCK task state flag 관련 구현이 들어온 뒤 이 절을 갱신하라고 적고 있다.
GPU buffer reservation과 임의 순서
ww-mutex-design.rst:7-35GPU command submission은 VRAM, system memory, dmabuf로 공유된 여러 buffer를 한꺼번에 예약해야 합니다. Buffer 목록 순서는 user-space command와 migration·eviction 결과에 따라 달라지므로 모든 context가 같은 전역 lock 순서를 지킨다는 보장이 없습니다. 서로 다른 순서로 두 buffer를 잡으면 ABBA deadlock이 됩니다.
각 acquisition transaction에 global counter에서 ticket을 부여하고 충돌 시 나이를 비교합니다. 패배한 transaction은 이미 잡은 모든 lock을 풀고 contended lock을 기다린 뒤 같은 ticket으로 처음부터 재시도하여 결국 가장 오래된 transaction이 전진하도록 합니다.