요약·해설과 원문, 전문 번역을 서로 분리했습니다. API 이름, symbol, source path는 원문 표기를 사용합니다.
1. 요약·해설
원문의 핵심 논리와 kernel programming 관점의 보충 설명입니다. 아래의 전문 번역과는 별도로 작성했습니다.
WAIT_REQUEUE_PI의 반환 계약
futex-requeue-pi.rst:47-85pthread_cond_wait_pi(cond, mutex)
{
unlock(mutex);
futex_wait_requeue_pi(cond_futex, mutex_futex);
/* 성공 반환 시 kernel이 mutex를 이미 획득해 두었다. */
}
PI-aware wait에서는 syscall이 성공하여 user space로 돌아올 때 caller가 target PI futex를 이미 보유해야 합니다. 이 계약 덕분에 rt_mutex waiter가 존재하는 동안 owner가 비는 구간이 사라지고 glibc가 다시 lock(mutex)를 호출할 필요도 없습니다.
같은 원리는 pthread_cond_timedwait(), pthread_cond_signal(), pthread_cond_broadcast()에 적용됩니다. 실제 library는 별도 public API를 만들기보다 mutex attribute가 PI인지 보고 내부 futex operation을 선택합니다.
두 futex operation과 proxy lock
futex-requeue-pi.rst:86-121| Operation | 호출자 | 역할 |
|---|---|---|
| FUTEX_WAIT_REQUEUE_PI | cond wait의 waiter | 초기 non-PI futex에서 잠들고 PI futex로 requeue될 준비 |
| FUTEX_CMP_REQUEUE_PI | signal 또는 broadcast waker | 비교 후 top waiter를 대신해 target rt_mutex 획득을 시도하고 나머지를 이동 |
| rt_mutex_start_proxy_lock() | kernel requeue path | waiter 대신 uncontended lock을 얻거나 contended rt_mutex waiter로 enqueue |
| rt_mutex_finish_proxy_lock() | 깨어난 waiter | proxy acquire 결과를 마무리하고 반환 상태 결정 |
Waker는 top waiter를 대신해 target PI futex를 먼저 획득하려고 시도합니다. 성공하면 그 waiter를 깨우고, 나머지 task를 옮길 때마다 start_proxy_lock()으로 rt_mutex waiter 상태를 먼저 구성합니다. 단순 wake 후 경쟁시키는 방식과 달리 requeue syscall 반환과 waiter 실행 사이의 race window를 닫습니다.
nr_wake와 nr_requeue 규칙
futex-requeue-pi.rst:122-133FUTEX_CMP_REQUEUE_PI는 nr_wake가 반드시 1이어야 합니다. 실제 처리 상한은 nr_wake + nr_requeue이고, kernel은 target lock을 대신 획득할 수 있는 task만 즉시 깨웁니다. Broadcast는 nr_requeue를 INT_MAX로, signal은 0으로 두는 방식이 사용됩니다.
Condvar signal 또는 broadcast caller가 연결 mutex를 보유하는 정상 사용에서는 target PI mutex가 contended 상태이므로 requeue 순간 즉시 깨울 수 있는 waiter 수는 보통 0입니다.
2. 영어 원문 전체
번역 기준이 된 Linux v6.18.37 원문입니다. 줄 번호는 이 버전의 파일 좌표입니다.
원문 전체 펼치기
================
Futex Requeue PI
================
Requeueing of tasks from a non-PI futex to a PI futex requires
special handling in order to ensure the underlying rt_mutex is never
left without an owner if it has waiters; doing so would break the PI
boosting logic [see rt-mutex-design.rst] For the purposes of
brevity, this action will be referred to as "requeue_pi" throughout
this document. Priority inheritance is abbreviated throughout as
"PI".
Motivation
----------
Without requeue_pi, the glibc implementation of
pthread_cond_broadcast() must resort to waking all the tasks waiting
on a pthread_condvar and letting them try to sort out which task
gets to run first in classic thundering-herd formation. An ideal
implementation would wake the highest-priority waiter, and leave the
rest to the natural wakeup inherent in unlocking the mutex
associated with the condvar.
Consider the simplified glibc calls::
/* caller must lock mutex */
pthread_cond_wait(cond, mutex)
{
lock(cond->__data.__lock);
unlock(mutex);
do {
unlock(cond->__data.__lock);
futex_wait(cond->__data.__futex);
lock(cond->__data.__lock);
} while(...)
unlock(cond->__data.__lock);
lock(mutex);
}
pthread_cond_broadcast(cond)
{
lock(cond->__data.__lock);
unlock(cond->__data.__lock);
futex_requeue(cond->data.__futex, cond->mutex);
}
Once pthread_cond_broadcast() requeues the tasks, the cond->mutex
has waiters. Note that pthread_cond_wait() attempts to lock the
mutex only after it has returned to user space. This will leave the
underlying rt_mutex with waiters, and no owner, breaking the
previously mentioned PI-boosting algorithms.
In order to support PI-aware pthread_condvar's, the kernel needs to
be able to requeue tasks to PI futexes. This support implies that
upon a successful futex_wait system call, the caller would return to
user space already holding the PI futex. The glibc implementation
would be modified as follows::
/* caller must lock mutex */
pthread_cond_wait_pi(cond, mutex)
{
lock(cond->__data.__lock);
unlock(mutex);
do {
unlock(cond->__data.__lock);
futex_wait_requeue_pi(cond->__data.__futex);
lock(cond->__data.__lock);
} while(...)
unlock(cond->__data.__lock);
/* the kernel acquired the mutex for us */
}
pthread_cond_broadcast_pi(cond)
{
lock(cond->__data.__lock);
unlock(cond->__data.__lock);
futex_requeue_pi(cond->data.__futex, cond->mutex);
}
The actual glibc implementation will likely test for PI and make the
necessary changes inside the existing calls rather than creating new
calls for the PI cases. Similar changes are needed for
pthread_cond_timedwait() and pthread_cond_signal().
Implementation
--------------
In order to ensure the rt_mutex has an owner if it has waiters, it
is necessary for both the requeue code, as well as the waiting code,
to be able to acquire the rt_mutex before returning to user space.
The requeue code cannot simply wake the waiter and leave it to
acquire the rt_mutex as it would open a race window between the
requeue call returning to user space and the waiter waking and
starting to run. This is especially true in the uncontended case.
The solution involves two new rt_mutex helper routines,
rt_mutex_start_proxy_lock() and rt_mutex_finish_proxy_lock(), which
allow the requeue code to acquire an uncontended rt_mutex on behalf
of the waiter and to enqueue the waiter on a contended rt_mutex.
Two new system calls provide the kernel<->user interface to
requeue_pi: FUTEX_WAIT_REQUEUE_PI and FUTEX_CMP_REQUEUE_PI.
FUTEX_WAIT_REQUEUE_PI is called by the waiter (pthread_cond_wait()
and pthread_cond_timedwait()) to block on the initial futex and wait
to be requeued to a PI-aware futex. The implementation is the
result of a high-speed collision between futex_wait() and
futex_lock_pi(), with some extra logic to check for the additional
wake-up scenarios.
FUTEX_CMP_REQUEUE_PI is called by the waker
(pthread_cond_broadcast() and pthread_cond_signal()) to requeue and
possibly wake the waiting tasks. Internally, this system call is
still handled by futex_requeue (by passing requeue_pi=1). Before
requeueing, futex_requeue() attempts to acquire the requeue target
PI futex on behalf of the top waiter. If it can, this waiter is
woken. futex_requeue() then proceeds to requeue the remaining
nr_wake+nr_requeue tasks to the PI futex, calling
rt_mutex_start_proxy_lock() prior to each requeue to prepare the
task as a waiter on the underlying rt_mutex. It is possible that
the lock can be acquired at this stage as well, if so, the next
waiter is woken to finish the acquisition of the lock.
FUTEX_CMP_REQUEUE_PI accepts nr_wake and nr_requeue as arguments, but
their sum is all that really matters. futex_requeue() will wake or
requeue up to nr_wake + nr_requeue tasks. It will wake only as many
tasks as it can acquire the lock for, which in the majority of cases
should be 0 as good programming practice dictates that the caller of
either pthread_cond_broadcast() or pthread_cond_signal() acquire the
mutex prior to making the call. FUTEX_CMP_REQUEUE_PI requires that
nr_wake=1. nr_requeue should be INT_MAX for broadcast and 0 for
signal.
3. 한국어 전문 번역
영어 원문의 문단 순서와 의미를 유지한 전체 번역입니다. 코드, 함수명, symbol과 URL은 원문 표기를 유지합니다.
Requeue PI가 필요한 불변 조건
1-11Task를 non-PI futex에서 PI futex로 requeue할 때는 특별한 처리가 필요하다. 기반 rt_mutex에 waiter가 있는데 owner가 없는 상태를 절대로 만들면 안 되기 때문이다. 그런 상태는 rt-mutex-design.rst에서 설명하는 PI priority boosting logic을 깨뜨린다.
이 문서에서는 이 동작을 줄여서 requeue_pi라고 부르고 priority inheritance는 PI로 표기한다.
pthread_cond_broadcast()와 thundering herd
13-22requeue_pi가 없으면 glibc의 pthread_cond_broadcast() 구현은 pthread condition variable에서 기다리는 모든 task를 깨워야 한다. 깨어난 task들은 누가 먼저 실행할지를 두고 고전적인 thundering herd 형태로 경쟁한다.
이상적인 구현은 가장 높은 priority의 waiter 하나만 깨우고, 나머지는 condition variable과 연결된 mutex가 unlock될 때 자연스럽게 이어지는 wakeup에 맡기는 방식이다.
일반 condvar requeue의 문제
24-51/* caller must lock mutex */
pthread_cond_wait(cond, mutex)
{
lock(cond->__data.__lock);
unlock(mutex);
do {
unlock(cond->__data.__lock);
futex_wait(cond->__data.__futex);
lock(cond->__data.__lock);
} while (...)
unlock(cond->__data.__lock);
lock(mutex);
}
pthread_cond_broadcast(cond)
{
lock(cond->__data.__lock);
unlock(cond->__data.__lock);
futex_requeue(cond->data.__futex, cond->mutex);
}
pthread_cond_broadcast()가 task를 requeue하면 cond->mutex에 waiter가 생긴다. 하지만 pthread_cond_wait()는 userspace로 돌아온 뒤에야 mutex lock을 시도한다. 이 사이에는 기반 rt_mutex에 waiter는 있지만 owner는 없는 상태가 되어 PI boosting algorithm의 전제를 깨뜨린다.
PI-aware condvar의 호출 형태
53-84PI를 인식하는 pthread condition variable을 지원하려면 kernel이 task를 PI futex로 requeue할 수 있어야 한다. 이 방식에서는 futex_wait system call이 성공하면 caller가 PI futex를 이미 보유한 채 userspace로 돌아온다.
/* caller must lock mutex */
pthread_cond_wait_pi(cond, mutex)
{
lock(cond->__data.__lock);
unlock(mutex);
do {
unlock(cond->__data.__lock);
futex_wait_requeue_pi(cond->__data.__futex);
lock(cond->__data.__lock);
} while (...)
unlock(cond->__data.__lock);
/* the kernel acquired the mutex for us */
}
pthread_cond_broadcast_pi(cond)
{
lock(cond->__data.__lock);
unlock(cond->__data.__lock);
futex_requeue_pi(cond->data.__futex, cond->mutex);
}
실제 glibc는 PI 전용 API를 새로 만들기보다 기존 호출 안에서 PI 여부를 검사하고 필요한 동작을 선택할 가능성이 높다. pthread_cond_timedwait()와 pthread_cond_signal()에도 같은 변경이 필요하다.
Owner 없는 rt_mutex를 막는 proxy lock
86-102rt_mutex에 waiter가 있다면 반드시 owner도 있도록 보장하려면 requeue code와 wait code 모두 userspace로 돌아가기 전에 rt_mutex를 획득할 수 있어야 한다. Requeue code가 waiter를 깨운 뒤 waiter 스스로 rt_mutex를 획득하게 두면 requeue system call이 userspace로 돌아간 시점과 waiter가 실제로 깨어 실행되는 시점 사이에 race window가 생긴다. 경쟁이 없는 경우에도 이 문제는 중요하다.
해결책은 rt_mutex_start_proxy_lock()과 rt_mutex_finish_proxy_lock()이라는 두 helper다. Requeue code는 waiter를 대신해 경쟁 없는 rt_mutex를 획득하거나, 경쟁 중인 rt_mutex의 wait queue에 waiter를 넣을 수 있다.
Kernel과 userspace 사이의 requeue_pi interface는 FUTEX_WAIT_REQUEUE_PI와 FUTEX_CMP_REQUEUE_PI라는 두 futex operation이 제공한다.
FUTEX_WAIT_REQUEUE_PI
104-109Waiter 측의 pthread_cond_wait()와 pthread_cond_timedwait()는 FUTEX_WAIT_REQUEUE_PI를 호출해 최초 futex에서 block되고 PI-aware futex로 requeue되기를 기다린다. 구현은 futex_wait()와 futex_lock_pi()를 결합한 형태이며 추가 wakeup 상황을 검사하는 logic이 더해져 있다.
FUTEX_CMP_REQUEUE_PI
111-132Waker 측의 pthread_cond_broadcast()와 pthread_cond_signal()은 FUTEX_CMP_REQUEUE_PI를 호출해 기다리는 task를 requeue하고 가능하면 깨운다. Kernel 내부에서는 requeue_pi=1을 넘긴 futex_requeue()가 이 operation을 처리한다.
Requeue 전에 futex_requeue()는 가장 높은 priority의 waiter를 대신해 target PI futex 획득을 시도한다. 획득할 수 있으면 해당 waiter를 깨운다. 이어서 남은 nr_wake+nr_requeue개 task를 PI futex로 옮기며, 각 requeue 전에 rt_mutex_start_proxy_lock()을 호출해 task를 기반 rt_mutex의 waiter로 준비한다. 이 단계에서 lock을 획득할 수 있다면 다음 waiter를 깨워 lock 획득을 마무리하게 한다.
FUTEX_CMP_REQUEUE_PI는 nr_wake와 nr_requeue를 받지만 실질적으로 중요한 값은 둘의 합이다. futex_requeue()는 최대 nr_wake+nr_requeue개 task를 깨우거나 requeue한다. 실제로 깨우는 수는 lock을 대신 획득할 수 있는 task 수뿐이다.
올바른 사용에서는 pthread_cond_broadcast()나 pthread_cond_signal() caller가 먼저 mutex를 보유하므로 대개의 경우 즉시 깨울 수 있는 task 수는 0이다. FUTEX_CMP_REQUEUE_PI는 nr_wake=1을 요구한다. Broadcast에서는 nr_requeue=INT_MAX, signal에서는 nr_requeue=0을 사용한다.
Condition variable broadcast의 문제
futex-requeue-pi.rst:1-46일반 pthread_cond_broadcast()가 condition futex의 모든 waiter를 깨우면 모두가 연결된 mutex를 얻으려 경쟁하는 thundering herd가 생깁니다. 이상적인 동작은 최고 priority waiter만 mutex owner로 진행시키고, 나머지는 그 mutex의 unlock 순서에 따라 깨우는 것입니다.
단순 FUTEX_REQUEUE로 waiter를 PI mutex 쪽 futex queue에 옮기기만 하면 user space로 돌아간 waiter가 나중에 mutex를 획득합니다. 그 사이 underlying rt_mutex에는 waiter가 있지만 owner가 없는 상태가 생겨 priority inheritance가 누구에게 boost를 전달해야 할지 알 수 없게 됩니다.