한 줄로 말하면
futex는 사용자 공간의 잠금 상태와 커널의 대기 큐를 이어 주는 Linux 동기화 장치야. Requeue-PI는 한 futex에서 잠든 스레드를 우선순위 상속을 지원하는 다른 잠금 경로로 옮기는 기능인데, 이때 “누가 실제 대기자인가”를 끝까지 보존해야 해.
비유로 이해하기
futex를 번호표 창구라고 생각해 보자. 손님은 창구 앞의 번호판을 먼저 확인하고, 정말 기다려야 할 때만 커널에 번호표를 맡긴 뒤 잠들어. 상태가 이미 바뀌었다면 커널까지 가지 않고 바로 일을 계속할 수 있어.
Requeue-PI는 창구가 바뀌는 상황이야. 첫 창구에서 기다리던 손님을 다른 우선순위 관리 창구의 줄로 옮길 수 있어. 여기서 창구 직원이 줄을 옮긴 사람과 실제 손님을 헷갈리면, 손님의 대기 기록을 지우지 못한 채 손님을 돌려보내는 문제가 생겨.
이 비유는 대기의 소유권을 이해하는 데까지만 유효해. 실제 Linux에서는 사용자 공간의 futex 값, 커널의 rt_mutex 대기 구조체, 태스크별 pi_blocked_on 포인터가 함께 움직여.
정확한 정의
futex는 빠른 사용자 공간 경로와 필요한 경우의 커널 대기 경로를 결합한 동기화 원리야. 경쟁이 없을 때는 사용자 공간에서 상태를 확인하고, 잠금 상태가 충돌하거나 스레드가 잠들어야 할 때 커널이 대기와 깨우기를 맡는 구조로 이해하면 돼.
priority inheritance(PI)는 높은 우선순위의 태스크가 낮은 우선순위 태스크가 가진 잠금 때문에 기다릴 때, 잠금 소유자의 실행 우선순위를 일시적으로 끌어올리는 방식이야. 목적은 우선순위가 낮은 작업이 중간 우선순위 작업에 밀려 높은 우선순위 작업의 대기를 길게 만드는 우선순위 역전을 줄이는 데 있어. Linux 커널은 PI futex의 커널 쪽 잠금 경로에 rt_mutex를 사용해.
Requeue-PI는 일반 futex에서 기다리는 태스크를 PI futex 쪽으로 재배치하는 경로야. FUTEX_WAIT_REQUEUE_PI로 잠든 태스크와, 다른 futex의 대기자를 옮기는 FUTEX_CMP_REQUEUE_PI 호출이 짝을 이룬다.1 이때 재배치 작업을 실행하는 태스크와 실제로 줄에서 기다리는 태스크가 다를 수 있어. 바로 이 차이가 일반적인 자기 대기 정리 경로와 프록시 경로를 가르는 기준이야.
rt_mutex_waiter는 이런 PI 잠금에서 한 태스크의 대기 상태를 나타내는 커널 구조체야. 대기 태스크는 자기 pi_blocked_on을 통해 어떤 PI 잠금과 대기 구조체에 매달려 있는지 추적할 수 있어. 따라서 대기자를 큐에서 빼는 정리는 큐에서 구조체를 제거하는 일과 그 구조체를 가리키는 해당 태스크의 포인터를 지우는 일을 함께 처리해야 해.1
왜 중요한가
동기화 코드는 “잠금을 잡았나”만 관리하지 않아. 누가 잠금을 소유하는지, 누가 기다리는지, 어느 태스크의 우선순위를 조정해야 하는지, 오류가 나면 어느 상태를 되돌려야 하는지를 동시에 관리해.
GhostLock 분석은 이 경계가 Requeue-PI의 오류 롤백에서 어떻게 흔들릴 수 있는지 보여줘. 프록시 경로에서 실제 waiter 대신 FUTEX_CMP_REQUEUE_PI를 호출한 current의 pi_blocked_on을 지우면, 잠들어 있던 태스크는 자기 커널 스택의 rt_mutex_waiter를 계속 가리킨 채 사용자 공간으로 돌아올 수 있어. 나중에 PI 체인이 그 태스크를 다시 따라가면 이미 끝난 스택 프레임을 참조하는 use-after-free가 된다.1
이 사례의 핵심은 futex가 위험한 기능이라는 뜻이 아니야. 정상 경로에서는 current가 자기 대기 상태를 정리하는 가정이 맞을 수 있지만, 프록시 경로에서는 작업을 대신 수행한 태스크와 대기 상태의 주인이 다르다. 동기화 코드에서는 함수가 “누가 호출했는가”와 “누구의 상태를 바꾸는가”가 같은지 먼저 확인해야 해.
실제 예시
GhostLock은 세 futex와 세 스레드가 만드는 PI 의존성 순환에서 -EDEADLK 롤백을 유도했다. 실제 waiter, PI 잠금의 owner, 재배치를 호출한 스레드가 서로 다른 역할을 맡으면서 프록시 정리 경로가 실행된다.1
수정안은 이 구분을 코드에 직접 새겼어. remove_waiter()가 current가 아니라 waiter->task를 기준으로 대상 태스크의 pi_lock을 잡고, 그 태스크의 pi_blocked_on을 지우도록 바뀌었다. 후속 우선순위 체인 조정에도 current 대신 waiter의 태스크를 넘긴다.1
이 변화는 짧지만 의미가 분명해. 오류가 난 뒤 정리하는 함수는 “지금 실행 중인 태스크”를 기본 주인으로 삼지 않고, 구조체가 가리키는 실제 소유 태스크를 확인해야 한다는 거야. 자기 자신이 블록되는 일반 경로에서는 current를 쓰고, 프록시 롤백에서는 프록시 대상 태스크를 쓰도록 경로별 계약을 나눈다.1
헷갈리지 말아야 할 점
- futex는 잠금 자료구조의 이름 하나가 아니야. 사용자 공간의 값 확인과 커널의 대기·깨우기 경로를 묶는 인터페이스 원리야.
- PI는 모든 대기를 없애지 않아. 높은 우선순위 태스크가 낮은 우선순위 소유자를 기다릴 때 우선순위를 전달하는 방식이지, 잠금 경쟁 자체를 없애는 방식은 아니야.
- Requeue와 PI는 같은 기능이 아니야. Requeue는 대기자를 다른 큐로 옮기는 동작이고, PI는 잠금 소유자의 우선순위를 조정하는 동작이야. Requeue-PI는 두 경로가 만나는 특수한 조합이야.
- 프록시 호출자와 waiter를 같은 태스크로 보면 안 돼. 호출자가 다른 태스크의 대기 구조체를 대신 조작하는 경로에서는 소유권을 별도로 추적해야 해.
- use-after-free는 포인터 하나의 수명이 끝났다는 뜻만은 아니야. 이 사례처럼 오류 정리에서 어떤 포인터를 지우는지 틀리면, 나중의 정상적인 체인 탐색이 이미 끝난 커널 스택을 다시 읽게 될 수 있어.
- 동기화 경로의 설명과 취약점 재현 절차는 다르다. 구체적인 주소·구조체 배치·제어 흐름 탈취는 사건을 다룬 GhostLock 분석에서 확인하고, 여기서는 반복해서 쓸 수 있는 동기화 경계만 봐야 해.
관련 문서
- 사례로 읽는 Requeue-PI 오류 경로: 잠든 스레드의 포인터 하나가 Linux root까지 갔다
- 순차 의존성과 스레드 실행 흐름: 단일 스레드 성능
남은 질문들
- Linux 커널 문서와 테스트는 일반 futex, PI futex, Requeue-PI의 소유권 전이를 어떤 불변식으로 설명할까?
- priority inheritance가 실제 실시간 스케줄링에서 우선순위 역전을 줄이는 조건과 비용은 무엇일까?
- 프록시 대기자를 다루는 다른 커널 동기화 경로에서도 “호출자와 상태의 주인”을 분리해 검증하는 테스트가 충분할까?
댓글