← Documents Documentation/locking/ww-mutex-design.rst GitHub 원문 ↗

Linux 6.18.37 · Locking

Wound/Wait ww_mutex 설계

GPU buffer처럼 순서를 미리 정할 수 없는 여러 object lock을 transaction ticket과 rollback으로 deadlock 없이 획득하는 방법을 설명합니다.

Source pathDocumentation/locking/ww-mutex-design.rst
Source versionLinux v6.18.37
TranslationDUJINLABS 전문 번역 + 해설

요약·해설과 원문, 전문 번역을 서로 분리했습니다. API 이름, symbol, source path는 원문 표기를 사용합니다.

1. 요약·해설

원문의 핵심 논리와 kernel programming 관점의 보충 설명입니다. 아래의 전문 번역과는 별도로 작성했습니다.

GPU buffer reservation과 임의 순서

ww-mutex-design.rst:7-35

GPU 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이 전진하도록 합니다.

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-109

ww_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-233
ww_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-330

Graph 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-363

ww_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 원문입니다. 줄 번호는 이 버전의 파일 좌표입니다.

원문 전체 펼치기
1 ======================================
2 Wound/Wait Deadlock-Proof Mutex Design
3 ======================================
4
5 Please read mutex-design.rst first, as it applies to wait/wound mutexes too.
6
7 Motivation for WW-Mutexes
8 -------------------------
9
10 GPU's do operations that commonly involve many buffers. Those buffers
11 can be shared across contexts/processes, exist in different memory
12 domains (for example VRAM vs system memory), and so on. And with
13 PRIME / dmabuf, they can even be shared across devices. So there are
14 a handful of situations where the driver needs to wait for buffers to
15 become ready. If you think about this in terms of waiting on a buffer
16 mutex for it to become available, this presents a problem because
17 there is no way to guarantee that buffers appear in a execbuf/batch in
18 the same order in all contexts. That is directly under control of
19 userspace, and a result of the sequence of GL calls that an application
20 makes. Which results in the potential for deadlock. The problem gets
21 more complex when you consider that the kernel may need to migrate the
22 buffer(s) into VRAM before the GPU operates on the buffer(s), which
23 may in turn require evicting some other buffers (and you don't want to
24 evict other buffers which are already queued up to the GPU), but for a
25 simplified understanding of the problem you can ignore this.
26
27 The algorithm that the TTM graphics subsystem came up with for dealing with
28 this problem is quite simple. For each group of buffers (execbuf) that need
29 to be locked, the caller would be assigned a unique reservation id/ticket,
30 from a global counter. In case of deadlock while locking all the buffers
31 associated with a execbuf, the one with the lowest reservation ticket (i.e.
32 the oldest task) wins, and the one with the higher reservation id (i.e. the
33 younger task) unlocks all of the buffers that it has already locked, and then
34 tries again.
35
36 In the RDBMS literature, a reservation ticket is associated with a transaction.
37 and the deadlock handling approach is called Wait-Die. The name is based on
38 the actions of a locking thread when it encounters an already locked mutex.
39 If the transaction holding the lock is younger, the locking transaction waits.
40 If the transaction holding the lock is older, the locking transaction backs off
41 and dies. Hence Wait-Die.
42 There is also another algorithm called Wound-Wait:
43 If the transaction holding the lock is younger, the locking transaction
44 wounds the transaction holding the lock, requesting it to die.
45 If the transaction holding the lock is older, it waits for the other
46 transaction. Hence Wound-Wait.
47 The two algorithms are both fair in that a transaction will eventually succeed.
48 However, the Wound-Wait algorithm is typically stated to generate fewer backoffs
49 compared to Wait-Die, but is, on the other hand, associated with more work than
50 Wait-Die when recovering from a backoff. Wound-Wait is also a preemptive
51 algorithm in that transactions are wounded by other transactions, and that
52 requires a reliable way to pick up the wounded condition and preempt the
53 running transaction. Note that this is not the same as process preemption. A
54 Wound-Wait transaction is considered preempted when it dies (returning
55 -EDEADLK) following a wound.
56
57 Concepts
58 --------
59
60 Compared to normal mutexes two additional concepts/objects show up in the lock
61 interface for w/w mutexes:
62
63 Acquire context: To ensure eventual forward progress it is important that a task
64 trying to acquire locks doesn't grab a new reservation id, but keeps the one it
65 acquired when starting the lock acquisition. This ticket is stored in the
66 acquire context. Furthermore the acquire context keeps track of debugging state
67 to catch w/w mutex interface abuse. An acquire context is representing a
68 transaction.
69
70 W/w class: In contrast to normal mutexes the lock class needs to be explicit for
71 w/w mutexes, since it is required to initialize the acquire context. The lock
72 class also specifies what algorithm to use, Wound-Wait or Wait-Die.
73
74 Furthermore there are three different class of w/w lock acquire functions:
75
76 * Normal lock acquisition with a context, using ww_mutex_lock.
77
78 * Slowpath lock acquisition on the contending lock, used by the task that just
79 killed its transaction after having dropped all already acquired locks.
80 These functions have the _slow postfix.
81
82 From a simple semantics point-of-view the _slow functions are not strictly
83 required, since simply calling the normal ww_mutex_lock functions on the
84 contending lock (after having dropped all other already acquired locks) will
85 work correctly. After all if no other ww mutex has been acquired yet there's
86 no deadlock potential and hence the ww_mutex_lock call will block and not
87 prematurely return -EDEADLK. The advantage of the _slow functions is in
88 interface safety:
89
90 - ww_mutex_lock has a __must_check int return type, whereas ww_mutex_lock_slow
91 has a void return type. Note that since ww mutex code needs loops/retries
92 anyway the __must_check doesn't result in spurious warnings, even though the
93 very first lock operation can never fail.
94 - When full debugging is enabled ww_mutex_lock_slow checks that all acquired
95 ww mutex have been released (preventing deadlocks) and makes sure that we
96 block on the contending lock (preventing spinning through the -EDEADLK
97 slowpath until the contended lock can be acquired).
98
99 * Functions to only acquire a single w/w mutex, which results in the exact same
100 semantics as a normal mutex. This is done by calling ww_mutex_lock with a NULL
101 context.
102
103 Again this is not strictly required. But often you only want to acquire a
104 single lock in which case it's pointless to set up an acquire context (and so
105 better to avoid grabbing a deadlock avoidance ticket).
106
107 Of course, all the usual variants for handling wake-ups due to signals are also
108 provided.
109
110 Usage
111 -----
112
113 The algorithm (Wait-Die vs Wound-Wait) is chosen by using either
114 DEFINE_WW_CLASS() (Wound-Wait) or DEFINE_WD_CLASS() (Wait-Die)
115 As a rough rule of thumb, use Wound-Wait iff you
116 expect the number of simultaneous competing transactions to be typically small,
117 and you want to reduce the number of rollbacks.
118
119 Three different ways to acquire locks within the same w/w class. Common
120 definitions for methods #1 and #2::
121
122 static DEFINE_WW_CLASS(ww_class);
123
124 struct obj {
125 struct ww_mutex lock;
126 /* obj data */
127 };
128
129 struct obj_entry {
130 struct list_head head;
131 struct obj *obj;
132 };
133
134 Method 1, using a list in execbuf->buffers that's not allowed to be reordered.
135 This is useful if a list of required objects is already tracked somewhere.
136 Furthermore the lock helper can use propagate the -EALREADY return code back to
137 the caller as a signal that an object is twice on the list. This is useful if
138 the list is constructed from userspace input and the ABI requires userspace to
139 not have duplicate entries (e.g. for a gpu commandbuffer submission ioctl)::
140
141 int lock_objs(struct list_head *list, struct ww_acquire_ctx *ctx)
142 {
143 struct obj *res_obj = NULL;
144 struct obj_entry *contended_entry = NULL;
145 struct obj_entry *entry;
146
147 ww_acquire_init(ctx, &ww_class);
148
149 retry:
150 list_for_each_entry (entry, list, head) {
151 if (entry->obj == res_obj) {
152 res_obj = NULL;
153 continue;
154 }
155 ret = ww_mutex_lock(&entry->obj->lock, ctx);
156 if (ret < 0) {
157 contended_entry = entry;
158 goto err;
159 }
160 }
161
162 ww_acquire_done(ctx);
163 return 0;
164
165 err:
166 list_for_each_entry_continue_reverse (entry, list, head)
167 ww_mutex_unlock(&entry->obj->lock);
168
169 if (res_obj)
170 ww_mutex_unlock(&res_obj->lock);
171
172 if (ret == -EDEADLK) {
173 /* we lost out in a seqno race, lock and retry.. */
174 ww_mutex_lock_slow(&contended_entry->obj->lock, ctx);
175 res_obj = contended_entry->obj;
176 goto retry;
177 }
178 ww_acquire_fini(ctx);
179
180 return ret;
181 }
182
183 Method 2, using a list in execbuf->buffers that can be reordered. Same semantics
184 of duplicate entry detection using -EALREADY as method 1 above. But the
185 list-reordering allows for a bit more idiomatic code::
186
187 int lock_objs(struct list_head *list, struct ww_acquire_ctx *ctx)
188 {
189 struct obj_entry *entry, *entry2;
190
191 ww_acquire_init(ctx, &ww_class);
192
193 list_for_each_entry (entry, list, head) {
194 ret = ww_mutex_lock(&entry->obj->lock, ctx);
195 if (ret < 0) {
196 entry2 = entry;
197
198 list_for_each_entry_continue_reverse (entry2, list, head)
199 ww_mutex_unlock(&entry2->obj->lock);
200
201 if (ret != -EDEADLK) {
202 ww_acquire_fini(ctx);
203 return ret;
204 }
205
206 /* we lost out in a seqno race, lock and retry.. */
207 ww_mutex_lock_slow(&entry->obj->lock, ctx);
208
209 /*
210 * Move buf to head of the list, this will point
211 * buf->next to the first unlocked entry,
212 * restarting the for loop.
213 */
214 list_del(&entry->head);
215 list_add(&entry->head, list);
216 }
217 }
218
219 ww_acquire_done(ctx);
220 return 0;
221 }
222
223 Unlocking works the same way for both methods #1 and #2::
224
225 void unlock_objs(struct list_head *list, struct ww_acquire_ctx *ctx)
226 {
227 struct obj_entry *entry;
228
229 list_for_each_entry (entry, list, head)
230 ww_mutex_unlock(&entry->obj->lock);
231
232 ww_acquire_fini(ctx);
233 }
234
235 Method 3 is useful if the list of objects is constructed ad-hoc and not upfront,
236 e.g. when adjusting edges in a graph where each node has its own ww_mutex lock,
237 and edges can only be changed when holding the locks of all involved nodes. w/w
238 mutexes are a natural fit for such a case for two reasons:
239
240 - They can handle lock-acquisition in any order which allows us to start walking
241 a graph from a starting point and then iteratively discovering new edges and
242 locking down the nodes those edges connect to.
243 - Due to the -EALREADY return code signalling that a given objects is already
244 held there's no need for additional book-keeping to break cycles in the graph
245 or keep track off which looks are already held (when using more than one node
246 as a starting point).
247
248 Note that this approach differs in two important ways from the above methods:
249
250 - Since the list of objects is dynamically constructed (and might very well be
251 different when retrying due to hitting the -EDEADLK die condition) there's
252 no need to keep any object on a persistent list when it's not locked. We can
253 therefore move the list_head into the object itself.
254 - On the other hand the dynamic object list construction also means that the -EALREADY return
255 code can't be propagated.
256
257 Note also that methods #1 and #2 and method #3 can be combined, e.g. to first lock a
258 list of starting nodes (passed in from userspace) using one of the above
259 methods. And then lock any additional objects affected by the operations using
260 method #3 below. The backoff/retry procedure will be a bit more involved, since
261 when the dynamic locking step hits -EDEADLK we also need to unlock all the
262 objects acquired with the fixed list. But the w/w mutex debug checks will catch
263 any interface misuse for these cases.
264
265 Also, method 3 can't fail the lock acquisition step since it doesn't return
266 -EALREADY. Of course this would be different when using the _interruptible
267 variants, but that's outside of the scope of these examples here::
268
269 struct obj {
270 struct ww_mutex ww_mutex;
271 struct list_head locked_list;
272 };
273
274 static DEFINE_WW_CLASS(ww_class);
275
276 void __unlock_objs(struct list_head *list)
277 {
278 struct obj *entry, *temp;
279
280 list_for_each_entry_safe (entry, temp, list, locked_list) {
281 /* need to do that before unlocking, since only the current lock holder is
282 allowed to use object */
283 list_del(&entry->locked_list);
284 ww_mutex_unlock(entry->ww_mutex)
285 }
286 }
287
288 void lock_objs(struct list_head *list, struct ww_acquire_ctx *ctx)
289 {
290 struct obj *obj;
291
292 ww_acquire_init(ctx, &ww_class);
293
294 retry:
295 /* re-init loop start state */
296 loop {
297 /* magic code which walks over a graph and decides which objects
298 * to lock */
299
300 ret = ww_mutex_lock(obj->ww_mutex, ctx);
301 if (ret == -EALREADY) {
302 /* we have that one already, get to the next object */
303 continue;
304 }
305 if (ret == -EDEADLK) {
306 __unlock_objs(list);
307
308 ww_mutex_lock_slow(obj, ctx);
309 list_add(&entry->locked_list, list);
310 goto retry;
311 }
312
313 /* locked a new object, add it to the list */
314 list_add_tail(&entry->locked_list, list);
315 }
316
317 ww_acquire_done(ctx);
318 return 0;
319 }
320
321 void unlock_objs(struct list_head *list, struct ww_acquire_ctx *ctx)
322 {
323 __unlock_objs(list);
324 ww_acquire_fini(ctx);
325 }
326
327 Method 4: Only lock one single objects. In that case deadlock detection and
328 prevention is obviously overkill, since with grabbing just one lock you can't
329 produce a deadlock within just one class. To simplify this case the w/w mutex
330 api can be used with a NULL context.
331
332 Implementation Details
333 ----------------------
334
335 Design:
336 ^^^^^^^
337
338 ww_mutex currently encapsulates a struct mutex, this means no extra overhead for
339 normal mutex locks, which are far more common. As such there is only a small
340 increase in code size if wait/wound mutexes are not used.
341
342 We maintain the following invariants for the wait list:
343
344 (1) Waiters with an acquire context are sorted by stamp order; waiters
345 without an acquire context are interspersed in FIFO order.
346 (2) For Wait-Die, among waiters with contexts, only the first one can have
347 other locks acquired already (ctx->acquired > 0). Note that this waiter
348 may come after other waiters without contexts in the list.
349
350 The Wound-Wait preemption is implemented with a lazy-preemption scheme:
351 The wounded status of the transaction is checked only when there is
352 contention for a new lock and hence a true chance of deadlock. In that
353 situation, if the transaction is wounded, it backs off, clears the
354 wounded status and retries. A great benefit of implementing preemption in
355 this way is that the wounded transaction can identify a contending lock to
356 wait for before restarting the transaction. Just blindly restarting the
357 transaction would likely make the transaction end up in a situation where
358 it would have to back off again.
359
360 In general, not much contention is expected. The locks are typically used to
361 serialize access to resources for devices, and optimization focus should
362 therefore be directed towards the uncontended cases.
363
364 Lockdep:
365 ^^^^^^^^
366
367 Special care has been taken to warn for as many cases of api abuse
368 as possible. Some common api abuses will be caught with
369 CONFIG_DEBUG_MUTEXES, but CONFIG_PROVE_LOCKING is recommended.
370
371 Some of the errors which will be warned about:
372 - Forgetting to call ww_acquire_fini or ww_acquire_init.
373 - Attempting to lock more mutexes after ww_acquire_done.
374 - Attempting to lock the wrong mutex after -EDEADLK and
375 unlocking all mutexes.
376 - Attempting to lock the right mutex after -EDEADLK,
377 before unlocking all mutexes.
378
379 - Calling ww_mutex_lock_slow before -EDEADLK was returned.
380
381 - Unlocking mutexes with the wrong unlock function.
382 - Calling one of the ww_acquire_* twice on the same context.
383 - Using a different ww_class for the mutex than for the ww_acquire_ctx.
384 - Normal lockdep errors that can result in deadlocks.
385
386 Some of the lockdep errors that can result in deadlocks:
387 - Calling ww_acquire_init to initialize a second ww_acquire_ctx before
388 having called ww_acquire_fini on the first.
389 - 'normal' deadlocks that can occur.
390
391 FIXME:
392 Update this section once we have the TASK_DEADLOCK task state flag magic
393 implemented.
394

3. 한국어 전문 번역

영어 원문의 문단 순서와 의미를 유지한 전체 번역입니다. 코드, 함수명, symbol과 URL은 원문 표기를 유지합니다.

여러 GPU buffer lock과 deadlock 문제

1-25

Wait/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-55

TTM 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-181

DEFINE_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-233

Method 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-267

Method 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-325
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) {
        /* 현재 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-330

Object 하나만 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 관련 구현이 들어온 뒤 이 절을 갱신하라고 적고 있다.