io_uring을 위한 스레드 정체성 바꿔치기
io_uring이 실제로 블록될 때만 워커 스레드 비용을 부담하도록 task_struct의 정체성을 교환하는 RFC 패치가 제안됐습니다. tmpfs fsync()에서 약 700% 향상이 관찰됐지만, 커널 참조 무결성과 shadow stack 호환성 같은 위험도 큽니다.
본문
io_uring은 애플리케이션이 비동기 실행을 기대하는 서브시스템이므로, 명시적으로 요청하지 않은 이상 호출이 블록되지 않아야 합니다. 사용자는 submission ring에 Submission Queue Entry(SQE)를 기록한 뒤 io_uring_enter()를 호출하고, 커널은 해당 항목들을 처리합니다. page cache에 데이터가 이미 있고 사용자 공간 버퍼도 RAM에 상주하는 경우처럼 작업을 즉시 끝낼 수 있다면 io_uring_enter() 안에서 바로 실행할 수 있습니다. 그러나 커널 내부의 모든 경로가 비동기 실행을 전제로 설계된 것은 아니기 때문에, 실제로 블록될 가능성이 있는 작업을 어떻게 처리할지가 계속 문제가 되어 왔습니다.
기존 방식의 비용
현재 커널은 io_uring_enter()를 블록시킬 수 있는 작업을 별도의 워커 스레드에 넘깁니다. fdatasync(), statx(), 일부 openat() 경로가 대표적인 예이며, O_NONBLOCK 플래그가 있어도 블록 가능성이 남는 경로가 있습니다. 워커 스레드로 넘기면 제출 스레드는 계속 진행할 수 있고, 워커가 블록되더라도 제출 스레드가 멈추지는 않습니다. 하지만 워커 스레드를 깨우고 컨텍스트 스위치를 수행하는 비용이 발생합니다.
작업이 실제로 블록된다면 이 비용이 전체 실행 시간에서 큰 비중을 차지하지 않을 수 있습니다. 반대로 작업이 운 좋게 블록 없이 완료되면, 블록을 피하기 위해 수행한 스레드 전환 자체가 요청 처리 비용의 상당 부분이 됩니다. 커널의 모든 시스템 콜 경로를 비블로킹으로 다시 작성하는 방법도 있지만, 이는 오래 걸리고 쉽지 않은 작업입니다. Jens Axboe가 제안한 RFC는 모든 경로를 고치는 대신, 우선 제출 스레드에서 작업을 실행한 뒤 실제로 잠들어야 하는 순간에만 다른 방식으로 처리하는 접근을 취합니다.
스케줄러가 블록 시점을 알리는 구조
패치 세트는 task_struct의 플래그 필드에 PF_IO_HANDOFF를 추가합니다. 이 플래그가 설정된 스레드가 어떤 이유로든 잠들기 직전이 되면 스케줄러가 새로운 io_uring_task_sleeping() 함수를 호출합니다. io_uring은 특정 파일 시스템이나 시스템 콜 경로를 일일이 수정하지 않고도, 커널 어디에서든 해당 작업이 실제로 블록되는 순간을 전달받을 수 있게 됩니다.
io_uring이 블록 사실을 알게 된 뒤 작업을 처음부터 워커 스레드에서 재시작하는 것은 일반적으로 불가능합니다. 이미 커널 내부에서 상당한 작업이 진행된 뒤일 수 있고, 어느 경로에서 블록될지 알 수 없기 때문입니다. 따라서 해당 작업은 현재 실행 중인 흐름에서 끝까지 진행해야 합니다. 여기서 RFC가 제안하는 방법이 “thread identity handoff”입니다.
task_struct 정체성 교환
제출 스레드가 잠들기 직전이라는 알림을 받으면 io_uring은 스레드 풀에서 워커 스레드 하나를 선택합니다. 진행 중인 작업 자체를 워커에게 넘기는 대신, 두 스레드의 정체성을 교환합니다. 원래 제출 스레드에서 사용하던 task_struct의 정체성은 워커 스레드에 부여됩니다. 여기에는 스레드 ID와 시그널 처리 설정 등이 포함됩니다. 그 결과 워커 스레드는 원래 제출 스레드인 것처럼 submission ring의 처리를 이어가고, 최종적으로 사용자 공간으로 돌아갑니다.
동시에 원래 제출 스레드는 워커 스레드의 정체성을 갖게 된 뒤, 원래 진행하던 작업에서 블록됩니다. 깨어난 뒤에는 해당 작업을 완료하고 워커 스레드 풀의 구성원으로 들어갑니다. 사용자 공간에서 보면 io_uring_enter()를 반환한 스레드가 호출 당시와 다른 task_struct를 사용하게 되지만, 그 밖의 상태는 동일하게 보이도록 만드는 것이 목표입니다. 작업이 블록되지 않으면 전체 처리를 제출 스레드에서 수행하므로 워커 스레드를 깨우는 비용이 발생하지 않고, 실제 블록이 발생한 경우에만 정체성 교환과 워커 활용 비용을 부담합니다.
가장 큰 위험: task_struct 참조
이 방식은 task_struct가 커널 내부에서 널리 참조된다는 점 때문에 위험합니다. 두 스레드가 정체성을 교환하기 전에 다른 커널 코드가 해당 task_struct를 참조하고 있지 않다는 사실을 확인해야 합니다. 참조가 남아 있는 상태에서 교환하면, 이후 그 참조를 통해 잘못된 task_struct에 작업이 적용될 수 있습니다. 이는 메모리 안전성과 커널 안정성 문제로 이어질 수 있으므로 반드시 피해야 합니다.
패치는 thread_handoff_allowed()와 thread_handoff_compatible()에 여러 조건을 두어, 정체성 교환이 가능한 경우에만 이 최적화를 사용하도록 합니다. 예를 들어 ptrace()로 추적되는 스레드는 추적자가 해당 task_struct를 참조하므로 교환할 수 없습니다. 반대로 해당 스레드가 다른 작업을 추적하고 있는 경우에도 추적 대상이 task_struct를 참조할 수 있으므로 허용되지 않습니다. perf event를 사용 중인 경우, 커널에서 futex 소유권 추적을 사용하는 경우, 실시간 스케줄러로 실행 중인 경우, core scheduling cookie를 보유한 경우, vfork()를 수행 중인 경우 등도 제외됩니다.
이 조건 목록이 이 RFC에서 특히 취약하고 복잡한 부분으로 지적됩니다. task_struct를 참조하는 경로를 모두 현재 시점에 찾아냈다고 하더라도, 이후 io_uring을 고려하지 않은 다른 커널 개발자가 새로운 참조를 추가하면 정체성 교환 조건이 다시 깨질 수 있기 때문입니다. Peter Zijlstra는 shadow stack을 사용하는 스레드도 handoff를 막는 조건 중 하나이며, 이 상태라면 대부분의 배포 시스템에서 기능을 사용할 수 없게 된다고 지적했습니다. Axboe는 shadow stack 역시 스레드 정체성의 나머지 요소와 함께 이동시킬 수 있다고 보고 있습니다.
벤치마크 결과와 한계
RFC cover letter에 포함된 벤치마크에서는 작업 유형에 따라 큰 성능 차이가 나타났습니다. 빠르게 끝나는 비블로킹 작업에서는 개선 폭이 컸으며, tmpfs에서 실행한 fsync() 벤치마크는 거의 700% 향상됐습니다. 반면 항상 블록되는 테스트에서는 성능이 저하된 경우도 있었습니다.
특히 여러 작업을 한 번에 제출하는 높은 queue depth에서 회귀가 두드러졌습니다. 기존 방식은 각 작업을 즉시 워커 스레드로 넘겨 병렬 처리할 수 있지만, 새 방식은 제출 스레드에서 각 작업을 블록 지점까지 순차적으로 진행하므로 그 전처리가 직렬화됩니다. Axboe는 이 문제를 해결할 아이디어가 있다고 밝혔지만, 이런 작업을 수행하는 애플리케이션은 애초에 높은 queue depth를 사용하는 경우가 많지 않다고 보고 있어 추가 개선이 실제로 가치가 있을지는 확신하지 못하고 있습니다.
Axboe는 이 패치 세트를 가까운 시일 안에 병합하려는 것이 아니라, 접근 방식 자체가 실현 가능한지 확인하는 데 초점을 두고 있습니다. Gabriel Krisman Bertazi는 이 아이디어가 “정말 멋지고 아주 위험한 일처럼 보인다”고 평가했습니다. 현재 논의는 아직 초기 단계이며, shadow stack을 비롯한 모든 커널 상태와 참조를 안전하게 함께 이동할 수 있는지가 핵심 검증 대상입니다. 이 위험을 해결할 수 있다면 일부 io_uring 워크로드에서는 실제로 블록되지 않는 작업의 성능을 크게 높일 가능성이 있습니다.
Lobsters 반응
- @1amzave — 저처럼 task_struct를 그대로 둔 채 나머지를 모두 교환하는 대신, task_struct 사이에서 스택만 교환하는 편이 더 간단하지 않을까 생각하셨다면, 메일링 리스트 논의에서도 이 문제가 다뤄졌습니다: https://lwn.net/ml/all/[email protected]/
원문: LWN.net / 번역·요약: Trawling