CHAPTER 01

제 1 장: 비동기의 멘탈 모델: Future, Waker, 실행기 삼총사

Upstream: tokio-rs/tokio · Commit @e800714a · 진행률: 제 1 장 / 총 14 장

Rust에서 비동기 프로그래밍은 라이브러리가 아니라 언어 차원의 프로토콜입니다. Tokio가 프로덕션급 런타임이 될 수 있었던 것은 Future를 발명했기 때문이 아니라, 이 프로토콜의 모든 계약에 대한 경계 조건을 정확히 구현했기 때문입니다. 이 장에서는 Tokio의 스케줄러 코드로 성급히 뛰어들지 않고, 먼저 '삼총사'——Future, Waker, Executor——의 책임 경계와 역방향 제어 흐름을 철저히 설명합니다. 이 세 가지가 어떻게 맞물리는지 이해해야 이후 장에서 Runtime의 조립, work-stealing 스케줄링, I/O 드라이버가 발판을 가질 수 있습니다.

1.1 블로킹에서 폴링으로: Rust가 콜백 대신 poll을 선택한 이유

직관적 모델

레스토랑에서 즉석 조리가 필요한 요리를 주문한다고 상상해 보세요. 콜백식 비동기(예: Node.js 초기 스타일)는 전화번호를 남기고 요리사가 완성되면먼저 전화를 걸어주는것입니다——제어권이 요리사에게 있고, 당신의 코드는 수동적으로 응답할 뿐입니다. 폴링식 비동기(Rust의 선택)는 픽업 증표를 받아스스로 결정하여언제 창구에 가서 "다 됐나요"라고 물을지 정하는 것입니다: 안 됐으면 다른 일을 하고, 됐으면 가져갑니다.

이 차이는 사소해 보이지만 전체 시스템의 형태를 결정합니다. 콜백식 모델에서는 모든 비동기 작업이 '완료 후 무엇을 할지'에 대한 클로저를 반드시 동반해야 하며, 클로저가 겹겹이 중첩되어 콜백 지옥을 형성하고 취소 작업이 극히 어렵습니다——이미 등록된 콜백을 '철회'할 수 없습니다. 폴링식 모델에서 Future는 단지 상태 기계일 뿐이며,poll은 순수한 조회 동작으로, 진행하지 않으면 자원을 소비하지 않고, 취소는 곧 drop이며, 깔끔합니다.

폴링식 모델의 핵심 계약

Rust 표준 라이브러리가 정의한Futuretrait은 두 가지 요소만 있습니다: 하나의poll메서드, 하나의Output연관 타입입니다. Tokio는 이 trait을 재정의하지 않고 표준 라이브러리의 구현을 직접 재사용합니다. 이 점은 소스코드에 명확히 나타납니다:

rust
// tokio/src/future/mod.rs
cfg_not_trace! {
    cfg_rt! {
        pub(crate) use std::future::Future;
    }
}

📎 tokio/src/future/mod.rs:24-28

이 코드는 중요한 사실을 드러냅니다:tracing기능이 활성화되지 않았을 때, Tokio 내부의Future은std::future::Future의 별칭이며, 어떤 래핑도 없습니다.tracing이 활성화될 때만InstrumentedFuture로 교체됩니다:

rust
cfg_trace! {
    mod trace;
    #[allow(unused_imports)]
    pub(crate) use trace::InstrumentedFuture as Future;
}

📎 tokio/src/future/mod.rs:18-22

〔설계 추론과 아키텍처 트레이드오프〕

이러한 '기본 제로 오버헤드, 필요 시 계측' 설계는 Tokio의 일관된 철학입니다: 핵심 경로에는 어떤 추가 추상 계층도 도입하지 않고, 관측 가능성은 선택적 기능으로叠加합니다.InstrumentedFuture의 존재는 Tokio 팀이 tracing의 계측 비용을 모든 사용자가 부담해서는 안 된다고 생각한다는 것을 보여줍니다.

poll 계약의 세 가지 암묵적 제약

poll메서드의 시그니처는fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>입니다. 이 시그니처에는 세 가지 계약이 숨어 있으며, 어느 하나라도 위반하면 정의되지 않은 동작이나 논리 오류가 발생합니다:

계약 1: Pin은 자기 참조 안전성을 보장합니다. Pin<&mut Self>은 Future가 한 번 poll되면 그 메모리 주소가 더 이상 이동할 수 없음을 의미합니다. 이는 async 블록이 컴파일된 후 자기 참조를 포함하는 상태 기계를 생성하기 때문입니다——지역 변수가 동일한 상태 기계 내 다른 필드를 가리키는 참조를 보유할 수 있습니다. 이동이 허용되면 이러한 참조는 dangling됩니다.

계약 2: Pending은 반드시 깨우기가 등록되어 있어야 합니다.이poll을 반환할 때Poll::PendingFuture는 반드시 를 통해cx.waker()Waker를 획득하고 저장했거나, 이미 Waker를 특정 이벤트 소스에 등록했습니다. 그렇지 않으면 실행기는 해당 Future가 언제 다시 poll될 수 있는지 영원히 알 수 없게 되어, 태스크가 영구적으로 중단됩니다.

계약 3: Ready 이후에는 다시 poll해서는 안 됩니다.일단poll가 반환되면Poll::Ready, 동일한 Future를 다시 poll하는 것은 논리적 오류입니다(UB를 초래하지는 않지만 동작이 정의되지 않음). 실행기는 Ready를 받은 후 해당 태스크를 다시 스케줄링하지 않을 책임이 있습니다.

이 세 가지 계약 중에서 계약 2가 가장 실수하기 쉬운 부분이며, Waker가 존재하는 근본적인 이유이기도 합니다.

1.2 Waker: 역방향 제어 흐름의 매개체

직관적 모델

Waker는 식당에서 주는 '진동 호출기'입니다. 창구 앞에 서서 "다 됐나요"를 반복해서 물을 필요가 없습니다 — 그러면 시간만 낭비됩니다. 처음 창구에 갈 때 호출기를 요리사에게 건네주고( Waker 등록), 그다음에는 안심하고 다른 일을 하면 됩니다. 음식이 완성되면 요리사가 버튼을 누르고, 호출기가 진동합니다(wake호출). 신호를 받은 후 다시 창구에 가서 음식을 받으면 됩니다(다시 poll).

Waker가 없다면 실행기는 두 가지 선택지만 있습니다: 모든 태스크를 바쁘게 폴링하거나(CPU 낭비), Pending을 반환한 태스크를 영원히 poll하지 않거나(태스크 기아). Waker는 이 교착 상태를 깨는 유일한 메커니즘입니다.

Waker의 메모리 레이아웃과 가상 테이블 설계

Waker는 표준 라이브러리 타입이지만, 그 설계는 Tokio의 태스크 구조에 직접적인 영향을 미쳤습니다.Waker는 본질적으로 팻 포인터입니다:RawWaker구조체로, 데이터 포인터와 가상 테이블 포인터를 포함합니다.

rust
// 标准库中的定义(非 Tokio 源码,此处为背景说明)
pub struct RawWaker {
    data: *const (),
    vtable: &'static RawWakerVTable,
}

pub struct RawWakerVTable {
    clone: unsafe fn(*const ()) -> RawWaker,
    wake: unsafe fn(*const ()),
    wake_by_ref: unsafe fn(*const ()),
    drop: unsafe fn(*const ()),
}
〔설계 추론 및 아키텍처 트레이드오프〕

이 설계의 정교한 점은 다음과 같습니다:Waker자체는 '깨우기'가 구체적으로 무엇을 의미하는지 관심이 없습니다. 단지 네 개의 함수 포인터를 담는 매개체일 뿐입니다. Tokio는wake함수가 태스크를 다시 스케줄링 큐에 푸시하는 Waker를 제공할 수 있고, 다른 런타임(예:futurescrate의block_on)은 완전히 다른 Waker 구현을 제공할 수 있습니다. 이러한 '데이터 + 가상 테이블' 패턴 덕분에 Waker는 서로 다른 런타임 간에 전달되어도 의미를 잃지 않습니다.

wake와wake_by_ref의 차이는 매우 중요합니다:wake는 Waker의 소유권을 소비하고(호출 후 Waker가 drop됨), 반면wake_by_ref는 단지 빌립니다. 실행기는 일반적으로wake_by_ref를 '태스크를 준비 상태로 표시하고 큐에 넣기'로 구현하며,wake는 그 위에 추가로 참조 카운트 감소를 처리합니다. Tokio의 태스크 구조에서 Waker의 데이터 포인터는 태스크의 참조 카운트 헤드를 가리키며, clone할 때마다 카운트가 증가하고, drop할 때마다 감소하며, 카운트가 0이 되면 태스크 메모리가 해제됩니다.

깨우기의 전체 타이밍

아래 타이밍 다이어그램은 TCP 읽기 작업이 시작되어 깨어나기까지의 전체 경로를 보여줍니다. Waker가 어떻게 태스크 컨텍스트에서 I/O 드라이버까지 전달되는지 주목하세요:

mermaid
sequenceDiagram
    participant App as 应用任务
    participant Exec as 调度器 Worker
    participant Future as TcpStream::read Future
    participant Reactor as I/O 驱动 (epoll)
    participant Kernel as 操作系统内核

    App->>Future: poll(cx) 携带 Waker
    Future->>Reactor: 注册可读兴趣 + 保存 Waker
    Reactor->>Kernel: epoll_ctl(ADD, fd, EPOLLIN)
    Future-->>Exec: 返回 Poll::Pending
    Note over Exec: 任务挂起,Worker 去执行其他任务
    Kernel-->>Reactor: epoll_wait 返回 fd 就绪
    Reactor->>Reactor: 查找 fd 对应的 Waker
    Reactor->>Exec: waker.wake_by_ref()
    Note over Exec: 任务重新入队
    Exec->>Future: 再次 poll(cx)
    Future->>Kernel: read(fd, buf) 非阻塞读取
    Kernel-->>Future: 返回数据
    Future-->>App: 返回 Poll::Ready(n)

이 다이어그램의 핵심은:Waker는 Reactor에서 Executor로 역방향으로 도달할 수 있는 유일한 채널입니다. Reactor는 태스크에 대한 다른 정보를 전혀 보유하지 않으며, 단지 '이 fd가 준비되면 이 Waker를 호출하라'는 것만 알고 있습니다. 이러한 디커플링 덕분에 I/O 드라이버는 스케줄러와 독립적으로 구현될 수 있으며, 둘은 Waker라는 좁은 인터페이스를 통해서만 통신합니다.

거짓 깨우기: 계약의 회색 지대

Tokio의 문서는 거짓 깨우기의 존재를 명확히 인정합니다:

Normally, tasks are scheduled only if they have been woken by calling wake on their waker. However, this is not guaranteed, and Tokio may schedule tasks that have not been woken under some circumstances.

📎 tokio/src/runtime/mod.rs:306-309

〔설계 추론 및 아키텍처 트레이드오프〕

이는poll의 구현이 '깨워지지 않았는데도 다시 poll되는' 상황을 견딜 수 있어야 함을 의미합니다. 올바른 Future는 Pending을 반환한 후, 아무런 이벤트가 발생하지 않았더라도 다시 poll될 때 panic하거나 잘못된 결과를 생성하는 대신 Pending을 반환해야 합니다. 이 제약은 느슨해 보이지만, 실제로는 상태 머신 설계에 요구 사항을 제기합니다: '두 poll 사이에 반드시 이벤트가 발생한다'고 가정할 수 없습니다.

1.3 Executor: Future에서 태스크로의 캡슐화

직관적 모델

Executor는 식당의 배차 담당자입니다. 손에 주문 더미(태스크 큐)를 들고 어떤 주문을 먼저 할지, 누가 할지를 결정합니다. 호출기가 진동하면 해당 주문을 다시 큐에 넣습니다. 배차 담당자가 없으면 요리사들은 어떤 요리를 해야 할지, 언제 작업을 전환해야 할지 알 수 없습니다.

하지만 Executor의 책임은 'Future 폴링'에 그치지 않습니다. 세 가지 핵심 문제를 해결해야 합니다:태스크의 수명 주기 관리(생성, 스케줄링, 완료, 취소),공정성 보장(특정 태스크가 다른 태스크를 기아 상태로 만드는 것을 방지),리소스 드라이버 통합(I/O 및 타이머 이벤트가 어떻게 깨우기로 변환되는지).

태스크의 메모리 레이아웃: Future에서 Task로

를 호출할 때tokio::spawn, 전달된 Future는 직접 큐에 들어가지 않습니다. 참조 카운트 헤드, 스케줄링 메타데이터, Future 자체를 포함하는Task구조로 래핑됩니다. 이 래핑 과정에는 중요한 최적화 결정이 있습니다:

rust
/// Boundary value to prevent stack overflow caused by a large-sized
/// Future being placed in the stack.
pub(crate) const BOX_FUTURE_THRESHOLD: usize = if cfg!(debug_assertions)  {
    2048
} else {
    16384
};

pub(crate) struct AutoBox<T>(std::marker::PhantomData<T>);

impl<T> AutoBox<T> {
    /// `true` if a value of type `T` is larger than [`BOX_FUTURE_THRESHOLD`].
    pub(crate) const SHOULD_BOX: bool = std::mem::size_of::<T>() > BOX_FUTURE_THRESHOLD;
}

📎 tokio/src/runtime/mod.rs:649-673

이 코드는 매우 구체적인 문제를 해결합니다: Future가 너무 크면(16KB 초과, debug 모드에서 2KB), Task 구조에 직접 인라인하면 스택 오버플로나 메모리 낭비가 발생합니다.AutoBox는 컴파일 타임 상수SHOULD_BOX를 통해 Future를 박싱할지 여부를 결정합니다.

〔설계 추론 및 아키텍처 트레이드오프〕

주석에서 특히 '런타임이 아닌 연관 상수를 사용하라'고 강조합니다if」의 이유: 만약 런타임에 판단한다면, 컴파일러는 각각의T를 위해 동시에 두 분기 코드를 인스턴스화합니다 (하나는T를 처리하고, 하나는Pin<Box<T>>를 처리). 이는 코드 팽창을 초래합니다. 반면 상수 분기를 사용하면, 단형화 수집기가 도달 불가능한 분기를 잘라내고 실제로 사용되는 타입에 대해서만 코드를 생성합니다. 이는 전형적인 「타입 시스템으로 런타임 판단을 대체하는」 최적화입니다.

스케줄링 공정성: 31과 61의 매직 넘버

Tokio의 스케줄러 문서에는 형식화된 공정성 보장이 정의되어 있습니다:

If the total number of tasks does not grow without bound, and no task is blocking the thread, then it is guaranteed that tasks are scheduled fairly.

📎 tokio/src/runtime/mod.rs:279-281

이 보장의 구현은 두 가지 핵심 매개변수에 의존합니다. current-thread 런타임의 경우:

The runtime will prefer to choose the next task to schedule from the local queue, and will only pick a task from the global queue if the local queue is empty, or if it has picked a task from the local queue 31 times in a row.

📎 tokio/src/runtime/mod.rs:328-333

The runtime will check for new IO or timer events whenever there are no tasks ready to be scheduled, or when it has scheduled 61 tasks in a row.

📎 tokio/src/runtime/mod.rs:335-337

이 두 숫자(31과 61)는 임의로 선택된 것이 아닙니다. 31은 2의 5제곱에서 1을 뺀 값으로, 비트 연산으로 빠르게 판단할 수 있습니다. 61은 I/O 이벤트가 무한히 지연되지 않도록 보장하기 위한 것입니다 — 태스크 큐가 영원히 비어 있지 않더라도, 61번 스케줄링할 때마다 반드시 I/O를 한 번 확인해야 합니다.

〔설계 추론과 아키텍처 트레이드오프〕

왜 32가 아니라 31일까요? 카운터가 0에서 시작하여 스케줄링할 때마다 1씩 증가하고, 카운터가 31에 도달하면 전역 큐 검사를 트리거하기 때문입니다.counter & 31 == 31로 판단하는 것이counter % 32 == 0보다 더 효율적입니다 (현대 컴파일러가 자동으로 최적화해 주지만). 61의 선택은 더 미묘합니다: 빈번한 epoll_wait 시스템 콜 오버헤드를 피할 만큼 충분히 커야 하고, I/O 지연을 허용 가능한 범위 내로 보장할 만큼 충분히 작아야 합니다.

멀티스레드 런타임의 LIFO 슬롯 최적화

멀티스레드 런타임은 공정성 위에 성능 최적화를 하나 더 추가했습니다 — LIFO 슬롯:

The multi thread runtime uses the lifo slot optimization: Whenever a task wakes up another task, the other task is added to the worker thread's lifo slot instead of being added to a queue.

📎 tokio/src/runtime/mod.rs:373-377

이 최적화의 직관은 다음과 같습니다: 한 태스크가 다른 태스크를 깨울 때, 깨어난 태스크는 현재 태스크와 데이터 의존 관계가 있을 가능성이 높습니다 (예: 생산자-소비자 패턴). 이를 LIFO 슬롯에 넣으면, 현재 태스크가 완료된 직후에 실행하여 CPU 캐시의 뜨거운 데이터를 활용할 수 있습니다.

그러나 LIFO 슬롯에는 남용 방지 메커니즘이 있습니다:

if a worker thread uses the lifo slot three times in a row, it is temporarily disabled until the worker thread has scheduled a task that didn't come from the lifo slot.

📎 tokio/src/runtime/mod.rs:380-382

〔설계 추론과 아키텍처 트레이드오프〕

이 「3회 연속 사용 후 비활성화」 규칙은 두 태스크가 서로를 깨워 라이브락을 형성하는 것을 방지하기 위한 것입니다. 만약 태스크 A가 태스크 B를 깨우고, B가 다시 A를 깨운다면, 이 제한이 없을 경우 LIFO 슬롯이 이 두 태스크에 의해 영구적으로 점유되어 다른 태스크는 영원히 스케줄링될 수 없습니다. 3회 제한은 다른 태스크에게 끼어들 기회를 줍니다.

태스크 취소: abort의 실제 의미

JoinHandle::abort의 동작은 종종 오해받습니다. 문서에서 명확히 밝히고 있습니다:

Be aware that calls to JoinHandle::abort just schedule the task for cancellation, and will return before the cancellation has completed.

📎 tokio/src/task/mod.rs:146-148

이는abort가 동기적이지 않다는 것을 의미합니다. 단지 플래그를 설정할 뿐이며, 태스크는 다음.await지점에서 이 플래그를 확인하고 스스로 종료합니다. 만약 태스크가.await가 없는 CPU 집약적 코드를 실행 중이라면,abort는 즉시 적용되지 않습니다.

더 미묘한 점은:

Note that aborting a task does not guarantee that it fails with a cancelled error, since it may complete normally first.

📎 tokio/src/task/mod.rs:134-138

〔설계 추론과 아키텍처 트레이드오프〕

이 의미의 설계 동기는: 취소는 「최선 노력」 작업이라는 것입니다. Tokio는 태스크를 강제로 종료하지 않으며 (Rust에는 안전한 강제 종료 메커니즘이 없음), 협력적으로 태스크에 자발적 종료를 요청합니다. 이는spawn_blocking태스크가 취소 불가능하다는 설계와 일치합니다 — 블로킹 태스크에는.await지점이 없어 취소 플래그를 확인할 수 없습니다.

1.4 설계 사고: 삼총사의 경계와 대가

왜 Future는 Executor를 포함하지 않는가

Rust의Futuretrait는 의도적으로 「자신을 어떻게 스케줄링할지」에 대한 정보를 포함하지 않습니다. 이는 심사숙고한 디커플링 결정입니다. 만약 Future가 자신의 Executor를 안다면:

1. 동일한 Future를 다른 런타임에서 실행할 수 없습니다 (예: Tokio에서 async-std로 마이그레이션)

2. 테스트 시 간단한block_on로 구동할 수 없습니다

3. 컴비네이터(예:select!、join!)가 런타임 간에 작동할 수 없습니다

Waker의 존재는 바로 이러한 디커플링을 유지하면서도 Future가 Executor에 알릴 수 있도록 하기 위한 것입니다. Waker는 「능력 토큰」입니다 — Future는 「이것을 호출하여 재스케줄링을 요청할 수 있다」는 것만 알 뿐, 스케줄링이 구체적으로 어떻게 발생하는지는 모릅니다.

협력적 스케줄링의 대가

Tokio의 태스크는 협력적입니다: 태스크는.await지점에서만 실행 권한을 양보합니다. 이는 다음을 의미합니다:

code that spends a long time without reaching an .await will prevent other tasks from running.

📎 tokio/src/lib.rs:178-179

〔설계 추론과 아키텍처 트레이드오프〕

이것이 협력적 스케줄링의 근본적인 대가입니다. 운영체제는 임의의 명령어 경계에서 스레드를 선점할 수 있지만, Tokio는.await지점에서만 태스크를 전환할 수 있습니다. 만약 한 태스크가 중간에.await없이 10초짜리 CPU 집약적 루프를 실행한다면, 같은 worker 스레드의 다른 모든 태스크가 10초 동안 블로킹됩니다. Tokio의 대응 전략은spawn_blocking와block_in_place를 제공하여 이런 작업을 전용 스레드 풀로 옮기는 것입니다. 그러나 이는 사용자의 책임이며, 런타임이 자동으로 감지할 수 없습니다.

공정성 보장의 경계 조건

Tokio의 공정성 보장에는 두 가지 전제 조건이 있습니다: 태스크 총수가 상한을 가지며, 스레드를 블로킹하는 태스크가 없어야 합니다. 이 두 조건은 실제 프로덕션 환경에서 자주 위반됩니다:

  • 태스크가 계속 새 태스크를 spawn하고 회수하지 않으면 태스크 총수에 상한이 없어 공정성 보장이 무효화됩니다
  • 어떤 태스크가 블로킹 시스템 콜(예: 동기 파일 I/O)을 실행하면 전체 worker 스레드를 블로킹합니다
〔설계 추론과 아키텍처 트레이드오프〕

이것이 Tokio 문서가 「비동기 태스크에서 블로킹 작업을 수행하지 말라」고 반복적으로 강조하는 이유입니다. 공정성 보장은 런타임의 강제적 보장이 아니라 「올바르게 사용한다는 전제 하에」의 보장입니다. 런타임은 위반 행위를 감지하지 않습니다. 감지 자체에 오버헤드가 필요하기 때문입니다.

1.5 이 장의 요약

이 장은 Tokio를 이해하기 위한 세 가지 초석을 세웠습니다:

Future는 풀 방식의 상태 기계이다. poll순수한 조회 동작이며, 반환 시Pending반드시 이미 웨이크업이 등록되어 있어야 하며, 반환Ready후에는 더 이상 poll되어서는 안 된다. Tokio는 직접 재사용하며std::future::Future, 추가 래핑을 하지 않는다 (tracing이 활성화된 경우 제외).

Waker는 역방향 제어 흐름의 유일한 통로이다.그것은 「데이터 포인터 + 가상 테이블」 설계를 통해 런타임 독립성을 구현한다.wake소유권을 소비하고,wake_by_ref은(는) 빌리기만 한다. 거짓 웨이크업은 허용되며, Future는 이를 반드시 용인해야 한다.

Executor는 수명 주기, 공정성, 리소스 통합을 담당한다.그것은 Future를 Task로 래핑하고,AutoBox를 통해 컴파일 시점에 박싱 여부를 결정하며, 31/61이라는 두 매직 넘버를 통해 로컬 큐와 글로벌 큐의 스케줄링을 균형 잡고, LIFO 슬롯을 통해 데이터 의존성 시나리오의 성능을 최적화한다.

이 세 컴포넌트는 좁은 인터페이스를 통해 디커플링된다: Future는poll만 알고, Waker는wake만 알며, Executor는 「Pending 또는 Ready가 될 때까지 폴링」만 안다. 바로 이러한 디커플링 덕분에 Tokio는 Future 정의를 수정하지 않고도 work-stealing 스케줄링, I/O 드라이버 통합, 협력적 예산 등의 고급 기능을 구현할 수 있다.

이 장의 생각과 자가 점검

Q1: 만약AutoBox::SHOULD_BOX의 판단을 컴파일 시점 상수에서 런타임if size_of::<T>() > THRESHOLD로 변경하면 컴파일 산출물에 어떤 영향을 미치는가? 왜 Tokio의 주석이 이 점을 특별히 강조하는가?

참고 해석:📎 tokio/src/runtime/mod.rs:657-667의 주석에 따르면, 만약 런타임if을 사용하면 컴파일러는 각T에 대해 두 분기의 코드를 동시에 인스턴스화한다 — 하나는T이 직접 인라인되는 경우를 처리하고, 하나는Pin<Box<T>>인 경우를 처리한다. 이는 각 spawn된 Future 타입마다 두 개의 태스크 구동 코드(task harness)가 생성되어 바이너리 크기가 두 배로 늘어남을 의미한다. 반면 연관 상수SHOULD_BOX를 사용하면,T이 결정된 후에는 컴파일 시점 상수이므로 단형화 수집기가 도달 불가능한 분기를 제거하고 실제 사용되는 경로에 대해서만 코드를 생성한다. 이는 「타입 시스템으로 런타임 판단을 대체」하는 전형적인 최적화이며, 대가는AutoBox이 일반 함수가 아닌 제네릭 구조체여야 한다는 점이다.

Q2: 어떤 태스크가poll에서Pending을 반환했지만 Waker 등록을 잊었다고 가정하자. current-thread 런타임과 multi-thread 런타임에서 이 태스크는 각각 어떻게 되는가? Tokio에 이를 감지하는 메커니즘이 있는가?

참고 해석:📎 tokio/src/runtime/mod.rs:306-309에 따르면, Tokio는 거짓 웨이크업을 허용하며, 이는 태스크가 웨이크업되지 않은 상태에서 재스케줄링될 수 있음을 의미한다. 하지만 이것이 Waker 등록을 잊는 것이 안전하다는 뜻은 아니다. current-thread 런타임에서 로컬 큐와 글로벌 큐가 모두 비어 있으면, 런타임은park상태로 진입하여 I/O 또는 타이머 이벤트를 기다린다. Waker 등록을 잊은 태스크는 영원히 재큐잉되지 않아 영구적으로 중단된다. multi-thread 런타임에서도 상황은 유사하지만, 다른 태스크가 지속적으로 웨이크업하면 해당 태스크는 거짓 웨이크업으로 인해 우연히 재스케줄링될 수 있다 — 그러나 이는 의존할 수 없다. Tokio에는 「Pending을 반환했지만 Waker를 등록하지 않은」 상황을 발견하는 런타임 감지 메커니즘이 없는데, 이는 매 poll 후 Waker 사용 여부를 확인해야 하므로 오버헤드가 너무 크기 때문이다. 이는 Future 구현자의 책임이다.

Q3: LIFO 슬롯의 「세 번 연속 사용 후 비활성화」 규칙은 어떤 구체적 시나리오를 방지하기 위한 것인가? 이 제한을 제거하면 어떤 태스크 의존성 패턴에서 다른 태스크가 기아 상태에 빠지는가?

참고 해석:📎 tokio/src/runtime/mod.rs:380-382에 따르면, LIFO 슬롯은 세 번 연속 사용 후 LIFO가 아닌 소스의 태스크가 스케줄링될 때까지 일시적으로 비활성화된다. 이 규칙이 방지하는 시나리오는: 두 태스크가 서로를 깨워 긴밀한 루프를 형성하는 것이다. 예를 들어 태스크 A가 데이터 배치를 처리한 후 태스크 B를 깨우고, 태스크 B가 처리 후 즉시 태스크 A를 깨운다. 세 번 제한이 없으면 A와 B는 영원히 LIFO 슬롯을 차지하고, worker 스레드는 이 두 태스크 사이를 무한히 전환하며, 로컬 큐와 글로벌 큐의 다른 태스크는 영원히 실행 기회를 얻지 못한다. 세 번 제한은 매 세 라운드의 「상호 웨이크업」 후에 최소한 하나의 다른 태스크가 스케줄링되도록 보장하여 라이브락을 깨뜨린다. 이 숫자의 선택은 경험적이다: 너무 작으면 LIFO 최적화의 이득이 줄어들고, 너무 크면 다른 태스크의 지연이 증가한다.

여기까지 Future, Waker, Executor 세 가지의 책임 경계와 협력 메커니즘이 명확해졌다: Future는 계산을 정의하고, Waker는 웨이크업을 담당하며, Executor는 실행을 구동한다. 그러나 단일 컴포넌트는 독립적으로 작동할 수 없으며, 이들은 통합된 런타임 환경에 조립되어야 한다. 다음 장에서는 Runtime::new와 Builder::build의 전체 조립 경로를 추적하여 스케줄러, I/O 드라이버, 시간 드라이버, 블로킹 스레드 풀이 어떻게 동일한 Runtime 인스턴스에 주입되는지 살펴보고, current_thread와 multi_thread 두 형태의 조립 단계에서의 근본적 차이를 밝힌다.

모든 코드베이스를 진정으로 이해할 수 있는 책으로

이 장을 다 읽으셨나요? 내 프라이빗 저장소를 위한 아키텍처 책 만들기

Tauri 2 + Rust 로컬 퍼스트 아키텍처. 100% 오프라인 보안, 클라우드 코드 업로드 없음. 불변 커밋 라인 앵커로 정독.

⚡ Tauri 2 · Rust 네이티브 코어 · 100% 오프라인 보안 · 100만 행 이상 검증

CHAPTER 02

제 2 장: Runtime의 조립: Builder가 드라이버, 스케줄러, 스레드 풀을 어떻게 조립하는가

Upstream: tokio-rs/tokio · Commit @e800714a · 진행률: 제 2 장 / 총 14 장

지난 장에서 우리는 Future, Waker, Executor 세 가지의 책임 경계를 명확히 했다. 하지만 실제로 사용 가능한 런타임은 '하나의 Executor'만으로는 훨씬 부족하다 — I/O 이벤트 루프, 타이머, 블로킹 스레드 풀도 필요하며, 이러한 컴포넌트들은 반드시 동일한 핸들 세트와 동일한 라이프사이클을 공유해야 한다. 이번 장에서는Builder::build의 완전한 조립 체인을 추적하며, 핵심 질문 하나에 답한다:하나의Runtime내부에 어떤 컴포넌트들이 있으며, 그것들이 어떻게 조립되고 핸들을 공유하는가。

Tokio의 조립 진입점은Builder이다. 이것 자체는 순수한 설정 컨테이너이며, 모든 필드는 '의도 선언'이고 런타임 리소스를 전혀 보유하지 않는다. 실제 리소스 생성은build()호출 시에 발생한다.

직관적 모델: Builder는 '인테리어 도면', Runtime은 '입주 후의 집'

Builder은 마치 인테리어 도면과 같다: 그 위에 '방 몇 개(worker_threads)', '수도 연결 여부(enable_io)', '전기 연결 여부(enable_time)', '외주 도우미 상한(max_blocking_threads)'을 표시한다. 도면 자체는 아무런 실체도 만들어내지 않는다.build()을 호출할 때까지, 시공팀이 도면에 따라 시공하여 스케줄러, 드라이버, 스레드 풀이라는 '방'들을 실제로 세우고,Runtime인스턴스를 인도한다.

만약Builder이 계층이 없다면, 사용자는 각 컴포넌트를 수동으로 new하고, 수동으로 배선하고, 수동으로 실패 롤백을 처리해야 한다 — 어느 한 곳이라도 순서가 틀리면 핸들이 공중에 떠 있거나 리소스가 누출된다.Builder의 가치는:'설정'과 '구성'을 완전히 분리하여, 구성 과정에서 검증, 실패 정리, 핸들 공유를 집중적으로 수행할 수 있게 한다。

메모리 레이아웃:Builder의 필드 구역

Builder의 필드는 책임에 따라 네 그룹으로 나눌 수 있다. 첫 번째 그룹은형태와 스위치:kind는 스케줄러 형태를 결정하고,enable_io / enable_time는 해당 드라이버 생성 여부를 결정한다.

📎 tokio/src/runtime/builder.rs:55-68

rust
pub struct Builder {
    kind: Kind,
    name: Option<String>,
    enable_io: bool,
    nevents: usize,
    nevents_busy: Option<usize>,
    enable_time: bool,
    start_paused: bool,
    // ...
}

두 번째 그룹은스레드 풀 파라미터:worker_threads는Option<usize>,None로 'build 시점까지 지연하여 CPU 코어 수에 따라 자동 감지'를 나타내며;max_blocking_threads기본값은 512이다.

📎 tokio/src/runtime/builder.rs:73-79

rust
worker_threads: Option<usize>,
max_blocking_threads: usize,

세 번째 그룹은콜백 훅이며, 전부Option<Arc<dyn Fn ...>>이다. 이들은Arc대신Box을 사용하는데, 이 콜백들이 각 워커 스레드의Config。

📎 tokio/src/runtime/builder.rs:87-97

rust
pub(super) after_start: Option<Callback>,
pub(super) before_stop: Option<Callback>,
pub(super) before_park: Option<Callback>,
pub(super) after_unpark: Option<Callback>,

복사네 번째 그룹은:global_queue_interval、event_interval、disable_lifo_slot、seed_generator。

📎 tokio/src/runtime/builder.rs:116-134

rust
pub(super) global_queue_interval: Option<u32>,
pub(super) event_interval: u32,
pub(super) disable_lifo_slot: bool,
pub(super) seed_generator: RngSeedGenerator,

복사Kind여기서 주목할 만한 설계가 있다:Copy은

📎 tokio/src/runtime/builder.rs:261-265

rust
#[derive(Clone, Copy)]
pub(crate) enum Kind {
    CurrentThread,
    #[cfg(feature = "rt-multi-thread")]
    MultiThread,
}

MultiThread복사rt-multi-thread변형은rtfeature에 의해 게이트된다. 이는Kindfeature만 활성화한 빌드에서build()이 하나의 변형만 가지며,match의이 컴파일러에 의해 단일 분기로 최적화된다는 것을 의미한다 —。

타입 시스템을 사용하여 런타임 판단 대신 멀티스레드 스케줄러의 코드 크기를 제거한다

Builder::new기본값의 철학: 왜 I/O와 time이 기본적으로 꺼져 있는가enable_io은 모든 구성의 공통 진입점이다. 이것은enable_time과false。

📎 tokio/src/runtime/builder.rs:309-318

rust
// I/O defaults to "off"
enable_io: false,
nevents: 1024,
nevents_busy: None,

// Time defaults to "off"
enable_time: false,

// The clock starts not-paused
start_paused: false,
로 설정한다

복사#[tokio::main]〔설계 추론과 아키텍처 트레이드오프〕enable_all()。

enable_all()이 기본값 선택은 의도적이다: I/O 드라이버를 생성하려면 운영체제에 epoll/kqueue 핸들을 요청해야 하고, time 드라이버를 생성하려면 타이머 인프라를 시작해야 한다. 만약 사용자가 순수 계산 작업 스케줄러(예: CPU 집약적 async 로직 실행)만 원한다면, 이러한 드라이버를 강제로 생성하는 것은 순전한 낭비이다.

📎 tokio/src/runtime/builder.rs:398-419

rust
pub fn enable_all(&mut self) -> &mut Self {
    #[cfg(any(
        feature = "net",
        all(unix, feature = "process"),
        all(unix, feature = "signal")
    ))]
    self.enable_io();

    #[cfg(all(
        tokio_unstable,
        feature = "io-uring",
        // ...
    ))]
    self.enable_io_uring();

    #[cfg(feature = "time")]
    self.enable_time();

    self
}

을 호출하기 때문이다enable_io()의 구현은 feature 게이팅이 '전부 열기'의 의미에 어떻게 영향을 미치는지 보여준다.net、process복사signal주목할 점은time feature,enable_all()이

또는build()feature가 활성화된 경우에만 호출된다는 것이다. 만약 사용자가

build()만 활성화했다면kind은 I/O 드라이버를 열지 않는다 — 컴파일 산출물에 I/O 드라이버 코드가 전혀 없기 때문이다.

📎 tokio/src/runtime/builder.rs:1146-1152

rust
pub fn build(&mut self) -> io::Result<Runtime> {
    match &self.kind {
        Kind::CurrentThread => self.build_current_thread_runtime(),
        #[cfg(feature = "rt-multi-thread")]
        Kind::MultiThread => self.build_threaded_runtime(),
    }
}

의 분기

은 조립의 시작점이며,

build_current_thread_runtime에 따라 완전히 다른 두 경로로 분기한다.build_current_thread_runtime_components복사Runtime。

📎 tokio/src/runtime/builder.rs:1725-1736

rust
fn build_current_thread_runtime(&mut self) -> io::Result<Runtime> {
    use crate::runtime::runtime::Scheduler;

    let (scheduler, handle, blocking_pool) =
        self.build_current_thread_runtime_components(None)?;

    Ok(Runtime::from_parts(
        Scheduler::CurrentThread(scheduler),
        handle,
        blocking_pool,
    ))
}

경로 1: current_thread의 조립build_current_thread_runtime_components자체는 매우 얇으며,

📎 tokio/src/runtime/builder.rs:1760-1766

rust
let mut cfg = self.get_cfg();
cfg.timer_flavor = TimerFlavor::Traditional;
let (driver, driver_handle) = driver::Driver::new(cfg)?;

// Blocking pool
let blocking_pool = blocking::create_blocking_pool(self, self.max_blocking_threads, 0);
let blocking_spawner = blocking_pool.spawner().clone();

에 포장한다driver복사(driver, driver_handle)실제 조립 로직은?에 있다. 실행 순서가 매우 중요하다:build복사Err첫 번째 단계에서

을 생성하고, 한 쌍의spawner을 반환한다. 여기서spawner은 오류를 직접 상위로 전파한다 — 만약 I/O 드라이버 초기화가 실패하면(예: epoll 생성 실패), 전체

이

📎 tokio/src/runtime/builder.rs:1768-1770

rust
let seed_generator_1 = self.seed_generator.next_generator();
let seed_generator_2 = self.seed_generator.next_generator();
두 번째 단계에서 blocking pool을 생성하고, 즉시 그

클론을 꺼낸다. 이seed_generator_1은 스케줄러에 주입되어, 스케줄러가 블로킹 작업을 스레드 풀에 전달할 수 있는 능력을 갖게 한다.Config세 번째 단계에서 두 개의 독립적인 RNG 시드 생성기를 생성한다.select!복사seed_generator_2〔설계 추론과 아키텍처 트레이드오프〕CurrentThread::new왜 두 개가 필요한가?rng_seed은

에 들어가 스케줄러 내부에서 사용된다(예:Config의 랜덤 분기 순서);CurrentThread::new。

📎 tokio/src/runtime/builder.rs:1776-1807

rust
let (scheduler, handle) = CurrentThread::new(
    driver,
    driver_handle,
    blocking_spawner,
    seed_generator_2,
    Config {
        before_park: self.before_park.clone(),
        after_unpark: self.after_unpark.clone(),
        // ...
        global_queue_interval: self.global_queue_interval,
        event_interval: self.event_interval,
        // ...
        enable_eager_driver_handoff: false,
        seed_generator: seed_generator_1,
        // ...
    },
    local_tid,
    self.name.clone(),
);

에 전달되어 작업 측에서 사용된다. 두 생성기를 분리하면 스케줄러 내부에서 소비하는 난수가 사용자에게 보이는 난수 시퀀스에 영향을 미치는 것을 방지하여,enable_eager_driver_handoff의 재현성을 보장한다.false。

📎 tokio/src/runtime/builder.rs:1795-1798

rust
// This setting never makes sense for a current thread runtime,
// as it only configures how the I/O driver is stolen across
// workers.
enable_eager_driver_handoff: false,
을 함께

이 주석은 해당 옵션의 본질을 짚고 있다: 그것은 「여러 worker 간에 I/O 드라이버를 어떻게 선점하는가」를 설명하는데, current_thread는 스레드가 하나뿐이라 선점이 존재하지 않으므로 강제로 비활성화된다. 이는 「설정 항목의 의미가 형태와 강하게 연관된다」는 전형적인 예이다—동일한Builder필드가 서로 다른 형태에서 다른 의미를 가진다.

마지막으로,CurrentThread::new가 반환한handle가scheduler::Handle::CurrentThread에 감싸지고, 다시 공개된Handle。

📎 tokio/src/runtime/builder.rs:1816-1822

rust
let handle = Handle {
    inner: scheduler::Handle::CurrentThread(handle),
};

Ok((scheduler, handle, blocking_pool))

경로 2: multi_thread의 조립

build_threaded_runtime의 골격은 current_thread와 유사하지만, 세 가지 본질적 차이가 있다. 첫 번째 차이는 worker 스레드 수의 결정이다:

📎 tokio/src/runtime/builder.rs:2185

rust
let worker_threads = self.worker_threads.unwrap_or_else(num_cpus);

None가 여기서num_cpus()로 파싱된다. 이것이 「지연 자동 탐지」의 실현 지점이다—탐지는Builder::new시점이 아니라 build 시점에 발생하는데, CPU 친화성이 그 사이에 변할 수 있기 때문이다.

두 번째 차이는 blocking pool의 용량 계산에 있다:

📎 tokio/src/runtime/builder.rs:2189-2192

rust
let blocking_pool =
    blocking::create_blocking_pool(self, self.max_blocking_threads + worker_threads, worker_threads);
let blocking_spawner = blocking_pool.spawner().clone();

주목할 점은max_blocking_threads + worker_threads이다. current_thread 경로에서 전달되는 것은self.max_blocking_threads과0。

📎 tokio/src/runtime/builder.rs:1765

rust
let blocking_pool = blocking::create_blocking_pool(self, self.max_blocking_threads, 0);
〔설계 추론과 아키텍처 트레이드오프〕

이 차이는 blocking pool 용량 의미를 드러낸다: multi_thread에서max_blocking_threads는 「추가적인」 블로킹 스레드 상한이며, 실제 총 스레드 상한에는 worker 스레드 수를 더해야 한다. 세 번째 파라미터(current_thread는 0, multi_thread는worker_threads)는 「예약 스레드 수」 또는 「초기 스레드 수」에 대한 힌트일 가능성이 높다. 이 설계는max_blocking_threads의 의미를 두 형태에서 일관되게 유지한다: 그것은 「핵심 worker 외에 추가로 얼마나 많은 블로킹 스레드를 열 수 있는가」를 설명한다.

세 번째 차이는MultiThread::new가 2-튜플이 아닌 3-튜플을 반환한다는 점이다:

📎 tokio/src/runtime/builder.rs:2198-2226

rust
let (scheduler, handle, launch) = MultiThread::new(
    worker_threads,
    driver,
    driver_handle,
    blocking_spawner,
    seed_generator_2,
    Config {
        // ...
        enable_eager_driver_handoff: self.enable_eager_driver_handoff,
        // ...
    },
    self.timer_flavor,
    self.name.clone(),
);

추가된launch는 「시작 핸들」이다.MultiThread::new는 스케줄러 구조만 구성할 뿐,worker 스레드를 즉시 시작하지는 않는다. 실제 시작은 나중에 발생한다:

📎 tokio/src/runtime/builder.rs:2228-2234

rust
let handle = Handle { inner: scheduler::Handle::MultiThread(handle) };

// Spawn the thread pool workers
let _enter = handle.enter();
launch.launch();

Ok(Runtime::from_parts(Scheduler::MultiThread(scheduler), handle, blocking_pool))

handle.enter()가 런타임 컨텍스트에 진입한 후,launch.launch()가 비로소 모든 worker 스레드를 실제로 spawn한다. 이 「먼저 구성하고, 나중에 시작하는」 2단계 설계는 매우 중요하다.

〔설계 추론과 아키텍처 트레이드오프〕

왜 구성과 동시에 시작할 수 없는가? worker 스레드가 시작되면 즉시 태스크를 poll하기 시작하는데, 태스크가handle를 참조할 수 있기 때문이다. 만약handle가 아직 구성이 끝나지 않았다면, 「worker가 반쯤 완성된 핸들을 들고 있는」 경쟁 상태가 발생한다. 2단계 설계는 다음을 보장한다:모든 worker 스레드가 시작될 때, 완전한Handle가 이미 준비되어 있다。_enter가드는 worker 스레드가 시작되는 순간 올바른 런타임 컨텍스트에 있도록 보장한다.

조립 흐름도

아래 그림은 두 경로의 조립 순서, 핵심 분기, 오류 경로를 함께 그린 것이다. 주목할 점은driver::Driver::new실패 시 곧바로Err를 반환하며, 이때 blocking pool은 아직 생성되지 않았다는 것이다.

mermaid
flowchart TD
    start["Builder::build()"] --> match_kind{"self.kind?"}

    match_kind -->|CurrentThread| ct_cfg["get_cfg() + timer_flavor=Traditional"]
    match_kind -->|MultiThread| mt_workers["worker_threads = self.worker_threads.unwrap_or_else(num_cpus)"]

    ct_cfg --> ct_driver["driver::Driver::new(cfg)?"]
    mt_workers --> mt_driver["driver::Driver::new(self.get_cfg())?"]

    ct_driver -->|Err| ret_err["return Err(io::Error)"]
    mt_driver -->|Err| ret_err

    ct_driver -->|Ok driver, driver_handle| ct_pool["create_blocking_pool(self, max_blocking_threads, 0)"]
    mt_driver -->|Ok driver, driver_handle| mt_pool["create_blocking_pool(self, max_blocking_threads + worker_threads, worker_threads)"]

    ct_pool --> ct_seed["next_generator() x2"]
    mt_pool --> mt_seed["next_generator() x2"]

    ct_seed --> ct_new["CurrentThread::new(driver, driver_handle, blocking_spawner, ...)"]
    mt_seed --> mt_new["MultiThread::new(worker_threads, driver, ...) -> (scheduler, handle, launch)"]

    ct_new --> ct_wrap["Handle { inner: CurrentThread(handle) }"]
    mt_new --> mt_wrap["Handle { inner: MultiThread(handle) }"]

    ct_wrap --> ct_rt["Runtime::from_parts(Scheduler::CurrentThread, handle, blocking_pool)"]
    mt_wrap --> mt_enter["handle.enter()"]
    mt_enter --> mt_launch["launch.launch() 启动 worker 线程"]
    mt_launch --> mt_rt["Runtime::from_parts(Scheduler::MultiThread, handle, blocking_pool)"]

핸들 공유:Handle가 어떻게 컴포넌트 간의 「통행증」이 되는가

조립이 완료되면,Runtime는scheduler、handle、blocking_pool세트를 보유한다. 그중handle가 공유의 핵심이다. 그 내부는 열거형이다:

📎 tokio/src/runtime/scheduler/mod.rs:29-41

rust
#[derive(Debug, Clone)]
pub(crate) enum Handle {
    #[cfg(feature = "rt")]
    CurrentThread(Arc<current_thread::Handle>),

    #[cfg(feature = "rt-multi-thread")]
    MultiThread(Arc<multi_thread::Handle>),

    #[cfg(not(feature = "rt"))]
    #[allow(dead_code)]
    Disabled,
}

두 변형 모두Arc를 감싸고 있다는 점에 주목하라. 이는Handle의 클론이 저렴한 참조 카운트 증가이며, 임의의 스레드로 자유롭게 배포될 수 있음을 의미한다.Handle는 통일된 접근 인터페이스를 제공하여, 형태 차이를match내부에 캡슐화한다. 예를 들어driver():

📎 tokio/src/runtime/scheduler/mod.rs:53-64

rust
pub(crate) fn driver(&self) -> &driver::Handle {
    match *self {
        #[cfg(feature = "rt")]
        Handle::CurrentThread(ref h) => &h.driver,

        #[cfg(feature = "rt-multi-thread")]
        Handle::MultiThread(ref h) => &h.driver,

        #[cfg(not(feature = "rt"))]
        Handle::Disabled => unreachable!(),
    }
}

blocking_spawner()는match_flavor!매크로를 사용하여 중복을 제거한다:

📎 tokio/src/runtime/scheduler/mod.rs:96-98

rust
pub(crate) fn blocking_spawner(&self) -> &blocking::Spawner {
    match_flavor!(self, Handle(h) => &h.blocking_spawner)
}

이 매크로를 전개하면 위의driver()와 같은match이 된다. 그 가치는: 형태별로 분배해야 하는 접근자를 새로 추가할 때,match_flavor!한 줄만 필요하고,match분기를 두 번 손으로 작성할 필요가 없다는 것이다.

공개된Handle는 내부scheduler::Handle의 얇은 래퍼이다:

📎 tokio/src/runtime/handle.rs:13-15

rust
pub struct Handle {
    pub(crate) inner: scheduler::Handle,
}

사용자가 받는Handle는 스레드 간 클론이 가능하고,spawn가능하며,block_on。spawn의 구현은AutoBox의 컴파일 타임 분기를 보여준다:

📎 tokio/src/runtime/handle.rs:197-208

rust
pub fn spawn<F>(&self, future: F) -> JoinHandle<F::Output>
where
    F: Future + Send + 'static,
    F::Output: Send + 'static,
{
    let fut_size = mem::size_of::<F>();
    if AutoBox::<F>::SHOULD_BOX {
        self.spawn_named(Box::pin(future), SpawnMeta::new_unnamed(fut_size))
    } else {
        self.spawn_named(future, SpawnMeta::new_unnamed(fut_size))
    }
}

AutoBox::<F>::SHOULD_BOX는 연관 상수로,size_of::<F>()과 임계값의 비교로 도출된다.

📎 tokio/src/runtime/mod.rs:668-673

rust
pub(crate) struct AutoBox<T>(std::marker::PhantomData<T>);

impl<T> AutoBox<T> {
    pub(crate) const SHOULD_BOX: bool = std::mem::size_of::<T>() > BOX_FUTURE_THRESHOLD;
}
〔설계 추론과 아키텍처 트레이드오프〕

주석은 왜 런타임if대신 연관 상수를 사용하는지 설명한다: 만약 런타임 판단을 사용하면,spawn_named가 두 번 단형화되어(한 번은F에 대해, 한 번은Pin<Box<F>>에 대해), 모든 spawn된 future마다 두 개의 태스크 harness가 생성되어 코드 크기가 두 배가 된다. 상수 분기를 사용하면, 단형화 수집기가 실제로 도달한 분기만 유지한다.

설계 사고: 조립 순서, 오류 복구, 프로덕션 함정

순서가 곧 계약이다. 조립 순서driver -> blocking_pool -> scheduler는 임의가 아니다. driver가 가장 먼저 생성되는데, 이는 OS 자원 부족으로 실패할 수 있는 유일한 단계이며, 실패 후 다른 컴포넌트를 정리할 필요가 없기 때문이다. blocking_pool은 driver 이후, scheduler 이전인데, scheduler가 blocking_spawner를 필요로 하기 때문이다. 만약 blocking_pool 생성이 실패하면(실제로는 거의 실패하지 않지만), driver는 drop으로 자동 정리된다.

current_thread의local_tid분기。build_local는build_current_thread_local_runtime를 사용하며, 현재 스레드 ID를 전달한다:

📎 tokio/src/runtime/builder.rs:1738-1751

rust
fn build_current_thread_local_runtime(&mut self) -> io::Result<LocalRuntime> {
    use crate::runtime::local_runtime::LocalRuntimeScheduler;

    let tid = std::thread::current().id();

    let (scheduler, handle, blocking_pool) =
        self.build_current_thread_runtime_components(Some(tid))?;

    Ok(LocalRuntime::from_parts(
        LocalRuntimeScheduler::CurrentThread(scheduler),
        handle,
        blocking_pool,
    ))
}

이tid는Handle에 저장되고, 이후can_spawn_local_on_local_runtime가 이를 사용하여 「spawn_local이 owner 스레드에서 호출되었는지」를 검증한다:

📎 tokio/src/runtime/scheduler/mod.rs:140-147

rust
pub(crate) fn can_spawn_local_on_local_runtime(&self) -> bool {
    match self {
        Handle::CurrentThread(h) => h.local_tid.is_some_and(|x| std::thread::current().id() == x),

        #[cfg(feature = "rt-multi-thread")]
        Handle::MultiThread(_) => false,
    }
}
〔설계 추론과 아키텍처 트레이드오프〕

이것이LocalRuntime안전성의 초석이다:!Send의 future는 그 owner 스레드에서만 poll될 수 있으며,local_tid가 바로 이 제약의 런타임 검사 지점이다. 만약 이 검사를 제거하면, 스레드 간 spawn_local이!Send데이터에 대한 동시 접근을 유발하여 UB를 초래한다.

프로덕션 함정 1:worker_threads(0)는 panic을 일으킨다。worker_threads메서드에는 단언이 있다:

📎 tokio/src/runtime/builder.rs:582-586

rust
pub fn worker_threads(&mut self, val: usize) -> &mut Self {
    assert!(val > 0, "Worker threads cannot be set to 0");
    self.worker_threads = Some(val);
    self
}

이 단언은 build 때까지 기다리지 않고 구성 단계에서 실패한다. 장점은 오류 위치 파악이 더 빠르다는 것이고, 단점은 스레드 수가 설정 파일의 동적 값에서 오는 경우 사용자가 호출 전에 직접 검증해야 한다는 것이다.

프로덕션 함정 2:max_blocking_threads너무 작게 설정하면 멈춘다. 문서에서 명확히 경고한다:

📎 tokio/src/runtime/builder.rs:600-601

rust
/// It's recommended to not set this limit too low in order to avoid hanging on operations
/// requiring [`spawn_blocking`].
〔설계 추론 및 아키텍처 트레이드오프〕

blocking pool의 큐에는 배압이 없기 때문이다——작업은 스레드가 사용 가능해질 때까지 계속 쌓인다. 모든 블로킹 스레드가 '새 블로킹 스레드가 있어야 완료할 수 있는' 작업을 기다리고 있으면 교착 상태에 빠진다. 문서의 "the queue does not apply any backpressure, it could potentially grow unbounded"가 바로 이 위험에 대한 각주다.

프로덕션 함정 3:UnhandledPanic::ShutdownRuntimecurrent_thread만 지원。

📎 tokio/src/runtime/builder.rs:1374-1381

rust
pub fn unhandled_panic(&mut self, behavior: UnhandledPanic) -> &mut Self {
    if !matches!(self.kind, Kind::CurrentThread) && matches!(behavior, UnhandledPanic::ShutdownRuntime) {
        panic!("UnhandledPanic::ShutdownRuntime is only supported in current thread runtime");
    }

    self.unhandled_panic = behavior;
    self
}
〔설계 추론 및 아키텍처 트레이드오프〕

이 제한의 이유는: multi_thread에서 '런타임 즉시 종료'는 모든 worker 스레드의 중지를 조정해야 하며, 구현 복잡도가 높고 의미가 모호하기 때문이다(폴링 중인 다른 작업은 어떻게 되는가?). current_thread는 스레드가 하나뿐이라 종료 의미가 명확하다.

이 장 요약

이 장에서는Builder::build의 전체 조립 경로를 추적했다. 핵심 결론:

1. Builder은 순수 구성 컨테이너이고,build()이 리소스를 생성한다. 조립 순서driver -> blocking_pool -> scheduler는 오류 복구 요구사항에 의해 결정된다.

2. current_thread와 multi_thread의 차이는 스레드 수만이 아니다: blocking pool 용량 계산이 다르고(max_blocking_threads vs max_blocking_threads + worker_threads), multi_thread는launch2단계 시작이 하나 더 있으며,enable_eager_driver_handoff는 current_thread에서 강제로 비활성화된다.

3. Handle은 컴포넌트 간 공유되는 핵심이며, 내부적으로Arc로 형태별 핸들을 감싸고,match또는match_flavor!매크로를 통해 통일적으로 접근한다.

4. AutoBox은 연관 상수를 사용해 컴파일 타임에 future의 박싱 여부를 결정하여 코드 크기가 두 배로 늘어나는 것을 방지한다.

5. local_tid은LocalRuntime안전성의 런타임 검사 지점이다.

다음 장에서는 작업의 생명주기로 들어간다:spawn어떻게 Future를 스케줄 가능한 실체로 만드는지,JoinHandle어떻게 작업 상태 머신과 상호작용하는지, 그리고 작업이PENDING / RUNNING / COMPLETE사이에서 상태 전이하는지.

이 장 생각해보기와 자가 점검

Q1: 만약build_threaded_runtime에서create_blocking_pool의 용량 파라미터를self.max_blocking_threads + worker_threads에서self.max_blocking_threads로 바꾸면, 어떤 시나리오에서 블로킹 작업이 기아 상태에 빠지는가? 왜 current_thread 경로는self.max_blocking_threads?

참고 해석:📎 tokio/src/runtime/builder.rs:2189-2192에 따르면, multi_thread 경로는self.max_blocking_threads + worker_threads를 전달하고, current_thread 경로는📎 tokio/src/runtime/builder.rs:1765를 전달한다self.max_blocking_threads. 차이의 근원은: multi_thread에서는 worker 스레드 자체도 블로킹 작업을 실행한다(예:block_in_place는 worker 스레드를 임시로 블로킹 스레드로 전환한다), 따라서 블로킹 스레드의 총 예산에는 worker 스레드 수가 포함되어야 한다. 만약self.max_blocking_threads만 전달하도록 바꾸면,max_blocking_threads이 작게 설정되고(예: 1) 이미 worker 스레드가block_in_place에서 예산을 점유하고 있을 때, 새로운spawn_blocking작업은 사용 가능한 스레드가 없어 배압 없는 큐에 쌓여, 이 블로킹 작업에 의존하는 async 작업이 영구적으로 중단된다. current_thread는 스레드가 하나뿐이고block_in_place의 worker 전환 의미를 지원하지 않으므로 worker 수를 더할 필요가 없다.

Q2: MultiThread::new은launch핸들을 반환하고, 실제로 worker 스레드를 시작하는 것은launch.launch()이다. 만약handle.enter()이 줄을 제거하고 직접launch.launch()을 호출하면 무슨 일이 발생하는가?

참고 해석:📎 tokio/src/runtime/builder.rs:2230-2232에 따르면, 시작 전에let _enter = handle.enter();가 있고 그 다음에야launch.launch()。handle.enter()의 역할은 스레드 로컬 컨텍스트(thread-local)를 설정하여 현재 스레드가 런타임 내부에 '있는 것처럼' 보이게 하는 것이다. worker 스레드는 시작 후 즉시 작업 폴링을 시작하는데, 작업 코드가 컨텍스트에 의존하는Handle::current()、tokio::spawn등의 API를 호출할 수 있다. 만약_enter을 제거하면, worker 스레드의 시작 순간 컨텍스트 설정이 불완전할 수 있으며(launch내부에서 자체 설정하는지에 따라 다름), 최악의 경우 worker 스레드에서 실행되는 초기화 코드가Handle::current()를 호출하면 panic이 발생한다(CONTEXT_MISSING_ERROR). 비록launch내부에서 각 worker에 컨텍스트를 설정하더라도,_enter은 '시작 동작 자체'가 올바른 컨텍스트에서 발생하도록 보장하여 시작 과정의 경쟁 조건을 방지한다.

Q3: AutoBox::<F>::SHOULD_BOX은 런타임if size_of::<F>() > THRESHOLD대신 연관 상수를 사용한다

. 만약 런타임 판단으로 바꾸면, 코드 크기가 두 배가 되는 것 외에 어떤 경우에 성능 저하가 발생하는가?참고 해석📎 tokio/src/runtime/mod.rs:657-673:if의 주석에 따르면, 런타임spawn_named은T이 각T에 대해 두 번 단형화되게 한다(Pin<Box<T>>와Pin<Box<T>>가 각각 한 번씩). 코드 크기가 두 배가 되는 것 외에 성능 저하는: 1) 명령어 캐시(i-cache) 압력 증가, 두 세트의 harness 코드가 모두 상주해야 하므로; 2) 컴파일러가 '실제로는 한 분기만 타는' 것에 대한 최적화를 할 수 없고, 런타임 분기 예측이 일반적으로 정확하더라도 분기 자체와 두 세트 코드의 레지스터 할당 차이가 누적됨; 3) 더 은밀한 것은,size_of경로가 강제 힙 할당을 하며, 만약 런타임 판단이 어떤 이유로(예:

모든 코드베이스를 진정으로 이해할 수 있는 책으로

이 장을 다 읽으셨나요? 내 프라이빗 저장소를 위한 아키텍처 책 만들기

Tauri 2 + Rust 로컬 퍼스트 아키텍처. 100% 오프라인 보안, 클라우드 코드 업로드 없음. 불변 커밋 라인 앵커로 정독.

⚡ Tauri 2 · Rust 네이티브 코어 · 100% 오프라인 보안 · 100만 행 이상 검증

CHAPTER 03

제 3 장: 태스크의 일생 (상): spawn이 Future를 어떻게 스케줄 가능한 실체로 만드는가

Upstream: tokio-rs/tokio · Commit @e800714a · 진행률: 제 3 장 / 총 14 장

지난 장에서 우리는 Runtime의 조립을 완료했다: I/O driver, time driver, blocking pool과 스케줄러가 동일한Runtime인스턴스에 주입되어,Handle이 컴포넌트들에 대한 크로스 스레드 접근을 위한 공유 핸들이 되었다. 하지만 조립된 런타임은 아직 빈 껍데기에 불과하다——태스크를 구동할 엔진은 있지만 구동할 태스크가 없다. 이번 장에서 답할 질문은 바로 이것이다: 당신이tokio::spawn(async { ... })을 입력한 그 순간, 그async블록은 도대체 무엇을 겪었기에 평범한 Rust 코드에서 '스케줄러가 인수할 수 있고, 깨울 수 있고, join할 수 있는' 실체가 되었는가. 이것은 '태스크의 일생'의 전반부이며, 우리는 탄생에 초점을 맞춘다:Handle::spawn에서 출발하여,new_task의 참조 카운트 할당을 거쳐,Cell<T, S>의 메모리 레이아웃에 도달하고, 최종적으로 태스크가 어떻게 어떤 worker의 로컬 큐나 전역 주입 큐에 투입되는지 명확히 본다. 후반부(제4장)에서야 스케줄링 루프와 poll/wake 폐루프에 진입한다.

3.1 Future는 태스크가 아니다: 한 번의 spawn이 도대체 무엇을 창조하는가

직관적 모델

Future을 '레시피'라고 상상하고, 태스크를 '주방에서 요리되고 있는 한 접시'라고 상상하라. 레시피 자체는 정적이고, 복사 가능하며, 어떤 실행 상태도 없다; 오직 주방(스케줄러)이 '지금 이 요리를 만들자'고 결정하고, 그것에 조리대(worker), 주문 번호(TaskId), 서빙구(JoinHandle)를 할당할 때에야 비로소 '조리 중인 요리'가 된다. 이 래핑이 없다면, 스케줄러는 '이 요리가 어디까지 됐는지', '누가 그것을 기다리는지', '완성되면 누구에게 알릴지'를 알 방법이 없다——그것은 레시피 한 장만 볼 수 있을 뿐, 관리할 수 없다.

데이터 구조와 메모리 레이아웃

Tokio는Task<S>로 '런타임이 소유한 태스크 참조'를 나타내며, 이것은RawTask에 대한 투명한 래퍼다:

rust
#[repr(transparent)]
pub(crate) struct Task<S: 'static> {
    raw: RawTask,
    _p: PhantomData<S>,
}

📎 tokio/src/runtime/task/mod.rs:233-238

#[repr(transparent)]는Task<S>과RawTask이 메모리상 완전히 동일하다는 것을 의미하며, 추가 오버헤드가 없다.PhantomData<S>은 컴파일 타임의 타입 마커일 뿐이며, 이 태스크가 어떤 스케줄러 타입S。

에 속하는지 표시한다. 태스크의 모든 상태를 실제로 담고 있는 것은Cell<T, S>이며, 그 레이아웃은 전체 태스크 모듈의 초석이다:

rust
#[repr(C)]
pub(super) struct Cell<T: Future, S> {
    pub(super) header: Header,
    pub(super) core: Core<T, S>,
    pub(super) trailer: Trailer,
}

📎 tokio/src/runtime/task/core.rs:126-136

세 필드는 '핫-웜-콜드' 순서로 배열된다.Header은 핫 데이터(매 스케줄링, 매 상태 전환마다 접근해야 함)이고,Core은 웜 데이터(poll 시 접근)이며,Trailer은 콜드 데이터(생성과 소멸 시에만 접근)이다. 주석은 명확히 적고 있다:Header은 반드시 첫 번째 필드여야 한다, 왜냐하면 태스크 구조체가 동시에*mut Cell과*mut Header에 의해 참조되기 때문이다📎 tokio/src/runtime/task/core.rs:37-43。

더 중요한 것은 캐시 라인 정렬이다.Cell에는 긴#[cfg_attr(..., repr(align(...)))]목록이 달려 있으며, 대상 아키텍처에 따라 정렬 바이트 수를 선택한다: x86_64/aarch64/powerpc64는 128바이트, arm/mips/sparc/hexagon은 32바이트, m68k는 16바이트, s390x는 256바이트, 나머지는 기본 64바이트📎 tokio/src/runtime/task/core.rs:64-125. 주석은 왜 x86_64가 64가 아닌 128을 써야 하는지 설명한다: Intel Sandy Bridge부터 공간 프리페처가 한 번에쌍을 이루는64바이트 캐시 라인을 가져오므로, 128바이트로 정렬해야만 거짓 공유를 피할 수 있다📎 tokio/src/runtime/task/core.rs:45-53。

〔설계 추론과 아키텍처 트레이드오프〕

이 정렬 전략의 대가는 각 태스크가 최소한 하나의 캐시 라인 공간을 낭비한다는 것이다. 하지만 태스크 상태 비트(state)는 여러 worker 스레드에 의해 고빈도로 읽히고 쓰인다——한 스레드가 poll 시 RUNNING 비트를 설정하고, 다른 스레드가 깨울 때 NOTIFIED 비트를 읽는다——만약 두 태스크의 상태 비트가 같은 캐시 라인에 떨어진다면, 매 상태 전환마다 캐시 라인이 코어 사이를 왔다갔다 튕기는 현상(cache line ping-pong)이 발생하여, 성능 손실이 메모리 낭비를 훨씬 초과한다. Tokio는 공간을 시간과 교환하기로 선택했다.

Header자체는 8개의 포인터 크기 이내로 제약된다:

rust
#[test]
#[cfg(not(loom))]
fn header_lte_cache_line() {
    assert!(std::mem::size_of::<Header>() <= 8 * std::mem::size_of::<*const ()>());
}

📎 tokio/src/runtime/task/core.rs:591-593

이 테스트는Header이 64바이트(8 × 8)를 초과하지 않도록 보장하여, 64바이트 캐시 라인 아키텍처에서 완전히 한 줄에 들어갈 수 있게 한다.Header의 필드는 다음을 포함한다:state: State(원자적 상태 비트),queue_next: UnsafeCell<Option<NonNull<Header>>>(주입 큐의 연결 리스트 포인터),vtable: &'static Vtable(함수 포인터 테이블),owner_id: UnsafeCell<Option<NonZeroU64>>(소속OwnedTasks리스트의 ID),scheduled_at: UnsafeCell<ScheduleLatencyInstant>(스케줄링 지연 측정)📎 tokio/src/runtime/task/core.rs:169-198。

Core<T, S>은 스케줄러 핸들scheduler: S, 태스크 IDtask_id: Id, 그리고 가장 핵심적인stage: CoreStage<T> 📎 tokio/src/runtime/task/core.rs:148-165。Stage을 보유한다.

rust
#[repr(C)]
pub(super) enum Stage<T: Future> {
    Running(T),
    Finished(super::Result<T::Output>),
    Consumed,
}

📎 tokio/src/runtime/task/core.rs:225-229

복사Stage::Running이것이 바로 'Future와 Output이 같은 메모리 블록을 재사용'하는 핵심이다: 태스크 실행 중에는Stage::Finished(output)이 future를 보유하고, 완료 후 제자리에서JoinHandle로 교체되며,Stage::Consumed。#[repr(C)]에 의해 꺼내진 후📎 tokio/src/runtime/task/core.rs:225-229。

Trailer이 된다. 주석은 Miri 이슈를 가리키며, 이 레이아웃이 unsafe 코드의 정확성에 강제 요구사항이 있음을 설명한다owned: linked_list::Pointers<Header>(OwnedTasks은 콜드 데이터를 저장한다:waker: UnsafeCell<Option<Waker>>연결 리스트 포인터),hooks: TaskHarnessScheduleHooks 📎 tokio/src/runtime/task/core.rs:205-213。

(태스크 완료를 기다리는 소비자 waker),

단계별: spawn에서 큐잉까지tokio::spawn(async { 42 })。

구체적인 시나리오를 대입해보자: multi_thread 런타임에서 worker 스레드 A가 new_task을 실행한다. 첫 번째 단계: 태스크 삼종 세트를 구성한다.

rust
fn new_task<T, S>(
    task: T,
    scheduler: S,
    id: Id,
    spawned_at: SpawnLocation,
) -> (Task<S>, Notified<S>, JoinHandle<T::Output>)

📎 tokio/src/runtime/task/mod.rs:336-346

복사RawTask::new::<T, S>이것은Cell을 호출하여raw을 할당하고, 그런 다음 같은Task포인터에서 세 개의 참조를 파생한다:OwnedTasks)、Notified(owned 참조, 보통 즉시JoinHandle에 넣음),📎 tokio/src/runtime/task/mod.rs:347-363。세 가지가 동일한raw를 공유하며, 각자 하나의 참조 카운트를 보유한다.

두 번째 단계:Cell를 할당하고 초기 상태를 기록한다. Cell::new힙에 전체 구조체를 할당한다:

rust
let result = Box::new(Cell {
    trailer: Trailer::new(scheduler.hooks()),
    header: new_header(state, vtable, ...),
    core: Core {
        scheduler,
        stage: CoreStage {
            stage: UnsafeCell::new(Stage::Running(future)),
        },
        task_id,
        ...
    },
});

📎 tokio/src/runtime/task/core.rs:261-278

vtable는raw::vtable::<T, S>()에 의해 생성되며, 특정T와S에 대해 단형화된 함수 포인터 테이블📎 tokio/src/runtime/task/core.rs:260이다. future는 추가 박싱 없이Stage::Running로 직접 이동된다.

세 번째 단계: debug 어서션으로 레이아웃을 검증한다.에서debug_assertions하에,Cell::new는check함수를 호출하여Header::get_trailer、Header::get_scheduler、Header::get_id_ptr등 vtable 오프셋 기반 포인터 연산으로 「header를 통해 역조회한 필드 주소」와 「실제 필드 주소」가 일치하는지 하나씩 어서션한다📎 tokio/src/runtime/task/core.rs:280-321. 이는 vtable 오프셋 정확성에 대한 런타임 자체 검증이다.

네 번째 단계: 스케줄러에 전달한다.스케줄러가Notified<S>를 받은 후Schedule::schedule 📎 tokio/src/runtime/task/mod.rs:315를 호출한다. multi_thread 하에서는push_back_or_overflow를 거쳐 현재 worker의 로컬 큐에 태스크를 푸시하며, 큐가 가득 차면 injection 큐로 오버플로한다.

아래 그림은new_task부터 큐 삽입까지의 제어 흐름과 분기를 묘사한다:

mermaid
flowchart TD
    spawn_call["Handle::spawn(future)"] --> new_task["new_task::<T,S>(future, scheduler, id)"]
    new_task --> raw_new["RawTask::new::<T,S>"]
    raw_new --> cell_new["Cell::new: Box::new(Cell{header, core, trailer})"]
    cell_new --> vtable["raw::vtable::<T,S>() 生成函数指针表"]
    cell_new --> stage["Stage::Running(future) 移入"]
    cell_new --> debug_check{"debug_assertions?"}
    debug_check -->|是| check_layout["check(): 断言 trailer/scheduler/id 偏移量"]
    debug_check -->|否| skip_check["跳过"]
    check_layout --> triple["派生 (Task, Notified, JoinHandle)"]
    skip_check --> triple
    triple --> owned["Task 存入 OwnedTasks"]
    triple --> sched["Notified 交给 Schedule::schedule"]
    sched --> push{"本地队列有容量?"}
    push -->|是| local_push["push_back_finish: 写入 buffer[tail & MASK]"]
    push -->|否| overflow_check{"steal == real?"}
    overflow_check -->|否, 有并发窃取| inject_only["overflow.push(task) 仅注入"]
    overflow_check -->|是| push_overflow["push_overflow: CAS 认领后半批"]
    push_overflow --> cas_ok{"CAS 成功?"}
    cas_ok -->|是| inject_batch["overflow.push_batch(后半批 + 当前 task)"]
    cas_ok -->|否| retry["返回 Err(task), 重试 push_back_or_overflow"]
    retry --> push

이 그림은 몇 가지 핵심 분기를 드러낸다: debug 어서션은 디버그 빌드에서만 적용된다; 로컬 큐가 가득 찼을 때 곧바로 오버플로하는 것이 아니라 먼저 동시 스틸러가 있는지(steal != real) 판단하고, 있으면 현재 태스크만 injection 큐에 푸시하는데, 스틸러가 비운 공간이 곧 사용 가능해지기 때문이다.

설계 고찰: 왜 하나가 아니라 세 개의 참조인가

new_task는 하나가 아닌 세 개의 참조를 반환한다. 이것이 참조 카운팅 설계의 핵심이다:Task는 「런타임이 이 태스크를 소유함」을,Notified는 「이 태스크가 통지되었고 스케줄 대기 중」을,JoinHandle는 「누군가 그 결과에 관심이 있음」을 나타낸다. 세 가지의 수명은 독립적이다——JoinHandle는 drop될 수 있고(태스크는 계속 실행되고 결과는 버려짐),Notified는 poll 후 사라지며,Task는 태스크가 완료되고OwnedTasks에서 제거된 후 해제된다. 참조가 하나뿐이라면 「태스크는 아직 실행 중이지만 아무도 join하지 않음」이라는 상태를 표현할 수 없다.

UnownedTask는 또 다른 중요한 분기이다: 이것은두 개의참조 카운트를 보유하며, blocking 태스크용이다(OwnedTasks)📎 tokio/src/runtime/task/mod.rs:286-295。unowned에 저장되지 않음).mem::forget(task)함수는mem::forget(notified)와UnownedTask 📎 tokio/src/runtime/task/mod.rs:388-397를 통해 두 참조를OwnedTasks에 병합한다. 이 「두 개의 참조」 설계 동기는: blocking 태스크에는 owned 참조를 보유할

리스트가 없으므로, 태스크가 실행 중에 해제되지 않도록 보장할 추가 참조 카운트가 필요하다.

3.2 상태 비트: 하나의 usize로 태스크의 전체 수명 주기를 인코딩하는 방법

직관적 모델태스크 상태를 「건강 검진 보고서」로 상상해 보자. 그 위에는 여러 독립적인 체크박스가 있다: poll 중인지, 완료되었는지, 통지되었는지, 취소되었는지, join하는 사람이 있는지. Tokio는 여러 개의 불리언 필드를 쓰지 않고 이 체크 비트들을AtomicUsize하나의

에 압축했다. 이렇게 하면 매 상태 전환마다 여러 번 잠그는 대신 단 한 번의 CAS만 필요하다. 이 설계가 없다면 태스크 상태 전환은 여러 락의 중첩이 되어 교착 위험과 오버헤드가 치솟을 것이다.

State비트필드 레이아웃📎 tokio/src/runtime/task/mod.rs:32-53:

  • RUNNING의 비트필드는 모듈 문서에 완전히 정의되어 있다: 태스크가 현재 poll 중인지 또는 취소되었는지. 📎 tokio/src/runtime/task/mod.rs:37-38。
  • COMPLETE이 비트는 동시에 태스크의 락 역할을 한다RUNNING: future가 완전히 완료되어 drop되었음. 한 번 설정되면 절대 지워지지 않으며, 절대📎 tokio/src/runtime/task/mod.rs:40-41。
  • NOTIFIED와 동시에 설정되지 않음Notified: 현재📎 tokio/src/runtime/task/mod.rs:43。
  • CANCELLED객체가 존재하는지📎 tokio/src/runtime/task/mod.rs:45-46。
  • JOIN_INTEREST: 태스크를 가능한 한 빨리 취소해야 함JoinHandle 📎 tokio/src/runtime/task/mod.rs:48。
  • JOIN_WAKER:📎 tokio/src/runtime/task/mod.rs:50-51。

가 존재함📎 tokio/src/runtime/task/mod.rs:53。

RUNNING: join handle waker의 접근 제어 비트RUNNING나머지 비트는 참조 카운팅에 사용됨📎 tokio/src/runtime/task/mod.rs:130-133가 락 역할을 한다는 점은 더 짚을 가치가 있다. 모듈 문서의 Safety 섹션은 다음과 같이 지적한다: future에 대한 모든 가변 접근은RUNNING비트를 수정하여 락을 획득한 후에만 수행되어야 하며, 이를 통해 배타적 접근을 보장한다

. 이는 태스크를 poll할 때 스레드가 먼저 CAS로

JOIN_WAKER를 설정하고, 성공하면 future를 배타적으로 점유하며, 실패하면 다른 스레드가 poll 중임을 의미하므로 이번 poll은 곧바로 반환됨을 뜻한다. 이것은 「poll의 상호 배제」와 「상태 전환」을 하나의 원자적 연산으로 합쳐 별도의 뮤텍스를 피한다.wakerJOIN_WAKER의 접근 제어 프로토콜Trailer비트는 전체 상태 기계에서 가장 정교한 부분이다. 이것이 해결하는 문제는:필드(내)가 두 스레드에 의해 동시 접근된다는 것이다——런타임은 태스크 완료 시JoinHandle그것을 읽어join자를 깨우고,는 poll 시📎 tokio/src/runtime/task/mod.rs:75-120:

1. JOIN_WAKER그것을 써서

waker를 등록한다. 모듈 문서는 7가지 규칙을 제시한다JoinHandle는 초기에 0이다.

2.JoinHandle가 0일 때,

는 waker 필드에 배타적(가변) 접근 권한을 가진다.COMPLETE3.

5. JoinHandle가 1일 때,JOIN_WAKER는 공유(읽기 전용) 접근 권한만 가진다.JOIN_WAKER4.

6. JoinHandle가 1이고COMPLETE가 1일 때, 런타임은 waker 필드에 공유(읽기 전용) 접근 권한을 가진다.JOIN_WAKERwaker를 쓰려면: (i)COMPLETE를 0으로 성공적으로 설정하여 배타적 권한을 얻고, (ii) waker를 쓰고, (iii)

를 1로 성공적으로 설정해야 한다.JOIN_INTEREST는COMPLETE가 0일 때만

를 변경할 수 있고, 런타임은COMPLETE가 1일 때만 변경할 수 있다.📎 tokio/src/runtime/task/mod.rs:110-1207.

가 0이고

Task가 1이면, 런타임은 waker 필드에 배타적 접근 권한을 가진다(waker를 drop하기 위해).UnownedTask의 drop이 두 번 감소합니다:

rust
impl<S: 'static> Drop for Task<S> {
    fn drop(&mut self) {
        if self.header().state.ref_dec() {
            self.raw.dealloc();
        }
    }
}

📎 tokio/src/runtime/task/mod.rs:580-586

rust
impl<S: 'static> Drop for UnownedTask<S> {
    fn drop(&mut self) {
        if self.raw.header().state.ref_dec_twice() {
            self.raw.dealloc();
        }
    }
}

📎 tokio/src/runtime/task/mod.rs:590-596

ref_dec반환true이것이 마지막 참조임을 나타내며, 이때 비로소 실제로 해제됩니다Cell메모리.ref_dec_twice은UnownedTask두 개의 카운트를 보유하는 직접적인 구현입니다.

설계 고찰: 왜 상태 비트와 참조 카운트가 하나의 원자를 공유하는가

〔설계 추론 및 아키텍처 트레이드오프〕

상태 비트와 참조 카운트를 동일한AtomicUsize에 배치한 것은 「참조 카운트 감소」와 「상태 비트 설정」이라는 두 동작을한 번의 CAS로 완료할 수 있게 하기 위해서입니다. 모듈 문서는Schedule::release의 주석에서 명확히 언급합니다: 「태스크 모듈은 ref-dec과 다른 옵션 설정을 일괄 처리합니다」📎 tokio/src/runtime/task/mod.rs:302-304. 만약 상태 비트와 참조 카운트가 두 개의 원자 변수에 속한다면, 「마지막 참조 해제」와 「완료 표시」 사이에 윈도우가 생겨 추가적인 동기화가 필요합니다. 병합 후,ref_dec은 원자적으로 「카운트 감소 + 0인지 확인」을 완료할 수 있어 ABA 류 문제를 방지합니다.

3.3 JoinHandle: 결과가 어떻게 태스크 경계를 넘어 반환되는가

직관적 모델

JoinHandle은 식당이 주는 「진동벨」과 같습니다. 태스크(주방)가 완료되면 요리(output)를 픽업대(Stage::Finished)에 놓고, 당신의 진동벨(waker)을 울립니다. 당신은 진동벨을 가지고 가서 받는데, 진동벨 자체는 요리를 보유하지 않고 픽업대를 가리키는 포인터일 뿐입니다. 만약 진동벨을 잃어버리면(dropJoinHandle), 요리는 그냥 버려지지만(output이 drop됨), 주방은 그로 인해 멈추지 않습니다.

데이터 구조

JoinHandle<T>역시RawTask에 대한 투명한 래퍼입니다:

rust
pub struct JoinHandle<T> {
    raw: RawTask,
    _p: PhantomData<T>,
}

📎 tokio/src/runtime/task/join.rs:163-166

PhantomData<T>은 출력 타입을 표시합니다.JoinHandle<T>은T: Send일 때만Send/Sync 📎 tokio/src/runtime/task/join.rs:169-170이며, 이는 non-Send 출력이 스레드 간 이동되지 않음을 보장합니다.

단계별: JoinHandle을 await하기

JoinHandle은Future을 구현하며, 그poll이 결과 반환의 핵심입니다:

rust
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
    ready!(crate::trace::trace_leaf());
    let mut ret = Poll::Pending;
    let coop = ready!(crate::task::coop::poll_proceed(cx));
    unsafe {
        self.raw.try_read_output(&mut ret, cx.waker());
    }
    if ret.is_ready() {
        coop.made_progress();
    }
    ret
}

📎 tokio/src/runtime/task/join.rs:327-354

몇 가지 세부 사항에 주목하세요:trace_leaf은 tracing 계측에 사용됩니다;coop::poll_proceed은 협력 예산을 소비합니다(12장에서 상세 설명);try_read_output은 vtable을 통해 제네릭을 소거하고, 반환값을 스택에 놓고*mut ()을 통해📎 tokio/src/runtime/task/join.rs:327-354에 전달합니다. 이 「반환값을 스택에 놓는」 기법은 vtable 함수가 반환 타입을 제네릭화할 수 없기T때문이며, 원시 포인터를 통해서만 되쓸 수 있습니다.

〔설계 추론 및 아키텍처 트레이드오프〕

try_read_output의 내부 로직(raw.rs에 있으며, 이 장에서는 소스 코드를 제공하지 않음): 먼저COMPLETE비트를 확인하고, 이미 설정되어 있으면take_output을 호출하여Stage::Finished에서 결과를 가져옵니다; 그렇지 않으면cx.waker()을Trailer::waker필드에 등록하고Pending을 반환합니다. 등록 과정은 바로 3.2절의JOIN_WAKER프로토콜을 따릅니다.

결과의 소유권 이전

모듈 문서의 「Non-Send output」 섹션은 결과의 소유권 규칙을 정확히 설명합니다📎 tokio/src/runtime/task/mod.rs:151-170:

  • 태스크 완료 시, output이Stage에 놓이고, 그런 다음 「COMPLETE 설정」 변환이 실행되며, 이 시점의JOIN_INTEREST값을 읽습니다.
  • 만약JOIN_INTEREST이 0이면(JoinHandle없음), output은 즉시 drop됩니다📎 tokio/src/runtime/task/mod.rs:157-158。
  • 만약JOIN_INTEREST이 1이면,JoinHandle이 output 정리를 담당합니다📎 tokio/src/runtime/task/mod.rs:160-161。

non-Send output에 대해, 문서는 세 단계 논증을 제시합니다: output은 poll future의 스레드에서 생성됩니다;JoinHandle<Output>은 Output이 non-Send일 때 역시 non-Send이므로, 이것도 spawn 스레드에 있습니다; 따라서JoinHandle이 output을 가져가거나 drop할 때 스레드 간 이동이 발생하지 않습니다📎 tokio/src/runtime/task/mod.rs:164-170。

JoinHandle의 drop: 빠른 경로와 느린 경로

rust
impl<T> Drop for JoinHandle<T> {
    fn drop(&mut self) {
        if self.raw.state().drop_join_handle_fast().is_ok() {
            return;
        }
        self.raw.drop_join_handle_slow();
    }
}

📎 tokio/src/runtime/task/join.rs:358-364

drop_join_handle_fast은 한 번의 CAS로 「JOIN_INTEREST비트 지우기 + 참조 카운트 감소」를 완료하려고 시도합니다. 실패하면(예: 태스크가 완료 중이고 상태 비트가 점유됨),drop_join_handle_slow의 느린 경로를 따릅니다. 이는 전형적인 「낙관적 빠른 경로 + 비관적 느린 경로」 패턴입니다.

설계 고찰: 왜 JoinHandle이 output을 직접 보유하지 않는가

〔설계 추론 및 아키텍처 트레이드오프〕

만약JoinHandle이 output을 직접 보유한다면, output은 태스크 완료 시JoinHandle이 있는 스레드로 이동해야 합니다. 하지만JoinHandle은 임의의 스레드로 이동될 수 있으며(T: Send이기만 하면), output의 생성 스레드는 poll 스레드입니다. 직접 보유하면 「output이 poll 스레드에서 생성되지만 join 스레드에서 drop되어야 하는」 스레드 간 이동이 발생하여, non-Send output에 대해 타입 시스템을 직접 위반합니다. Tokio는 output을Cell에 남겨두는 것을 선택했습니다(Stage::Finished),JoinHandle은Cell을 가리키는RawTask만 보유하고, 결과를 가져올 때take_output을 통해 제자리에서 가져옵니다. 이렇게 하면 output의 drop이JoinHandle이 있는 스레드에서 발생하지만, 전제는 해당 스레드가 poll 스레드와 동일하다는 것입니다(non-Send 시나리오에서 성립).

3.4 로컬 큐: work-stealing 생산자-소비자 구조

직관적 모델

각 worker는 「개인 할 일 목록」(로컬 큐)을 가지며, 용량은 256입니다. worker 자신은헤드에서 태스크를 가져오고(LIFO, 캐시 지역성 활용), 다른 worker는테일에서 태스크를 훔칩니다(FIFO, 가장 오래된, 가장 완료되었을 가능성이 높은 태스크를 가져감). 만약 로컬 큐가 없다면, 모든 태스크가 전역 큐에 몰려 매번 태스크를 가져올 때마다 전역 락을 경쟁해야 하며, 멀티코어 확장성이 붕괴될 것입니다.

메모리 레이아웃: head와 tail의 분리

rust
pub(crate) struct Inner<T: 'static> {
    head: AtomicUnsignedLong,
    tail: AtomicUnsignedShort,
    buffer: Box<[UnsafeCell<MaybeUninit<task::Notified<T>>>; LOCAL_QUEUE_CAPACITY]>,
}

📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:36-57

head은AtomicUnsignedLong입니다(64비트, 플랫폼이 u64를 지원하는 경우),tail은AtomicUnsignedShort입니다(32비트). 주석은 왜 인덱스가 실제로 필요한 것보다 더 넓은지 설명합니다: ABA 완화, 그리고 「가득 참」과 「비어 있음」 버퍼를 구분하기 위해서입니다📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:37-49。

head내부적으로두 개를 패킹했습니다 UnsignedShort:낮은 비트는 「실제 헤드」(real head)이고, 높은 비트는 「도둑이 처리 중인 첫 번째 위치」(steal head)입니다. 둘이 같으면 활성 도둑이 없습니다📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:39-49. 이 이중 값 패킹은 work-stealing 큐의 핵심 기법입니다: 도둑이 먼저 steal 값을 CAS로 갱신하여 한 묶음의 작업을 「선점」하고, 완료 후 steal 값을 real 값까지 따라잡게 하여 도둑질이 끝났음을 나타냅니다.

LOCAL_QUEUE_CAPACITYnon-loom에서는 256이고, loom에서는 더 많은 경계를 테스트하기 위해 4로 줄입니다📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:62-69。MASK = LOCAL_QUEUE_CAPACITY - 1, 링 버퍼 인덱스에 사용됩니다📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:71。

Step-by-Step: push_back_or_overflow의 전체 분기

이것은 로컬 큐에서 가장 복잡한 함수입니다. 분기별로 분석해 보겠습니다:

rust
pub(crate) fn push_back_or_overflow<O: Overflow<T>>(
    &mut self,
    mut task: task::Notified<T>,
    overflow: &O,
    stats: &mut Stats,
) {
    let tail = loop {
        let head = self.inner.head.load(Acquire);
        let (steal, real) = unpack(head);
        let tail = unsafe { self.inner.tail.unsync_load() };

        if tail.wrapping_sub(steal) < LOCAL_QUEUE_CAPACITY as UnsignedShort {
            break tail;
        } else if steal != real {
            overflow.push(task);
            return;
        } else {
            match self.push_overflow(task, real, tail, overflow, stats) {
                Ok(_) => return,
                Err(v) => { task = v; }
            }
        }
    };
    self.push_back_finish(task, tail);
}

📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:188-223

세 가지 분기:

1. 용량 있음(tail - steal < CAPACITY):break tail, 루프를 빠져나온 후 호출push_back_finish버퍼에 씁니다.

2. 용량 없지만 동시 도둑 있음(steal != real): 도둑이 공간을 확보하므로 현재 작업만 주입 큐에 푸시하고 즉시 반환📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:204-208。

3. 용량 없고 도둑도 없음: 호출push_overflow후반부 작업을 주입 큐로 오버플로📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:209-219. CAS 실패 시(동시 도둑에게 패배),push_overflow반환Err(task), 루프 재시도.

push_back_finish작업을 쓰고 tail 갱신:

rust
fn push_back_finish(&self, task: task::Notified<T>, tail: UnsignedShort) {
    let idx = tail as usize & MASK;
    self.inner.buffer[idx].with_mut(|ptr| {
        unsafe { ptr::write((*ptr).as_mut_ptr(), task); }
    });
    self.inner.tail.store(tail.wrapping_add(1), Release);
}

📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:226-244

Release순서는 쓰인 작업이 도둑에게 보이도록 보장합니다.

push_overflow: 왜 후반부를 오버플로하는가

rust
const NUM_TASKS_TAKEN: UnsignedShort = (LOCAL_QUEUE_CAPACITY / 2) as UnsignedShort;

📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:265

오버플로 시 128개 작업을 가져갑니다. 주석은 왜후반부를 가져가는지 전반부가 아닌지 자세히 설명합니다📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:295-306: 주입 큐에서 작업을 가져올 때는 항상 전반부에 놓입니다. 따라서 작업이 후반부에 있으면 방금 주입 큐에서 가져온 것이 아님을 확신할 수 있습니다. 이는 「주입 큐에서 꺼낸 작업이 즉시 주입 큐로 되돌아가지 않음」(적어도 한 번 poll되기 전까지)을 보장합니다.

CAS로 후반부 선점:

rust
if self.inner.head.compare_exchange_weak(
    pack(head, head), pack(tail, tail), Release, Relaxed
).is_err() {
    return Err(task);
}

📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:283-293

head를(head, head)에서(tail, tail)로 갱신, 즉 steal과 real을 동시에 tail로 진행시켜 모든 작업을 선점합니다. 성공 후 tail을tail + NUM_TASKS_TAKEN로 되돌려 전반부가 여전히 로컬 큐에 남아 있음을 나타냅니다📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:314-316。

pop과 steal_into: 작업을 가져오는 두 경로

pop는 worker 자신이 작업을 가져오는 것(헤드에서, LIFO):

rust
pub(crate) fn pop(&mut self) -> Option<task::Notified<T>> {
    let mut head = self.inner.head.load(Acquire);
    let idx = loop {
        let (steal, real) = unpack(head);
        let tail = unsafe { self.inner.tail.unsync_load() };
        if real == tail { return None; }
        let next_real = real.wrapping_add(1);
        let next = if steal == real {
            pack(next_real, next_real)
        } else {
            assert_ne!(steal, next_real);
            pack(steal, next_real)
        };
        let res = self.inner.head.compare_exchange_weak(head, next, AcqRel, Acquire);
        match res {
            Ok(_) => break real as usize & MASK,
            Err(actual) => head = actual,
        }
    };
    Some(self.inner.buffer[idx].with(|ptr| unsafe { ptr::read(ptr).assume_init() }))
}

📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:361-399

핵심 분기:steal == real(도둑 없음)이면 둘 다 진행하고, 그렇지 않으면 real만 진행하고 steal은 그대로 유지📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:377-384。assert_ne!(steal, next_real)real이 steal 위치까지 진행되지 않도록 보장합니다. 그렇지 않으면 도둑의 선점 상태가 깨집니다.

steal_into는 도둑 경로로, 먼저 대상 큐에 충분한 공간이 있는지 확인합니다:

rust
if dst_tail.wrapping_sub(steal) > LOCAL_QUEUE_CAPACITY as UnsignedShort / 2 {
    return None;
}

📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:431-435

대상 큐가 절반 이상 차면 도둑질하지 않아, 도둑질 후 즉시 다시 오버플로하는 것을 방지합니다.

steal_into2는 도둑질의 핵심으로, 도둑질 수량을 계산합니다:

rust
let n = src_tail.wrapping_sub(src_head_real);
let n = n - n / 2;

📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:487-488

절반(올림)을 도둑질합니다. 그런 다음 head의 steal 값을 CAS로 갱신하여 선점:

rust
let steal_to = src_head_real.wrapping_add(n);
next_packed = pack(src_head_steal, steal_to);
let res = self.0.head.compare_exchange_weak(prev_packed, next_packed, AcqRel, Acquire);

📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:496-506

여기서는 real 값만 갱신되었음에 주의(pack(src_head_steal, steal_to)에서 steal은 변경되지 않음), real을steal_to로 진행시킵니다. 이는 「이 작업들이 선점되었으니 다른 도둑은 더 이상 건드릴 수 없음」을 나타냅니다. 도둑질 완료 후 steal을 real까지 따라잡게 합니다:

rust
loop {
    let head = unpack(prev_packed).1;
    next_packed = pack(head, head);
    let res = self.0.head.compare_exchange_weak(prev_packed, next_packed, AcqRel, Acquire);
    match res {
        Ok(_) => return n,
        Err(actual) => prev_packed = actual,
    }
}

📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:548-561

아래 시퀀스 다이어그램은 「생산자 push, 소비자 pop, 도둑 steal」 삼자 동시 상호작용을 묘사합니다:

mermaid
sequenceDiagram
    participant P as "Worker A (生产者)"
    participant Q as "Local 队列 Inner"
    participant C as "Worker A (消费者 pop)"
    participant S as "Worker B (窃取者)"

    P->>Q: "load head (Acquire)"
    P->>Q: "unsync_load tail"
    Note over P: "tail - steal < 256?"
    P->>Q: "push_back_finish: buffer[idx] = task"
    P->>Q: "store tail+1 (Release)"

    C->>Q: "load head (Acquire)"
    C->>Q: "unsync_load tail"
    Note over C: "real == tail? 空则返回 None"
    C->>Q: "CAS head: pack(real+1, real+1)"
    Q-->>C: "Ok, 读取 buffer[real & MASK]"

    S->>Q: "load head (Acquire)"
    S->>Q: "load tail (Acquire)"
    Note over S: "src_head_steal != src_head_real? 返回 0"
    S->>Q: "CAS head: pack(steal, real+n) 认领一半"
    Q-->>S: "Ok, 拷贝 n 个任务到 dst"
    S->>Q: "CAS head: pack(real+n, real+n) 完成窃取"
    Q-->>S: "返回 n"

설계 사고: 왜 로컬 큐는 LIFO이고 도둑질은 FIFO인가

〔설계 추론과 아키텍처 트레이드오프〕

worker 자신은 헤드에서 가져옵니다(LIFO). 가장 최근에 푸시된 작업이 CPU 캐시에 아직 남아 있을 가능성이 가장 높고, 「방금 깨어나 데이터가 아직 뜨거운」 작업일 가능성이 가장 높기 때문입니다. 도둑은 테일에서 가져옵니다(FIFO). 가장 오래된 작업이 이미 대부분의 작업을 완료했을 가능성이 가장 높아, 그것을 도둑질하면 피해자의 부하를 가장 빠르게 줄일 수 있기 때문입니다. 이 「LIFO 로컬 + FIFO 도둑질」 조합은 work-stealing 스케줄링의 고전적 설계로, 캐시 지역성과 부하 균형을 모두 고려합니다.

여기까지 작업은 Future에서 스케줄 가능한 실체로의 변신을 완료했습니다: 참조 카운트가 할당되고,Cell의 메모리 레이아웃에 들어가, worker의 로컬 큐나 전역 주입 큐에 성공적으로 전달되었습니다. 하지만 작업이 큐에 들어가는 것은 시작일 뿐, 실제로 그것을 움직이게 하는 것은 worker 스레드의 스케줄링 루프입니다. 다음 장에서는 「작업의 일생」 하반부로 들어가, worker가 큐에서 작업을 꺼내Future::poll을 호출하고,Pending반환 시Waker을 통해 깨우기를 등록하여 최종적으로schedule재입대를 트리거하는——「깨우기 → 입대 → 재 poll」이라는 이闭环의 완전한 호출 경로, 그리고 work-stealing 전략과 LIFO 슬롯 최적화가 거기서 밝혀질 것입니다.

모든 코드베이스를 진정으로 이해할 수 있는 책으로

이 장을 다 읽으셨나요? 내 프라이빗 저장소를 위한 아키텍처 책 만들기

Tauri 2 + Rust 로컬 퍼스트 아키텍처. 100% 오프라인 보안, 클라우드 코드 업로드 없음. 불변 커밋 라인 앵커로 정독.

⚡ Tauri 2 · Rust 네이티브 코어 · 100% 오프라인 보안 · 100만 행 이상 검증

CHAPTER 04

제 4 장: 작업의 일생(하): 스케줄링 루프, poll과 깨우기의 闭环

Upstream: tokio-rs/tokio · Commit @e800714a · 진행률: 제 4 장 / 총 14 장

이전 장에서 우리는 작업을Local큐나 전역 주입 큐에 넣었습니다. 하지만 큐는 「할 일 목록」일 뿐, 실제로 작업을 돌게 하는 것은 worker 스레드 안의 멈추지 않는 루프입니다. 이 장에서는Context::run——전체 멀티스레드 스케줄러의 심장——을 추적합니다.

먼저 직관을 세웁니다: worker 스레드는 요리사와 같아, 앞에 자신의 주문 더미(run_queue)가 있고, 옆에 공용 주문 선반(inject)도 있습니다. 요리사는 먼저 자신 손에 가장 가까운 한 장을 봅니다(lifo_slot), 없으면 자기 더미에서 가져오고, 그것도 없으면 공용 선반에서 한 줌 집어오고, 그래도 안 되면 다른 요리사의 더미에서 몇 장 훔쳐온다. 전부 비었을 때만 휴식을 취하는데, 휴식 중에도 귀를 세우고 있어서—주문이 들어오면 즉시 깨어난다.

이 루프가 없으면, 태스크는 큐에 들어간 후 영원히 큐에 누워 있게 되고,Future::poll영원히 호출되지 않으며, 전체 런타임은 죽은 데이터 덩어리에 불과하다.

Core의 메모리 레이아웃과 상태 필드

worker의 가변 상태는 전부Core안에 들어 있으며, 이것은Box에 의해 힙에 할당되고,AtomicCell<Core>를 통해Worker와 스레드 로컬Context사이에서 전달된다.

Core의 핵심 필드는 다음과 같다📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:113-167:

  • tick: u32: 매 루프마다 자동 증가하며, 주기적으로 유지보수(maintenance)와 전역 큐 검사를 트리거하는 데 사용된다.
  • lifo_slot: Option<Notified>:LIFO 슬롯, 이것이 이 장에서 가장 정교한 설계다. worker가 스스로 태스크를 스케줄할 때, 그것은run_queue에 들어가지 않고 이 슬롯에 넣으며, 다음에 태스크를 가져올 때우선적으로여기서 꺼낸다.
  • lifo_enabled: bool: LIFO 슬롯의 스위치로, ping-pong 시나리오에서의 기아를 방지하는 데 사용된다.
  • run_queue: queue::Local<Arc<Handle>>: 로컬 큐, 이전 장에서 분석한Local구조.
  • is_searching: bool: worker가 훔칠 수 있는 태스크를 검색 중인지 여부.
  • is_shutdown: bool / is_traced: bool: 종료 및 추적 플래그.
  • park: Option<Parker>: parker,Option로 감싼 것은 borrow checker 아래에서 편리하게 꺼내고/되돌려 놓기 위해서다.
  • global_queue_interval: u32: 전역 큐를 얼마나 자주 검사할지.
  • rand: FastRand: 빠른 난수 생성기, 훔치기 시작점을 무작위로 선택하는 데 사용된다.
〔설계 추론과 아키텍처 트레이드오프〕

주목하라,lifo_slot는Option<Notified>이지 큐가 아니다—그것은 단지하나의태스크만 저장한다. 이 설계 동기는 소스 주석에 명확히 나와 있다📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:117-121: worker가 스스로 스케줄한 태스크는 이 슬롯에 저장되고, worker는run_queue 을 검사하기 전에먼저 이것을 검사하며, 효과는 「마지막으로 스케줄된 태스크가 다음에 실행된다」(LIFO)이다. 이는 지역성을 개선하기 위한 것으로, 메시지 전달 패턴에 특히 효과적이며 지연을 낮춘다.

왜 LIFO가 지연을 낮추는가? 전형적인 메시지 전달 시나리오를 고려하자: 태스크 A가 메시지를 처리한 후 태스크 B를 깨우고, B가 처리한 후 다시 A를 깨운다. A가 B를 깨운 후 B가 즉시 실행되면, B가 필요로 하는 데이터는很可能 아직 CPU 캐시에 남아 있을 것이다(A가 방금 건드렸으므로). B가 큐 꼬리에 밀려나 앞의 수십 개 태스크가 실행될 때까지 기다리면, 캐시는 이미 밀려나 버린다.

그러나 LIFO에는 기아 위험이 있다. 소스는MAX_LIFO_POLLS_PER_TICK = 3를 사용하여📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:263-263을 제한한다: 각 tick마다 최대 3번 LIFO 슬롯을 우선하며, 초과하면 비활성화하여 다른 태스크가 실행될 기회를 갖게 한다.

메인 루프 walkthrough: 하나의 완전한 스케줄링 주기

구체적인 시나리오를 대입해 보자: worker 0이 방금park에서 깨어났고,run_queue에 5개의 태스크가 있으며,lifo_slot에 1개의 태스크가 있고, 전역 큐에 3개의 태스크가 있다.

메인 루프 진입점은Context::run 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:570-642이다. 먼저lifo_enabled을 재설정하고(core가block_in_place에 의해 훔쳐졌을 수 있으므로 상태를 원위치시켜야 함)📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:571-573, 그런 다음while !core.is_shutdown루프에 들어간다.

매 루프마다 네 가지를 한다:

첫 번째 단계: tick과 유지보수. core.tick()자동 증가 카운터📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:587. 이어서self.maintenance(core)가tick % event_interval == 0을 검사하고, 그렇다면park_yield를 호출하여 0 타임아웃으로 I/O와 타이머를 구동한다📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:809-826。

두 번째 단계: 태스크 가져오기. core.next_task(&self.worker)는 핵심 태스크 가져오기 로직이다📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1090-1156. 두 가지 경로로 나뉜다:

  • 当tick % global_queue_interval == 0일 때,우선적으로전역 큐에서 가져오고, 가져오지 못하면 로컬📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1091-1098을 가져온다. 이는 전역 큐의 태스크가 굶주리는 것을 방지하기 위해서다.
  • 그렇지 않으면우선적으로로컬 태스크를 가져온다📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1090-1156。

로컬 태스크 가져오기는next_local_task에 의해 완료된다📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1158-1160:

rust
fn next_local_task(&mut self) -> Option<Notified> {
    self.lifo_slot.take().or_else(|| self.run_queue.pop())
}

먼저 LIFO 슬롯을 가져오고, 그다음 큐 헤드를 가져온다(LIFO 팝). 이것이 이전 장에서 말한 「로컬 LIFO」다.

로컬이 비었지만 전역 큐가 비어 있지 않으면, worker는배치로전역 큐에서 태스크를 가져온다📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1110-1154. 배치 크기n의 계산은 매우 정교하다:min(inject.len() / remotes.len() + 1, cap), 여기서cap는 다시min(remaining_slots, max_capacity / 2)을 취한다. 소스 주석은 왜 큐 용량의 절반으로 제한하는지 설명한다📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1120-1131: 가져온 태스크가 로컬 큐의전반부에 떨어지도록 보장하여, 이후 오버플로가 발생하더라도 이 태스크들이 전역 큐로 다시 밀려나지 않도록 한다(오버플로는 후반부에만 영향을 미침).

세 번째 단계: 태스크 실행.태스크를 얻은 후run_task 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:647-796을 호출한다. 이것은 이 장에서 가장 복잡한 함수이며, 다음 절에서专门展开한다.

네 번째 단계: 훔치기 또는 park.만약next_task가None을 반환하면, 로컬과 전역 모두 할 일이 없다는 뜻이므로steal_work 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1167-1195을 호출한다. 훔치기 실패 시park또는park_yield 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:613-621。

에 들어간다. 전체 제어 흐름은 다음과 같다:

mermaid
flowchart TD
    start["Context::run 进入循环"] --> tick["core.tick() 自增"]
    tick --> maint{"tick % event_interval == 0?"}
    maint -->|是| park_yield["park_yield 驱动 I/O 与定时器"]
    maint -->|否| next
    park_yield --> next["core.next_task()"]
    next --> has_task{"取到任务?"}
    has_task -->|是| run_task["run_task 执行 poll"]
    run_task --> cont{"core 还在?"}
    cont -->|是| tick
    cont -->|否| ret["return 退出"]
    has_task -->|否| steal["core.steal_work()"]
    steal --> steal_ok{"窃取成功?"}
    steal_ok -->|是| run_task
    steal_ok -->|否| defer_check{"defer 非空?"}
    defer_check -->|是| py["park_yield"]
    defer_check -->|否| pk["park 阻塞等待"]
    py --> tick
    pk --> tick

run_task: poll과 LIFO 슬롯의 폐루프

run_task는 태스크가 실제로poll되는 곳이며, 「깨우기 → 인큐 → 재-poll」 폐루프의 수렴점이기도 하다.

함수에 들어간 후 첫 번째 일은assert_owner 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:648으로,Notified을Task로 변환하고, 동시에 현재 스레드가 действительно 이 태스크의 owner임을 단언한다(debug 단언).

이어서transition_from_searching 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:652—만약 worker가 이전에 검색 상태였다면, 이제 태스크를 찾았으므로 검색 상태를 종료하고, 다른 parked worker를 깨울 수 있다.

그다음은 핵심 budget 감싸기📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:695-795:

rust
coop::budget(|| {
    task.run();
    let mut lifo_polls = 0;
    loop {
        let mut core = match self.core.borrow_mut().take() {
            Some(core) => core,
            None => return ControlFlow::Break(()),
        };
        let task = match core.lifo_slot.take() {
            Some(task) => task,
            None => {
                self.reset_lifo_enabled(&mut core);
                core.stats.end_poll();
                return ControlFlow::Continue(core);
            }
        };
        if !coop::has_budget_remaining() {
            core.run_queue.push_back_or_overflow(task, ...);
            return ControlFlow::Continue(core);
        }
        lifo_polls += 1;
        if lifo_polls >= MAX_LIFO_POLLS_PER_TICK {
            core.lifo_enabled = false;
        }
        let task = self.worker.handle.shared.owned.assert_owner(task);
        *self.core.borrow_mut() = Some(core);
        task.run();
    }
})

이 코드는 LIFO 슬롯의 완전한 폐루프를 드러낸다:task.run()가Future::poll를 실행하고, poll 과정에서 태스크가 자신이나 다른 태스크를 깨우면,schedule_local가 새 태스크를lifo_slot 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1396-1408에 넣는다. poll이 반환된 후 루프는 즉시lifo_slot를 검사하고, 태스크가 있으면 계속 실행한다—메인 루프로 돌아가지 않고, 같은 budget 내에서 연속 poll한다.

이것이 LIFO 경로에서의 「깨우기 → 인큐 → 재-poll」의 구현이다: 깨울 때 태스크가lifo_slot에 들어가고, poll 반환 후 즉시 꺼내져 재-poll되어 긴밀한 폐루프를 형성한다.

주목하라,self.core.borrow_mut().take()의None분기📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:716-724: 만약 core가 훔쳐졌다면(예: 태스크 내에서block_in_place를 호출한 경우), worker는 반드시ControlFlow::Break(())를 반환하여Context::run가 종료되도록 해야 한다. 이것은block_in_place스케줄링 루프와의 상호작용 지점.

깨우기 경로: Waker가 어떻게 재큐잉을 트리거하는가

当Future::poll가Pending를 반환할 때, 태스크는Waker를 등록해야 하며, 이벤트가 준비되면 깨어납니다. Tokio의Waker구현은 극도로 간결합니다——태스크Header를 가리키는 원시 포인터와 vtable 하나일 뿐입니다.

waker_ref를 구성하고,WakerRef 📎 tokio/src/runtime/task/waker.rs:11-34로ManuallyDrop를 감싸서Wakerdrop 시 참조 카운트가 감소하는 것을 방지합니다. vtable은 정적📎 tokio/src/runtime/task/waker.rs:119-119:

rust
static WAKER_VTABLE: RawWakerVTable =
    RawWakerVTable::new(clone_waker, wake_by_val, wake_by_ref, drop_waker);

네 함수 모두 원시 포인터를Header로 복원한 다음,RawTask의 해당 메서드를 호출합니다📎 tokio/src/runtime/task/waker.rs:70-116. 예를 들어wake_by_ref는 최종적으로raw.wake_by_ref() 📎 tokio/src/runtime/task/waker.rs:106-116。

wake_by_ref를 호출합니다. 그 의미는: 태스크 상태를PENDING에서SCHEDULED로 전환하고, 전환이 성공하면(즉, 이전에 실제로 PENDING이었다면),Schedule::schedule를 호출하여 태스크를 다시 큐에 넣습니다.

멀티스레드 스케줄러의 경우,schedule의 구현은Handle::schedule_task 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1353-1376:

rust
pub(super) fn schedule_task(&self, task: Notified, is_yield: bool) {
    with_current(|maybe_cx| {
        if let Some(cx) = maybe_cx {
            if self.ptr_eq(&cx.worker.handle) {
                if let Some(core) = cx.core.borrow_mut().as_mut() {
                    self.schedule_local(core, task, is_yield);
                    return;
                }
            }
        }
        self.push_remote_task(task);
        self.notify_parked_remote();
    });
}

로직이 두 갈래로 나뉩니다:

  • 현재 스레드가 이 스케줄러의 worker이고 core를 보유하고 있다면,schedule_local 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1385-1417를 통해——LIFO 슬롯이나 로컬 큐에 넣습니다.
  • 그렇지 않으면(외부 스레드에서 깨우거나 core가 도난당한 경우),push_remote_task를 통해 전역 주입 큐에 푸시하고,notify_parked_remote로 parked worker를 깨웁니다📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1379-1383。

schedule_local내부적으로 다시 두 갈래로 나뉩니다📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1385-1417: 만약yield이거나 LIFO가 비활성화된 경우,run_queue의 꼬리에 푸시하고; 그렇지 않으면lifo_slot에 넣고, 원래 슬롯에 있던 태스크를 큐 꼬리로 밀어냅니다.

mermaid
sequenceDiagram
    participant Future as "Future::poll"
    participant Waker as "Waker(wake_by_ref)"
    participant RawTask as "RawTask::wake_by_ref"
    participant Handle as "Handle::schedule_task"
    participant Core as "Core(schedule_local)"
    participant Inject as "InjectQueue"
    participant Parker as "Unparker"

    Future->>Waker: "返回 Pending, 注册 waker"
    Note over Future: "事件就绪(如 epoll)"
    Waker->>RawTask: "raw.wake_by_ref()"
    RawTask->>RawTask: "state: PENDING -> SCHEDULED"
    RawTask->>Handle: "schedule(Notified)"
    alt 当前线程是同一 worker 且持有 core
        Handle->>Core: "schedule_local: 放入 lifo_slot"
    else 外部线程或 core 被偷走
        Handle->>Inject: "push_remote_task"
        Handle->>Parker: "notify_parked_remote().unpark()"
    end

park와 unpark: 상태 머신과 깨우기의 원자성

worker가 할 일이 없을 때 park해야 하지만, park/unpark는 경쟁 조건이 가장 쉽게 발생하는 곳입니다. Tokio는AtomicUsize상태 머신과Condvar를 보완책으로 사용하여 해결합니다.

Inner의 필드📎 tokio/src/runtime/scheduler/multi_thread/park.rs:31-43:state: AtomicUsize、mutex: Mutex<()>、condvar: Condvar、shared: Arc<Shared>. 상태 상수는 네 개📎 tokio/src/runtime/scheduler/multi_thread/park.rs:36-45:

  • EMPTY = 0: park되지 않음.
  • PARKED_CONDVAR = 1: condvar에서 park.
  • PARKED_DRIVER = 2: I/O driver에서 park.
  • NOTIFIED = 3: 이미 깨어남.

이것은 명시적 상태 머신이며, 우리는 이를 사용하여 상태 다이어그램을 그립니다(이것이 이 장에서stateDiagram-v2진입 조건에 부합하는 유일한 곳입니다——소스 코드에 실제로 이 네 가지 상태 상수가 존재합니다):

mermaid
stateDiagram-v2
    [*] --> Empty
    Empty --> ParkedCondvar : "park_condvar() CAS(EMPTY->PARKED_CONDVAR)"
    Empty --> ParkedDriver : "park_driver() CAS(EMPTY->PARKED_DRIVER)"
    Empty --> Notified : "unpark() swap(NOTIFIED)"
    ParkedCondvar --> Empty : "condvar 唤醒后 CAS(NOTIFIED->EMPTY)"
    ParkedCondvar --> Empty : "超时 swap(EMPTY)"
    ParkedDriver --> Empty : "driver 返回后 swap(EMPTY)"
    Notified --> Empty : "park() CAS(NOTIFIED->EMPTY) 消费通知"
    Notified --> Notified : "再次 unpark() swap(NOTIFIED)"

unpark의 구현📎 tokio/src/runtime/scheduler/multi_thread/park.rs:277-290은swap를 사용하고 CAS가 아닙니다. 소스 코드 주석에서 그 이유를 설명합니다📎 tokio/src/runtime/scheduler/multi_thread/park.rs:277-290: park 스레드가 unpark 이전의 쓰기를 관찰할 수 있도록 release 연산을 수행해야 하므로, state가 이미NOTIFIED이더라도 한 번 써야 합니다.

park은 먼저 기존 알림을 소비하려고 시도합니다📎 tokio/src/runtime/scheduler/multi_thread/park.rs:132-149: 만약 CASNOTIFIED -> EMPTY가 성공하면, 이전에 이미 깨어났음을 의미하므로 블로킹 없이 직접 반환합니다. 그렇지 않으면 driver 잠금을 시도하고, 획득하면 driver에서 park하고, 획득하지 못하면 condvar를 보완책으로 사용합니다📎 tokio/src/runtime/scheduler/multi_thread/park.rs:143-148。

park_condvar에는 고전적인 이중 검사📎 tokio/src/runtime/scheduler/multi_thread/park.rs:162-180가 있습니다EMPTY -> PARKED_CONDVAR: 먼저 CASNOTIFIED, 만약 실패하고swap(EMPTY)라면, 상태를 설정하기 전에 깨어났음을 의미하므로, 이때 반드시📎 tokio/src/runtime/scheduler/multi_thread/park.rs:167-177를 통해 unpark의 쓰기를 동기화해야 합니다NOTIFIED. 주석에서 특히 강조합니다:NOTIFIED임을 알고 있더라도 반드시 한 번 읽어야 합니다. 왜냐하면 unpark가 우리가

unpark_condvar를 읽은 후에 다시 호출되었을 수 있기 때문입니다.📎 tokio/src/runtime/scheduler/multi_thread/park.rs:292-307의 주석PARKED은 condvar의 고전적인 함정을 지적합니다: parked 스레드가wait상태를 설정하는 것과 실제로mutex사이에 윈도우 기간이 있으며, 이 기간 동안 notify가 발생하면 무시됩니다. 해결책은 park 스레드가 이때drop(self.mutex.lock())를 보유하고, unpark 스레드가 먼저notify_one。

로 잠금을 획득하여(park 스레드가 해제할 때까지 대기), 그런 다음

설계 사고: 왜 LIFO 슬롯이 큐가 아닌 단일 슬롯인가

〔설계 추론과 아키텍처 트레이드오프〕

MAX_LIFO_POLLS_PER_TICK = 3단일 슬롯 설계는 의도적인 트레이드오프입니다. 큐를 사용하면 매번 깨울 때마다 큐에 넣고, 매번 태스크를 가져올 때마다 큐에서 꺼내야 하므로 오버헤드가 더 큽니다; 또한 큐는 여러 태스크를 축적하여 "가장 최근에 깨어난 것이 가장 먼저 실행된다"는 지역성 가정을 깨뜨립니다. 단일 슬롯의 의미는 "가장 최근 하나만 기억한다"이며, 밀려난 태스크는 일반 큐로 들어갑니다——이는 지역성 수익 체감의 법칙에 정확히 부합합니다: 가장 최근 태스크가 가장 뜨겁고, 두 번째가 그 다음이며, 세 번째부터는 수익이 매우 작아집니다.📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:263-263이 매직 넘버

도 경험값입니다. 소스 코드 주석에 따르면 "LIFO 슬롯을 몇 번 실행하는 것만으로도 지역성 이점을 누리기에 충분해 보이며, 3회를 초과하면 과도하게 가중될 수 있다"고 합니다. 이는 A가 B를 깨우고, B가 A를 깨우는 핑퐁 시나리오가 다른 태스크를 기아 상태에 빠뜨리는 것을 방지합니다.steal_work또 다른 주목할 만한 설계는📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1158-1160의 "절반 검색" 전략transition_to_searching입니다idle.transition_worker_to_searching(): worker의 절반 미만이 검색 중일 때만 새 worker가 실제로 훔치기를 시도합니다. 이는 모든 worker가 동시에 미친 듯이 훔치려고 시도하여 발생하는 CAS 경쟁을 방지합니다.📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1197-1203。

은📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1172-1174를 통해📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1179-1182를 조정합니다steal_into훔치기는 무작위 시작점에서 시작하여📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1197-1203。

, 모든 remote를 순회하고, 자신을 건너뛰고

,Context::run를 호출하여 훔치기를 시도합니다. 모두 실패하면 전역 큐로 폴백합니다run_task이 장 요약run_taskworker 메인 루프Waker는 스케줄러의 심장입니다: 매 라운드 tick 후 먼저 태스크를 가져오고(LIFO 슬롯 → 로컬 큐 → 전역 큐), 가져오면wake_by_ref를 실행하여 poll하고, 가져오지 못하면 훔치고, 훔치기 실패하면 park합니다.schedule내부의 LIFO 루프는 "깨우기 → 큐잉 → 재poll"을 동일한 budget 내에 압축하여 저지연 폐루프를 형성합니다.park/unpark는 원시 포인터와 정적 vtable이며,

는 상태 전환을 통해Waker를 트리거하고, 현재 스레드가 동일한 worker인지에 따라 로컬 큐로 갈지 전역 큐로 갈지 결정합니다.AsyncFd는 4상태 원자 머신과 condvar 보완책을 사용하여 깨우기 손실의 고전적인 경쟁 조건을 해결합니다.Pending다음 장에서 우리는 스케줄러를 떠나 I/O 세계로 들어갑니다: Reactor가 어떻게 epoll 이벤트를Ready。

깨우기로 변환하여

의next_local_task를run_queue다시 가져오면lifo_slot, 메시지 전달이密集한 시나리오에서 어떤 결과가 발생하는가?

참고 해석:next_local_task현재 구현은self.lifo_slot.take().or_else(|| self.run_queue.pop()) 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1158-1160, LIFO 슬롯을 먼저 가져온다. 만약 반대로 먼저 가져온다면run_queue, 방금 깨어나고 데이터가 아직 뜨거운 작업이 큐의 다른 작업 뒤에 밀려 실행된다. A→B→A 메시지 전달 패턴에서 B는 깨어난 후 즉시 실행되지 않고 큐의 다른 작업이 끝날 때까지 기다리며, 이때 A가 쓴 데이터는 이미 CPU 캐시에서 밀려나 지역성 이점을 잃는다. 더 심각한 것은,lifo_slot의 작업이run_queue이 비워질 때까지 계속 기다려 지연이 현저히 증가한다. 소스 주석📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:117-121은 이 순서가 「지역성을 개선하고 메시지 전달 패턴의 이점을 누리며 지연을 낮추기 위함」이라고 명확히 밝힌다.

Q2: park_condvar에서 만약Err(NOTIFIED)분기의self.state.swap(EMPTY, SeqCst)을 제거하고return만 유지하면 어떤 문제가 발생하는가?

참고 해석: 소스는Err(NOTIFIED)분기에서let old = self.state.swap(EMPTY, SeqCst) 📎 tokio/src/runtime/scheduler/multi_thread/park.rs:167-177을 실행한다. 주석은📎 tokio/src/runtime/scheduler/multi_thread/park.rs:168-173을 설명한다: unpark는 우리가NOTIFIED을 읽은 후 다시 호출될 수 있으므로, 그 unpark와 동기화하는 acquire 연산을 한 번 수행해야 그 이전의 모든 쓰기를 관찰할 수 있다. 만약return만 하고 swap하지 않으면 state는NOTIFIED에 머물고, 다음 park 시 CASNOTIFIED -> EMPTY이 성공하여 즉시 반환된다(이미 만료된 알림을 소비). 하지만 더 나쁜 것은 unpark의 release 쓰기가 동기화되지 않아 park 스레드가 unpark 이전에 쓴 데이터를 보지 못해 메모리 가시성 문제가 발생한다. 이는 전형적인 「깨진 깨우기 + 메모리 순서」 이중 버그다.

Q3: run_task에서self.core.borrow_mut().take()이None을 반환할 때 왜ControlFlow::Break(())이 아니라Continue?

을 반환하는가?:self.core.borrow_mut().take()참고 해석None이📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:716-724을 반환한다는 것은 core가 이미 훔쳐졌다는 의미다block_in_place. core가 훔쳐지는 유일한 경로는 작업 내부에서maybe_move_runtime을 호출하는 것이며, 이는cx.core을 통해 core를📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:473-497에서 꺼내 새 스레드Continue,Context::run에게 넘긴다. 이때 현재 스레드는 더 이상 스케줄링 능력을 보유하지 않으며, 만약core.next_task()을 반환하면 계속 루프를 돌며self.core등 core가 필요한 메서드를 호출하지만 core는 이미Break에 없으므로 panic 또는 상태 불일치가 발생한다.Context::run을 반환하면return 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:594-597이 직접run하여 제어권을cx.defer.wake() 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:564함수에 돌려주고, 그것이 후속(예:📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:719-721)을 처리한다. 주석도reset_lifo_enabled을 설명한다: 이때Context::run을 호출할 수 없는데, core가 훔쳐졌고 훔친 자가

모든 코드베이스를 진정으로 이해할 수 있는 책으로

이 장을 다 읽으셨나요? 내 프라이빗 저장소를 위한 아키텍처 책 만들기

Tauri 2 + Rust 로컬 퍼스트 아키텍처. 100% 오프라인 보안, 클라우드 코드 업로드 없음. 불변 커밋 라인 앵커로 정독.

⚡ Tauri 2 · Rust 네이티브 코어 · 100% 오프라인 보안 · 100만 행 이상 검증

CHAPTER 05

제 5 장: I/O 준비 알림: Reactor가 epoll 이벤트를 Waker 깨우기로 변환하는 방법

Upstream: tokio-rs/tokio · Commit @e800714a · 진행률: 제 5 장 / 총 14 장

이전 장에서 우리는 worker 스레드의 메인 루프를 추적했다: 작업이 poll되고, Pending을 반환하면 Waker를 어딘가에 저장하고, 이벤트가 준비되면 Waker가 트리거되어 작업이 다시 큐에 들어간다. 하지만 「어딘가」는 대체 어디인가? Waker는 epoll 이벤트가 도착할 때 어떻게 다시 찾아지는가? 이것이 바로 Reactor가 답해야 할 질문이다. 먼저 직관적 모델을 세우자: 전체 I/O 준비 알림 메커니즘을 식당의 진동벨 호출 시스템이라고 상상하자——손님(작업)은 주문 후 창구에서 죽치고 기다리지 않고 진동벨(Waker)을 받아 자리로 돌아간다; 주방(커널 epoll)이 음식을 완성하면 프런트(Reactor)가 주문 번호(Token)로 해당 진동벨을 찾아 버튼을 누른다. 이 시스템이 없다면 각 작업은 소켓을 폴링해야 하고 CPU가 타버리거나, 블로킹 스레드로 기다려 연결당 스레드 하나로 규모가 커지지 않는다. Tokio의 Reactor는 세 파일로 세 계층 구조를 이루며 책임이 엄격히 분리된다: driver.rs는 이벤트 루프 본체로 mio::Poll을 보유하고 poll()을 호출해 커널 이벤트를 블로킹 대기하며 이벤트를 ScheduledIo의 읽기/쓰기로 변환한다; registration.rs는 사용자 대상 등록 핸들로 TcpStream 내부가 이것을 보유하며 poll_read_ready / poll_write_ready 등의 API를 제공한다; scheduled_io.rs는 각 fd의 상태 슬롯으로 읽기/쓰기 준비 비트와 Waker 목록을 저장하며 이벤트와 작업 사이의 다리다. 모듈 조립 관계는 tokio/src/runtime/io/mod.rs:5-16을 참고하라: driver는 Driver, Handle, ReadyEvent를 내보내고, registration은 Registration을, scheduled_io는 ScheduledIo를 내보낸다. 아래 그림은 이 장에서 추적할 전체 데이터 흐름을 앵커링한다: TcpStream → Registration → ScheduledIo → Handle/Driver → 커널 → ScheduledIo로 복귀 → Waker. 이제 계층별로 분해한다.

드라이버 계층:Driver과Handle의 책임 분할

직관적 모델

Driver은유일하게mio::Poll을 보유한 엔티티이며, 단일 스레드에서만&mut접근할 수 있다——이것이 이벤트 루프의 독점성 요구다. 반면Handle은복제 가능하고 스레드 간 공유 가능한 등록 진입점, 어떤 스레드든 새 fd를 등록하려면 이를 통한다. 만약 이 분할이 없다면,要么mio::Poll에 락을 걸거나(매 등록마다 경쟁),要么 모든 등록을 driver 스레드로 되돌려야 한다(스레드 간 메시지 큐 도입). Tokio는Handle이 직접mio::Registry의 클론을 보유하도록 선택하여, 등록 작업은 동시에 진행될 수 있고, 실제 이벤트 대기만 독점이 필요하다.

메모리 레이아웃과 필드

먼저Driver의 필드📎 tokio/src/runtime/io/driver.rs:25-38:

  • signal_ready: bool를 보자: Unix 시그널 이벤트 도착 여부로, signal 구동에 사용된다.
  • events: mio::Events: 메인 이벤트 버퍼로,turn호출에 걸쳐 재사용되어 매번 할당을 피한다.
  • events_busy: Option<mio::Events>:논블로킹 poll 전용 버퍼,max_io_events_per_busy_tick이 설정된 경우에만 존재한다.
  • poll: mio::Poll: 커널 이벤트 큐의 래퍼.

다음으로Handle 📎 tokio/src/runtime/io/driver.rs:41-75:

  • registry: mio::Registry:mio::Poll::registry()의 클론을 보자,register/deregister。
  • registrations: RegistrationSet에 사용된다: 모든 활성 등록의 집합으로,Token과ScheduledIo。
  • synced: Mutex<registration_set::Synced>할당을 담당한다RegistrationSet의 동기화 상태를 보호한다.
  • waker: mio::Waker: 임의의 스레드에서turn에 블로킹된 driver를 깨우는 데 사용된다.
  • metrics: IoDriverMetrics: fd 수, 준비된 이벤트 수를 통계한다.

여기에는 핵심 설계가 있다:events_busy의 존재📎 tokio/src/runtime/io/driver.rs:25-38는논블로킹 poll이 이벤트를 삼켜버리는문제를 해결하기 위한 것이다. 주석📎 tokio/src/runtime/io/driver.rs:189-190에 명확히 나와 있다: 논블로킹 poll이 가져간 이벤트가 메인 버퍼에 남아 있으면 다음 poll에서 보이지 않는다; 별도 버퍼를 사용하면 처리되지 않은 이벤트가 여전히 커널 큐에 남아 다음 poll에서 다시 반환된다.

Step-by-Step: 한 번의turn실행

turn은 driver의 핵심 함수📎 tokio/src/runtime/io/driver.rs:184-261이다. worker 스레드가 실행할 작업이 없다고 판단하여park → turn(handle, None)을 호출해 블로킹 대기한다고 가정하자:

첫 번째 단계: shutdown되지 않았음을 단언📎 tokio/src/runtime/io/driver.rs:185하고, 정리할 등록📎 tokio/src/runtime/io/driver.rs:187。release_pending_registrations을 해제한다.needs_release()을 확인하고, 있으면registrations.release() 📎 tokio/src/runtime/io/driver.rs:336-340。

을 호출한다두 번째 단계📎 tokio/src/runtime/io/driver.rs:191-194: 이벤트 버퍼 선택max_wait. 만약events_busy이 0이고

이 존재하면 busy 버퍼를 사용하고, 그렇지 않으면 메인 버퍼를 사용한다.세 번째 단계self.poll.poll(events, max_wait) 📎 tokio/src/runtime/io/driver.rs:198:Interrupted을 호출한다. 이것이 실제로 epoll_wait에 블로킹되는 곳이다. 에러 처리는 매우 절제되어 있다:📎 tokio/src/runtime/io/driver.rs:200은 직접 무시한다(시그널에 의한 중단은 정상)InvalidInput, WASI에서의📎 tokio/src/runtime/io/driver.rs:201-205도 무시📎 tokio/src/runtime/io/driver.rs:206。

, 다른 에러는 직접 panic네 번째 단계📎 tokio/src/runtime/io/driver.rs:211-233: 이벤트 순회event:

  • . 각token == TOKEN_WAKEUP에 대해📎 tokio/src/runtime/io/driver.rs:214만약unpark(값이 0)
  • 이면, 아무것도 하지 않는다——이것은token == TOKEN_SIGNAL이 블로킹을 중단하는 데 사용된다.📎 tokio/src/runtime/io/driver.rs:216만약signal_ready = true。
  • (값이 1)📎 tokio/src/runtime/io/driver.rs:218-231이면,mio::Ready을 설정한다Ready그렇지 않으면 일반 I/O 이벤트EXPOSE_IO.from_exposed_addr(token.0):*const ScheduledIo을 Tokio의set_readiness(Tick::Set, |curr| curr | ready)로 변환하고,io.wake(ready)을 사용해 token을Waker。

포인터로 복원한 다음,EXPOSE_IO이 준비 비트를 누적하고,PtrExposeDomain<ScheduledIo> 📎 tokio/src/runtime/io/mod.rs:21-22이 해당 방향의usize을 트리거한다mio::Token여기서📎 tokio/src/runtime/io/driver.rs:222-225은으로, 포인터를로 「노출」시켜Arc<ScheduledIo>로 사용한다. 안전성 주석

은 이 unsafe 변환이 왜 안전한지 설명한다: 포인터는 mio에서 등록 해제되고且📎 tokio/src/runtime/io/driver.rs:235-258driver가 더 이상 동시에 poll하지 않기 전까지 해제되지 않으며, driver가

의 소유권을 보유한다.다섯 번째 단계📎 tokio/src/runtime/io/driver.rs:265-267。

mermaid
flowchart TD
    start["turn(handle, max_wait)"] --> assert["debug_assert!(!is_shutdown)"]
    assert --> release["release_pending_registrations()"]
    release --> pick{"max_wait == 0<br/>且 events_busy 存在?"}
    pick -->|是| busy["events = events_busy"]
    pick -->|否| main["events = events"]
    busy --> poll["poll.poll(events, max_wait)"]
    main --> poll
    poll --> pollres{"poll 返回?"}
    pollres -->|"Ok / Interrupted"| iter["遍历 events.iter()"]
    pollres -->|"其他 Err"| panic["panic!(unexpected error)"]
    iter --> tok{"event.token()?"}
    tok -->|"TOKEN_WAKEUP"| skip["忽略,仅用于打断阻塞"]
    tok -->|"TOKEN_SIGNAL"| sig["signal_ready = true"]
    tok -->|"普通 fd token"| cast["EXPOSE_IO.from_exposed_addr(token.0)"]
    cast --> setr["io.set_readiness(Tick::Set, curr | ready)"]
    setr --> wake["io.wake(ready)"]
    wake --> iter
    skip --> iter
    sig --> iter
    iter --> uring["dispatch_completions() (io-uring)"]
    uring --> metrics["metrics.incr_ready_count_by(ready_count)"]

, CQ 오버플로 시 flush 루프를 포함한다.Handle여섯 번째 단계mio::Waker

unpark 📎 tokio/src/runtime/io/driver.rs:280-283: metrics 누적self.waker.wake()복사mio::Waker설계 사고: 왜Driver::new이TOKEN_WAKEUP을 보유하고📎 tokio/src/runtime/io/driver.rs:124을 호출해야 하는가poll.poll(). 이unpark은TOKEN_WAKEUP시에poll을 사용해📎 tokio/src/runtime/io/driver.rs:214-215。

을 등록한다. driver가

에 블로킹되어 있을 때, 다른 스레드가deregister_source을 호출하면 epoll에📎 tokio/src/runtime/io/driver.rs:315-334이벤트를 넣고,registrations.deregister이 즉시 반환되며, 순회 시 이 token을 보면 직접 건너뛴다unpark()〔설계 추론 및 아키텍처 트레이드오프〕poll이 메커니즘은max_wait에서

에 사용된다: source를 등록 해제한 후, 만약deregister_source이 true를 반환하면(마지막 참조임을 나타냄),self.registry.deregister(source) 📎 tokio/src/runtime/io/driver.rs:322을 한다. 왜? driver가registrations 📎 tokio/src/runtime/io/driver.rs:315-334에 블로킹되어 이 fd의 이벤트를 기다리고 있을 수 있는데, fd가 이미 등록 해제되어 커널이 더 이상 이벤트를 생성하지 않기 때문이다; 반드시 driver를 능동적으로 깨워 등록 집합을 다시 확인하고 블로킹에서 벗어날 수 있게 해야 한다. 그렇지 않으면 driver는📎 tokio/src/runtime/io/driver.rs:320-321타임아웃까지 계속 잠들어 shutdown이 지연된다.📎 tokio/src/runtime/io/driver.rs:336-340또 다른 세부사항:이 먼저을 호출하고, 그다음

을 정리한다. 주석Registration에 「Cleanup ALWAYS happens」라고 나와 있다——OS 계층 deregister가 실패하더라도 내부 상태를 정리하고, 마지막에야 OS 에러Waker를 반환한다. 이것은 전형적인ScheduledIo

자원 정리가 에러 전파보다 우선

Registration패턴이다.등록 계층:이 어떻게scheduler::Handle을Arc<ScheduledIo>에 저장하는가poll_read_ready직관적 모델Registration은Waker작업과 fd 사이의 계약ScheduledIo이다. 그것은 두 가지를 보유한다: 하나는ScheduledIo(필요할 때 runtime에 접근하기 위함), 하나는Waker(fd의 상태 슬롯). 작업이

을 호출할 때,

Registration이📎 tokio/src/runtime/io/registration.rs:46-54:

  • handle: scheduler::Handle을📎 tokio/src/runtime/io/registration.rs:46-54에 맡겨 보관한다; driver가 이벤트를 받으면
  • shared: Arc<ScheduledIo>에서Arc을 꺼내 깨운다.
메모리 레이아웃과 필드

은 두 개의 필드만 있다Registration: runtime 핸들, 주석Send에 「TODO: this can probably be moved into ScheduledIo」라고 나와 있어, 작성자가 이 필드 위치가 최적화될 수 있다고 생각함을 보여준다.Sync 📎 tokio/src/runtime/io/registration.rs:57-58: 공유 상태,scheduler::Handle이 driver와 작업 모두 접근할 수 있음을 보장한다.Send/Sync〔설계 추론 및 아키텍처 트레이드오프〕Rc주목하라Registration이 수동으로📎 tokio/src/runtime/io/registration.rs:28-33과을 구현했다. 왜 unsafe impl이 필요한가?Registration내부에

Step-by-Step:poll_read_ready이 아닌 필드(예:

)를 포함할 수 있지만,TcpStream::poll_read에서 socket에 데이터가 없는 것을 발견하면 읽기 관심을 등록해야 합니다. 호출 체인은TcpStream::poll_read_priv → PollEvented::poll_read → Registration::poll_read_io → poll_io → poll_ready。

poll_ready는 핵심입니다📎 tokio/src/runtime/io/registration.rs:155-171:

첫 번째 단계:trace_leaf() 📎 tokio/src/runtime/io/registration.rs:160는 tracing埋点에 사용됩니다.

두 번째 단계:coop::poll_proceed(cx) 📎 tokio/src/runtime/io/registration.rs:155-171. 이것은 제12장에서 다룰 협력적 예산 메커니즘입니다. 예산이 소진되면Pending을 반환하고 특수한Waker을 등록하여 작업이 다음 라운드에서 다시 스케줄되도록 합니다.

세 번째 단계:self.shared.poll_readiness(cx, direction) 📎 tokio/src/runtime/io/registration.rs:155-171. 이것은 실제로ScheduledIo와 상호작용하는 곳입니다: 현재 준비 비트를 확인하고, 이미 준비되었다면 즉시Ready을 반환합니다. 그렇지 않으면cx.waker()을ScheduledIo의 해당 방향 슬롯에 저장하고Pending。

을 반환합니다네 번째 단계ev.is_shutdown 📎 tokio/src/runtime/io/registration.rs:155-171: 확인합니다RUNTIME_SHUTTING_DOWN_ERROR。

. runtime이 종료 중이면:coop.made_progress() 📎 tokio/src/runtime/io/registration.rs:169을 반환합니다

poll_io다섯 번째 단계poll_ready, 예산 소비를 표시하고 준비 이벤트를 반환합니다.📎 tokio/src/runtime/io/registration.rs:173-192:

rust
loop {
    let ev = ready!(self.poll_ready(cx, direction))?;
    match f() {
        Ok(ret) => return Poll::Ready(Ok(ret)),
        Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {
            self.clear_readiness(ev);
        }
        Err(e) => return Poll::Ready(Err(e)),
    }
}

위에 재시도 루프를 추가했습니다복사여기서poll_readyreadiness는 보장이 아닌 힌트라는read()핵심 사상을 보여줍니다:WouldBlock이 읽기 가능하다고 말하지만, 실제로clear_readiness(ev) 📎 tokio/src/runtime/io/registration.rs:187할 때

을 반환할 수 있습니다 (예를 들어 다른 스레드가 먼저 데이터를 읽어갔을 경우). 이때 반드시try_io준비 비트를 지우고 루프를 돌며 다시 기다려야 합니다. 지우지 않으면 작업은 '읽기 가능하다고 생각 → read 실패 → 다시 읽기 가능하다고 생각'하는 바쁜 루프에 빠집니다.async_io설계 고찰:

try_io 📎 tokio/src/runtime/io/registration.rs:194-213과ready_event(interest)의 분업WouldBlock 📎 tokio/src/runtime/io/registration.rs:194-213은 동기 버전입니다: 먼저f()준비 비트를 확인하고, 비어 있으면 직접f()을 반환합니다. 그렇지 않으면WouldBlock을 실행하고,📎 tokio/src/runtime/io/registration.rs:207-210이을 반환하면 준비 비트를 지웁니다. 이것은try_readWaker를 등록하지 않으며

async_io 📎 tokio/src/runtime/io/registration.rs:225-245과 같은 '한번 시도하고 떠나는' 시나리오에 적합합니다.readiness(interest).await는 비동기 버전입니다:f(),WouldBlock은 Waker를 등록하고 기다린 후,coop::poll_proceed 📎 tokio/src/runtime/io/registration.rs:233을 실행할 때 준비 비트를 지우고 루프를 돕니다. 루프 안에서WouldBlock도 호출하여 대량의

재시도에서 예산이 소진되는 것을 방지합니다.Drop프로덕션 함정:

Registration::drop 📎 tokio/src/runtime/io/registration.rs:253-262의 Waker 정리self.shared.clear_wakers()이📎 tokio/src/runtime/io/registration.rs:253-262을 호출합니다. 주석ScheduledIo이 이유를 설명합니다:Waker에 저장된Arc<driver::Inner>이driver::Inner을 보유할 수 있고,ScheduledIo이 다시Registration을 보유하여 순환 참조가 형성됩니다. Waker 정리는 순환을 끊는 수단입니다. 하지만 주석은 이것이 'imperfect solution'임을 인정합니다 — 만약Waker자체가

에 저장되면 순환은 여전히 존재합니다. 이것은 tokio-rs/tokio#3481에서 논의된 문제입니다.

〔설계 추론 및 아키텍처 트레이드오프〕clear_wakers프로덕션 환경에서의 동작은: 많은 연결이 drop되었지만 runtime이 종료되지 않으면, 다음ScheduledIo또는 runtime shutdown까지 메모리가 즉시 회수되지 않습니다. 장기 연결 서비스에서는 일반적으로 문제가 되지 않지만, 단기 연결이 빈번하게 생성/소멸되는 시나리오에서는

의 회수 시점을 주의해야 합니다.TcpStream::read에서Waker깨우기까지의 전체 체인

직관적 모델

이제 세 계층을 연결해 봅시다. 사용자가TcpStream에서.read().await을 호출하면, 실제로 실행되는 것은AsyncRead::poll_read → PollEvented::poll_read → Registration::poll_read_io입니다. 데이터가 도착하지 않았을 때,Waker이ScheduledIo에 저장됩니다. epoll이 읽기 가능을 보고하면, driver가ScheduledIo에서Waker을 꺼내 깨우고, 작업이 다시 스케줄되며, 다시 poll할 때poll_readiness이 준비 비트가 설정된 것을 발견하고 직접Ready,read()성공을 반환합니다.

Step-by-Step: 완전한 읽기 대기 한 번

1단계: 관심 등록。TcpStream::new 📎 tokio/src/net/tcp/stream.rs:166-169이PollEvented::new(connected)을 호출하고, 후자가 내부적으로Registration::new_with_interest_and_handle 📎 tokio/src/runtime/io/registration.rs:73-81을 호출하며, 이어서handle.driver().io().add_source(io, interest) 📎 tokio/src/runtime/io/registration.rs:73-81。

add_source 📎 tokio/src/runtime/io/driver.rs:288-312이 세 가지를 수행합니다:

1. registrations.allocate(&mut synced.lock())이ScheduledIo을 할당하고token 📎 tokio/src/runtime/io/driver.rs:293-294。

2. self.registry.register(source, token, interest.to_mio())을 얻습니다📎 tokio/src/runtime/io/driver.rs:298커널에을 등록합니다. 실패하면반드시ScheduledIo방금 할당한📎 tokio/src/runtime/io/driver.rs:300-303을 집합에서 제거해야 합니다

3. metrics.incr_fd_count(), 그렇지 않으면 누수됩니다.📎 tokio/src/runtime/io/driver.rs:309。

이을 카운트합니다TcpStream::poll_read 📎 tokio/src/net/tcp/stream.rs:1492-1498 → poll_read_priv 📎 tokio/src/net/tcp/stream.rs:1451-1458 → PollEvented::poll_read → Registration::poll_read_io 📎 tokio/src/runtime/io/registration.rs:133-139 → poll_io → poll_ready → ScheduledIo::poll_readiness2단계: 준비 대기Waker. 작업 pollScheduledIo. 이때 준비되지 않았다면,Pending。

이의 읽기 슬롯에 저장되고turn을 반환합니다poll.poll()3단계: 이벤트 도착📎 tokio/src/runtime/io/driver.rs:198. driver의io.set_readiness(Tick::Set, |curr| curr | ready)이io.wake(ready) 📎 tokio/src/runtime/io/driver.rs:228-229。wake에서 이벤트Waker을 가져오고, 순회하면서 각 fd 이벤트에 대해wake()。

을 실행하고。Waker::wake()내부에서 해당 방향의poll_readiness을 꺼내Ready,read()을 호출합니다

mermaid
sequenceDiagram
    participant Task as "任务 (worker 线程)"
    participant Reg as "Registration"
    participant SIO as "ScheduledIo"
    participant Drv as "Driver (I/O 线程)"
    participant OS as "epoll/kqueue"

    Task->>Reg: "poll_read_ready(cx)"
    Reg->>SIO: "poll_readiness(cx, Read)"
    SIO-->>Reg: "Pending (Waker 已存入读槽位)"
    Reg-->>Task: "Poll::Pending"
    Note over Task: 任务让出,worker 去跑别的任务
    Drv->>OS: "poll.poll(events, max_wait)"
    OS-->>Drv: "event(token=fd_ptr, READABLE)"
    Drv->>SIO: "set_readiness(Tick::Set, curr | READABLE)"
    Drv->>SIO: "wake(READABLE)"
    SIO->>Task: "Waker::wake() 重新入队"
    Note over Task: worker 再次 poll 该任务
    Task->>Reg: "poll_read_ready(cx)"
    Reg->>SIO: "poll_readiness(cx, Read)"
    SIO-->>Reg: "Ready(ReadyEvent{ready: READABLE})"
    Reg-->>Task: "Poll::Ready(Ok(ev))"
    Task->>Task: "read() 成功返回数据"

이 작업을 worker의 로컬 큐에 다시 넣습니다 (이전 장에서 설명). worker가 해당 작업을 다시 poll하면,assume_ready이 준비 비트가 설정된 것을 발견하고

TcpStream::new_accepted 📎 tokio/src/net/tcp/stream.rs:174-181성공을 반환합니다.accept복사new_accepted중요한 분기:assume_ready(Ready::READABLE | Ready::WRITABLE) 📎 tokio/src/net/tcp/stream.rs:174-181。

assume_ready최적화📎 tokio/src/runtime/io/registration.rs:103-105은 주목할 만한 최적화입니다.WouldBlock이 반환한 socket은 자연스럽게 쓰기 가능하며, 일반적으로 이미 상대방의 첫 번째 바이트를 보유하고 있습니다. driver의 첫 이벤트를 기다리면, 고부하에서 이 이벤트가 모든 설정된 연결의 이벤트 뒤에排队될 수 있어 지연이 발생합니다. 그래서WouldBlock,poll_io이 직접을 호출합니다. 주석은 이렇게 말합니다: 「A wrong guess costs one

, which clears the readiness again.」— 잘못 추측한 대가는 단 한 번의

루프가 준비 비트를 지우고 다시 기다리는 것입니다. 이것은

낙관적 추측 + 빠른 오류 수정Driver설계입니다.Driver설계 고찰: 왜 I/O 드라이버와 스케줄러가 분리되는가block_on〔설계 추론 및 아키텍처 트레이드오프〕Handle소스 구조에서 보면,

1. 과 worker 스레드는 분리되어 있습니다::Handle은 runtime의 특정 전용 위치 (일반적으로mio::Registry스레드 또는 전용 I/O 스레드)에 배치되고, worker 스레드는

2. 만 보유합니다. 이러한 분리는 몇 가지 이점을 가져옵니다:등록 무잠금화epoll_wait이

3. 클론을 보유하여, 어떤 worker든 동시에 새 fd를 등록할 수 있고 driver 스레드로 돌아갈 필요가 없습니다.이벤트 대기 집중화ScheduledIo: 오직 하나의 스레드만Waker::wake(),wake()에서 블록되어, 여러 스레드가 동시에 같은 epoll fd를 poll하는 thundering herd 문제를 피합니다.

깨우기 경로 단축ScheduledIo: driver가 이벤트를 받은 후 직접set_readiness을 조작하고poll_readiness을 호출합니다

내부에서 작업을 worker 큐에 푸시하며, 스레드 간 메시지 전달이 필요 없습니다.is_shutdown대가는RUNTIME_SHUTTING_DOWN_ERROR

poll_ready이 동시 접근을 처리해야 한다는 것입니다 (ev.is_shutdown 📎 tokio/src/runtime/io/registration.rs:155-171과gone() 📎 tokio/src/runtime/io/registration.rs:265-267이 동시에 발생할 수 있음). 이는 원자적 연산과 내부 잠금으로 해결됩니다.RUNTIME_SHUTTING_DOWN_ERROR。

프로덕션 함정:

과shutdown 📎 tokio/src/runtime/io/driver.rs:174-182등록된 모든 것을 순회하며 호출합니다io.shutdown(),is_shutdown를 설정하고 모든 대기자를 깨웁니다. 이 플래그를 확인하지 않으면, 태스크가 runtime이 이미 스케줄링을 중단한 후에도 소켓을 읽으려고 시도하여 정의되지 않은 동작이나 행(hang)을 유발할 수 있습니다. 프로덕션 환경에서RUNTIME_SHUTTING_DOWN_ERROR를 보게 된다면, 일반적으로 runtime drop 이후에도 실행 중인 태스크가 있다는 의미입니다——제대로 join되지 않은spawn태스크가 있는지 확인하세요.

또 다른 함정은deregister_source의unpark 📎 tokio/src/runtime/io/driver.rs:328입니다. driver가poll에서 블로킹 중이고 이때 마지막Registration가 drop되면,unpark가 driver를 깨웁니다. 하지만 driver가 블로킹 상태가 아니라면(예: 다른 이벤트를 처리 중),unpark는 다음turn가 즉시📎 tokio/src/runtime/io/driver.rs:280-283를 반환하게 할 뿐입니다. 이 의미는Handle::unpark의 문서 주석에 설명되어 있습니다.

설계 고찰: Reactor의 세 가지 핵심 트레이드오프

트레이드오프 1:Token은 인덱스 대신 포인터를 사용합니다。EXPOSE_IO.from_exposed_addr(token.0) 📎 tokio/src/runtime/io/driver.rs:220는mio::Token를 직접*const ScheduledIo의 주소로 취급합니다. 이는Token → ScheduledIo의 매핑 테이블 유지를 피하며, 조회는 O(1)이고 락이 없습니다. 대가는 안전성이 엄격한 수명 관리에 의존한다는 것입니다: 포인터는 등록 해제되고 driver가 더 이상 poll하지 않은 후에만 해제되어야 합니다📎 tokio/src/runtime/io/driver.rs:222-225。

트레이드오프 2: 읽기/쓰기 이중 Waker 슬롯。Registration문서📎 tokio/src/runtime/io/registration.rs:24-26는 "A registration instance represents two separate readiness streams"라고 말합니다——읽기와 쓰기 각각 독립적인Waker슬롯을 가집니다. 이는 동일한 소켓의 읽기 태스크와 쓰기 태스크가 각각 등록되어 서로 간섭하지 않도록 합니다. 하지만poll_read_ready의 주석📎 tokio/src/net/tcp/stream.rs:549-552은 경고합니다:poll_read_ready/poll_read/poll_peek를 여러 번 호출하면 마지막Waker만 유지됩니다——읽기 방향에는 슬롯이 하나뿐입니다.

트레이드오프 3:events_busy의 독립 버퍼. 테스트📎 tokio/src/runtime/io/driver.rs:364-386가 이 동작을 검증합니다:Driver::new(16, Some(2))busy 용량이 2인 driver를 생성하고, 5개의 읽기 가능한 source를 등록한 후, 비블로킹turn는 2개의 이벤트만 가져오고📎 tokio/src/runtime/io/driver.rs:375-376, 나머지 3개는 커널 큐에 남아 다음 블로킹turn에서📎 tokio/src/runtime/io/driver.rs:379-380를 가져옵니다. 이는 비블로킹 poll이 한 번에 모든 이벤트를 삼켜 이후 poll이 기아 상태가 되는 것을 방지합니다.

이 장 요약

이 장은TcpStream::read뒤의 완전한 Reactor 체인을 추적했습니다:

  • 드라이버 계층:Driver은mio::Poll,turn를 독점하여 이벤트를 블로킹 대기하고,EXPOSE_IO를 사용하여Token를ScheduledIo포인터로 복원하고,set_readiness + wake를 호출하여Waker。Handle를 트리거합니다.unpark는 스레드 간 등록 진입점을 제공하며,
  • 는 블로킹을 중단하는 데 사용됩니다.:Registration등록 계층Arc<ScheduledIo>,poll_ready은Waker,poll_io를 보유하고 준비 비트를 확인하거나WouldBlock에 저장합니다.try_io/async_io는
  • 재시도 루프로 거짓 양성을 처리하며,:ScheduledIo는 각각 동기 및 비동기 시나리오를 서비스합니다.Waker상태 계층

은 fd의 상태 슬롯으로, 읽기/쓰기 준비 비트와 이중

슬롯을 저장하며, 이벤트와 태스크 사이의 유일한 다리입니다.poll_io이 장 생각과 자가 테스트WouldBlockQ1: 만약self.clear_readiness(ev)에서

분기의:poll_io를 삭제하면, 어떤 시나리오에서 태스크 busy-loop를 유발할까요? 왜일까요?📎 tokio/src/runtime/io/registration.rs:173-192참고 해석f()의 루프WouldBlock는clear_readiness(ev) 📎 tokio/src/runtime/io/registration.rs:187。ev가poll_ready를 반환할 때ReadyEvent를 호출합니다.clear_readiness는ScheduledIo가 반환한

로, 현재 준비 비트를 포함합니다.poll_ready → poll_readiness는 이 비트들을ScheduledIo에서 지웁니다.poll_readiness만약 지우지 않으면, 다음 루프에서Ready를 호출할 때f()에 여전히 이전 "읽기 가능" 비트가 남아 있어,read()가 즉시WouldBlock를 반환하고(준비 비트가 비어 있지 않으므로),Pending가 다시

를 실행합니다. 소켓에 실제로 데이터가 없으면 다시Registration를 반환하고 루프가 계속됩니다. 준비 비트가 절대 지워지지 않으므로 이 루프는 결코📎 tokio/src/runtime/io/registration.rs:28-33에 진입하지 않고, 태스크는 계속 CPU를 점유하며 폴링합니다.try_read트리거 시나리오: 여러 태스크가 동일한 소켓의 읽기 방향을 공유하거나(poll_read문서read()는 최대 두 태스크라고 하지만 읽기 방향에는 슬롯이 하나뿐입니다),WouldBlock와

Q2: add_source를 혼용하는 경우입니다. 더 흔한 것은: epoll이 읽기 가능을 보고한 후 다른 스레드가 먼저 데이터를 읽어갔고, 현재 태스크의registry.register가registrations.remove를 반환하는 경우입니다. 이때 반드시 준비 비트를 지워야 하며, 그렇지 않으면 계속 재시도합니다.

가:add_source 📎 tokio/src/runtime/io/driver.rs:288-312실패 시 왜registrations.allocate를 호출해야 할까요? 호출하지 않으면 무슨 일이 발생할까요?ScheduledIo 📎 tokio/src/runtime/io/driver.rs:293참고 해석registry.register는 먼저📎 tokio/src/runtime/io/driver.rs:298를 할당하고, 그다음ScheduledIo로 커널에 등록합니다. 등록이 실패하면RegistrationSet는 이미 할당되었지만 연결된 fd가 없습니다. 제거하지 않으면 영원히

에 남습니다.📎 tokio/src/runtime/io/driver.rs:296-297주석scheduled_io from the registrations set if registering the source with the OS fails. Otherwise it will leak the scheduled_io은 명확히 말합니다: "we should remove the

remove."——이것은 메모리 누수입니다.📎 tokio/src/runtime/io/driver.rs:300-303호출ScheduledIo은 unsafe 블록으로 감싸집니다. 왜냐하면RegistrationSet는RegistrationSet의 일부이고, 제거 작업은 다른 참조가 없음을 보장해야 하기 때문입니다. 누수의 결과:Token가 계속 증가하고,allocate공간이 낭비되며, 최종적으로

Q3: deregister_source실패나 메모리 고갈을 초래할 수 있습니다. 연결 생성/소멸이 빈번한 시나리오(예: 단기 연결 서버)에서 등록 실패율이 높으면(예: fd 고갈), 누수가 자원 고갈을 가속화합니다.unpark()에서 왜registrations.deregister는

가 true를 반환할 때만 호출될까요? 무조건 호출하면 어떤 문제가 있을까요?:deregister_source 📎 tokio/src/runtime/io/driver.rs:315-334참고 해석registry.deregister(source)의 로직은: 먼저📎 tokio/src/runtime/io/driver.rs:322로 커널에registrations.deregister를 등록 해제하고, 그다음📎 tokio/src/runtime/io/driver.rs:315-334로 내부 상태unpark() 📎 tokio/src/runtime/io/driver.rs:328。

registrations.deregister를 정리하며, true를 반환하면ScheduledIo를 호출합니다. true 반환은 이것이 마지막 참조이고poll가 실제로 제거되었음을 의미합니다. 이때 driver는unpark에서 이 fd의 이벤트를 기다리며 블로킹 중일 수 있지만, fd가 이미 등록 해제되어 커널은 더 이상 이벤트를 생성하지 않습니다.mio::Waker는TOKEN_WAKEUP를 통해 epoll에📎 tokio/src/runtime/io/driver.rs:280-283이벤트poll를 넣어

가 즉시 반환하게 하고, driver는 등록 집합을 다시 확인하고 블로킹을 종료할 수 있습니다.unpark만약 무조건ScheduledIo를 호출하면: 마지막이 아닌 참조를 등록 해제할 때마다 driver를 깨워 불필요한 웨이크업을 유발합니다. 많은 연결이 동일한TcpStream를 공유하는 시나리오(예:split후반 읽기/쓰기 두 부분), 매번 절반을 drop할 때마다 driver를 깨워 CPU 오버헤드가 증가한다. 더 심각하게는

이 장에서는 Reactor가 epoll 이벤트를 Waker 깨우기로 변환하는 방법을 분석했다: TcpStream의 poll_read_ready에서 출발하여 Registration의 등록과 조회를 거쳐 ScheduledIo의 준비 비트와 Waker 슬롯에 도달하고, Driver가 이벤트 루프에서 Token을 기반으로 위치를 찾아 깨우기를 트리거한다. 핵심 설계로는 Token이 포인터로서 O(1) 조회를 구현하고, 읽기/쓰기 이중 Waker 슬롯이 동시 읽기/쓰기 분리를 지원하며, events_busy 독립 버퍼가 이벤트 기아를 방지하고, assume_ready 낙관적 추측이 accept 시나리오를 최적화한다. 여기까지 I/O 준비 알림의 폐쇄 루프가 완성되었다. 하지만 비동기 런타임은 또 다른 종류의 '준비'—시간—을 처리해야 한다. 다음 장에서는 tokio::time::sleep과 timeout의 구현을 분석할 것이다: 타이머가 어떻게 시간 휠에 삽입되는지, 시간 휠이 만료 시간에 따라 어떻게 계층화되는지, driver가 다음 park의 타임아웃을 어떻게 계산하고 만료된 작업을 트리거하는지. '시간도 하나의 I/O 이벤트'라는 통합 추상화와 start_paused 및 test clock이 테스트에서 시간을 어떻게 제어 가능하게 만드는지 살펴볼 것이다.

모든 코드베이스를 진정으로 이해할 수 있는 책으로

이 장을 다 읽으셨나요? 내 프라이빗 저장소를 위한 아키텍처 책 만들기

Tauri 2 + Rust 로컬 퍼스트 아키텍처. 100% 오프라인 보안, 클라우드 코드 업로드 없음. 불변 커밋 라인 앵커로 정독.

⚡ Tauri 2 · Rust 네이티브 코어 · 100% 오프라인 보안 · 100만 행 이상 검증

CHAPTER 06

제 6 장: 시간 구동: 시간 휠, Sleep과 타임아웃이 어떻게 깨어나는가

Upstream: tokio-rs/tokio · Commit @e800714a · 진행률: 제 6 장 / 총 14 장

이전 장에서 우리는 TcpStream::read의 전체 체인을 추적하며 ScheduledIo가 epoll의 fd 준비 이벤트를 Waker 깨우기로 변환하는 방법을 살펴보았다. 하지만 비동기 런타임은 또 다른 종류의 '준비'를 처리해야 한다: sleep(100ms) Future는 100ms 후에 반드시 깨어나야 한다. 이런 이벤트는 커널 fd에서 오지 않고 '시간 자체'에서 온다. Tokio의 설계 선택은 시간도 I/O 이벤트로 취급하는 것이다: Driver 구조체에는 park: IoStack 필드 하나만 있으며, I/O driver의 park/unpark 메커니즘을 재사용한다. 시간 휠이 '다음 만료 시각'을 계산하면, driver는 park_timeout을 호출하여 스레드를 그 시각까지 재운다; 깨어난 후 시간 휠에서 만료 항목을 꺼내 해당 Waker를 트리거한다. 이렇게 하면 스케줄러는 하나의 통합된 park 진입점만으로 'fd 준비'와 '타이머 만료' 두 종류의 이벤트를 동시에 기다릴 수 있다. 이 장에서는 세 가지 질문에 답한다: 타이머는 어떻게 시간 휠에 삽입되는가? 시간 휠은 만료 시간에 따라 어떻게 계층화되는가? driver는 다음 park의 타임아웃을 어떻게 계산하고 만료된 작업을 트리거하는가?

1. 시간 휠: 6계층 64슬롯의 해시 계층 구조

직관적 모델

기계식 시계를 상상해 보자: 초침이 한 바퀴 돌면 분침을 움직이고, 분침이 한 바퀴 돌면 시침을 움직인다. 초침만 있다면 '12일 후'를 표현하려면 100만 칸을 세어야 한다; 하지만 계층화하면 초침은 64초 이내의 정밀도만 담당하고, 분침은 64분, 시침은 64시간을 담당한다—각 계층은 64개 슬롯만으로 2년 후까지 커버할 수 있다.

계층화가 없다면 먼 미래의 타이머를 삽입할 때 O(N) 순회가 필요하거나 거대한 배열이 필요하다. 시간 휠은 '만료 시간에 따른 계층화'로 삽입과 트리거를 모두 거의 O(1)로 압축한다.

메모리 레이아웃과 필드

Wheel의 핵심 필드는 단 세 개뿐이다📎 tokio/src/runtime/time/wheel/mod.rs:22-40:

rust
pub(crate) struct Wheel {
    elapsed: u64,                          // 自 wheel 创建以来经过的毫秒数
    levels: Box<[Level; NUM_LEVELS]>,      // 6 层,每层 64 槽
    pending: LinkedList<TimerShared>,      // 已到期、待触发的条目
}

NUM_LEVELS = 6,BITS_PER_LEVEL = 6(즉 계층당 64슬롯)📎 tokio/src/runtime/time/wheel/mod.rs:45-47。MAX_DURATION = 1 << (6 * 6) = 1 << 36밀리초, 약 2년📎 tokio/src/runtime/time/wheel/mod.rs:50。

6계층의 세분성은 문서 주석에 따르면📎 tokio/src/runtime/time/wheel/mod.rs:22-40:

계층슬롯 세분성커버 범위
01 ms64 ms
164 ms~4 s
2~4 s~4 min
3~4 min~4 hr
4~4 hr~12 day
5~12 day~2 yr

pending는 침투적 연결 리스트(LinkedList<TimerShared>)로, 이미 휠에서 꺼내져 Waker 트리거를 기다리는 항목을 저장한다. 주의할 점은 이것이LinkedList가 아니라Vec라는 것이다: 항목 자체가TimerShared에 내장되어 있어 삽입/제거에 할당이 필요 없다.

시나리오 구동: 100ms sleep 삽입

가sleep(100ms)처음 poll될 때,Sleep::poll_elapsed는Timer::new를 생성하고init 📎 tokio/src/time/sleep.rs:436-440。init를 호출하며 최종적으로Handle::reregister를 호출하고, 이어서Wheel::insert。

insert를 호출한다. 첫 번째 단계는 이미 만료되었는지 확인하는 것이다📎 tokio/src/runtime/time/wheel/mod.rs:90-98:

rust
let when = unsafe { item.sync_when() };
if when <= self.elapsed {
    return Err((item, InsertError::Elapsed));
}

만약when가 이미elapsed이전에 위치한다면(예: deadline이 지난 경우), 바로Elapsed를 반환하고 호출자는 해당 타이머를 즉시 트리거한다.

그렇지 않으면 해당 항목이 어느 계층에 들어가야 하는지 계산한다📎 tokio/src/runtime/time/wheel/mod.rs:90-114:

rust
let level = self.level_for(when);
unsafe { self.levels[level].add_entry(item); }

level_for는 계층화 알고리즘의 핵심이다📎 tokio/src/runtime/time/wheel/mod.rs:276-289:

rust
fn level_for(elapsed: u64, when: u64) -> usize {
    const SLOT_MASK: u64 = (1 << BITS_PER_LEVEL) - 1;
    let masked = elapsed ^ when | SLOT_MASK;
    if masked >= MAX_DURATION {
        return NUM_LEVELS - 1;
    }
    masked.ilog2() as usize / BITS_PER_LEVEL
}

여기서elapsed ^ when가 아니라when - elapsed를 사용하는 것은 정교한 기법이다: XOR의 최상위 유효 비트는 '두 타임스탬프가 어느 비트부터 다른지', 즉 '이들을 구분하려면 얼마나 거친 세분성이 필요한지'를 반영한다.| SLOT_MASK는 하위 6비트를 강제로 1로 설정하여ilog2가 같은 슬롯에 떨어질 때 너무 작은 계층이 계산되는 것을 방지한다.ilog2() / 6는 비트 폭을 계층 번호로 매핑한다. XOR 결과가MAX_DURATION를 초과하면(즉 2년 초과), 최상위 계층에 강제로 넣는다—이것이 'fudge the timer into the top level'이다.

100ms sleep의 경우,elapsed가 0에 가깝다고 가정하면,when ≈ 100,elapsed ^ when ≈ 100,ilog2(100) = 6,6 / 6 = 1,따라서 1계층(64ms 입도)에 위치합니다. 이는 1계층의 특정 슬롯에서 대기하다가, 시간이 해당 슬롯의 경계로 진행될 때 0계층으로 내려간다는 것을 의미합니다.

계층적 하강: process_expiration

当poll(now)시간을 진행할 때,Wheel::poll는 반복적으로 호출합니다next_expiration와process_expiration 📎 tokio/src/runtime/time/wheel/mod.rs:142-166:

rust
pub(crate) fn poll(&mut self, now: u64) -> Option<TimerHandle> {
    loop {
        if let Some(handle) = self.pending.pop_back() {
            return Some(handle);
        }
        match self.next_expiration() {
            Some(ref expiration) if expiration.deadline <= now => {
                self.process_expiration(expiration);
                self.set_elapsed(expiration.deadline);
            }
            _ => {
                self.set_elapsed(now);
                break;
            }
        }
    }
    self.pending.pop_back()
}

process_expiration는 특정 계층의 만료 항목을 다음 계층으로 "하강"시키거나 (0계층에서는) pending으로 표시하는 역할을 합니다📎 tokio/src/runtime/time/wheel/mod.rs:218-251:

rust
let mut entries = self.take_entries(expiration);
while let Some(item) = entries.pop_back() {
    match unsafe { item.mark_pending(expiration.deadline) } {
        Ok(()) => {
            self.pending.push_front(item);   // 真正到期
        }
        Err(expiration_tick) => {
            let level = level_for(expiration.deadline, expiration_tick);
            unsafe { self.levels[level].add_entry(item); }  // 下沉到更低层
        }
    }
}

mark_pending가 핵심입니다: 항목의 실제 deadline이 도달했는지 확인합니다. 도달했다면Ok(())을 반환하고, 항목은pending연결 리스트에 들어갑니다; 아직 도달하지 않았다면 (단지 해당 슬롯의 경계에 도달했을 뿐이라면)Err(expiration_tick)을 반환하고, 항목은 더 세밀한 계층에 다시 삽입됩니다.

주석에서 강조하는 한 가지 점📎 tokio/src/runtime/time/wheel/mod.rs:219-228: 슬롯 전체의 항목을 모두 꺼낸 후에 처리해야 합니다. 일부 항목이 동일한 슬롯에 다시 삽입될 수 있기 때문입니다 (삽입 시간이MAX_DURATION를 초과할 때 랩어라운드가 발생합니다). 꺼내면서 동시에 삽입하면 무한 루프에 빠질 수 있습니다.

다음 만료 시각 계산

next_expiration저계층에서 고계층으로 스캔하여 첫 번째 비어 있지 않은 만료 지점을 반환합니다📎 tokio/src/runtime/time/wheel/mod.rs:169-191:

rust
fn next_expiration(&self) -> Option<Expiration> {
    if !self.pending.is_empty() {
        return Some(Expiration { level: 0, slot: 0, deadline: self.elapsed });
    }
    for (level_num, level) in self.levels.iter().enumerate() {
        if let Some(expiration) = level.next_expiration(self.elapsed) {
            debug_assert!(self.no_expirations_before(level_num + 1, expiration.deadline));
            return Some(expiration);
        }
    }
    None
}

만약pending이 비어 있지 않다면, 이미 만료된 항목이 트리거를 기다리고 있다는 뜻이므로 즉시 현재elapsed를 deadline으로 반환합니다 (이렇게 하면 driver가 0 타임아웃으로 park하고 즉시 돌아와 처리합니다). 그렇지 않으면 계층별로 스캔하여 내용이 있는 첫 번째 슬롯의 deadline을 반환합니다.debug_assert는 불변량을 검증합니다: 더 높은 계층이 현재 계층보다 더 이른 만료 지점을 가질 수 없다는 것입니다.

mermaid
flowchart TD
    start["Wheel::poll(now)"] --> check_pending{"pending 非空?"}
    check_pending -->|是| pop["pop_back 返回 TimerHandle"]
    check_pending -->|否| next_exp{"next_expiration() 有到期点?"}
    next_exp -->|无| set_elapsed["set_elapsed(now) 后 break"]
    next_exp -->|有| cmp{"expiration.deadline <= now?"}
    cmp -->|否| set_elapsed
    cmp -->|是| proc["process_expiration(expiration)"]
    proc --> take["take_entries 取出整槽"]
    take --> mark{"item.mark_pending()"}
    mark -->|Ok 已到期| push_pending["pending.push_front(item)"]
    mark -->|Err 未到期| reinsert["level_for 后 add_entry 下沉"]
    push_pending --> set_elapsed2["set_elapsed(expiration.deadline)"]
    reinsert --> set_elapsed2
    set_elapsed2 --> check_pending
    set_elapsed --> pop2["pending.pop_back() 返回"]

---

二、Driver의 park 루프: 타이밍 휠을 I/O 스택에 연결하기

직관적 모델

타이밍 휠 자체는 "스스로 돌아가지" 않습니다. 외부 루프가 반복적으로 물어봐야 합니다: "다음 만료는 언제인가?" 그런 다음 그 시각까지 잠들고, 깨어난 후 시간을 진행합니다. 이 루프가 바로Driver::park_internal입니다. 이는 "타이밍 휠의 다음 만료"를park_timeout의 duration으로 변환하여 하위 I/O 스택에 전달해 잠들게 합니다.

이 루프가 없다면 타이머는 절대 트리거되지 않습니다 — 타이밍 휠은 정적 데이터 구조일 뿐이며, 누군가 "돌려줘야" 합니다.

데이터 구조: Driver와 InnerState

Driver에는 필드가 하나만 있습니다park: IoStack 📎 tokio/src/runtime/time/mod.rs:90-93. 실제 상태는Handle에 있으며,Inner열거형을 통해 전통적 구현과 실험적 구현을 구분합니다📎 tokio/src/runtime/time/mod.rs:95-127. 전통적 구현의InnerState은 두 개의 필드를 포함합니다📎 tokio/src/runtime/time/mod.rs:130-136:

rust
struct InnerState {
    next_wake: Option<NonZeroU64>,   // 承诺的最早唤醒时刻
    wheel: wheel::Wheel,
}

next_wake은NonZeroU64대신Option<u64>의 중첩을 사용하여 niche 최적화를 활용합니다 —Option<NonZeroU64>와u64은 같은 크기입니다. 이는 "driver가 어느 tick 전에 깨어날 것을 약속했는지"를 기록하며,reregister시unpark。

is_shutdown이 필요한지 판단하는 데 사용됩니다AtomicBool는 독립적인📎 tokio/src/runtime/time/mod.rs:90-93:Handle이며, 주석에서 Mutex에서 분리한 이유를 설명합니다is_shutdown은 mutex를 잠그지 않고

을 확인해야 합니다. 이는 전형적인 "읽기 많고 쓰기 적은" 최적화입니다 — shutdown은 한 번만 발생하지만, 확인은 빈번할 수 있습니다.

park_internal시나리오 기반: 한 번의 park 전체 흐름📎 tokio/src/runtime/time/mod.rs:213-256:

rust
fn park_internal(&mut self, rt_handle: &driver::Handle, limit: Option<Duration>) {
    let handle = rt_handle.time();
    let mut lock = handle.inner.lock();
    assert!(!handle.is_shutdown());

    let next_wake = lock.wheel.next_expiration_time();
    lock.next_wake = next_wake.map(|t| NonZeroU64::new(t).unwrap_or_else(|| NonZeroU64::new(1).unwrap()));
    drop(lock);

    match next_wake {
        Some(when) => {
            let now = handle.time_source.now(rt_handle.clock());
            let mut duration = handle.time_source.tick_to_duration(when.saturating_sub(now));
            if duration > Duration::from_millis(0) {
                if let Some(limit) = limit {
                    duration = std::cmp::min(limit, duration);
                }
                self.park_thread_timeout(rt_handle, duration);
            } else {
                self.park.park_timeout(rt_handle, Duration::from_secs(0));
            }
        }
        None => {
            if let Some(duration) = limit {
                self.park_thread_timeout(rt_handle, duration);
            } else {
                self.park.park(rt_handle);
            }
        }
    }

    handle.process(rt_handle.clock());
}

복사

1. 단계별 분석::lock.wheel.next_expiration_time()잠금 획득, 다음 만료 읽기Option<u64>이lock.next_wake을 반환합니다, 즉 다음 만료 tick입니다. 동시에 이를reregister에 기록하여

2. 이 unpark 필요 여부를 판단할 수 있게 합니다.:drop(lock)잠금 해제

3. 은 park 전에 반드시 이루어져야 합니다, 그렇지 않으면 park 중에 다른 스레드가 타이머를 삽입할 수 없습니다.:when.saturating_sub(now)park 시간 계산tick_to_duration이 남은 tick 수를 얻고,Duration이📎 tokio/src/runtime/time/mod.rs:228-230로 변환합니다. 주석에 따르면 실제로는 1ms로 올림합니다

4. , 마이크로초 수준의 sleep이 OS에 의해 0 길이로 처리되는 것을 방지합니다.limit 처리limit: 호출자가park_timeout을 전달한 경우 (예:min(limit, duration)의 명시적 타임아웃),

5. 을 취하여 너무 오래 자지 않도록 보장합니다.특수 경우duration == 0: 만약park_timeout(0)(이미 만료됨)이면,

6. 으로 즉시 반환하고 실제로 자지 않습니다.타이머 없을 때next_wake: 만약None이limit이면,park_thread_timeout(limit)이 있으면park。

7. , 그렇지 않으면 무한:handle.process(clock)깨어난 후 처리

이 타이밍 휠을 진행하고 만료 항목을 트리거합니다.

processprocess_at_time: 만료 항목 트리거process_at_time 📎 tokio/src/runtime/time/mod.rs:296-337:

rust
pub(self) fn process_at_time(&self, mut now: u64) {
    let mut waker_list = WakeList::new();
    let mut lock = self.inner.lock();

    if now < lock.wheel.elapsed() {
        // 时间倒流保护
        now = lock.wheel.elapsed();
    }

    while let Some(entry) = lock.wheel.poll(now) {
        debug_assert!(unsafe { entry.is_pending() });
        if let Some(waker) = unsafe { entry.fire(Ok(())) } {
            waker_list.push(waker);
            if !waker_list.can_push() {
                drop(lock);
                waker_list.wake_all();
                lock = self.inner.lock();
            }
        }
    }

    lock.next_wake = lock.wheel.poll_at()
        .map(|t| NonZeroU64::new(t).unwrap_or_else(|| NonZeroU64::new(1).unwrap()));
    drop(lock);
    waker_list.wake_all();
}

을 호출합니다

  • 복사 📎 tokio/src/runtime/time/mod.rs:301-309몇 가지 핵심 사항:now < wheel.elapsed()시간 역행 보호Instant: 만약now이면, 시계가 역행한 것입니다. 주석에 따르면 이는 일반적으로 발생하지 않아야 합니다 (Rust는elapsed。
  • 이 단조로움을 보장합니다), 하지만 Windows 호스트의 Linux VM에서는 발생합니다, std가 하드웨어 시계의 단조로움을 잘못 신뢰하기 때문입니다. 보호 방식은:WakeList을!can_push()으로 클램프하는 것입니다📎 tokio/src/runtime/time/mod.rs:319배치 깨우기
  • 이 Waker를 수집하고, 가득 차면 () 잠금을 임시로 해제하고, 한 배치를 깨우고, 다시 잠급니다. 주석에서 강조하는 것은 교착 상태를 피하기 위함입니다poll_at(). 잠금을 보유한 상태에서 Waker를 호출하고, Waker가 다시 타이밍 휠을 조작하려 하면 (예: 타이머 재등록), 교착 상태가 발생합니다.next_wake。

next_wake 업데이트

: 처리 후Sleep::reset을 재계산하고,reregister을 업데이트합니다📎 tokio/src/runtime/time/mod.rs:398-450:

rust
pub(self) unsafe fn reregister(&self, unpark: &IoHandle, new_tick: u64, entry: NonNull<TimerShared>) {
    let waker = unsafe {
        let mut lock = self.inner.lock();
        if unsafe { entry.as_ref().might_be_registered() } {
            lock.wheel.remove(entry);
        }
        let entry = entry.as_ref().handle();
        if self.is_shutdown() {
            unsafe { entry.fire(Err(crate::time::error::Error::shutdown())) }
        } else {
            entry.set_expiration(new_tick);
            match unsafe { lock.wheel.insert(entry) } {
                Ok(when) => {
                    if lock.next_wake.is_none_or(|next_wake| when < next_wake.get()) {
                        unpark.unpark();
                    }
                    None
                }
                Err((entry, crate::time::error::InsertError::Elapsed)) => unsafe {
                    entry.fire(Ok(()))
                },
            }
        }
    };
    if let Some(waker) = waker {
        waker.wake();
    }
}

当next_wake이 호출되면, 타이머를 재등록해야 합니다.unpark.unpark()이 이 시나리오를 처리합니다

복사unpark핵심 로직: 삽입 성공 후, 새 만료 시각이보다 이르면,을 호출하여 driver를 깨웁니다. 이는 driver가 더 늦은 시각에 자고 있을 수 있으며, park 시간을 재계산하기 위해 미리 깨워야 하기 때문입니다.waker.wake()주의:은잠금을 보유한 상태에서📎 tokio/src/runtime/time/mod.rs:441호출되고,unpark은

mermaid
sequenceDiagram
    participant Sleep as Sleep::poll
    participant Handle as time::Handle
    participant Wheel as Wheel
    participant Driver as Driver::park_internal
    participant IoStack as IoStack

    Sleep->>Handle: reregister(unpark, new_tick, entry)
    Handle->>Handle: lock.inner.lock()
    Handle->>Wheel: wheel.remove(entry) [若已注册]
    Handle->>Wheel: wheel.insert(entry)
    Wheel-->>Handle: Ok(when)
    alt when < next_wake
        Handle->>IoStack: unpark.unpark()
    end
    Handle->>Handle: drop(lock)
    Handle-->>Sleep: 返回 waker (若有)

    Note over Driver: 另一线程
    Driver->>Handle: lock.inner.lock()
    Driver->>Wheel: next_expiration_time()
    Wheel-->>Driver: Some(when)
    Driver->>Driver: drop(lock)
    Driver->>IoStack: park_timeout(duration)
    IoStack-->>Driver: 被 unpark 或超时
    Driver->>Handle: process(clock)
    Handle->>Wheel: poll(now)
    Wheel-->>Handle: TimerHandle
    Handle->>Sleep: waker.wake()

---

호출됩니다. 주석에서

을 설명합니다

Sleep: 교착 상태를 피하기 위해 Waker 호출 전에 잠금을 해제해야 합니다. 하지만.await은 다릅니다 — 단지 epoll에 이벤트를 넣을 뿐이며, 사용자 코드를 콜백하지 않으므로 잠금을 보유한 상태에서 호출해도 안전합니다.Timeout다른 Future를 감싸는 어댑터입니다. 이것들은 자체적으로 타이머 휠을 관리하지 않고, 단지 "deadline"을 tick으로 변환하여 위임합니다.Timer와Handle。

Sleep의 메모리 레이아웃

Sleep를 사용하여pin_project!매크로로 정의합니다📎 tokio/src/time/sleep.rs:221-227:

rust
pub struct Sleep {
    deadline: Instant,
    driver: scheduler::Handle,
    inner: Inner,
    #[pin]
    timer: Option<Timer>,
}

timer은Option<Timer>이고#[pin]을 가집니다: 최초 poll 전에는None이며, 최초 poll 시에만Timer을 생성하고 등록합니다. 이러한 "지연 초기화"는sleep()호출 시점에 런타임에 접근하는 것을 방지합니다——sleep()은 런타임 외부에서 호출할 수 있으며,.await시에만 실제로 등록됩니다.

PinnedDrop구현은 drop 시 타이머 취소를 보장합니다📎 tokio/src/time/sleep.rs:230-235:

rust
impl PinnedDrop for Sleep {
    fn drop(this: Pin<&mut Self>) {
        let this = this.project();
        if let Some(timer) = this.timer.as_pin_mut() {
            timer.cancel(this.driver);
        }
    }
}

poll_elapsed의 전체 흐름

poll_elapsed은Sleep의 핵심입니다📎 tokio/src/time/sleep.rs:396-454:

rust
fn poll_elapsed(self: Pin<&mut Self>, cx: &mut task::Context<'_>) -> Poll<Result<(), Error>> {
    ready!(crate::trace::trace_leaf());
    let mut this = self.project();

    // coop 预算
    let coop = ready!(crate::task::coop::poll_proceed(cx));

    let handle = this.driver;
    let timer = match this.timer.as_mut().as_pin_mut() {
        Some(timer) => timer,
        None => {
            let time_source = handle.driver().time().time_source();
            let deadline = time_source.deadline_to_tick(*this.deadline);
            let timer = Timer::new(handle, deadline);
            this.timer.set(Some(timer));
            let mut timer = this.timer.as_pin_mut().unwrap();
            timer.as_mut().init(handle, deadline);
            timer
        }
    };

    let result = timer.poll_elapsed(cx, handle).map(move |r| {
        coop.made_progress();
        r
    });
    result
}

단계별:

1. coop 예산 검사:poll_proceed(cx)협력 예산을 한 번 소비합니다. 예산이 소진되면Pending을 반환하고 실행 권한을 양보합니다. 이것은 Tokio가 단일 태스크가 다른 태스크를 기아 상태로 만드는 것을 방지하는 메커니즘입니다.

2. 지연 Timer 생성: 만약timer이None이면,deadline을 tick으로 변환하고,Timer을 생성하여init을 호출해 타이머 휠에 등록합니다.

3. Timer::poll_elapsed에 위임: 실제 만료 검사는Timer이 수행합니다.

4. 성공 후 진행 상황 표시:coop.made_progress()은 이번 poll에 실제 진행이 있었음을 나타냅니다.

Timeout의 poll: 먼저 value를 poll하고, 그 다음 delay를 poll

Timeout의 poll 순서는 매우 중요합니다📎 tokio/src/time/timeout.rs:210-224:

rust
fn poll(self: Pin<&mut Self>, cx: &mut task::Context<'_>) -> Poll<Self::Output> {
    let me = self.project();
    let had_budget_before = coop::has_budget_remaining();

    // 先 poll 被包裹的 future
    if let Poll::Ready(v) = me.value.poll(cx) {
        return Poll::Ready(Ok(v));
    }

    match me.delay.as_pin_mut() {
        Some(delay) => poll_delay(had_budget_before, delay, cx).map(Err),
        None => Poll::Pending,
    }
}

주석은 명확히 지적합니다📎 tokio/src/time/timeout.rs:24-26: future가 먼저 poll되고, 그 다음에 타임아웃이 검사됩니다. 따라서 future가 yield 없이 완료되면, timeout을 초과한 후에도Ok을 반환할 수 있습니다. 이것은 설계 선택이지 버그가 아닙니다.

poll_delay은 미묘한 시나리오를 처리합니다📎 tokio/src/time/timeout.rs:229-251:

rust
fn poll_delay(had_budget_before: bool, delay: Pin<&mut Sleep>, cx: &mut task::Context<'_>) -> Poll<Elapsed> {
    let delay_poll = || match delay.poll(cx) {
        Poll::Ready(()) => Poll::Ready(Elapsed::new()),
        Poll::Pending => Poll::Pending,
    };

    let has_budget_now = coop::has_budget_remaining();

    if let (true, false) = (had_budget_before, has_budget_now) {
        // 如果预算是被底层 future 耗尽的,用无约束预算 poll delay
        coop::with_unconstrained(delay_poll)
    } else {
        delay_poll()
    }
}

로직: 만약poll에 진입할 때 예산이 남아 있지만, value를 poll한 후 예산이 소진되었다면, value가 예산을 소비한 것입니다. 이때 제한된 예산으로 delay를 poll하면, delay가 즉시Pending을 반환하여 타임아웃 도달 여부를 영원히 판단할 수 없게 됩니다. 따라서with_unconstrained으로 일시적으로 예산 제한을 해제합니다. 주석은 이를 "pathological cases"라고 부릅니다📎 tokio/src/time/timeout.rs:243-246。

timeout의 deadline 오버플로 처리

timeout함수는checked_add로 오버플로를 처리합니다📎 tokio/src/time/timeout.rs:86-99:

rust
Timeout {
    value: future.into_future(),
    delay: match Instant::now().checked_add(duration) {
        Some(deadline) => Some(Sleep::new_timeout(deadline, trace::caller_location())),
        None => None,
    },
}

만약Instant::now() + duration이 오버플로하면 (duration이 극도로 큰 경우),delay이None이 되고, poll 시 직접Poll::Pending 📎 tokio/src/time/timeout.rs:222을 반환합니다. 이는 "절대 타임아웃되지 않음"에 해당하며, 합리적인 성능 저하 동작입니다.

---

설계 고찰과 프로덕션 함정

왜 계층 계산에 뺄셈 대신 XOR을 사용하는가? elapsed ^ when의 최상위 유효 비트는 "두 타임스탬프가 어느 비트부터 다른지"를 직접 반영하며, 이것이 바로 "얼마나 거친 입자가 필요한지"의 척도입니다. 뺄셈when - elapsed은elapsed이when에 가까울 때 상위 비트가 모두 0이 되어,ilog2이 너무 작은 계층을 계산합니다. XOR은 랩어라운드 시나리오를 자연스럽게 처리합니다.

시간 역행 보호의 필요성 📎 tokio/src/runtime/time/mod.rs:301-309: Rust는Instant의 단조성을 보장하지만, 하위 OS는 보장하지 않을 수 있습니다. Windows 호스트의 Linux VM에서 std가 하드웨어 시계를 신뢰하여Instant이 역행합니다. Tokio는now = lock.wheel.elapsed()으로 클램프하여set_elapsed의 assert 실패를 방지합니다.

배치 깨우기와 교착 상태 📎 tokio/src/runtime/time/mod.rs:319: 타이머 휠 잠금을 보유한 상태에서 Waker를 호출하는 것은 위험합니다——Waker가 태스크 재-poll을 트리거하고, 이어서Sleep::reset을 호출하여 타이머 휠 잠금을 다시 획득하려 시도하면 교착 상태가 발생합니다.WakeList의 배치 메커니즘은 잠금이 가득 찼을 때 일시적으로 잠금을 해제하며, 이는 표준적인 "잠금 외 콜백" 패턴입니다.

next_wake의 niche 최적화 📎 tokio/src/runtime/time/mod.rs:130-136:Option<NonZeroU64>은u64과 같은 크기입니다. 0이None의 niche로 사용되기 때문입니다. 하지만 tick 0은 유효한 값이므로, 코드는NonZeroU64::new(t).unwrap_or_else(|| NonZeroU64::new(1).unwrap())으로 0을 1에 매핑합니다📎 tokio/src/runtime/time/mod.rs:221. 이것은 미묘한 경계 처리입니다: tick 0이 tick 1로 취급되어, 최대 1ms의 추가 깨우기가 발생합니다.

process_expiration의 "먼저 꺼내고 나중에 처리" 📎 tokio/src/runtime/time/wheel/mod.rs:219-228: 반드시 전체 슬롯 항목을 먼저 꺼낸 후 처리해야 합니다. 왜냐하면MAX_DURATION을 초과하는 항목은 랩어라운드되어 동일한 슬롯에 다시 삽입되기 때문입니다. 꺼내면서 동시에 삽입하면 무한 루프에 빠집니다.

Timeout의 poll 순서 함정 📎 tokio/src/time/timeout.rs:24-26: future가 먼저 poll되고, 타임아웃 후에 검사됩니다. 만약 future가 CPU 집약적이고 yield하지 않으면, timeout을 초과한 후에도Ok을 반환할 수 있습니다. 프로덕션 환경에서는timeout에 의존하여 비협조적인 future를 강제 중단하지 마십시오.

---

이 장의 요약

이 장에서는 Tokio 시간 구동의 3계층 구조를 분석했습니다:

1. 타이머 휠(Wheel): 6계층 64슬롯의 해시 계층 구조로,elapsed ^ when의 비트 폭이 항목 계층을 결정하며, 삽입과 트리거가 거의 O(1)입니다.pending연결 리스트는 만료된 항목을 저장하고,process_expiration은 계층별 하강을 담당합니다.

2. Driver(Driver::park_internal): 타이머 휠의next_expiration_time을park_timeout기간으로 변환하고, I/O 스택의 park/unpark를 재사용합니다.process_at_time은 깨어난 후 타이머 휠을 진행시키고, Waker를 배치 트리거하며, 시간 역행과 교착 상태 보호를 처리합니다.

3. 사용자 API(Sleep / Timeout):Sleep은 지연적으로Timer을 생성하고 등록하며,Timeout은 먼저 value를 poll한 후 delay를 poll하고,with_unconstrained으로 예산 소진 시나리오를 처리합니다.

핵심 설계는 "시간도 일종의 I/O 이벤트"입니다: driver는 단 하나의 park 진입점을 가지며, fd 준비와 타이머 만료를 동시에 기다립니다.next_wake은 약속된 깨우기 시각을 기록하고,reregister은 더 이른 타이머가 삽입될 때unpark을 깨워 driver가 재계산하도록 합니다.

다음 장에서는 동기화 원시 요소로 들어갑니다:Mutex、Semaphore과 채널이 어떻게 비동기 대기를 구현하는지. 이것들이 이 장의 Waker 메커니즘을 어떻게 재사용하는지, 그리고 "허가 카운트"와 "대기 큐"가 어떻게 협력하는지 보게 될 것입니다.

이 장의 생각과 자가 점검

Q1: 만약Wheel::insert의if when <= self.elapsed을if when < self.elapsed(등호를 제거하면), 어떤 시나리오에서 타이머가 영원히 트리거되지 않게 되는가?

참고 해석:when == self.elapsed타이머의 만료 시각이 현재 진행된 시간과 정확히 같음을 나타낸다. 원래 코드는<=이를Elapsed로 판정하고, 호출자가 즉시📎 tokio/src/runtime/time/wheel/mod.rs:96-98를 트리거한다. 만약<로 바꾸면, 이 항목은level_for(elapsed, when)가 계산한 계층에 삽입된다.elapsed ^ when == 0,masked = 0 | SLOT_MASK = 63,ilog2(63) = 5,5 / 6 = 0로 인해 0번째 계층에 떨어진다. 하지만 0번째 계층의next_expiration은deadline >= elapsed인 슬롯을 반환하고,Wheel::poll의 조건은expiration.deadline <= now이다. 만약now == elapsed이면 조건이 성립하고,process_expiration이 해당 항목을 꺼내며,mark_pending(elapsed)실제 deadline이 도달했는지 확인한다——이때when == elapsed,mark_pending이Ok을 반환하면 항목은 pending에 들어간다. 따라서 실제로는 여전히 트리거되지만, 한 바퀴 더 돌게 된다. 진짜 위험은:elapsed이 이미when이후로 진행된 경우(when < elapsed), 원래 코드는Elapsed를 반환하여 즉시 트리거하지만, 변경 후에는 이미 지나간 슬롯에 삽입되어,next_expiration이deadline < elapsed,set_elapsed의 assertelapsed <= when를 반환하면 실패 panic📎 tokio/src/runtime/time/wheel/mod.rs:253-264이 발생할 수 있다. 따라서 이 등호는 assert 실패를 방지하는 핵심 경계이다.

Q2: process_at_time에서WakeList이 가득 찬 후 왜drop(lock)를 다시wake_all()한 뒤 다시lock하는가? 만약 이 drop을 제거하면, 어떤 동시성 시나리오에서 데드락이 발생하는가?

참고 해석:WakeList은 Waker를 수집하고, 가득 차면 공간을 확보하기 위해 일부를 깨워야 한다📎 tokio/src/runtime/time/mod.rs:318-325. 만약self.inner.lock()를 보유한 상태에서waker.wake()를 호출하면, 깨어난 태스크가 즉시 다른 스레드(또는 같은 스레드의 스케줄러)에서 실행되어Sleep::reset또는Sleep::poll_elapsed를 호출하고, 이어서Handle::reregister를 호출할 수 있는데,reregister이 가장 먼저 하는 일이self.inner.lock() 📎 tokio/src/runtime/time/mod.rs:405이다.std::sync::Mutex은 재진입 불가능하므로 같은 스레드는 데드락에 빠진다. 다른 스레드에 있더라도process_at_time이 락을 해제할 때까지 블록되는데,process_at_time은wake_all이 반환되기를 기다리고 있어 순환 대기가 형성된다. 주석은 명확히 "To avoid deadlock, we must do this with the lock temporarily dropped"📎 tokio/src/runtime/time/mod.rs:319라고 말한다. drop 후 다시 lock할 때, 타이밍 휠 상태는 다른 스레드에 의해 이미 수정되었을 수 있으므로(예: 새 타이머 삽입),while let Some(entry) = lock.wheel.poll(now)은 새 상태에서 계속 항목을 가져오며, 이는 안전하다.

Q3: Timeout::poll에서had_budget_before와has_budget_now의 조합 판정(true, false)은 왜 "진입 시 예산이 있고, value를 poll한 후 예산이 없을 때"에만 사용되는가with_unconstrained? 만약 반대로(false, true)하면 어떻게 되는가?

참고 해석:had_budget_before은 value를 poll하기 전에 기록하고📎 tokio/src/time/timeout.rs:208-208,has_budget_now은 value를 poll한 후에 기록한다.📎 tokio/src/time/timeout.rs:239。(true, false)은 예산이 value를 poll하는 동안 소진되었음을 의미하며, value가 "예산 소비자"임을 나타낸다. 이때 제한된 예산으로 delay를 poll하면,poll_proceed이 즉시Pending을 반환하여 delay가 실제로 검사되지 않고 타임아웃 판정이 무효화된다. 따라서with_unconstrained으로 일시적으로 제한을 해제한다📎 tokio/src/time/timeout.rs:247。(false, true)은 불가능하다——예산은 소비될 수만 있고 복구될 수는 없다(명시적with_unconstrained이 있지 않은 한, 하지만 여기에는 없다).(false, false)은 진입 시 예산이 없음을 의미하며, 이때 value poll은 이미Pending을 반환했을 수 있고(poll_proceed실패로 인해), delay도 제한된 예산으로 poll되어 둘 다 pending이며, 이는 예상대로이다.(true, true)은 정상 상황으로, 예산이 충분하여 delay를 직접 poll한다.

여기까지 우리는 시간 구동이 어떻게 I/O driver의 park/unpark 메커니즘을 재사용하여 타이머와 fd 준비가 동일한 대기 진입점을 공유하게 하는지 살펴보았다. 타이밍 휠의 계층화, 만료 계산 및 Waker 트리거는 비동기 런타임이 "시간 준비"를 처리하는 완전한 폐루프를 구성한다. 그러나 비동기 대기는 I/O와 시간에 그치지 않는다——여러 태스크가 같은 락을 두고 경쟁하거나 채널을 통해 메시지를 전달할 때, Waker는 어디에 저장되어야 하는가? 다음 장에서는 tokio::sync 패밀리로 들어가, Mutex, Semaphore 및 각종 채널이 "대기자 큐 + Waker 깨우기"에서 어떻게 서로 다른 절충을 하는지 살펴본다.

모든 코드베이스를 진정으로 이해할 수 있는 책으로

이 장을 다 읽으셨나요? 내 프라이빗 저장소를 위한 아키텍처 책 만들기

Tauri 2 + Rust 로컬 퍼스트 아키텍처. 100% 오프라인 보안, 클라우드 코드 업로드 없음. 불변 커밋 라인 앵커로 정독.

⚡ Tauri 2 · Rust 네이티브 코어 · 100% 오프라인 보안 · 100만 행 이상 검증

CHAPTER 07

제 7 장: 동기화 원시 요소: Mutex, Semaphore 및 채널이 비동기 대기를 구현하는 방법

Upstream: tokio-rs/tokio · Commit @e800714a · 진행률: 제 7 장 / 총 14 장

이전 장에서는 시간이 어떻게 I/O 이벤트로 추상화되어 타이머와 fd 준비가 동일한 park/unpark 대기 진입점을 공유하게 하는지 밝혔다. 그러나 여러 태스크가 같은 락을 두고 경쟁하거나 채널을 통해 메시지를 전달할 때, 대기 대상은 더 이상 fd나 시계가 아니라 다른 태스크의 상태 변화이다. 이 장에서는 tokio::sync 패밀리로 들어가, 한 번의 lock().await 또는 recv().await가 블록될 때 Waker를 어디에 저장하는지, 깨어날 때 어떻게 다시 스케줄링되는지 탐구한다.

왜 비동기 Mutex가 std 구현을 재사용할 수 없는가

직관적 모델: "자리 차지"에서 "자리 양보"로

std::sync::Mutex의lock()은 락이 점유되었을 때현재 스레드를 블록한다——스레드는 운영체제에 의해 일시 중단되고, 락이 해제될 때까지 기다린다. 이는 비동기 런타임에서 재앙적이다: 하나의 worker 스레드가 동시에 수백 수천 개의 태스크를 구동할 수 있는데, 락을 기다리느라 블록되면 그 스레드가 담당하는 다른 모든 태스크가 전부 멈춘다. 비동기 Mutex의 핵심 요구는: 락을 기다릴 때스레드를 양보하고, "내가 이 락을 기다리고 있다"는 사실을 큐에 등록한 뒤Pending을 반환하여, 실행기가 다른 태스크를 실행하게 하는 것이다.

Tokio의Mutex은 자체적으로 대기 큐를 구현하지 않고,완전히 세마포어 위에 구축됨。

데이터 구조와 메모리 레이아웃

Mutex<T>의 필드는 극도로 간결하다:

📎 tokio/src/sync/mutex.rs:133-138

rust
pub struct Mutex<T: ?Sized> {
    #[cfg(all(tokio_unstable, feature = "tracing"))]
    resource_span: tracing::Span,
    s: semaphore::Semaphore,
    c: UnsafeCell<T>,
}

세 필드가 각자의 역할을 한다:s은 하나의허가 수가 1인 세마포어,c은UnsafeCell<T>으로 감싸진 보호 데이터이다. 여기서semaphore은batch_semaphore의 별칭📎 tokio/src/sync/mutex.rs:3-3, 즉 저수준 구현이며,sync::Semaphore그 공개 래퍼 계층이 아니다.

MutexGuard<'a, T>은 단지Mutex에 대한 참조만 보유한다:

📎 tokio/src/sync/mutex.rs:151-157

rust
pub struct MutexGuard<'a, T: ?Sized> {
    #[cfg(all(tokio_unstable, feature = "tracing"))]
    resource_span: tracing::Span,
    lock: &'a Mutex<T>,
}

여기에는 핵심 설계가 있다:MutexGuard 세마포어 허가 객체를 보유하지 않고, 오직&Mutex만 보유한다. 잠금 해제 동작은Drop에서 발생하며, 직접self.lock.s.release(1) 📎 tokio/src/sync/mutex.rs:959-961을 호출한다. 이는SemaphorePermit이permits: usize카운트를 보유하고 Drop 시 반환하는 것과 다르다 — Mutex의 허가 수는 항상 1이므로 카운트가 필요 없다.

Send/Sync의 경계는 따로 살펴볼 가치가 있다:

📎 tokio/src/sync/mutex.rs:258-259

rust
unsafe impl<T> Send for Mutex<T> where T: ?Sized + Send {}
unsafe impl<T> Sync for Mutex<T> where T: ?Sized + Send {}

Sync은T: Send만 요구하며T: Sync은 요구하지 않는다 — 이는 합리적인데, 상호 배타적 접근이 동시에 하나의 스레드만T을 건드릴 수 있음을 보장하므로, 스레드 간에T의 소유권(Send)을 전달하는 것으로 충분하며,T자체가 공유 가능(Sync)할 필요는 없다. 이것이 바로Mutex<T>이Sync가 아닌T을Sync로 만들 수 있는 이유이다.

단계별: 한 번의lock().await완전한 여정

시나리오: 태스크 A가mutex.lock().await을 호출하고, 이때 잠금은 유휴 상태이다.

첫 번째 단계,lock()은 async 블록을 생성하고, 내부에서 먼저self.acquire().await한 후, 성공하면MutexGuard 📎 tokio/src/sync/mutex.rs:434-443。

을 생성한다.acquire()두 번째 단계,

📎 tokio/src/sync/mutex.rs:655-663

rust
async fn acquire(&self) {
    crate::trace::async_trace_leaf().await;
    self.s.acquire(1).await.unwrap_or_else(|_| {
        unreachable!()
    });
}

unwrap_or_else(|_| unreachable!())복사acquire이 줄의 주석은 설계 제약을 드러낸다: Mutex는 세마포어를 명시적으로 close하지 않으며, 독점적으로 보유하므로Err은 결코

을 반환하지 않는다. 이는 "세마포어 닫힘"이라는 오류 경로를 타입 수준에서 배제한 것이다.s.acquire(1)세 번째 단계, 잠금이 점유되어 있으면,Pending은을 반환하고, 현재 태스크의 Waker가 세마포어의 대기 큐에 등록된다.Waker는 어디에 저장되는가?batch_semaphore답은

의 대기 큐에 있다 (이 장의 소스 자료에서는 해당 파일을 전개하지 않았지만, 그 역할은: 각 대기자가 하나의 Waker를 보유하고 FIFO로 대기한다).MutexGuard::drop네 번째 단계, 잠금을 보유한 태스크 B가 잠금을 해제할 때,s.release(1) 📎 tokio/src/sync/mutex.rs:965-975은acquire을 호출하고, 세마포어는 허가를 큐 선두 대기자에게 넘기고 그 Waker를 깨우며, 태스크 A가 다시 스케줄되고,Ok이MutexGuard。

을 반환하여

mermaid
sequenceDiagram
    participant TaskA as 任务 A
    participant Mutex as Mutex.s (batch_semaphore)
    participant TaskB as 任务 B (持锁者)
    participant Exec as Executor

    TaskA->>Mutex: acquire(1).await
    Mutex-->>TaskA: Pending (Waker 入队)
    TaskA->>Exec: 让出,调度其他任务
    Note over TaskB: 持有锁执行临界区
    TaskB->>Mutex: MutexGuard::drop -> release(1)
    Mutex->>TaskA: 唤醒队首 Waker
    Exec->>TaskA: 重新 poll
    TaskA->>Mutex: acquire(1) 重试
    Mutex-->>TaskA: Ok(()) 获得许可
    TaskA->>TaskA: 构造 MutexGuard

전체 흐름은 아래 시퀀스 다이어그램으로 묘사할 수 있다:

복사📎 tokio/src/sync/mutex.rs:20-22설계 사고: FIFO 공정성과 취소 안전성lock문서는 Tokio의 Mutex가 FIFOselect!을 보장한다고 명확히 선언한다. 이 공정성은 저수준 세마포어의 큐잉 의미론에서 비롯된다. 공정성의 대가는: 한 번의이 취소되면 (예를 들어 📎 tokio/src/sync/mutex.rs:415-419에서 패배하면)lock큐에서의 위치를 잃는다

. 이는 버그가 아니라 FIFO 큐의 필연이다 — 취소는 큐에서 제거됨을 의미하며, 다시하려면 다시 줄을 서야 한다.(no poisoning)。std::sync::Mutex또 다른 반직관적 설계는lock이 독을 넣지 않는다Err는 것이다.📎 tokio/src/sync/mutex.rs:122-125은 잠금 보유 스레드가 panic할 때 poisoned로 표시되고, 이후

MutexGuard::map이MutexGuard<T>을 반환한다. Tokio의 Mutex는 그렇게 하지 않는다: 보유자가 panic하면 잠금은 정상적으로 해제된다MappedMutexGuard<U>. 문서는 panic이 포착되면 보호 데이터가 일관성 없는 상태에 있을 수 있다고 경고한다. 이는 비동기 시나리오에서의 실용적 절충이다 — 비동기 태스크에서 panic은 보통 태스크 종료를 의미하며, 독극물 메커니즘은 오히려 복잡성을 증가시킨다.data시리즈 메서드는 언급할 가치가 있다. 이는 전체skip_drop을 특정 하위 필드만 보호하는MutexGuardInner로 강등할 수 있게 한다. 구현상, 먼저 클로저로 하위 필드 포인터📎 tokio/src/sync/mutex.rs:869-883。skip_drop를 계산하고, 그다음ManuallyDrop + ptr::read을 통해 원래 guard를 Drop을 트리거하지 않는Drop로 분해하고, 마지막으로 새 guard📎 tokio/src/sync/mutex.rs:827-836를 생성한다.

은

을 사용하여 필드 소유권을 이전하고,

이 두 번 호출되는 것을 피한다acquire. 이는 Rust에서 "소유권을 이전하되 소멸을 트리거하지 않는" 전형적인 기법이다.releaseSemaphore: 허가 카운트와 대기 큐가 백프레셔를 구현하는 방법acquire_many(n)직관적 모델: 주차장의 주차 공간

세마포어는 주차장과 같다:

은 차를 몰고 들어가는 것이고, 빈자리가 있으면 들어가고, 없으면 입구에서 줄을 선다;Semaphore은 차를 몰고 나가는 것이고, 자리가 하나 비면 큐 선두의 차에게 들어오라고 알린다. 허가 수는 총 주차 공간 수이고,batch_semaphore::Semaphore은 n개의 주차 공간을 차지하는 큰 차이다.

📎 tokio/src/sync/semaphore.rs:427-432

rust
pub struct Semaphore {
    ll_sem: ll::Semaphore,
    #[cfg(all(tokio_unstable, feature = "tracing"))]
    resource_span: tracing::Span,
}

SemaphorePermit<'a>공개된

📎 tokio/src/sync/semaphore.rs:442-445

rust
pub struct SemaphorePermit<'a> {
    sem: &'a Semaphore,
    permits: usize,
}

permits의 얇은 래퍼이다:forget/merge/split복사forget은 세마포어 참조와 허가 카운트를 보유한다:permits복사📎 tokio/src/sync/semaphore.rs:1193-1195필드는split을 이해하는 핵심이다.📎 tokio/src/sync/semaphore.rs:1260-1271。merge은📎 tokio/src/sync/semaphore.rs:1230-1240。

을 0으로 설정하여

MAX_PERMITS, Drop 시 0개의 허가를 반환한다 — 이는 이 허가들을 "영구 소비"하는 것과 동등하다.usize::MAX >> 3 📎 tokio/src/sync/semaphore.rs:476-479은 현재 허가에서 n개를 잘라 새 permit에 준다.batch_semaphore은 다른 permit의 카운트를 병합하고, 둘이 같은 세마포어에서 왔음을 단언한다usize〔설계 추론과 아키텍처 절충〕

은

이다. 왜 오른쪽으로 3비트 시프트하는가? 저수준acquire()은 상위 비트에 상태 플래그(예: 닫힘 플래그)를 인코딩해야 하므로, 사용 가능한 허가 수를 하위 비트로 제한하고 상위 비트를 플래그용으로 남긴다. 이는 "카운트 + 상태"를 단일acquire_many(2)。

acquire()에 압축하는 흔한 기법이다.ll_sem.acquire(1)단계별: acquire와 release의 허가 흐름SemaphorePermit { permits: 1 } 📎 tokio/src/sync/semaphore.rs:614-631。acquire_many(2)시나리오: 세마포어 초기 허가 2개, 태스크 A📎 tokio/src/sync/semaphore.rs:661-679。

, 태스크 Bll_sem.acquire(n)은Pending에 위임하고, 성공 후acquire_many(5)을 생성한다.acquire(1)과 유사하지만 2를 전달한다📎 tokio/src/sync/semaphore.rs:19-24허가가 부족하면,

은

📎 tokio/src/sync/semaphore.rs:1402-1404

rust
impl Drop for SemaphorePermit<'_> {
    fn drop(&mut self) {
        self.sem.add_permits(self.permits);
    }
}

add_permits인데 현재 3개의 허가만 남아 있고, 뒤에ll_sem.release(n) 📎 tokio/src/sync/semaphore.rs:568-570이 있어 즉시 충족할 수 있더라도, 기다려야 한다고 지적한다 — 큐 선두의 큰 차가 큐를 차지하고 있기

때문이다. 이는 엄격한 FIFO의 대가이며, 기아를 방지한다.AcqRel해제 경로는 Drop에 있다:AcqRel 📎 tokio/src/sync/semaphore.rs:35-42. 이는 '데이터를 먼저 쓰고 release 허가를 하는' 쓰기가 '나중에 acquire 허가를 하는' 태스크에 가시적임을 의미한다——세마포어는 태스크 간에 데이터를 안전하게 전달할 수 있다.

설계 고찰: close와 배압

close()모든 대기자가AcquireError를 수신하고, 이후try_acquire가Closed 📎 tokio/src/sync/semaphore.rs:1161-1163를 반환하게 한다. 이것이 우아한 종료의 기초이다: 수신 측이 더 이상 데이터를 필요로 하지 않을 때, close 세마포어는 모든 블로킹된 송신자가 영원히 기다리는 대신 즉시 실패로 반환하게 할 수 있다.

배압의 본질은 mpsc에서 가장 명확하게 드러난다. 다음 절에서 보겠지만, mpsc의 용량 제어는 허가 수가 buffer 크기와 같은 세마포어로 구현된다.

채널 패밀리: 대기자 큐와 Waker 깨우기의 서로 다른 절충

직관적 모델: 네 가지 채널, 네 가지 대기 전략

oneshot은 '일회용 봉투'이다——편지 한 통만 보낼 수 있고, 송신자는 기다리지 않으며(send는 동기적이다), 수신자는await편지를 기다린다.mpsc은 '유계 컨베이어 벨트'이다——송신자는 벨트가 가득 차면 기다리고, 수신자는 비어 있으면 기다리며, 용량은 세마포어로 제어된다.broadcast와watch는 '방송 확성기'이다——하나의 송신자, 여러 수신자이지만, 둘은 '뒤처짐'에 대한 처리가 전혀 다르다.

이 절의 소스 자료는oneshot와mpsc::bounded에 초점을 맞추며, 하나씩 분석한다.

oneshot: 상태 비트로 인코딩된 극도로 간결한 핸드셰이크

oneshot의Inner구조는 그 설계를 이해하는 핵심이다:

📎 tokio/src/sync/oneshot.rs:386-409

rust
struct Inner<T> {
    state: AtomicUsize,
    value: UnsafeCell<Option<T>>,
    tx_task: Task,
    rx_task: Task,
}

state은AtomicUsize이며, 비트 플래그로 전체 채널의 상태를 인코딩한다. 네 개의 플래그 비트는 파일 끝에 정의되어 있다:

📎 tokio/src/sync/oneshot.rs:1488-1505

rust
const RX_TASK_SET: usize = 0b00001;
const VALUE_SENT: usize = 0b00010;
const CLOSED: usize = 0b00100;
const TX_TASK_SET: usize = 0b01000;

value은UnsafeCell<Option<T>>,tx_task과rx_task은Task타입이며, 내부는UnsafeCell<MaybeUninit<Waker>> 📎 tokio/src/sync/oneshot.rs:411-411이다. 주목할 점은MaybeUninit——Waker는 초기화되지 않았을 수 있으며, 유효한지는state안의RX_TASK_SET/TX_TASK_SET비트로 결정된다📎 tokio/src/sync/oneshot.rs:396-399。

이 설계의 정수:VALUE_SENT비트는 '값이 전송되었음'을 나타낼 뿐만 아니라,UnsafeCell의 접근 권한 귀속을 결정한다. 주석은 매우 명확하게 쓰여 있다📎 tokio/src/sync/oneshot.rs:1491-1496: 만약VALUE_SENT이 설정되면,UnsafeCell은 수신자만 접근할 수 있고; 설정되지 않으면 송신자만 접근할 수 있다. 이렇게 하나의 원자적 비트로 락 없는 소유권 이전을 구현하여 추가적인 락을 피한다.

send의 흐름:

📎 tokio/src/sync/oneshot.rs:622-646

rust
pub fn send(mut self, t: T) -> Result<(), T> {
    let inner = self.inner.take().unwrap();
    inner.value.with_mut(|ptr| unsafe {
        *ptr = Some(t);
    });
    if !inner.complete() {
        unsafe {
            return Err(inner.consume_value().unwrap());
        }
    }
    Ok(())
}

먼저 값을UnsafeCell에 쓴다(이때VALUE_SENT은 설정되지 않았으므로 수신자는 접근하지 않는다), 그런 다음complete()를 호출하여VALUE_SENT。complete()설정을 시도한다. 이는 CAS 루프이다:

📎 tokio/src/sync/oneshot.rs:1516-1549

rust
fn set_complete(cell: &AtomicUsize) -> State {
    let mut state = cell.load(Ordering::Relaxed);
    loop {
        if State(state).is_closed() {
            break;
        }
        match cell.compare_exchange_weak(
            state, state | VALUE_SENT, Ordering::AcqRel, Ordering::Acquire,
        ) {
            Ok(_) => break,
            Err(actual) => state = actual,
        }
    }
    State(state)
}

왜 단순한fetch_or대신 CAS를 사용하는가? 주석이 명확히 설명한다📎 tokio/src/sync/oneshot.rs:1517-1529: 만약 채널이 이미CLOSED이면,할 수 없다다시VALUE_SENT을 설정할 수 없다. 왜냐하면 일단 설정되면 수신자는UnsafeCell에 접근할 수 있다고 생각하는데, 이때 송신자는 값을 되가져가려고 준비 중이며(consume_value), 양쪽이 동시에 접근하면 데이터 경쟁이 발생한다. 따라서 CAS 루프는CLOSED을 발견하면 조기에 break하여 설정하지 않는다.

complete()가 반환된 후, 성공적으로 설정되었고RX_TASK_SET이 이미 설정되어 있으면 수신자를 깨운다:

📎 tokio/src/sync/oneshot.rs:1300-1315

rust
fn complete(&self) -> bool {
    let prev = State::set_complete(&self.state);
    if prev.is_closed() {
        return false;
    }
    if prev.is_rx_task_set() {
        unsafe {
            self.rx_task.with_task(Waker::wake_by_ref);
        }
    }
    true
}

수신자의poll_recv은 상태 기계의 핵심이다:

📎 tokio/src/sync/oneshot.rs:1317-1384

먼저 상태를 로드하고, 만약is_complete()이면 직접consume_value을 반환하며; 만약is_closed()이면Err을 반환하고; 그렇지 않으면 'Waker 등록' 분기로 들어간다. 등록 시 먼저is_rx_task_set()을 확인하고, 이미 설정되어 있고will_wake이 동일한 Waker로 판단되면 중복 설정하지 않으며; 다르면 먼저 unset한 후 set한다. 여기에는 미묘한 경쟁 처리 가 있다: unset 후에is_complete()이 참이 된 것을 발견하면, 플래그 비트를다시 set해야 한다 📎 tokio/src/sync/oneshot.rs:1342-1344, 그렇지 않으면 Waker가 Drop 시 누출된다(Drop이 플래그 비트에 의존하여 Waker를 drop할지 판단하기 때문이다).

이 'unset 후 재설정' 패턴은poll_closed에서도 나타나며📎 tokio/src/sync/oneshot.rs:839-848, oneshot이 동시 깨우기를 처리하는 표준 기법이다.

mpsc::bounded: 세마포어가 구동하는 배압

mpsc의 용량 제어는 전적으로 세마포어에 위임된다.channel함수는 허가 수가 buffer와 같은 세마포어를 생성한다:

📎 tokio/src/sync/mpsc/bounded.rs:159-171

rust
pub fn channel<T>(buffer: usize) -> (Sender<T>, Receiver<T>) {
    assert!(buffer > 0, "mpsc bounded channel requires buffer > 0");
    let semaphore = Semaphore {
        semaphore: semaphore::Semaphore::new(buffer),
        bound: buffer,
    };
    let (tx, rx) = chan::channel(semaphore);
    let tx = Sender::new(tx);
    let rx = Receiver::new(rx);
    (tx, rx)
}

Semaphore은 mpsc 내부의 래퍼로,底层 세마포어와bound(최대 용량)을 동시에 보유한다📎 tokio/src/sync/mpsc/bounded.rs:176-179。bound은max_capacity조회에 사용되고,available_permits은 현재 용량을 제공한다📎 tokio/src/sync/mpsc/bounded.rs:591-593。

송신 경로send은 먼저reserve한 후send:

📎 tokio/src/sync/mpsc/bounded.rs:816-824

rust
pub async fn send(&self, value: T) -> Result<(), SendError<T>> {
    match self.reserve().await {
        Ok(permit) => {
            permit.send(value);
            Ok(())
        }
        Err(_) => Err(SendError(value)),
    }
}

reserve내부에서reserve_inner(1)을 호출하며, 후자는 먼저n > max_capacity을 확인하여 직접 오류를 반환하고, 그런 다음acquire(n) 📎 tokio/src/sync/mpsc/bounded.rs:1272-1311을 한다. 여기에는 정교한WakeReceiverOnDrop가드가 있다:

📎 tokio/src/sync/mpsc/bounded.rs:1286-1301

rust
struct WakeReceiverOnDrop<'a, T> {
    chan: &'a chan::Tx<T, Semaphore>,
}
impl<T> Drop for WakeReceiverOnDrop<'_, T> {
    fn drop(&mut self) {
        use chan::Semaphore;
        let semaphore = self.chan.semaphore();
        if semaphore.is_closed() && semaphore.is_idle() {
            self.chan.wake_rx();
        }
    }
}

주석이 동기를 설명한다📎 tokio/src/sync/mpsc/bounded.rs:1279-1285: 만약reserve이 부분 허가를 얻은 후 취소되면(예:select!이 패배), 底层Acquire은 Drop 시 이 허가들을 반환하지만,하지 않는다처럼Permit수신자에게 알리지 않는다. 만약 이때 채널이 이미 닫혔고 유휴 상태라면, 수신자는 '채널이 닫혔음' 알림을 영원히 받지 못할 수 있다. 이 가드는 Drop 시 이 깨우기를 보충한다. 성공 시mem::forget(guard)으로 가드를 취소하며📎 tokio/src/sync/mpsc/bounded.rs:1306-1306, 성공 경로는Permit이 알림 책임을 인계받기 때문이다.

Permit의 Drop도 같은 일을 한다:

📎 tokio/src/sync/mpsc/bounded.rs:1732-1745

rust
impl<T> Drop for Permit<'_, T> {
    fn drop(&mut self) {
        use chan::Semaphore;
        let semaphore = self.chan.semaphore();
        semaphore.add_permit();
        if semaphore.is_closed() && semaphore.is_idle() {
            self.chan.wake_rx();
        }
    }
}

Permit::send은mem::forget으로 Drop을 건너뛰어 허가 반환을 피한다📎 tokio/src/sync/mpsc/bounded.rs:1721-1728。

수신 경로recv은poll_fn으로chan.recv(cx) 📎 tokio/src/sync/mpsc/bounded.rs:243-246。poll_recv을 래핑하여 직접📎 tokio/src/sync/mpsc/bounded.rs:650-652에 위임한다. 실제 대기 큐 로직은chan모듈에 있지만(이 장에서 다루지 않음), 추론할 수 있다: 수신자 Waker는chan::Rx에 저장되고, 송신자가send할 때 깨운다.

try_send은 비블로킹 경로를 보여준다:

📎 tokio/src/sync/mpsc/bounded.rs:924-934

rust
pub fn try_send(&self, message: T) -> Result<(), TrySendError<T>> {
    match self.chan.semaphore().semaphore.try_acquire(1) {
        Ok(()) => {}
        Err(TryAcquireError::Closed) => return Err(TrySendError::Closed(message)),
        Err(TryAcquireError::NoPermits) => return Err(TrySendError::Full(message)),
    }
    self.chan.send(message);
    Ok(())
}

try_acquire의 두 가지 오류는 정확히Closed과Full에 매핑되어, '채널 닫힘'과 '버퍼 가득 참' 두 가지 실패를 구분한다.

설계 고찰: 취소 안전성과 메시지 손실

mpsc 문서는 취소 안전성을 반복적으로 강조한다📎 tokio/src/sync/mpsc/bounded.rs:776-784:send이select!에서 패배하면,메시지가 버려진다. 손실을 피하려면 반드시reserve으로Permit을 얻은 후send해야 한다——왜냐하면Permit이 이미 용량을 예약했고,send은 동기적이며 중단되지 않기 때문이다.

recv은 취소 안전한📎 tokio/src/sync/mpsc/bounded.rs:199-204이다: 만약recv이select!에서 패배하면, 메시지가 소비되지 않음이 보장된다. 이는recv의poll_recv이 실제로 메시지를 얻었을 때만Ready,Pending을 반환하고

oneshot시 큐를 건드리지 않기 때문이다.Receiver의📎 tokio/src/sync/oneshot.rs:246-251은 Future로서 취소 안전하다oneshot. 하지만 주의할 점:send의Err은 동기적이므로 'send가 취소되는' 문제는 존재하지 않는다——要么 보내지거나,

이 원래 값을 반환한다.

함정 1: 비동기 Mutex로 순수 데이터를 보호하는 것.문서는 명확히 권장한다📎 tokio/src/sync/mutex.rs:26-36: 보호 대상이 순수 데이터(없음.await요구사항)라면,std::sync::Mutex또는parking_lot가 더 빠르다. 비동기 Mutex의 오버헤드는 세마포어의 원자적 연산과 가능한 태스크 스케줄링에 있다. 오직 잠금을 보유한 동안.await(예: 잠금을 보유하고 데이터베이스 연결에 접근)이 필요할 때만 비동기 Mutex를 사용해야 한다.

함정 2: 잠금을 보유한 채.await를 넘어 데드락 발생.이것은 비동기 Mutex의 가장 위험한 함정이다. 만약 태스크 A가 잠금을 보유한 후.await태스크 B의 완료가 필요한 이벤트를 기다리고, 태스크 B는 이 잠금을 기다린다면, 데드락이 발생한다.std::sync::Mutex의 guard는Send가 아니므로(이동 가능한 태스크에서), 컴파일러는.await를 넘어 보유하는 것을 막는다; 그러나 비동기 Mutex의 guard는Send 📎 tokio/src/sync/mutex.rs:314-314이므로, 컴파일러가 막지 않으며, 순환 대기를 형성하지 않도록 스스로 보장해야 한다.

함정 3:reserve후send。 Permit를 잊는 것. Drop은 허가를 반환하므로📎 tokio/src/sync/mpsc/bounded.rs:1732-1745, 용량이 누출되지 않는다. 그러나 채널이 닫혔고 유휴 상태라면, Drop은 수신자를 깨운다—이 깨움은 필수적이며, 그렇지 않으면 수신자가 닫힘 알림을 영원히 기다릴 수 있다.

함정 4:oneshot의poll는 거짓Pending。일 수 있다. 문서 설명📎 tokio/src/sync/oneshot.rs:236-242: 메시지가 이미 전송되었더라도,poll는Pending를 반환할 수 있다. 이것은 버그가 아니라 동시성 경쟁 상태에서의 정상 현상이다—호출자는 깨어나 재시도하며, 메시지는 손실되지 않고 단지 지연될 뿐이다.

함정 5:forget_permits의 의미론. forget_permits(n)n개의 허가를 줄이려 시도하고, 실제로 줄어든 수를 반환한다📎 tokio/src/sync/semaphore.rs:576-578. 이것은 블록하지 않고, 대기자를 깨우지도 않는다—단순히 허가를 "삼킨다". 동적으로 세마포어 용량을 축소하는 데 사용된다.

이 장 요약

이 장은tokio::sync의 핵심 패턴을 밝힌다:모든 비동기 대기 원시 요소는 "대기자 큐 + Waker 깨움" 위에 구축되며, 큐의 구체적 구현은 시나리오에 따라 다르다。

  • Mutex허가 수가 1인 세마포어를 재사용하고,MutexGuard참조만 보유하며, Drop 시release(1), FIFO 공정하지만 독살하지 않는다.
  • Semaphore는 허가 카운트 + 대기 큐이며,SemaphorePermit는permits카운트를 사용하여forget/merge/split,MAX_PERMITS를 지원하고, 오른쪽으로 3비트 시프트하여 상태 플래그를 위한 자리를 남긴다.
  • oneshot단일AtomicUsize의 비트 플래그로 상태를 인코딩하고,VALUE_SENT비트가 동시에UnsafeCell의 접근 권한 귀속을 결정하며, CAS 루프는CLOSED후 설정을 방지한다.
  • mpsc::bounded허가 수가 buffer와 같은 세마포어로 배압을 구현하고,WakeReceiverOnDrop가드는 취소 시 깨움 보상을 처리한다.

이 장 사고와 자가 테스트

Q: 만약set_complete의 CAS 루프를 단순한fetch_or(VALUE_SENT)로 바꾸면, 어떤 동시성 시나리오에서 데이터 경쟁이 발생하는가?

참고 해석:set_complete가fetch_or대신 CAS 루프를 사용하는 이유는 주석에 명시되어 있다📎 tokio/src/sync/oneshot.rs:1517-1529: 반드시VALUE_SENT를 설정하기 전에CLOSED를 확인해야 한다. 만약 무조건fetch_or로 바꾸면, 이 타이밍을 고려하라: 수신자가 먼저close()를 호출하여CLOSED 📎 tokio/src/sync/oneshot.rs:1569-1574를 설정하고, 송신자가 이후send로 값을 쓰고fetch_or(VALUE_SENT)를 한다. 이때VALUE_SENT와CLOSED가 동시에 설정되고, 수신자의poll_recv는is_complete()가 참임을 보고consume_value를 호출하여 값📎 tokio/src/sync/oneshot.rs:1325-1330을 가져간다; 그리고 송신자의complete()가 반환된 후,prev.is_closed()가 참이므로consume_value를 호출하여 값을 되찾는다📎 tokio/src/sync/oneshot.rs:1300-1315. 양쪽이 동시에UnsafeCell에 접근하여 데이터 경쟁이 발생한다. CAS 루프는CLOSED를 발견하면 조기 break하여VALUE_SENT를 설정하지 않음으로써, "닫힌 후 송신자 독점 접근 권한"이라는 불변성을 보장한다.

Q: reserve_inner의WakeReceiverOnDrop가드는 성공 경로에서mem::forget로 건너뛰는데, 만약 이forget를 제거하면 무슨 일이 발생하는가?

참고 해석: 가드의 Drop 로직은 "세마포어가 닫혔고 유휴 상태면 수신자를 깨운다"📎 tokio/src/sync/mpsc/bounded.rs:1290-1298이다. 성공 경로에서,acquire(n)는Ok를 반환하고, 호출자는 허가를 얻고Permit를 구성할 것이며,Permit가 이후 알림 책임을 맡는다. 만약 가드를 제거하지 않으면, 가드는 함수 반환 시 Drop되어 "닫혔고 유휴"를 추가로 확인한다—그러나 이때 허가는 이미reserve_inner의 호출자가 보유하고 있어, 세마포어는 유휴가 아니므로(is_idle가 거짓), 실제로 중복 깨움은 발생하지 않는다. 그러나 더 중요한 것은 의미론적 명확성이다: 성공 경로의 깨움 책임은 전적으로Permit가 맡아야 하며, 가드는 "취소/실패" 경로의 보상만 담당한다.mem::forget는 "이 경로는 가드가 필요 없다"는 의도를 명확히 표현한다. 만약forget를 제거하고 마침 세마포어가 "닫혔고 유휴" 경계 상태에 있다면(예:acquire가Ok를 반환했지만 허가가 아직Permit에 의해 인수되지 않음), 중복 깨움이 한 번 발생할 수 있다—오류를 초래하지는 않지만, 스케줄링을 한 번 낭비한다.

Q: 만약MutexGuard를 세마포어 허가 객체를 보유하도록(SemaphorePermit처럼) 바꾸면, 어떤 문제가 발생하는가?

참고 해석: 현재MutexGuard는&Mutex만 보유하고, Drop 시self.lock.s.release(1) 📎 tokio/src/sync/mutex.rs:959-961를 호출한다. 만약 허가 객체를 보유하도록 바꾸면, 몇 가지 문제가 발생한다. 첫째,MutexGuard::map계열 메서드는 guard를MappedMutexGuard로 분해하여 하위 필드📎 tokio/src/sync/mutex.rs:869-883만 보호해야 한다. 현재 설계에서,MappedMutexGuard는&Semaphore와 하위 필드 포인터📎 tokio/src/sync/mutex.rs:190-199만 보유하면 되고, Drop 시self.s.release(1) 📎 tokio/src/sync/mutex.rs:1252-1262를 한다. 만약 guard가 허가 객체를 보유하면, map 시 허가 객체의 소유권을 이전해야 하며,MappedMutexGuard의 필드 레이아웃이 더 복잡해진다. 둘째, 허가 객체는 보통permits: usize카운트를 가지며, Mutex에 대해 이 카운트는 항상 1이므로 중복이다. 셋째,MutexGuard의Send/Sync경계는 이미unsafe impl를 통해 정확히 제어되어📎 tokio/src/sync/mutex.rs:260-263, 허가 객체를 보유하면 추가 trait 제약이 도입된다. 현재 "참조만 보유 + 수동 release" 설계가 더 가볍고,map。

를 더 쉽게 지원한다. 여기까지, 우리는tokio::sync가 "대기자 큐 + Waker 깨움"이라는 통일된 패턴으로 Mutex, Semaphore 및 다양한 채널의 비동기 대기를 어떻게 지원하는지 명확히 보았다. 그러나 모든 블로킹이 비동기화될 수 있는 것은 아니다—일부 작업(예: 파일 시스템 호출, CPU 집약 계산)은 본질적으로 스레드를 블록한다. 다음 장에서는spawn_blocking스레드 풀과block_on의 경계로 들어가, Tokio가 비동기 런타임과 동기 블로킹 사이에 어떻게 다리를 놓는지 살펴본다.

Waker의 저장 위치는 원시 연산에 따라 다르다: Mutex/Semaphore는 하위 세마포어의 대기 큐에, oneshot은 Inner의 tx_task/rx_task 필드에, mpsc는 chan 모듈의 송수신 큐에 저장된다. 그러나 깨우기 메커니즘은 통일되어 있다: 상태 변경 시 Waker를 꺼내 wake_by_ref를 호출하면 실행기가 태스크를 다시 스케줄링한다. 여기까지 비동기 원시 연산 내부의 대기와 깨우기가 명확히 드러났다. 그러나 모든 코드가 비동기화될 수 있는 것은 아니다—다음 장에서는 spawn_blocking으로 블로킹 작업을 브리징하는 방법과 block_on이 비동기 컨텍스트가 아닌 곳에서 Future를 구동하는 방법을 살펴본다.

모든 코드베이스를 진정으로 이해할 수 있는 책으로

이 장을 다 읽으셨나요? 내 프라이빗 저장소를 위한 아키텍처 책 만들기

Tauri 2 + Rust 로컬 퍼스트 아키텍처. 100% 오프라인 보안, 클라우드 코드 업로드 없음. 불변 커밋 라인 앵커로 정독.

⚡ Tauri 2 · Rust 네이티브 코어 · 100% 오프라인 보안 · 100만 행 이상 검증

CHAPTER 08

제 8 장: 블로킹과 브리징: spawn_blocking 스레드 풀과 block_on의 경계

Upstream: tokio-rs/tokio · Commit @e800714a · 진행률: 제 8 장 / 총 14 장

이전 장에서 우리는 비동기 Mutex와 채널이 대기할 때 스레드를 점유하지 않을 수 있는 핵심이 Waker를 대기 큐에 저장하고, 조건이 충족된 후 깨우는 자가 태스크를 다시 스케줄링하는 데 있다는 것을 보았다. 그러나 이 모든 것의 전제는 태스크가 Pending일 때 능동적으로 스레드를 양보할 수 있다는 것이다. 일단 코드가 std::fs::read, libsqlite3 또는 순수 CPU 압축 루프를 호출하면, 반환될 때까지 worker 스레드를 독점하게 되고, 그 동안 해당 스레드의 다른 태스크들은 모두 굶어 죽는다. Tokio의 해결책은 이런 작업을 독립적인 블로킹 스레드 풀에 외주하고, block_on으로 비동기 컨텍스트가 아닌 곳에서 Future를 구동하는 것이다. 이 장에서는 이 두 경계를 분해한다.

8.1 블로킹 스레드 풀의 메모리 레이아웃: Inner와 이중 구현 큐

직관적 모델:spawn_blocking스레드 풀은 식당의 「외주 도우미 풀」과 같다. 홀 서빙 직원(worker 스레드)은 주문 받고 음식 나르는 것만 담당하고, 오래 끓여야 하는 요리를 만나면 작업 지시서를 써서 주방의 전달 창구(큐)에 던져 넣고, 도우미(블로킹 스레드)가 창구에서 주문서를 가져간다. 이 풀이 없다면 서빙 직원이 직접 요리해야 하고, 식당 전체가 멈춘다.

핵심 구조. 전체 풀은BlockingPool이 보유하며, 두 가지만 저장한다: 복제 가능한Spawner(투입 입구)와shutdown_rx(종료 신호 수신단)📎 tokio/src/runtime/blocking/pool.rs:20-23。Spawner내부는Arc<Inner>이며, 모든 투입자가 동일한 상태를 공유한다📎 tokio/src/runtime/blocking/pool.rs:26-28。

Inner은 풀의 전체 상태이며, 필드를 하나씩 살펴볼 가치가 있다📎 tokio/src/runtime/blocking/pool.rs:77-104:

  • inner_impl: InnerImpl: 큐 + 알림 + 잠금 토폴로지의 구현으로, 열거형이며Locked과Sharded두 가지 변형이 있다📎 tokio/src/runtime/blocking/pool.rs:107-110. 이것이 이 장의 가장 핵심적인 추상화다—「단일 잠금 큐」와 「샤딩 큐」 두 토폴로지를 하나의 인터페이스 아래 통합한다.
  • thread_cap: usize: 스레드 수 상한, 즉max_blocking_threads。
  • scheduler_threads: usize: 스케줄러 worker 스레드 수, 지표에서 차감하는 데 사용되어num_blocking_threads이 블로킹 스레드만 집계하도록 한다📎 tokio/src/runtime/blocking/pool.rs:455-460。
  • keep_alive: Duration: 유휴 스레드 생존 시간, 기본값KEEP_ALIVE = 10s 📎 tokio/src/runtime/blocking/pool.rs:231。
  • metrics: SpawnerMetrics: 세 개의 원자 카운터——num_threads、num_idle_threads、queue_depth 📎 tokio/src/runtime/blocking/pool.rs:31-35。
〔설계 추론과 아키텍처 트레이드오프〕

왜 잠금 내 필드 대신 원자 카운터를 사용하는가? num_idle_threads은spawn_task의 핫 패스에서 읽힌다(유휴 스레드를 깨워야 하는지 판단). 만약 이것이Mutex안에 숨어 있다면, 매번 투입할 때마다 먼저 잠금을 잡고 읽어야 한다. 이것을MetricAtomicUsize으로 만들면, 투입 경로가 큐 잠금을 보유하지 않고 먼저 빠른 판단을 할 수 있다. 대가는 이 카운터들과 큐 상태 사이에 원자성 보장이 없다는 것이며, 따라서 코드에서num_notify카운터로 보상한다—아래 참조.

스레드 관리 상태。ThreadManagementState이 별도로 추출되어 두 큐 구현에서 재사용된다📎 tokio/src/runtime/blocking/pool.rs:135-150:

  • shutdown: bool: 종료 플래그.
  • shutdown_tx: Option<shutdown::Sender>: 각 worker 스레드가 하나의 복제본을 보유하며, 모두 drop된 후shutdown_rx이 알림을 받는다.
  • last_exiting_thread: Option<JoinHandle<()>>: 마지막으로 타임아웃 종료한 스레드 핸들.
  • worker_threads: HashMap<usize, JoinHandle<()>>: 모든 살아있는 worker의 핸들.
  • worker_thread_index: usize: 단조 증가하는 스레드 ID 할당기.

last_exiting_thread의 설계 동기는 주석에 명확히 쓰여 있다: 타임아웃 종료한 스레드가 이전에 타임아웃 종료한 스레드를 join하여 Valgrind 오탐을 피한다📎 tokio/src/runtime/blocking/pool.rs:135-150。worker_timed_out이 바로 이 체인 join의 구현이다—자신의 핸들을 제거하고, 이전last_exiting_thread을 교체하여 호출자에게 반환해 join하게 한다📎 tokio/src/runtime/blocking/pool.rs:172-178。

태스크 래핑. 큐에 저장되는 것은Task이며, 이는UnownedTask<BlockingSchedule>과Mandatory플래그를 감싼다📎 tokio/src/runtime/blocking/pool.rs:187-191。Mandatory은 종료 시 이 태스크가 버려질지 강제 실행될지를 결정한다:shutdown_or_run_if_mandatory은NonMandatory시shutdown()을 호출하고,Mandatory시run() 📎 tokio/src/runtime/blocking/pool.rs:223-228을 호출한다. 이것이spawn_blocking(비강제)와spawn_mandatory_blocking(강제, fs에서 사용)의 차이다📎 tokio/src/runtime/blocking/pool.rs:233-265。

단일 잠금 구현의 메모리 레이아웃。LockedImpl은 가장 원시적인 토폴로지다:Mutex<LockedInner>하나에Condvar 📎 tokio/src/runtime/blocking/pool.rs:113-116。LockedInner하나를 더한 것VecDeque<Task>、num_notify: u32안에는thread_mgmt_state 📎 tokio/src/runtime/blocking/pool.rs:118-124과num_notify이 있다. 주목할 점은thread_mgmt_state과num_idle_threads이 같은 잠금 아래에 있고,

은 잠금 밖의 원자량이라는 것이다—이러한 「일부 상태는 잠금 안에, 일부는 잠금 밖에」 있는 혼합 레이아웃이 바로 이후 모든 동시성 미묘함의 근원이다.

8.2 투입 경로: spawn_blocking에서 스레드 깨우기까지시나리오tokio::task::spawn_blocking(move || heavy_compute(data)): 비동기 태스크에서

을 호출하면, 이 순간 무슨 일이 일어나는가?。Spawner::spawn_blocking1단계: 박싱 결정과 태스크 구성fn_size은 먼저 클로저 크기AutoBox::<F>::SHOULD_BOX를 측정한 후,Box에 따라 클로저를📎 tokio/src/runtime/blocking/pool.rs:359-389할지 결정한다

. 이것은 Tokio의 일반적인 「큰 Future 자동 박싱」 전략이다: 클로저가 너무 크면 박싱하여 태스크 구조체 비대화를 피한다.spawn_blocking_inner에 진입하여, 먼저 태스크 ID를 할당하고,blocking_task으로 클로저를 Future로 감싼 후,task::unowned으로UnownedTask과JoinHandle 📎 tokio/src/runtime/blocking/pool.rs:440-449을 구성한다. 여기서 반환되는 것은(JoinHandle<R>, Result<(), SpawnError>)이중 튜플이다—핸들과 투입 결과가 분리되어 반환된다.

2단계: 투입 결과의 세 가지 처리.spawn_blocking로 돌아가서,spawn_result에 대해 매칭한다📎 tokio/src/runtime/blocking/pool.rs:381-388:

  • Ok(()): 정상이면 핸들을 반환한다.
  • Err(ShuttingDown):panic하지 않음, 여전히 핸들을 반환한다. 주석은 이것이 호환성 고려사항이라고 설명한다 — 핸들은 결코 resolve되지 않지만, 호출자는 런타임이 종료 중이라는 이유로 크래시하지 않는다.
  • Err(NoThreads(e)): OS가 스레드를 생성할 수 없고 풀에서 아무도 인수하지 않으면, 직접 panic한다.

세 번째 단계: 큐잉과 깨우기 결정。spawn_task가on_no_idle클로저를InnerImpl::spawn_task에 전달하고, 구체적 구현이 언제 그것을 호출할지 결정한다📎 tokio/src/runtime/blocking/pool.rs:462-506.LockedImpl::spawn_task의 임계 영역을 보자📎 tokio/src/runtime/blocking/pool.rs:603-639:

rust
let mut locked = self.mutex.lock();

if locked.thread_mgmt_state.shutdown {
    task.task.shutdown();
    return Err(SpawnError::ShuttingDown);
}

locked.queue.push_back(task);
metrics.inc_queue_depth();

if metrics.num_idle_threads() == 0 {
    on_no_idle(&mut locked.thread_mgmt_state)?;
} else {
    metrics.dec_num_idle_threads();
    locked.num_notify += 1;
    self.condvar.notify_one();
}

여기에는 두 가지 핵심 사항이 있다. 첫째, 종료 검사가 큐잉 이전에 이루어지며, 작업이Mandatory이더라도 직접shutdown()— 주석은 설명한다: 종료가 시작된 이후에 스케줄되었으므로 폐기가 합법적이다📎 tokio/src/runtime/blocking/pool.rs:614-620. 둘째, 깨우기 결정은 락 외부의num_idle_threads에 의존한다: 0이면on_no_idle를 호출하여 새 스레드를 시작하려 시도하고, 그렇지 않으면 유휴 카운트를 감소시키고num_notify、notify_one。

num_notify를 증가시킨다. 왜 반드시 존재해야 하는가?는 허위 깨우기(spurious wakeup)를 생성할 수 있기 때문이다.Condvar만 사용하고 카운트하지 않으면, 허위로 깨어난 스레드는 가져갈 작업이 있다고 잘못 생각하고, 큐가 비어 있는 것을 발견한 후 다시 잠들며, 실제로 깨어나야 할 스레드는 영원히 알림을 받지 못할 수 있다.notify_one는 "합법적 깨우기"를 카운트 가능한 토큰으로 만든다: 전달자가num_notify, 깨어난 쪽이+1일 때만 깨우기가 합법적이라고 간주하고num_notify != 0-1 📎 tokio/src/runtime/blocking/pool.rs:674-684。

네 번째 단계: 새 스레드 시작。on_no_idle클로저는 큐 락을 보유한 상태에서📎 tokio/src/runtime/blocking/pool.rs:462-506을 실행한다. 먼저num_threads == thread_cap을 검사하고, 상한에 도달하면 직접 반환한다Ok(())— 작업은 큐에 남아 기존 스레드가 처리하기를 기다리며, 이것이 배압이다. 그렇지 않으면shutdown_tx을 복제하고,spawn_thread을 호출하여 스레드를 생성하고, 성공하면num_threads을 증가시키고,worker_thread_index을 증가시키고, 핸들을worker_threads。

spawn_thread에 삽입한다.thread::Builder로 스레드 이름과 스택 크기를 설정한 다음, 클로저를 spawn한다: 런타임 컨텍스트에 진입하고rt.enter(),inner.run(id)을 호출하고, 마지막으로 drop한다shutdown_tx 📎 tokio/src/runtime/blocking/pool.rs:508-528。

OS 스레드 생성 실패에 대한 내성。spawn_thread은 실패할 수 있다. 코드는 오류를 분류한다📎 tokio/src/runtime/blocking/pool.rs:488-500: 만약WouldBlock(일시적 오류,is_temporary_os_thread_error이 판정📎 tokio/src/runtime/blocking/pool.rs:750-752)이고 풀에 이미 블로킹 스레드가 있다면,조용히 무시한다— 작업은 결국 현재 바쁜 스레드 중 하나가 가져갈 것이다. 그렇지 않으면SpawnError::NoThreads을 반환하고, 최종적으로 panic을 유발한다.

제어 흐름도로 전달 경로의 결정 분기를 요약하면:

mermaid
flowchart TD
    call["Spawner::spawn_blocking(func)"] --> box{"AutoBox::SHOULD_BOX?"}
    box -->|是| boxed["Box::new(func)"]
    box -->|否| raw["func"]
    boxed --> inner["spawn_blocking_inner"]
    raw --> inner
    inner --> unowned["task::unowned -> Task + JoinHandle"]
    unowned --> spawn_task["InnerImpl::spawn_task"]
    spawn_task --> lock["LockedImpl: mutex.lock()"]
    lock --> shutting{"thread_mgmt_state.shutdown?"}
    shutting -->|是| discard["task.task.shutdown()"]
    discard --> err_sd["Err(ShuttingDown)"]
    shutting -->|否| push["queue.push_back(task)"]
    push --> idle{"num_idle_threads == 0?"}
    idle -->|是| on_no_idle["on_no_idle(thread_mgmt_state)"]
    on_no_idle --> cap{"num_threads == thread_cap?"}
    cap -->|是| backpressure["返回 Ok, 任务留队列"]
    cap -->|否| spawn_th["spawn_thread(shutdown_tx, rt, id)"]
    spawn_th --> th_ok{"spawn 成功?"}
    th_ok -->|是| reg["inc_num_threads, 注册 JoinHandle"]
    th_ok -->|否| tmp{"WouldBlock 且已有线程?"}
    tmp -->|是| ignore["忽略, 等忙碌线程取走"]
    tmp -->|否| err_nt["Err(NoThreads)"]
    idle -->|否| notify["dec_num_idle_threads, num_notify+=1, notify_one"]
    err_sd --> ret["返回 JoinHandle"]
    backpressure --> ret
    reg --> ret
    ignore --> ret
    err_nt --> panic_os["panic: OS can't spawn worker thread"]

8.3 worker 메인 루프: BUSY/IDLE 상태 머신과 타임아웃 회수

직관적 모델: 각 블로킹 스레드는 하나의 "대기 중인 조력자"이다. 작업이 있으면 연속으로 일하고(BUSY), 작업이 없으면 졸고(IDLE),keep_alive이상 졸면 퇴근한다(타임아웃 종료). 타임아웃 회수가 없으면 풀은 피크 시 생성된 모든 스레드를 영구적으로 유지하여 메모리와 커널 스케줄링 오버헤드를 낭비한다.

메인 루프 구조。LockedImpl::run_worker은'main루프이며, 내부에서 BUSY와 IDLE 두 단계를 교대로 거친다📎 tokio/src/runtime/blocking/pool.rs:642-735. 주의: 여기서 BUSY/IDLE은 루프 내부의단계이며, 명시적 열거 상태가 아니므로 아래에서는 상태도가 아닌 흐름도로 설명한다.

BUSY 단계: 내부while let Some(task) = locked.queue.pop_front()이 계속 작업을 가져온다📎 tokio/src/runtime/blocking/pool.rs:655-661. 가져온 후queue_depth,을 감소시키고, 락을 drop하고,task.run()을 실행한 후 다시 락을 획득한다. 락을 drop하는 단계는 매우 중요하다 — 블로킹 작업은 오래 실행될 수 있으므로 절대 락을 보유한 채 실행해서는 안 된다.

IDLE 단계: 큐가 비면num_idle_threads을 증가시키고,is_counted_idle = true을 설정한 후 대기 루프에 진입한다📎 tokio/src/runtime/blocking/pool.rs:663-696. 핵심은condvar.wait_timeout(locked, keep_alive)이며, 반환 후 세 가지를 검사한다:

1. num_notify != 0: 합법적 깨우기.num_notify을 감소시키고,is_counted_idle = false을 설정한다(전달자가 이미num_idle_threads을 감소시켰으므로), break하여 BUSY로 돌아간다📎 tokio/src/runtime/blocking/pool.rs:674-684。

2. 종료되지 않았고 타임아웃:worker_timed_out을 호출하여 이전에 종료된 스레드의 핸들을 가져오고,break 'main루프를 종료한다📎 tokio/src/runtime/blocking/pool.rs:689-693。

3. 그렇지 않으면 허위 깨우기이므로 계속 대기한다.

종료 시 큐 비우기. 만약thread_mgmt_state.shutdown이 참이면, 비우기 로직에 진입한다📎 tokio/src/runtime/blocking/pool.rs:698-710: 작업을 하나씩 꺼내고, 락을 drop하고,task.shutdown_or_run_if_mandatory()을 호출한다 — 비강제 작업은 폐기되고, 강제 작업은 정상적으로 실행된다. 그런 다음 break하여 메인 루프를 종료한다.

종료 정리. 스레드가 종료되기 전에num_threads 📎 tokio/src/runtime/blocking/pool.rs:714을 감소시킨다. 만약is_counted_idle이 참이면,num_idle_threads도 감소시키고,assert_ne!(prev_idle, 0)로 언더플로가 없음을 단언한다📎 tokio/src/runtime/blocking/pool.rs:716-726. 이 단언은 디버그 시기의 가드레일이다:num_idle_threads회계에 오류가 생기면, 오류가 조용히 전파되도록 두는 대신 여기서 즉시 panic한다.

마지막으로, 종료 중이고num_threads == 0(마지막 스레드)이면,notify_one은 대기 중일 수 있는 종료 발기자를 깨운다📎 tokio/src/runtime/blocking/pool.rs:728-730.join_on_thread을 반환하고,Inner::run이 종료 전에 join한다📎 tokio/src/runtime/blocking/pool.rs:755-771。

종료 핸드셰이크。BlockingPool::shutdown는 먼저begin_shutdown을 호출하여 모든 worker 핸들을 가져오고📎 tokio/src/runtime/blocking/pool.rs:310-312。LockedImpl::begin_shutdown종료 플래그를 설정하고,shutdown_tx、notify_all을 drop하고📎 tokio/src/runtime/blocking/pool.rs:740-745모든 대기 스레드를 깨운다. 그런 다음shutdown_rx.wait(timeout)은📎 tokio/src/runtime/blocking/pool.rs:324。

shutdown::Receiver::wait을 블로킹 대기한다.📎 tokio/src/runtime/blocking/shutdown.rs:37-70의 구현은 매우 정교하다timeout == 0: 먼저try_enter_blocking_region()의 빠른 경로를 처리하여 직접 false를 반환하고; 그런 다음📎 tokio/src/runtime/blocking/shutdown.rs:44-57을 호출하여 블로킹 영역에 진입하며, 실패하고 현재 panic 중이면 false를 반환하고, 그렇지 않으면 panic하며 "비동기 컨텍스트에서 runtime을 drop할 수 없음"이라는 힌트를 제공한다block_on_timeout. 마지막으로 timeout에 따라block_on또는

shutdown_tx을 호출하여 그 oneshot을 구동한다.Arc<oneshot::Sender<()>>의 메커니즘은: 각 worker 스레드가📎 tokio/src/runtime/blocking/shutdown.rs:12-14의 클론을 하나씩 보유한다Arc. 모든 스레드가 종료되면, 모든 클론이 drop되고,oneshot::Sender카운트가 0이 되고,Receiver이 drop되고,

mermaid
sequenceDiagram
    participant App as "应用线程 (drop Runtime)"
    participant Pool as "BlockingPool::shutdown"
    participant Locked as "LockedImpl"
    participant Worker as "阻塞 worker 线程"
    participant Rx as "shutdown::Receiver"

    App->>Pool: shutdown(timeout)
    Pool->>Locked: begin_shutdown()
    Locked->>Locked: thread_mgmt_state.begin_shutdown() 设 shutdown=true, shutdown_tx=None
    Locked->>Worker: condvar.notify_all()
    Locked-->>Pool: Some((last_exited_thread, workers))
    Pool->>Rx: wait(timeout)
    Worker->>Worker: 从 wait_timeout 醒来, 见 shutdown=true
    Worker->>Worker: 排空队列 shutdown_or_run_if_mandatory()
    Worker->>Worker: dec_num_threads, 退出 run_worker
    Worker->>Worker: drop(shutdown_tx) 克隆
    Worker-->>Rx: 最后一个 Sender drop, oneshot 完成
    Rx-->>Pool: 返回 true
    Pool->>Worker: join 所有 worker 句柄

복사

8.4 block_on: 비동기 컨텍스트에서 Future 구동:block_on직관적 모델main은 런타임의 "정문"이다. 현재 스레드를 임시 실행기로 만들어, 전달된 Future를 완료될 때까지 반복적으로 poll한다. 이것이 없으면,

함수는 어떤 비동기 코드도 시작할 수 없다.。Runtime::block_on진입점과 박싱SHOULD_BOX도 마찬가지로 먼저 크기를 측정하고,Box::pin에 따라block_on_inner 📎 tokio/src/runtime/runtime.rs:343-350。block_on_inner여부를 결정한 후,self.enter()에 진입한다. 내부에는 두 개의 조건부 컴파일된 trace 래퍼(taskdump와 tracing)가 있고, 그런 다음📎 tokio/src/runtime/runtime.rs:353-383:

rust
let _enter = self.enter();

match &self.scheduler {
    Scheduler::CurrentThread(exec) => exec.block_on(&self.handle.inner, future),
    Scheduler::MultiThread(exec) => exec.block_on(&self.handle.inner, future),
}

복사block_on의미가 다르며, 문서에 아주 명확히 설명되어 있다📎 tokio/src/runtime/runtime.rs:302-320:

  • 멀티스레드 스케줄러: Future는 I/O 드라이버와 타이머 컨텍스트에서 실행되며,block_on반환된 후 이미 spawn된 작업은 계속 실행된다.
  • 현재 스레드 스케줄러:block_on은 여러 스레드에서 동시에 호출될 수 있으며, 첫 번째 호출자가 I/O 및 타이머 드라이버의 소유권을 획득하고 다른 스레드들은 여기에 "훅인"한다. 첫 번째block_on가 완료되면 다른 스레드들이 드라이버를 "훔칠" 수 있다.block_on반환된 후 이미 spawn된 작업은 일시 중단되며, 다시 호출하면block_on이들을 재개한다.

핵심 제한: 비동기 컨텍스트에서 호출할 수 없다. 문서에서 명확히block_on을 비동기 실행 컨텍스트에서 호출하면 panic이 발생한다고 밝히고 있다📎 tokio/src/runtime/runtime.rs:321-324. 이유는 직접적이다:block_on은 Future가 완료될 때까지 현재 스레드를 블로킹하며, 현재 스레드 자체가 어떤 worker 스레드라면 전체 실행기를 블로킹하게 된다——이것이 바로spawn_blocking이 해결하려는 문제이므로, 둘은 상호 배타적이다.

종료 경로。Runtime::drop은 스케줄러 유형에 따라 디스패치된다📎 tokio/src/runtime/runtime.rs:506-521: 현재 스레드 스케줄러는 먼저try_set_current로 컨텍스트에 진입한 후 shutdown해야 한다(작업이 런타임 컨텍스트에서 drop되도록 보장); 멀티스레드 스케줄러는 직접 shutdown한다(worker 스레드 자체가 이미 컨텍스트에 있으므로).shutdown_timeout스케줄러를 먼저 닫고 블로킹 풀을 나중에 닫는 것은📎 tokio/src/runtime/runtime.rs:457-461,shutdown_background과 동등하다shutdown_timeout(Duration::from_nanos(0)) 📎 tokio/src/runtime/runtime.rs:494-496。

설계 고찰, 오류 복구 및 프로덕션 함정

왜spawn_blocking의ShuttingDown은 panic하지 않는가? 📎 tokio/src/runtime/blocking/pool.rs:383-384주석에는 호환성 고려라고 되어 있다.spawn_blocking은JoinHandle이 아닌Result을 반환하며, 종료 시 panic하면 "런타임이 종료 중"이라는 예측 가능한 상태가 크래시로 변하게 된다. 절대 resolve되지 않는 핸들을 반환하면, 호출자가await할 때 계속 일시 중단된다——하지만 이 시점에 런타임은 이미 종료되었고 전체block_on도 종료되므로, 실제로 영구 누수되지는 않는다.

max_blocking_threads의 백프레셔 의미. 기본값이 매우 크며(512),spawn_blocking이 파일 I/O에 자주 사용되기 때문이다. 하지만 문서에서는 경고한다: CPU 집약적 작업을 실행할 때는 세마포어로 동시성을 제한해야 하며, 그렇지 않으면 대량의 스레드가 생성된다📎 tokio/src/task/blocking.rs:94-100. 상한에 도달하면 작업이 큐에서 대기하며 백프레셔를 형성한다——하지만 이 백프레셔는 블로킹 풀에만 작용하며, 비동기 스케줄러로 역압되지 않는다.

spawn_blocking은 취소 불가. 문서에서 명확히:abort은 이미 실행을 시작한 블로킹 작업에 대해 무효이며, 작업은 계속 끝까지 실행된다📎 tokio/src/task/blocking.rs:106-120. 아직 시작하지 않은 작업만 abort로 중단될 수 있다. 종료 시 런타임은 이미 시작된 모든 블로킹 작업을 대기하며,shutdown_timeout타임아웃 후에는 이 스레드들이 누수된다.

num_idle_threads의 회계 함정。is_counted_idle플래그의 존재는 이 카운트가 쉽게 오류를 일으킬 수 있음을 보여준다. 투입 측은 깨어날 때num_idle_threads을 감소시키고, 깨어난 측은num_notify != 0을 본 후is_counted_idle = false을 설정하여 중복 감소를 방지한다📎 tokio/src/runtime/blocking/pool.rs:679-682. 이 경로에 버그가 있으면,assert_ne!(prev_idle, 0)이 종료 시 panic한다📎 tokio/src/runtime/blocking/pool.rs:722-725. 프로덕션 환경에서 "num_idle_threadsunderflowed on thread exit"가 보인다면, 풀의 회계 로직이 파괴되었음을 의미한다.

〔설계 추론 및 아키텍처 트레이드오프〕

last_exiting_thread체인형 join의 비용. 타임아웃으로 종료된 스레드는 이전에 타임아웃으로 종료된 스레드를 join한다📎 tokio/src/runtime/blocking/pool.rs:172-178. 이는 join 체인을 형성한다: 각 종료 스레드는 이전 스레드가 실제로 끝날 때까지 기다려야 한다. 블로킹 스레드가 고빈도로 생성/소멸되는 시나리오에서 이 체인은 길어질 수 있으며, 스레드 종료 지연이 누적된다. 이는 Valgrind 오탐을 피하기 위한 트레이드오프이며, 일반 프로덕션 환경에서의 영향은 제한적이지만 스레드가 빈번히 타임아웃되는 부하에서는 주목할 가치가 있다.

InnerImpl열거형 추상화의 의미. 주석에 따르면Locked변형의 동작은 리팩토링 전과 완전히 동일하며,Sharded변형은 미래의 동시 큐를 위해 대칭적인 슬롯을 예약한다📎 tokio/src/runtime/blocking/pool.rs:537-539。spawn_task、run_worker、begin_shutdown세 메서드 모두 열거형을 통해 디스패치된다📎 tokio/src/runtime/blocking/pool.rs:548-582. 이러한 "열거형 디스패치 + 변형별 자체 임계 영역 보유" 설계는 새로운 큐 토폴로지를 추가할 때 호출자를 수정할 필요가 없게 한다.

이 장 요약

이 장에서는 Tokio가 동기 코드를 수용하는 두 가지 경계를 분석했다.spawn_blocking은 클로저를 독립적인 블로킹 스레드 풀에 투입한다:Inner은 큐, 스레드 상한, 생존 시간 및 원자적 지표를 보유하며;LockedImpl단일 락 +Condvar으로 큐를 구현하고,num_notify카운터로 허위 깨우기를 보상한다; worker는 BUSY/IDLE 사이를 순환하며, 유휴 타임아웃 후 체인형 join으로 종료된다;max_blocking_threads상한에 도달하면 작업이 대기열에 쌓여 백프레셔를 형성한다.block_on은 비동기 컨텍스트가 아닌 곳에서 Future를 구동하며, 멀티스레드와 현재 스레드 스케줄러의 의미가 다르고, 비동기 컨텍스트에서 호출하는 것이 엄격히 금지된다. 종료 경로는shutdown_tx의Arc카운트가 0이 되면oneshot을 트리거하여, "모든 worker가 종료된 후 종료 개시자를 깨우는" 핸드셰이크를 구현한다.

이 장 생각해보기와 자가 테스트

Q1: 만약LockedImpl::spawn_task에서if metrics.num_idle_threads() == 0의 판단을 항상 참으로 변경하면(즉, 매번on_no_idle을 호출하면), 고동시성 투입 시나리오에서 무슨 일이 발생하는가? 왜인가?

참고 해석:on_no_idle은num_threads == thread_cap을 확인하고, 상한에 도달하지 않으면 새 스레드를 생성한다📎 tokio/src/runtime/blocking/pool.rs:471-487. 판단이 항상 참이면, 유휴 스레드가 있어도 새 스레드를 시작하려 시도하여 스레드 수가 빠르게thread_cap까지 치솟는다. 더 심각한 것은, 유휴 스레드가notify_one으로 깨어나지 않는다는 점이다(on_no_idle분기가 아닌else분기의num_notify += 1; notify_one 📎 tokio/src/runtime/blocking/pool.rs:627-636을 타기 때문). 큐의 작업은 아무도 처리하지 않을 수 있으며, 어떤 새 스레드가 시작된 후에야 큐가 비어 있지 않음을 발견한다. 이는 "스레드는 폭주하지만 작업은 여전히 대기 중"인 가사 상태를 초래한다. 원래 판단의 의미는 바로 이것이다: 유휴 스레드가 있을 때 우선적으로 깨워 불필요한 스레드 생성을 피한다.

Q2: LockedImpl::run_worker은 BUSY 단계에서task.run()을 실행하기 전에drop(locked) 📎 tokio/src/runtime/blocking/pool.rs:657-658을 한다. 만약 이drop을 제거하면, 어떤 시나리오에서 교착 상태가 발생하는가?

참고 해석:task.run()은 사용자 클로저를 실행하며, 클로저 내부에서 다시spawn_blocking을 호출하여 새 작업을 투입할 수 있다. 투입 경로LockedImpl::spawn_task의 첫 번째 작업이 바로self.mutex.lock() 📎 tokio/src/runtime/blocking/pool.rs:612이다. 만약 worker가 락을 보유한 채 클로저를 실행하면, 클로저 내부의 투입이 동일한 락을 획득하려 시도하고,std::sync::Mutex재진입 불가, 직접 교착 상태 발생. 또한 잠금을 보유한 채 장시간 작업을 실행하면 다른 모든 제출자와 worker의 작업 가져오기 작업을 차단하여, 교착 상태가 발생하지 않더라도 전체 풀이 직렬화됩니다.drop(locked)필수입니다.

Q3: shutdown::Receiver::wait에서try_enter_blocking_region()실패하고 현재 panic 중일 때 false를 반환하고, 그렇지 않으면 panic📎 tokio/src/runtime/blocking/shutdown.rs:44-57합니다. 왜 panic 시에 특별 처리를 해야 할까요? 이 분기를 제거하면 어떤 시나리오에서 문제가 발생할까요?

참고 해석:try_enter_blocking_region실패는 현재 비동기 컨텍스트에 있으므로 블로킹이 허용되지 않음을 의미합니다. 정상적인 경우에는 사용자에게 「비동기 컨텍스트에서 runtime을 drop할 수 없습니다」라고 panic으로 알려야 합니다. 하지만 현재 스레드가 이미 panic 중이라면(std::thread::panicking()이 true), 다시 panic하면 이중 panic이 발생하여 Rust의 기본 동작은 프로세스를 즉시 abort하는 것입니다. 시나리오: 사용자가 비동기 작업에서 Runtime을 drop하는데, 해당 작업 자체가 다른 이유로 panic 중일 때, drop이 트리거한 shutdown이 두 번째 panic을 일으킵니다. false를 반환하면 shutdown이 대기를 포기하여 프로세스 abort를 방지하고, 사용자가 원래 panic 정보를 볼 수 있는 기회를 보존합니다. 이것은 「panic 안전」의 전형적인 처리입니다.

블로킹 스레드 풀과 block_on은 비동기 런타임의 능력 경계를 설정합니다: 전자는 스레드를 양보할 수 없는 작업을 전용 스레드로 격리하고, 후자는 비동기 진입점이 아닌 곳에서도 Future를 구동할 수 있게 합니다. 하지만 이 두 경계는 코드에서 항상 직접 작성되는 것이 아닙니다——다음 장에서는 매크로의 세계로 들어가서 #[tokio::main], select! 및 join!이 컴파일 시점에 이러한 런타임 코드를 어떻게 생성하는지 살펴보겠습니다.

모든 코드베이스를 진정으로 이해할 수 있는 책으로

이 장을 다 읽으셨나요? 내 프라이빗 저장소를 위한 아키텍처 책 만들기

Tauri 2 + Rust 로컬 퍼스트 아키텍처. 100% 오프라인 보안, 클라우드 코드 업로드 없음. 불변 커밋 라인 앵커로 정독.

⚡ Tauri 2 · Rust 네이티브 코어 · 100% 오프라인 보안 · 100만 행 이상 검증

CHAPTER 09

제 9 장: 매크로의 마법: #[tokio::main], select!와 join! 뒤의 코드 생성

Upstream: tokio-rs/tokio · Commit @e800714a · 진행률: 제 9 장 / 총 14 장

이전 장에서 우리는block_on과 블로킹 스레드 풀이 어떻게 비동기 런타임의 능력 경계를 설정하는지 보았고, 사용자는 거의 이러한 경계를 직접 작성하지 않습니다——그들은#[tokio::main]、select!、join!을 작성하여 매크로가 컴파일 시점에 이러한 보일러플레이트 코드를 펼치도록 합니다. 매크로는 Tokio가 사용자에게 제공하는 첫 번째 설탕 코팅이며, 컴파일 시점에 실제로 런타임 코드를 생성하는 곳입니다. 이 장에서는tokio-macroscrate와tokio/src/macros/select.rs에 초점을 맞추어, 가장 많이 사용되는 세 가지 매크로 확장 경로를 분해하고, 한 가지 질문에 중점적으로 답합니다: 매크로 확장 후 실제 호출 체인은 어떤 모습이며, 왜select!의 취소 안전 의미를 별도로 경계해야 하는가.

9.1 #[tokio::main]: async fn을 Runtime::block_on으로 재작성

직관적 모델:#[tokio::main]은 「인테리어 위임장」과 같습니다. 당신이 빈 집(async fn main)을 넘겨주면, 그것이 수전과 전기를 깔아주고(런타임 구축), 문과 창문(enable_all)을 설치하고, 마지막으로 당신의 원래 가구(함수 본문)를 옮겨 넣습니다. 이것이 없다면 모든main이Builder::new_multi_thread().enable_all().build().unwrap().block_on(...)을 직접 작성해야 하며, 보일러플레이트 코드가 비즈니스 로직을 압도할 것입니다.

데이터 구조와 메모리 레이아웃

매크로 자체는 런타임 데이터 구조를 생성하지 않지만, 파싱한 설정은 두 구조체에 담깁니다.Configuration은 「파싱 시점의 가변 누산기」이며, 필드가 전부Option입니다. 속성 매개변수가 누락되거나, 중복되거나, 불법일 수 있기 때문입니다📎 tokio-macros/src/entry.rs:74-84. 주의:worker_threads、start_paused、unhandled_panic모두Span을 가지고 있습니다——이는 오류 발생 시 오류를 매크로 내부가 아닌 사용자가 작성한 줄에 위치시키기 위함입니다📎 tokio-macros/src/entry.rs:74-84。FinalConfig은 「검증 후의 불변 결과」이며,flavor은 더 이상Option이 아닙니다.build()이 이미default_flavor로 폴백했기 때문입니다📎 tokio-macros/src/entry.rs:55-62。

RuntimeFlavor은 세 가지 변형만 있습니다:CurrentThread、Threaded、Local 📎 tokio-macros/src/entry.rs:10-14。from_str에는 역사적 레거시 이름을 위한 친절한 오류가特意로 제공됩니다:single_thread은current_thread,basic_scheduler이라고 불러야 함을 알려주고,threaded_scheduler은 이름이 변경되었음을 알려주며,📎 tokio-macros/src/entry.rs:17-27은 이름이

으로 변경되었음을 알려줍니다. 이것은 매크로가 「사용자 첫 접점」으로서의 전형적인 설계입니다: 오류 메시지가 곧 문서입니다.

단계별 확장 흐름#[tokio::main(flavor = "multi_thread", worker_threads = 4)] async fn main() { ... }。

시나리오 대입: 사용자가main을 작성합니다. 첫 번째 단계,ItemFn 📎 tokio-macros/src/entry.rs:577-580진입점이 먼저 item을 사용자 정의ItemFn으로 파싱합니다. 이syn::ItemFn은📎 tokio-macros/src/entry.rs:720-764이 아니라 Tokio가 자체 구현한 파서이며, 그 이유는 주석에 적혀 있습니다: 전체 문장을 재귀적으로 파싱하지 않고 「토큰 트리별 버퍼링, 세미콜론 만나면 분할」하는 경량 파싱만 수행합니다

. 이는 매크로에서 함수 본문에 대한 완전한 AST 구축 오버헤드를 피합니다.build_config두 번째 단계,async이async keyword is missing" 📎 tokio-macros/src/entry.rs:346-349키워드 존재 여부를 검증하고, 누락 시 "theworker_threads、flavor、start_paused、crate、unhandled_panic、name을 보고합니다. 그런 다음 속성 매개변수를 순회하며📎 tokio-macros/src/entry.rs:369-399을 해당 settercore_threads에 분배합니다. 주의:📎 tokio-macros/src/entry.rs:379-382。

은 명시적으로 거부되고 이름이Configuration::build으로 변경되었음을 알려줍니다worker_threads세 번째 단계,multi_thread 📎 tokio-macros/src/entry.rs:197-217;start_paused이 교차 필드 일관성 검증을 수행합니다. 여기에는 세 가지 핵심 제약이 있습니다:current_thread/local 📎 tokio-macros/src/entry.rs:219-229;unhandled_panic은current_thread/local 📎 tokio-macros/src/entry.rs:231-241만 허용하고,multi_thread은rt-multi-thread만 허용하며,📎 tokio-macros/src/entry.rs:209-216。

도 마찬가지로parse_knobs만 허용합니다. 사용자가asyncness 📎 tokio-macros/src/entry.rs:441을 선택했지만CurrentThread/Localfeature가 활성화되지 않은 경우, 오류 메시지는 flavor를 명시적으로 지정했는지 여부에 따라 달라집니다Builder::new_current_thread(),Threaded네 번째 단계,Builder::new_multi_thread() 📎 tokio-macros/src/entry.rs:468-477。Local이 코드를 생성합니다. 먼저build_local(Default::default())을 제거한 다음, flavor에 따라 builder 시작점을 선택합니다:build() 📎 tokio-macros/src/entry.rs:479-483은.worker_threads(#v)、.start_paused(#v)、.unhandled_panic(...)、.name(#v) 📎 tokio-macros/src/entry.rs:485-497。

을 사용하고,last_block:return #rt.enable_all().#build.expect("Failed building the Runtime").block_on(body) 📎 tokio-macros/src/entry.rs:509-522은return을 사용합니다. 특이한 점은 build 호출이📎 tokio-macros/src/entry.rs:508。

이 아니라async #body이라는 것입니다. 그런 다음 필요에 따라 체인으로!을追加합니다impl Trait다섯 번째 단계, 최종 함수 본문을 생성합니다. 핵심은if false { let _: &dyn Future<Output = #output_type> = &body; }입니다. 명시적인📎 tokio-macros/src/entry.rs:551-571에 주목하세요. 주석은 tokio-rs/tokio#4636을 가리키며, 타입 추론 문제를 수정하기 위한 것입니다pin!body를 스택에 고정하고Pin<&mut dyn Future>로 변환한다block_on제네릭 인스턴스화의 컴파일 오버헤드를 줄이기 위함이라고 주석에 설명되어 있다📎 tokio-macros/src/entry.rs:526-548。

mermaid
flowchart TD
    entry["main(args, item)"] --> parse_item{"syn::parse2(item) 成功?"}
    parse_item -->|否| err_ret["token_stream_with_error 返回原始 item + 编译错误"]
    parse_item -->|是| check_main{"ident == main 且有参数?"}
    check_main -->|是| err_args["报错: main 不能接受参数"]
    check_main -->|否| parse_args["AttributeArgs::parse_terminated"]
    parse_args --> build_cfg["build_config 校验 async 与各字段"]
    build_cfg --> cfg_ok{"config 构建成功?"}
    cfg_ok -->|否| fallback["parse_knobs(DEFAULT_ERROR_CONFIG) + 错误"]
    cfg_ok -->|是| knobs["parse_knobs 生成 Builder 链 + block_on"]
    knobs --> out["输出同步 fn main"]

설계 사고와 프로덕션 함정

main과test는parse_knobs을 공유하지만 기본 flavor가 다르다:test기본CurrentThread,main기본Threaded 📎 tokio-macros/src/entry.rs:91-94. 이것은 왜#[tokio::test]가 기본적으로 단일 스레드인지 설명한다——테스트는 보통 멀티코어가 필요 없고, 단일 스레드가 재현하기 더 쉽다.

쉽게 간과되는 함정: 매크로 확장 후 함수를 호출할 때마다 새로운 Runtime이 생성된다. 문서는 함수가 빈번하게 호출되는 경우 Builder를 사용하여 Runtime을 재사용해야 한다고 명확히 경고한다📎 tokio-macros/src/lib.rs:31-35.#[tokio::main]를 일반 함수에 사용하는 것은 합법적이지만, 매 호출마다 Runtime 구축 비용을 지불한다.

또 다른 함정은crate이름 변경이다. 사용자가use tokio as tokio1할 때, 매크로 내부에서 기본 생성되는tokio::runtime::Builder는 경로를 찾을 수 없으므로 명시적으로crate = "tokio1" 📎 tokio-macros/src/lib.rs:239-264。parse_knobs해야 한다.crate_path의 기본값은Ident::new("tokio", ...) 📎 tokio-macros/src/entry.rs:456-462이며, 이것이 바로 이름 변경 시나리오에서 오류가 발생하는 근본 원인이다.

9.2 select!: 다중 분기 폴링, 비트마스크와 무작위 공정성

직관적 모델:select!는 「여러 수령 창구를 동시에 지켜보는 서비스원」과 같다. 어느 창구에서 먼저 음식이 나오면, 그는 그 음식을 가져가고 나머지 창구의 대기는 무효화된다. 이것이 없다면, 사용자는 직접poll_fn를 작성하여 여러 Future를 튜플에 넣고 하나씩 poll해야 하며, 「어떤 분기가 준비되면 나머지 분기를 버려야 하는」 로직도 스스로 처리해야 한다.

데이터 구조와 메모리 레이아웃

select!가 확장되면 로컬 모듈__tokio_select_util이 생성되며, 그 안에 열거형Out과 타입 별칭Mask 📎 tokio/src/macros/select.rs:615-619。Out이 있다._0、_1의 변형 이름은Disabled……각 분기마다 하나씩, 그리고📎 tokio-macros/src/select.rs:33-39。Mask가 있어 모든 분기가 무효함을 나타낸다.u8의 하위 타입은 분기 수에 따라 동적으로 선택된다: ≤8이면u16, ≤16이면u32, ≤32이면u64, ≤64이면📎 tokio-macros/src/select.rs:17-31, 64를 초과하면 직접 panicselect!. 이 비트마스크는

의 핵심 상태이다: i번째 비트가 1이면 i번째 분기가 비활성화되었음을 의미한다.futures모든 Future는 튜플IntoFuture::into_future에 저장되며, 각 요소는 먼저📎 tokio/src/macros/select.rs:654-656를 거쳐futures_init로 변환된다. 여기서 먼저into_future를 구성한 후 하나씩📎 tokio/src/macros/select.rs:641-646하는데, 주석은 이것이 임시 수명 연장을 활용하기 위함이라고 설명한다let mut futures = &mut futures;. 이후poll_fn가 튜플을 가변 참조로 강등하여📎 tokio/src/macros/select.rs:658-662。

클로저가 소유권을 빼앗는 것을 방지한다

단계별 폴링 흐름select! { v = stream1.next() => ..., v = stream2.next() => ..., else => break }。

시나리오 대입:biased;첫 번째 단계, 매크로 진입 규칙 매칭. 만약start=0 📎 tokio/src/macros/select.rs:801-803접두사가 있으면,start; 그렇지 않으면thread_rng_n(BRANCHES) 📎 tokio/src/macros/select.rs:805-809는 무작위 표현식이다📎 tokio/src/macros/select.rs:61-65。

. 이것이 문서에서 말하는 「기본적으로 무작위로 분기를 선택하여 먼저 검사」하는 공정성의 근원이다(skip) pat = fut, if cond => handler,두 번째 단계, 정규화. tt-muncher가 각 분기를skip형태로 정규화하며,_는 일련의📎 tokio/src/macros/select.rs:770-793。skip이고, 길이는 해당 분기 이전의 branch 수와 같다futures_init.$($skip)*.count!는 튜플 필드 접근

을 생성하는 데도 사용되고,if $c를 통해 분기 인덱스를 계산하는 데도 사용된다.disabled |= 1 << index 📎 tokio/src/macros/select.rs:631-636세 번째 단계, 전제 조건 평가. 각 분기의$fut에 대해, false이면📎 tokio/src/macros/select.rs:39-41。

. 주의: 분기가 비활성화되어도 그poll_fn표현식은 여전히 평가되며, 단지 poll되지 않을 뿐이다ready!(poll_budget_available(cx))네 번째 단계,Pending 📎 tokio/src/macros/select.rs:664-667클로저 진입. 먼저 협력 예산을 확인한다:select!, 예산이 소진되면 직접

을 반환한다. 이것은for i in 0..BRANCHES,branch = (start + i) % BRANCHES 📎 tokio/src/macros/select.rs:680-685가 worker를 독점하지 않도록 보장한다.disabled & mask == mask다섯 번째 단계, 루프continue 📎 tokio/src/macros/select.rs:694-699. 각 branch에 대해: 먼저Pin::new_unchecked를 확인하고, 이미 비활성화되었으면📎 tokio/src/macros/select.rs:701-707; 그렇지 않으면 튜플에서 해당 Future를 꺼내Ready(out)로 한 겹 감싼다(안전성은 Future가 스택에 저장되고 이동되지 않는 것에 의존)disabled |= mask; poll하고,📎 tokio/src/macros/select.rs:710-730。

이면 먼저out한 후 패턴$bind을 매칭한다Poll::Ready(Out::_i(out)) 📎 tokio/src/macros/select.rs:727-733여섯 번째 단계, 패턴 매칭. 만약continue이📎 tokio/src/macros/select.rs:44-47。

와 매칭되면,is_pending을 반환한다; 매칭되지 않으면,Pending다른 분기를 계속 폴링한다——이것이 바로 문서 단계 5에서 말하는 「패턴이 매칭되지 않으면 현재 분기를 비활성화」이다Out::Disabled 📎 tokio/src/macros/select.rs:740-745일곱 번째 단계, 루프 종료. 만약match output이 참이면Out::_i을 반환하고, 그렇지 않으면 모든 분기가 무효이므로Disabled을 반환한다else. 외부📎 tokio/src/macros/select.rs:749-755。

mermaid
flowchart TD
    start["poll_fn 闭包被调用"] --> budget{"poll_budget_available(cx)?"}
    budget -->|否| pending_budget["返回 Pending"]
    budget -->|是| init["is_pending = false; start = $start"]
    init --> loop{"i < BRANCHES?"}
    loop -->|否| check_pending{"is_pending?"}
    check_pending -->|是| pending["返回 Pending"]
    check_pending -->|否| disabled_out["返回 Out::Disabled"]
    loop -->|是| branch["branch = (start+i) % BRANCHES"]
    branch --> is_disabled{"disabled & mask == mask?"}
    is_disabled -->|是| next_i["i += 1"]
    is_disabled -->|否| poll_fut["Pin::new_unchecked(fut).poll(cx)"]
    poll_fut --> poll_res{"Poll 结果?"}
    poll_res -->|Pending| set_pending["is_pending = true; i += 1"]
    poll_res -->|Ready| disable["disabled |= mask"]
    disable --> pat_match{"out 匹配 $bind?"}
    pat_match -->|否| next_i
    pat_match -->|是| ready_out["返回 Out::_i(out)"]
    next_i --> loop
    set_pending --> loop

을 해당 handler에 매핑하고,

은Vec<bool>?표현식에 매핑한다disabled |= mask복사select!설계 사고와 프로덕션 함정

왜 비트마스크를 사용하고를 사용하지 않는가? 비트마스크는 스택上的 단일 정수로, 힙 할당이 없고,select!는 단일 명령어이다. 핫 패스上的Some(v) = stream.next() => ...에 대해, 이는 매 반복마다의 힙 접근을 피한다.stream.next()왜 패턴이 매칭되지 않으면 분기를 비활성화해야 하는가?None이것이📎 tokio/src/macros/select.rs:198-223。

와 「단순 race」의 핵심 차이이다.:select!를 고려하면, 만약read_exact、read_to_end、write_all이📎 tokio/src/macros/select.rs:119-124를 반환하면(스트림 종료), 패턴이 매칭되지 않고, 해당 분기는 영구적으로 비활성화되어 이미 종료된 스트림을 무한 폴링하는 것을 방지한다. 문서 예제는 바로 이 의미론에 의존하여 두 스트림이 모두 종료될 때까지 수집한다Mutex::lock、Semaphore::acquire취소 안전성의 진정한 의미📎 tokio/src/macros/select.rs:126-133어떤 분기가 준비되면, 나머지 분기의 Future는 drop된다. 만약 drop된 Future가 이미 데이터를 소비했지만 아직 반환하지 않았다면, 데이터는 손실된다. 문서는 명확히.await가 취소 안전하지 않다고 나열하며,.await는 큐 공정성 때문에 취소 시 큐 위치를 잃는다📎 tokio/src/macros/select.rs:135-139。

if. 판정 방법:지점을 찾아,if !sleep.is_elapsed()에서 함수를 재시작해도 여전히 올바르면 취소 안전하다sleep전제 조건의 경쟁 조건 함정is_elapsed(): 문서는 고전적인 오류 예제를 제공한다——while가드를 사용하여select!분기를 보호하지만,📎 tokio/src/macros/select.rs:336-376이if검사와sleep사이에 true로 변할 수 있어 타임아웃이 누락된다break 📎 tokio/src/macros/select.rs:378-405。

biased;. 올바른 작성법은를 제거하고,📎 tokio/src/macros/select.rs:67-74분기가 항상 폴링에 참여하도록 하여, 타임아웃 후biased;의 비용📎 tokio/src/macros/select.rs:75-81。

: 무작위 RNG는 CPU 비용이 있으며, 일부 시나리오에서는 결정적인 폴링 순서가 필요하다

. 그러나:join!는 공정성 책임을 사용자에게 넘긴다: 만약 한 분기가 영원히 준비되면, 뒤의 분기는 기아 상태가 된다select!9.3 join!과 매크로 확장의 엔지니어링 제약Ready직관적 모델poll_fn는 「모든 택배가 동시에 도착하기를 기다리는 것」과 같다.

처럼 먼저 도착한 것이 나머지를 취소하지 않고, 모든 Future의

join!의 확장 역시 튜플에 Future를 저장하는 것을 기반으로 하지만, 상태는 비트마스크가 아니라 「완료된 값」의 튜플입니다. 각 Future가 완료되면 그 값이 추출되어 결과 튜플에 저장되고, 해당 슬롯은 완료로 표시됩니다.select!와 달리,join!는 완료되지 않은 Future를 drop하지 않습니다——모든 Future가 완료되어야 반환합니다.

단계별 흐름

join!의 폴링 로직은select!와 「튜플에 Future 저장 +poll_fn구동」이라는 골격을 공유하지만, 의미는 반대입니다:select!는 「하나라도 준비되면 반환」,join!는 「전부 준비되어야 반환」입니다. 매 라운드 poll은 모든 미완료 Future를 순회하며, 하나라도Pending를 반환하면 전체가Pending되고, 전부Ready이면 집계하여 반환합니다.

mermaid
flowchart LR
    subgraph input["输入"]
        f1["Future A"]
        f2["Future B"]
        f3["Future C"]
    end
    subgraph poll["poll_fn 驱动"]
        tuple["元组 (A, B, C)"]
        state["完成状态元组"]
    end
    subgraph output["输出"]
        result["(A::Output, B::Output, C::Output)"]
    end
    f1 --> tuple
    f2 --> tuple
    f3 --> tuple
    tuple --> state
    state -->|"全部 Ready"| result
    state -->|"任一 Pending"| pending["返回 Pending"]

설계 고찰과 프로덕션 함정

join!의 취소 안전성 의미는select!와 다릅니다:join!가 drop될 때, 완료되지 않은 모든 Future가 drop되며, 마찬가지로 데이터를 잃을 수 있습니다. 하지만join!는 어떤 분기도 능동적으로 취소하지 않으므로,select!처럼 「다른 분기가 준비되어 이 분기를 취소」하지는 않습니다. 진짜 위험은join!전체가 외부select!또는 타임아웃으로 취소되는 것입니다.

join!와try_join!의 차이는 주목할 만합니다:try_join!는 어떤 Future가Err를 반환하면 즉시 반환하고 나머지 Future를 취소하므로,select!의 취소 안전성 위험을 계승합니다.

설계 고찰

매크로를 컴파일 타임 코드 생성기로 사용하는 경계。#[tokio::main]는 설정 검증을 컴파일 타임에 두어, 잘못된 조합(예:multi_thread + start_paused)이 런타임 panic이 아니라 컴파일 실패가 되도록 합니다. 이것이 Builder 대비 매크로의 핵심 이점입니다: 오류를 앞당깁니다.

선언적 매크로 + 절차적 매크로의 혼합 아키텍처。select!의 본체는macro_rules!이지만, 두 곳의 핵심 로직은 절차적 매크로에 위임합니다:select_priv_declare_output_enum가Out열거형과Mask타입📎 tokio-macros/src/lib.rs:658-660,select_priv_clean_pattern을 생성하고, 패턴에서ref/mut 📎 tokio-macros/src/lib.rs:666-668를 제거합니다. 왜일까요? 주석의 설명: 선언적 매크로는 「분기 수에 따라 정수 타입을 동적으로 선택」하는 코드를 생성하기 어렵고, 패턴 위치에서 토큰 수준 정리도 어렵습니다.📎 tokio/src/macros/select.rs:577-579。

clean_pattern의 필요성。select!는out를&out형태로 패턴📎 tokio/src/macros/select.rs:727에 매칭하는데, 사용자가ref v를 작성하면&ref v가 되어 타입 오류가 발생합니다.clean_pattern는 재귀적으로by_ref、mutability를 제거하고,Reference패턴의mutability 📎 tokio-macros/src/select.rs:68-73📎 tokio-macros/src/select.rs:100-103도 제거합니다. 이것은 매크로가 「사용자 직관」과 「borrow checker」 사이에서 한 타협입니다.

64 분기 상한의 공학적 현실。count!、count_field!、select_variant!세 매크로가 각각 0부터 64까지의 매칭 규칙을 손으로 작성했습니다📎 tokio/src/macros/select.rs:821-1017📎 tokio/src/macros/select.rs:1021-1217📎 tokio/src/macros/select.rs:1221-1414. 주석은 노골적으로 「I'm not happy about it either」📎 tokio/src/macros/select.rs:816-817라고 말합니다. 이것은 선언적 매크로가 산술을 할 수 없는 대가입니다: 토큰 수를 정수에 하드코딩 매핑할 수밖에 없습니다.

이 장 요약

이 장 고찰과 자가 점검

Q1: select!의disabled비트마스크는select!에 진입할 때마다Default::default() 📎 tokio/src/macros/select.rs:627로 재초기화됩니다. 이 줄을poll_fn클로저 내부로 옮기면, 「루프에서 select!를 호출하고 어떤 분기 패턴이 매칭되지 않는」 시나리오에서 무슨 일이 발생할까요?

참고 해설:disabled클로저 내부에서 초기화하면 매 poll마다 리셋되어, 이전 라운드에서 패턴 불일치로 비활성화된 분기가 다시 폴링에 참여합니다.Some(v) = stream.next() => ...이고stream가 이미 종료되어(None반환) 패턴 불일치 후 해당 분기가 영구히 비활성화되어야 하는 상황을 생각해 봅시다.disabled가 리셋되면, 다음 라운드 poll이 이미 종료된 이 스트림을 다시 poll하게 되는데, 스트림이 fused가 아니면(즉 종료 후 다시 poll하면 panic하거나 정의되지 않은 동작을 반환할 수 있음) 문제가 생깁니다. 스트림이 fused라 해도, 영원히None를 반환하는 스트림을 반복 poll하며 CPU를 낭비합니다. 문서는 명확히 「Re-entering select! due to a loop clears the disabled state」📎 tokio/src/macros/select.rs:37-38라고 말합니다. 이는select!매크로에 재진입하는 것(새 루프 라운드)을 의미하며, 동일select!내의 여러 poll이 아닙니다.disabled는 클로저 외부에서 초기화해야 동일select!호출의 여러 poll 사이에서 상태를 유지할 수 있습니다.

Q2: select!는Ready(out)를 poll한 후disabled |= mask를 먼저 실행하고 패턴📎 tokio/src/macros/select.rs:720-730을 매칭합니다.disabled |= mask를 제거하면, 패턴이 불일치하고 해당 Future가 매 poll마다 즉시Ready를 반환하는 시나리오에서 무슨 일이 발생할까요?

참고 해설:disabled |= mask를 제거하면,out가$bind와 불일치할 때 코드는continue로 가서 다른 분기를 계속 폴링합니다. 하지만 다음 라운드에poll_fn가 호출될 때(예: 다른 분기가Pending를 반환한 후 다시 poll), 이 분기는 여전히 비활성화되지 않아 다시 poll됩니다. 해당 Future가 매 poll마다 즉시Ready를 반환하고 값이 패턴과 불일치하면, 「poll -> Ready -> 불일치 -> continue -> 다른 분기 Pending -> Pending 반환 -> 다시 poll -> 다시 Ready -> ...」의 라이브락이 형성되어 CPU가 헛돕니다.disabled |= mask는Ready후 즉시 플래그를 세워, 패턴이 불일치하더라도 해당 분기가 다시 poll되지 않도록 합니다. 플래그 설정이 패턴 매칭 전에 일어나므로, 「Ready지만 패턴 불일치」와 「Ready이고 패턴 일치」 두 경우 모두 해당 분기를 비활성화합니다——전자는 라이브락 방지, 후자는 중복 소비 방지입니다.

Q3: parse_knobs는 비-test 경로에서if false { let _: &dyn Future<Output = #output_type> = &body; }를 삽입해 타입 검사를📎 tokio-macros/src/entry.rs:557-561하지만,!를 반환하거나impl Trait를 포함하는 타입은 검사를 건너뜁니다📎 tokio-macros/src/entry.rs:551-556. 왜impl Trait는 건너뛰어야 할까요? 강제로 검사하면 어떻게 될까요?

참고 해설:impl Trait는 반환 위치가 「불투명 타입」이라 컴파일러가 이를&dyn Future<Output = impl Trait>로 강제 변환하는 것을 허용하지 않습니다. 왜냐하면dyn는 구체적 타입을 요구하는데,impl Trait의 구체적인 타입은 함수 외부에서 보이지 않는다. 억지로 검사를 삽입하면 「the size for values of typeimpl Futurecannot be known at compilation time」또는 「cannot be made into an object」같은 오류가 발생한다. 반환!의 타입도 마찬가지다:!는 어떤 타입으로든 강제 변환할 수 있지만,&dyn Future<Output = !>의Output = !자체가 never type의 불안정 특성 문제를 유발할 수 있다. 검사를 건너뛰는 대가는: 만약 사용자가async fn main() -> impl Trait를 작성했지만 실제 반환 타입이impl Trait과 맞지 않으면, 오류는block_on에서야 드러나며, 오류 메시지가 명시적 검사보다 명확하지 않을 수 있다. 이것은 「컴파일 타임 검사 완전성」과 「타입 시스템 제한」 사이의 트레이드오프다.

매크로는 보일러플레이트 코드와 컴파일 타임 검증을 사용자로부터 대신 떠맡지만, 그것이 생성하는 것은 여전히 평범한 Future와poll호출이다. 다음 장에서 우리는 매크로의 컴파일 타임 세계를 떠나 런타임 I/O 추상화 계층으로 들어가,AsyncRead/AsyncWrite가 어떻게 바이트 스트림을 프레임으로 자르는지, 그리고Framed코덱 프레임워크가select!의 취소 안전 제약 아래에서 어떻게 올바르게 작동하는지 살펴본다.

#[tokio::main]의 본질은 「설정 파싱 + Builder 체인 생성 +block_on래핑」이며, 설정 검증은 컴파일 타임에 완료되고, flavor가 builder 시작점과 build 메서드를 결정한다.select!의 핵심은 「튜플에 Future 저장 + 비트마스크로 비활성화 기록 + 무작위 시작점으로 공정성 보장」이며, 패턴이 일치하지 않으면 분기를 비활성화하고, 취소 안전성은 drop된 Future가.await에서 재시작 가능한지에 달려 있다.join!와select!는 골격을 공유하지만 의미는 반대이며, 전자는 전부 완료될 때까지 기다리고 후자는 어느 하나라도 준비되면 반환한다. 세 가지 모두 Tokio 매크로 설계의 핵심 트레이드오프를 보여준다: 보일러플레이트 코드와 컴파일 타임 검증은 매크로에 맡기고, 런타임 의미의 복잡성(특히 취소 안전성)은 사용자가 명시적으로 이해하도록 남겨둔다. 매크로가 런타임 코드를 어떻게 생성하는지 이해한 후, 다음 자연스러운 질문은: 이 코드들이 실제로 바이트 스트림을 읽고 쓰기 시작할 때, Tokio는 어떤 추상화를 제공하는가? 제 10장에서는AsyncRead/AsyncWrite와 코덱 프레임워크를 분석하여,BufReader/BufWriter가 어떻게 시스템 호출을 줄이는지,copy_bidirectional가 어떻게 양방향 전달을 구동하는지,Framed가 어떻게 바이트 스트림을 프레임으로 분할하는지 살펴봄으로써 「비동기 I/O의 추상화 경계는 어디인가」에 답한다.

모든 코드베이스를 진정으로 이해할 수 있는 책으로

이 장을 다 읽으셨나요? 내 프라이빗 저장소를 위한 아키텍처 책 만들기

Tauri 2 + Rust 로컬 퍼스트 아키텍처. 100% 오프라인 보안, 클라우드 코드 업로드 없음. 불변 커밋 라인 앵커로 정독.

⚡ Tauri 2 · Rust 네이티브 코어 · 100% 오프라인 보안 · 100만 행 이상 검증

CHAPTER 10

제 10 장: 스트리밍 I/O 추상화: AsyncRead/AsyncWrite와 코덱 프레임워크

Upstream: tokio-rs/tokio · Commit @e800714a · 진행률: 제 10 장 / 총 14 장

이전 장에서는 tokio-macros의 전개 과정을 분해했고, #[tokio::main], select!, join!이 어떻게 보일러플레이트 코드와 컴파일 타임 검증을 사용자로부터 대신 떠맡는지 보았다. 하지만 매크로가 생성하는 것은 여전히 평범한 Future와 poll 호출이다——이 Future들이 실제로 바이트를 읽고 쓰기 시작할 때, Tokio가 제공하는 저수준 추상화는 단 두 개의 trait뿐이다: AsyncRead와 AsyncWrite. 이들의 문제는 「너무 저수준」이라는 것이다: 한 번의 poll_read는 「약간의 바이트를 읽었다」만 보장할 뿐 「완전한 메시지를 읽었다」를 보장하지 않는다. 그리고 대부분의 프로토콜(HTTP, Redis, gRPC, 커스텀 RPC)은 「바이트 스트림」이 아닌 「프레임」 지향이다. 이 장에서 답할 핵심 질문은: 비동기 I/O의 추상화 경계는 어디에 그어야 하는가? Tokio의 답은 두 계층으로 나뉜다: tokio::io는 바이트 스트림 수준의 trait과 도구(BufReader/BufWriter/copy_bidirectional)를 제공하고, tokio-util의 codec 프레임워크는 그 위에 프레임 수준의 Stream/Sink 어댑터(Framed/LengthDelimitedCodec)를 제공한다. 이 두 계층의 분업을 이해하면 「왜 프로토콜 구현이 거의 모두 Framed에서 시작하는가」를 이해하게 된다.

一、AsyncRead/AsyncWrite: 왜 std::io::Read를 직접 재사용할 수 없는가

직관적 모델

std::io::Read::read는 「블로킹식 수령」이다: 당신이 창구 앞에 서서 물건이 올 때까지 계속 기다리면 스레드가 일시 중단된다.AsyncRead::poll_read는 「식권식 수령」이다: 당신이 「됐어요?」라고 한마디 물으면, 아직 안 됐으면(Poll::Pending) 먼저 다른 일을 하고, 동시에 Waker를 남겨 물건이 도착하면 시스템이 당신을 부르게 한다. 만약 이 trait이 없다면, 모든 비동기 I/O는epoll등록과 Waker 매핑을 직접 작성해야 한다——이것이 바로 제 5장 Reactor가 하는 일이며,AsyncRead는 그것이 상위 계층에 노출하는 통합 파사드다.

데이터 구조와 메모리 레이아웃

AsyncRead의 정의는 극도로 간결하며, 메서드가 하나뿐이다:

rust
pub trait AsyncRead {
    fn poll_read(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>,
        buf: &mut ReadBuf<'_>,
    ) -> Poll<io::Result<()>>;
}

📎 tokio/src/io/async_read.rs:44-60

세 매개변수에는 각각 이유가 있다.self: Pin<&mut Self>가 아니라&mut self: 왜냐하면AsyncRead는 종종async fn가 생성한 Future에 의해 보유되며, Future는 한 번 poll되면 이동할 수 없기(자기 참조) 때문이다.Pin는 컴파일러가 강제하는 계약이다.cx: &mut Context<'_>는 Waker를 운반하며, 「호출기」의 전달 통로다.buf: &mut ReadBuf<'_>는 Tokio가&mut [u8]를 래핑한 것이다——그것은 동시에 「채워진 길이」와 「초기화되지 않은 용량」을 기록하여,std::io::Read'읽은 바이트 수를 반환하지만 버퍼가 초기화되지 않았을 수 있다'는 모호함.

문서는 세 가지 반환 의미를 명확히 나열한다📎 tokio/src/io/async_read.rs:15-32:Ready(Ok(()))데이터가 기록되었음을 나타내며buf읽기량은ReadBuf::filled의 길이 증가분으로 결정된다; 증가분이 0이면 EOF이거나buf.remaining() == 0(버퍼 용량 0)이다;Pending현재 읽을 수 없지만 깨우기가 등록되었음을 나타낸다;Ready(Err(e))은 하위 I/O 오류이다. 여기 간과하기 쉬운 함정이 있다:'읽기량이 0'이 EOF를 의미하지는 않는다——호출자가 용량 0인 버퍼를 전달하면poll_read은 즉시Ready(Ok(()))을 반환하지만 아무것도 읽지 않는다. 상위 계층에서 '0 바이트'를 EOF로 처리하면 연결 종료를 오판하게 된다.

시나리오 기반 Walkthrough:&[u8]에서 바이트 일부 읽기

가장 단순한 구현을 고려하자——&[u8]의AsyncRead:

rust
impl AsyncRead for &[u8] {
    fn poll_read(
        mut self: Pin<&mut Self>,
        _cx: &mut Context<'_>,
        buf: &mut ReadBuf<'_>,
    ) -> Poll<io::Result<()>> {
        let amt = std::cmp::min(self.len(), buf.remaining());
        let (a, b) = self.split_at(amt);
        buf.put_slice(a);
        *self = b;
        Poll::Ready(Ok(()))
    }
}

📎 tokio/src/io/async_read.rs:98-108

단계별 분석:self.len()은 남은 미읽기 슬라이스 길이이고,buf.remaining()은 대상 버퍼의 남은 용량이며, 둘 중 작은 값amt。split_at(amt)을 취한다. 슬라이스를 '이번에 복사할a'과 '남은 미읽기b」。buf.put_slice(a)'으로 나눈다.a을ReadBuf에 복사하고 filled 포인터를 전진시킨다.*self = b슬라이스 자체를 남은 부분으로 전진시킨다——이것이&[u8]이 '커서'로서 갖는 핵심이다: 매 poll 후self은 미읽기 부분을 가리킨다. 마지막으로Ready(Ok(()))을 반환하는데, 메모리 슬라이스는 항상 '준비'되어 있어Pending。

하지 않기 때문이다._cx이 무시됨에 주의하라: 메모리 데이터 소스는 Waker가 필요 없다. 이는 네트워크 socket과 대조된다——후자는 데이터가 없을 때Pending을 반환하고 읽기 가능 관심을 등록한다.

io::Cursor<T>의 구현에는 경계 검사가 한 겹 더 있다📎 tokio/src/io/async_read.rs:113-134: 먼저position()을 가져오고,pos > slice.len()(위치 범위 초과)이면 panic 없이 바로Ready(Ok(()))을 반환한다📎 tokio/src/io/async_read.rs:113-134. 이는 방어적 설계이다:Cursor의 position은 외부set_position에 의해 임의 값으로 설정될 수 있으며, 범위를 벗어나면 '이미 다 읽음'으로 처리하는 것이 panic보다 I/O 의미에 더 부합한다.

설계 사고: deref 매크로와 Pin의 전파

AsyncRead은Box<T>、&mut T、Pin<P>에 전달 구현을 제공한다. 앞의 둘은deref_async_read!매크로를 통해📎 tokio/src/io/async_read.rs:64-70을 생성하며, 핵심은Pin::new(&mut **self).poll_read(cx, buf)이다——즉Pin<&mut Box<T>>을Pin<&mut T>로 역참조한 뒤 전달한다.Pin<P>의 구현은 더 미묘하다📎 tokio/src/io/async_read.rs:87-93: 이것은crate::util::pin_as_deref_mut(self)을 호출하여Pin<&mut Pin<P>>을Pin<&mut P::Target>으로 투영한다. 이 투영 계층은 필수적인데, 그렇지 않으면 중첩된Pin이 타입 불일치를 일으키기 때문이다.

〔설계 추론 및 아키텍처 트레이드오프〕

여기서의 설계 동기는 '제로 비용 추상화'이다: 전달 구현은Box<dyn AsyncRead>、&mut T등의 래퍼 타입이poll_read을 직접 작성할 필요 없게 하면서Pin의미를 올바르게 유지한다. 대가는 각 전달 계층마다 간접 호출이 한 번 도입된다는 점인데, 컴파일러가 보통 인라인으로 제거한다.

---

2. copy_bidirectional: 양방향 전달의 상태 머신

직관적 모델

copy_bidirectional은 '양방향 배달원'이다: A→B와 B→A 두 방향을 동시에 지켜보다가, 어느 쪽이든 데이터를 읽으면 반대쪽에 쓴다. 이것이 없다면 TCP 프록시를 구현할 때 두 개의copyFuture를 직접 작성하고select!로 조합해야 한다——그런데select!의 취소 안전 제약(9장) 때문에 '읽다가 중간에 취소된' 데이터가 손실될 수 있다.copy_bidirectional은 명시적 상태 머신으로 '읽기-쓰기-닫기'의 중간 상태를 보존하여 취소 안전을 달성한다.

데이터 구조와 메모리 레이아웃

핵심은 3-상태 열거형이다:

rust
enum TransferState {
    Running(CopyBuffer),
    ShuttingDown(u64),
    Done(u64),
}

📎 tokio/src/io/util/copy_bidirectional.rs:10-14

Running은CopyBuffer(내부에 8KB 버퍼와 읽기/쓰기 카운트 포함)을 보유하며, '데이터를 옮기는 중'을 나타낸다.ShuttingDown(u64)은 이미 복사한 바이트 수를 담고, '읽기 쪽이 EOF이고 쓰기 쪽을 닫는 중'을 나타낸다.Done(u64)은 '닫기 완료, 최종 바이트 수 기록'을 나타낸다. 이 열거형이 취소 안전의 핵심이다:어느 시점에 drop되더라도 상태가 열거형에 보존되어, 다음 poll에서 중단점부터 계속할 수 있다。

CopyBuffer은copy.rs에서 오며, 기본 크기는DEFAULT_BUF_SIZE이 결정한다(8KB)📎 tokio/src/io/util/copy_bidirectional.rs:76-88. 두 방향이 각각 독립적인CopyBuffer을 보유하므로 메모리 오버헤드는 16KB이다.

시나리오 기반 Walkthrough: 양방향 전달의 전체 생애주기

copy_bidirectional_impl은poll_fn로 두 방향의 상태 머신을 조합한다:

rust
let mut a_to_b = TransferState::Running(a_to_b_buffer);
let mut b_to_a = TransferState::Running(b_to_a_buffer);
poll_fn(|cx| {
    let a_to_b = transfer_one_direction(cx, &mut a_to_b, a, b)?;
    let b_to_a = transfer_one_direction(cx, &mut b_to_a, b, a)?;
    let a_to_b = ready!(a_to_b);
    let b_to_a = ready!(b_to_a);
    Poll::Ready(Ok((a_to_b, b_to_a)))
})
.await

📎 tokio/src/io/util/copy_bidirectional.rs:127-151

의 호출 순서에 주의하라: 먼저 a→b를 진행하고, 그다음 b→a를 진행하며, 둘 다transfer_one_direction을 반환한다Poll。ready!매크로는 어느 한 방향이 미완료면 즉시Pending을 반환한다——하지만다른 방향의 상태는 이미 진행되었다. 이것이 바로 주석이 강조하는📎 tokio/src/io/util/copy_bidirectional.rs:143-144이다: 설령ready!이 조기 반환하더라도, 다른 방향은 다음 poll에서 여전히Done(count)을 반환하여 진행 상황을 잃지 않는다.

transfer_one_direction내부는loop이며, 상태에 따라 진행한다:

rust
loop {
    match state {
        TransferState::Running(buf) => {
            let count = ready!(buf.poll_copy(cx, r.as_mut(), w.as_mut()))?;
            *state = TransferState::ShuttingDown(count);
        }
        TransferState::ShuttingDown(count) => {
            ready!(w.as_mut().poll_shutdown(cx))?;
            *state = TransferState::Done(*count);
        }
        TransferState::Done(count) => return Poll::Ready(Ok(*count)),
    }
}

📎 tokio/src/io/util/copy_bidirectional.rs:29-42

Running상태에서poll_copy을 호출하는데, 내부적으로 '한 블록 읽고, 한 블록 쓰기'를 읽기 쪽 EOF 또는 쓰기 쪽 블록까지 반복한다. EOF 시 복사한 총수를 반환하고 상태가ShuttingDown。ShuttingDown로 전환된다.poll_shutdown을 호출해 쓰기 쪽을 닫고(FIN 전송), 완료 후Done。Done로 전환한다.

직접 카운트를 반환한다.

mermaid
flowchart TD
    start["transfer_one_direction 进入 loop"] --> match_state{"当前 TransferState?"}
    match_state -->|Running| poll_copy["buf.poll_copy(cx, r, w)"]
    poll_copy --> copy_ready{"poll_copy 结果?"}
    copy_ready -->|Pending| ret_pending["返回 Poll::Pending<br/>状态保持 Running"]
    copy_ready -->|Err| ret_err["返回 Poll::Ready(Err)<br/>错误向上传播"]
    copy_ready -->|Ok(count)| to_shutdown["state = ShuttingDown(count)"]
    to_shutdown --> match_state
    match_state -->|ShuttingDown| poll_shutdown["w.poll_shutdown(cx)"]
    poll_shutdown --> shutdown_ready{"shutdown 结果?"}
    shutdown_ready -->|Pending| ret_pending2["返回 Poll::Pending<br/>状态保持 ShuttingDown"]
    shutdown_ready -->|Err| ret_err
    shutdown_ready -->|Ok| to_done["state = Done(count)"]
    to_done --> match_state
    match_state -->|Done| ret_done["返回 Poll::Ready(Ok(count))"]

복사

설계 사고: 왜 async fn 대신 명시적 상태 머신인가

〔설계 추론 및 아키텍처 트레이드오프〕transfer_one_direction만약async fn을CopyBuffer로 작성하면, 컴파일러는 내부 상태(copy_bidirectional, 복사 카운트)가 생성된 상태 머신에 숨겨진 Future를 만든다. 이는 단방향 사용에는 문제없지만,은같은 poll 주기 내에서async fn두 방향을 동시에 진행해야 한다——만약 두 개의select!에TransferState를 더하면, 한 방향이 완료될 때 다른 쪽이 drop되어 내부 버퍼와 카운트가 손실되므로 취소 안전을 위반한다. 명시적poll_fn은 상태를 스택에 노출하여,

매번 재진입할 때 상태가 여전히 남아 있으므로 '취소 후 중단점부터 복구'를 보장한다.poll_copy오류 처리에서,Err이 반환한?은📎 tokio/src/io/util/copy_bidirectional.rs:32을 통해 즉시 위로 전파된다📎 tokio/src/io/util/copy_bidirectional.rs:67-70. 문서는을 명확히 설명한다: 중단된 읽기/쓰기는 재시도되고, 다른 오류는 즉시 반환되며,부분적으로 읽은 데이터는 손실될 수 있다copy_bidirectional(반대쪽에 기록되지 않음). 이는 프로덕션 환경에서 주의할 점이다:

copy_bidirectional_with_sizes은 '전부 성공 또는 전부 실패'를 보장하지 않으며, 오류 발생 시 이미 절반의 데이터가 전송 중일 수 있다.📎 tokio/src/io/util/copy_bidirectional.rs:99-125은 추가로 크기 0 단언poll_copy항상 반환Ready(Ok(0))EOF로 오판되어 바쁜 루프(busy loop)가 형성된다.

---

三、Framed: 바이트 스트림을 프레임으로 자르기

직관적 모델

Framed은 「소시지 기계」다: 업스트림은 연속적인 물줄기(AsyncRead/AsyncWrite)이고, 다운스트림은 잘린 소시지 조각(Stream<Item = Frame> / Sink<Frame>)。Decoder은 「물줄기에서 한 조각을 잘라내는」 역할을,Encoder은 「한 조각을 물줄기로 포장하는」 역할을 담당한다. 만약Framed이 없다면, 모든 프로토콜 구현이 「버퍼 관리 + 반 패킷 처리 + 붙은 패킷 분할」을 직접 작성해야 한다——이것이 바로 codec 프레임워크가 제거하려는 반복 노동이다.

데이터 구조와 메모리 레이아웃

Framed자체는 단순한 얇은 래퍼일 뿐이다:

rust
pub struct Framed<T, U> {
    #[pin]
    inner: FramedImpl<T, U, RWFrames>
}

📎 tokio-util/src/codec/framed.rs:38-41

실제 상태는FramedImpl의state: RWFrames안에 있으며,read: ReadFrame과write: WriteFrame두 부분을 포함한다.ReadFrame의 필드는with_capacity에서 볼 수 있다📎 tokio-util/src/codec/framed.rs:107-126:eof: bool(읽기 측이 EOF인지),is_readable: bool(읽기 가능 관심이 등록되었는지),buffer: BytesMut(읽기 버퍼),has_errored: bool(오류 발생 여부, 중복 읽기 방지).WriteFrame필드📎 tokio-util/src/codec/framed.rs:119-122:buffer: BytesMut(쓰기 버퍼),backpressure_boundary: usize(배압 임계값).

backpressure_boundary은 배압 메커니즘의 핵심이다: 쓰기 버퍼가 해당 임계값을 초과하면,poll_ready은Pending을 반환하여 데이터가 플러시될 때까지, 업스트림Sink에 배압을 가한다. 기본값은capacity 📎 tokio-util/src/codec/framed.rs:121과 같으며,set_backpressure_boundary을 통해 조정할 수 있다📎 tokio-util/src/codec/framed.rs:271-273。

시나리오 기반 Walkthrough: socket에서 프레임 하나 읽기

Framed의Stream구현은 단지FramedImpl::poll_next 📎 tokio-util/src/codec/framed.rs:309-311으로 전달할 뿐이다. 실제 로직은FramedImpl안에 있다 (이 장에서는 해당 파일을 제공하지 않지만,Framed의 인터페이스로부터 호출 체인을 추론할 수 있다):

1. poll_next은 먼저read.buffer에 완전한 프레임이 이미 있는지 확인한다 (codec.decode);

호출decode2. 만약Some(frame)이

을 반환하면, 직접 산출하고 하위 I/O를 건드리지 않는다;None3. 만약read.eof(반 패킷)을 반환하면,None;

을 확인한다: 이미 EOF이고 버퍼가 비어 있지 않다면, 잔여 데이터를 디코딩할 수 없다는 뜻이므로 오류를 반환하거나AsyncRead::poll_read4. 그렇지 않으면 하위read.buffer;

을 호출하여 더 많은 바이트를decode5. 읽은 바이트로 다시Pending。

을 시도하고, 프레임이 산출되거나이 「먼저 decode 후 read」 순서는 중요하다: 이것은한 번의 read가 여러 프레임을 산출할 수 있음(붙은 패킷)을 보장하며, 또한하나의 프레임이 여러 번의 read에 걸칠 수 있음is_readable(반 패킷)을 보장한다.Pending플래그는 읽기 가능 관심의 중복 등록을 방지한다——만약 지난 poll에서 이미 등록되었고 준비되지 않았다면, 이번에는 직접

Sink을 반환하고 하위를 중복 호출하지 않는다.📎 tokio-util/src/codec/framed.rs:315-338:start_send구현의 호출 체인codec.encode(item, &mut write.buffer)은poll_flush을 호출하여 프레임을 쓰기 버퍼에 인코딩한다;write.buffer은AsyncWrite;poll_ready을 하위write.buffer.len() >= backpressure_boundary으로 플러시한다

은Framed을 확인하고, 임계값을 초과하면 먼저 flush한 후 준비 상태를 반환한다.

mermaid
sequenceDiagram
    participant App as 应用层
    participant F as FramedImpl
    participant C as Decoder/Encoder
    participant IO as AsyncRead/AsyncWrite

    App->>F: poll_next(cx)
    F->>C: decode(&mut read.buffer)
    alt 缓冲中已有完整帧
        C-->>F: Some(frame)
        F-->>App: Poll::Ready(Some(frame))
    else 半包
        C-->>F: None
        F->>IO: poll_read(cx, &mut read.buffer)
        alt 数据就绪
            IO-->>F: Ready(Ok(()))
            F->>C: decode(&mut read.buffer)
            C-->>F: Some(frame) 或 None
        else 无数据
            IO-->>F: Pending
            F-->>App: Poll::Pending
        end
    end

    App->>F: start_send(frame)
    F->>C: encode(frame, &mut write.buffer)
    C-->>F: Ok(())
    App->>F: poll_flush(cx)
    F->>IO: poll_write(cx, &write.buffer)
    IO-->>F: Ready(Ok(n))
    F->>IO: poll_flush(cx)
    IO-->>F: Ready(Ok(()))

이 한 번의 「프레임 읽기-프레임 쓰기」 왕복에서의 컴포넌트 간 협력을 보여준다:

Framed복사📎 tokio-util/src/codec/framed.rs:23-30:SinkExt::send취소 안전성: Framed의 문서 경고select!의 문서는 취소 안전성 의미를专门列出한다만약에서 다른 분기에 의해 먼저 완료되면,send메시지는 전송되지 않음이 보장되지만, 메시지 자체는 손실된다poll_ready——왜냐하면start_send내부에서 먼저poll_ready후item을 하며, 만약StreamExt::next단계에서 drop되면,

은 이미 소비되었지만 인코딩되지 않았다. 반면

은 취소 안전하다: 이것은 하위 stream에 대한 참조만 보유하므로, drop해도 이미 디코딩된 프레임을 잃지 않는다.read.buffer〔설계 추론과 아키텍처 트레이드오프〕Framed이 비대칭성은 읽기/쓰기 경로의 차이에서 비롯된다: 읽기 경로의 상태(next)는item내부에 저장되며,send이 drop되는 것은 단지 「프레임 가져오기」라는 동작을 포기하는 것일 뿐, 버퍼는 영향을 받지 않는다; 쓰기 경로의 상태(전송 대기 중인select!)는send의 Future 스택 위에 있으며, drop 즉시 손실된다. 프로덕션 코드에서 만약

안에서into_parts을 사용한다면, 메시지가 재전송 가능하거나 손실을 수용할 수 있음을 반드시 보장해야 한다.map_codec

Framed설계 사고:into_parts/from_parts과📎 tokio-util/src/codec/framed.rs:290-298 📎 tokio-util/src/codec/framed.rs:155-166。map_codec은📎 tokio-util/src/codec/framed.rs:221-234을 제공하여 「codec을 교체하되 버퍼는 유지」한다into_parts은 바로 이 메서드 쌍을 기반으로 구현되었다io/codec/read_buf/write_buf: 먼저map로from_parts을 분리하고, 그 다음

FramedParts함수로 codec을 변환하고, 마지막으로_priv: ()을 재조립한다. 이 설계는 프로토콜 업그레이드(예: 평문에서 TLS로 전환) 시 이미 버퍼링된 데이터를 유지하여 재읽기를 피할 수 있게 한다.📎 tokio-util/src/codec/framed.rs:373-375의new/from_parts필드

---

은 「비완전 구조체(non-exhaustive struct)」 기법이다: 비공개 필드가 외부의 직접 생성을 막고,

을 강제하여, 향후 필드를 추가해도 호환성을 깨뜨리지 않도록 한다.

LengthDelimitedCodec四、LengthDelimitedCodec: 길이 접두사 코덱의 상태 머신DecodeState직관적 모델

은 「길이에 따라 소시지를 자르는」 전용 칼이다: 이것은 각 프레임 앞에 고정 바이트 수의 길이 필드가 있다고 가정하고, 먼저 길이를 읽은 후 payload를 읽는다. 만약 이것이 없다면, 길이 접두사 프로토콜을 구현하려면 「4바이트 읽기 → 길이 파싱 → N바이트 읽기 → 반복」 상태 머신을 직접 작성해야 한다——이것이 바로 이것의 내부

rust
pub struct LengthDelimitedCodec {
    builder: Builder,
    state: DecodeState,
}

enum DecodeState {
    Head,
    Data(usize),
}

📎 tokio-util/src/codec/length_delimited.rs:451-457

DecodeState데이터 구조와 메모리 레이아웃Head복사Data(n)은 명시적 상태 머신이다:decode은 「길이 필드를 읽는 중」을 나타내고,은 「길이 n이 파싱되었고, payload를 읽는 중」을 나타낸다. 이 상태는。

Builder호출에 걸쳐 유지되므로,📎 tokio-util/src/codec/length_delimited.rs:416-435:max_frame_len반 패킷 시나리오에서 진행 상황을 잃지 않는다length_field_len은 모든 설정을 보유한다length_field_offset(기본 8MB),length_adjustment(기본 4바이트),num_skip(기본 0),None(기본 0),offset + len)、length_field_is_big_endian(기본

, 즉

decode(기본 true).

rust
fn decode(&mut self, src: &mut BytesMut) -> io::Result<Option<BytesMut>> {
    let n = match self.state {
        DecodeState::Head => match self.decode_head(src)? {
            Some(n) => {
                self.state = DecodeState::Data(n);
                n
            }
            None => return Ok(None),
        },
        DecodeState::Data(n) => n,
    };

    match self.decode_data(n, src) {
        Some(data) => {
            self.state = DecodeState::Head;
            src.reserve(self.builder.num_head_bytes().saturating_sub(src.len()));
            Ok(Some(data))
        }
        None => Ok(None),
    }
}

📎 tokio-util/src/codec/length_delimited.rs:579-603

Head은 상태 머신의 진입점이다:decode_head복사None상태에서Ok(None)을 호출한다. 만약Some(n)(데이터 부족)을 반환하면, 직접Data(n)。Data을 반환하고 더 많은 데이터를 기다린다; 만약decode_data(n, src)을 반환하면, 상태가split_to(n)로 전환된다Head상태에서는 직접 n을 취한다. 그런 다음None을 호출한다: 만약 버퍼에 이미 n바이트가 있으면,

decode_head이 프레임을 잘라내고, 상태가

rust
let head_len = self.builder.num_head_bytes();
let field_len = self.builder.length_field_len;

if src.len() < head_len {
    return Ok(None);
}

let n = {
    let mut src = Cursor::new(&mut *src);
    src.advance(self.builder.length_field_offset);
    let n = if self.builder.length_field_is_big_endian {
        src.get_uint(field_len)
    } else {
        src.get_uint_le(field_len)
    };

    if n > self.builder.max_frame_len as u64 {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            LengthDelimitedCodecError { _priv: () },
        ));
    }

    let n = n as usize;
    let n = if self.builder.length_adjustment < 0 {
        n.checked_sub(-self.builder.length_adjustment as usize)
    } else {
        n.checked_add(self.builder.length_adjustment as usize)
    };

    match n {
        Some(n) => n,
        None => {
            return Err(io::Error::new(
                io::ErrorKind::InvalidInput,
                "provided length would overflow after adjustment",
            ));
        }
    }
};

src.advance(self.builder.get_num_skip());
src.reserve(n.saturating_sub(src.len()));
Ok(Some(n))

📎 tokio-util/src/codec/length_delimited.rs:504-562

을 반환하고 기다린다.src.len() >= head_len은 핵심 파싱 로직이다:None 📎 tokio-util/src/codec/length_delimited.rs:499-502복사Cursor단계적으로 파싱한다: 먼저src을 확인하고, 부족하면advance/get_uint을 반환한다.advance(length_field_offset)을📎 tokio-util/src/codec/length_delimited.rs:517로 감싸서field_len연산이 원래 버퍼를 소비하지 않도록 한다.📎 tokio-util/src/codec/length_delimited.rs:520-524。

은 헤더 접두사를 건너뛴다. 엔디안에 따라n > max_frame_len바이트의 길이 값InvalidData을 읽는다📎 tokio-util/src/codec/length_delimited.rs:526-531핵심 방어

: 만약checked_sub/checked_add이면, 즉시📎 tokio-util/src/codec/length_delimited.rs:537-541오류를 반환한다InvalidInput오류이며 panic이 아니다.get_num_skip()반환num_skip또는 기본offset + len 📎 tokio-util/src/codec/length_delimited.rs:1070-1073을(를) 반환하고, 헤더의 나머지 부분을 건너뛴다. 마지막으로reserve(n.saturating_sub(src.len()))payload 공간을 예약한다📎 tokio-util/src/codec/length_delimited.rs:559——saturating_sub을(를) 사용하는 이유는src이(가) 이미 일부 payload를 포함하고 있을 수 있기 때문이다.

아래 흐름도는decode의 전체 의사결정 경로를 보여준다:

mermaid
flowchart TD
    entry["decode(src)"] --> check_state{"self.state?"}
    check_state -->|Head| head["decode_head(src)"]
    head --> head_result{"结果?"}
    head_result -->|Ok(None)| ret_none1["返回 Ok(None)<br/>等待更多数据"]
    head_result -->|Err| ret_err1["返回 Err<br/>长度超限或溢出"]
    head_result -->|Ok(Some(n))| set_data["state = Data(n)"]
    set_data --> decode_data
    check_state -->|Data(n)| decode_data["decode_data(n, src)"]
    decode_data --> data_result{"src.len() >= n?"}
    data_result -->|否| ret_none2["返回 Ok(None)<br/>等待更多数据"]
    data_result -->|是| split["src.split_to(n)<br/>state = Head<br/>reserve 下一帧头部"]
    split --> ret_frame["返回 Ok(Some(frame))"]

설계 고찰: max_frame_len의 클리핑과 오버플로 방지

Builder::adjust_max_frame_lencodec을 구성할 때max_frame_len을(를) 길이 필드가 표현할 수 있는 최댓값으로 클리핑한다📎 tokio-util/src/codec/length_delimited.rs:1075-1081。max_allowed_frame_len을(를) 계산하며, 여기서max_length_field_value + length_adjustment 📎 tokio-util/src/codec/length_delimited.rs:1083-1089은(는)max_length_field_value을(를) 사용하여checked_shl을(를) 처리한다length_field_len == 8시의 시프트 오버플로📎 tokio-util/src/codec/length_delimited.rs:1091-1096. 이 클리핑은 사용자가 "길이 필드 2바이트인데 max_frame_len을 1MB로 설정"하는 모순된 구성을 방지한다——2바이트는 최대 65535까지만 표현할 수 있으므로, 클리핑 후 max_frame_len은 65535가 된다.

인코딩 경로의 대칭적 방지:encode을(를) 검사한다n > max_frame_len을(를) 반환한다InvalidInput 📎 tokio-util/src/codec/length_delimited.rs:607-607, 길이 조정 역시checked_add/checked_sub 📎 tokio-util/src/codec/length_delimited.rs:620-631을(를) 사용한다. 인코딩 시의 조정 방향은 디코딩과 반대임에 유의하라: 디코딩은 "읽은 길이 ± adjustment = payload 길이"이고, 인코딩은 "payload 길이 ∓ adjustment = 기록하는 길이 필드"이다📎 tokio-util/src/codec/length_delimited.rs:620-624。

〔설계 추론과 아키텍처 트레이드오프〕

이러한 "디코딩은 더하고, 인코딩은 빼는" 대칭적 설계는length_adjustment의 의미를 통일하기 위한 것이다: 이는 "길이 필드 값과 payload 길이의 차이"를 나타낸다. 프로토콜의 길이 필드가 헤더를 포함할 때(예: Example 3),adjustment = -2, 디코딩 시n - (-2) = n + 2으로 payload 길이를 얻고, 인코딩 시payload - (-2) = payload + 2으로 길이 필드에 다시 기록한다.

---

설계 고찰: 추상화 경계의 세 가지 계층

이 장을 돌아보면, Tokio의 I/O 추상화는 명확한 3계층 구조를 보여준다:

첫 번째 계층: 바이트 스트림 trait(AsyncRead/AsyncWrite). "일부 바이트를 읽기/쓰기"만 약속하고, 프레임 경계는 약속하지 않는다. 이것은 최소 인터페이스이며, 모든 I/O 소스(socket, 파일, 메모리 슬라이스)가 구현할 수 있다. 대가는 상위 계층이 반 패킷/점착 패킷을 스스로 처리해야 한다는 것이다.

두 번째 계층: 바이트 스트림 도구(BufReader/BufWriter/copy_bidirectional). trait 위에 "시스템 호출 감소", "양방향 전달" 등의 범용 기능을 제공한다.copy_bidirectional의 명시적 상태 머신은 "취소 안전성"이 도구 계층에서 어떻게 구현되는지 보여준다——상태는 Future 내부가 아닌 스택에 저장된다.

세 번째 계층: 프레임 어댑터(Framed/Decoder/Encoder). 바이트 스트림을Stream<Frame>/Sink<Frame>로 승격시켜, 프로토콜 구현이 "버퍼 관리"가 아닌 "프레임의 인코딩/디코딩"만 신경 쓰면 되게 한다.LengthDelimitedCodec은(는) 이 계층의 표준 예시이며, 그DecodeState상태 머신과max_frame_len방지는 모든 길이 접두사 프로토콜이 재사용해야 할 패턴이다.

〔설계 추론와 아키텍처 트레이드오프〕

이 세 계층의 구분은 우연이 아니다: 이는 "추상화 누수"의 세 가지 그라디언트에 대응한다. 더 낮은 계층일수록 범용적이지만 사용하기 어렵고, 더 높은 계층일수록 사용하기 쉽지만 더 특수하다. Tokio가 "프레임"을 일급 시민으로tokio-util이 아닌tokio핵심에 둔 이유는 프레임의 정의가 프로토콜마다 다르기 때문이다——tokio은(는) 바이트 스트림만 제공하고,tokio-util은(는) 프레임 프레임워크를 제공하며, 구체적 프로토콜(HTTP/Redis/gRPC)은 각자의 crate에서 구현한다Decoder/Encoder。

---

이 장 요약

  • AsyncRead::poll_read은(는)Pin<&mut Self> + Context + ReadBuf세 매개변수로std::io::Read::read을(를) 대체하여, "블로킹 대기"를 "Waker 등록 + Pending 반환"으로 바꾼다.Ready(Ok(()))이고 읽은 양이 0일 때는 EOF와 영용량 버퍼를 구분해야 한다.
  • copy_bidirectional은(는)TransferState삼태 열거형(Running/ShuttingDown/Done)으로 중간 상태를 저장하여, 양방향 전달이select!취소 하에서도 복구될 수 있게 한다. 오류 발생 시 일부 데이터가 손실될 수 있다.
  • Framed은(는)AsyncRead/AsyncWrite을(를)Stream/Sink,ReadFrame/WriteFrame로 적응시켜 읽기/쓰기 버퍼와 배압을 각각 관리한다.SinkExt::send은(는) 취소 안전하지 않으며(메시지 손실),StreamExt::next은(는) 취소 안전하다.
  • LengthDelimitedCodec은(는)DecodeState(Head/Data(n)) 상태 머신으로 반 패킷을 처리하고,max_frame_len은(는) 길이 필드 DoS를 방지하며,checked_add/checked_sub은(는) 조정 오버플로를 방지한다.

이 장 생각해보기와 자가 점검

Q1: copy_bidirectional의transfer_one_direction에서, 만약TransferState::ShuttingDown분기의ready!(w.as_mut().poll_shutdown(cx))?을(를) 직접*state = TransferState::Done(*count)(으)로 바꾸면(shutdown 건너뛰기), 어떤 시나리오에서 상대방 연결이 정상적으로 닫히지 않게 되는가?

참고 해석:poll_shutdown의 역할은 상대방에게 FIN 패킷을 보내 "내 쪽에는 더 이상 데이터가 없다"고 알리는 것이다. 이를 건너뛰고 바로Done로 전환하면, 쓰기 측이 닫히지 않아 상대방은 계속 데이터를 기다리게 되어 "반개방 연결"이 형성된다——상대방은 타임아웃까지read에서 영원히 블로킹될 수 있다. TCP 프록시 시나리오에서는 연결 누수가 발생한다: 클라이언트는 이미 끊겼지만, 프록시에서 백엔드로의 연결은 여전히 유지된다. 소스 코드에서ShuttingDown상태의 존재📎 tokio/src/io/util/copy_bidirectional.rs:35-39는 바로 EOF 후 쓰기 측을 명시적으로 닫도록 보장하기 위한 것이다.poll_shutdown자체가Pending을(를) 반환할 수 있으므로(예: 전송 버퍼 가득 참),ready!으로 대기해야 하며 무시해서는 안 된다.

Q2: LengthDelimitedCodec::decode_head에서, 만약if n > self.builder.max_frame_len as u64의 검사📎 tokio-util/src/codec/length_delimited.rs:526-531을(를) 제거하면, 악의적 클라이언트가 길이 필드를0xFFFFFFFF(4GB)로 하는 프레임 헤더를 보낼 때 어떤 결과가 발생하는가? 왜 이 검사가length_adjustment이전에 있어야 하는가?

참고 해석: 검사를 제거하면,n은(는)usize(으)로 변환되어decode_data。decode_data에 전달된다src.len() < n을(를) 검사할 때None을(를) 반환하지만,decode_head끝의src.reserve(n.saturating_sub(src.len())) 📎 tokio-util/src/codec/length_delimited.rs:559이(가) 4GB 메모리를 예약하려 시도하여 OOM 또는 할당 실패 panic을 유발한다. 검사는length_adjustment이전에 있어야 하는데,length_adjustment이(가) 음수일 수 있기 때문이다(예:-2). 만약 먼저 조정하고 나중에 검사하면,0xFFFFFFFF - 2은(는) 여전히 4GB에 가까워 검사가 무의미해지며; 또한 음수 조정으로 인해checked_sub이(가) 먼저 실패할 수 있어, 오류 메시지가 "오버플로"로 오인되어 "프레임 과대"가 아닌 것으로误导된다. 소스 코드 순서 [FACT:tokio-util/src/codec/length_delimited.rs:526-

여기까지 우리는 Tokio가 바이트 스트림과 메시지 프레임 사이에 두는 두 계층의 추상화를 정리했다. tokio::io는 바이트 운반을 담당하고, tokio-util의 codec 프레임워크는 프레임 분할과 인코딩/디코딩을 담당한다. Framed가 프로토콜 구현의 출발점이 되는 이유는 바로 '완전한 메시지 하나 읽기'라는 고빈도 요구를 재사용 가능한 Stream/Sink 어댑터로 캡슐화하기 때문이다. 하지만 프레임은 데이터의 컨테이너일 뿐이며, 프로토콜이 동적 작업 집합, 구조적 취소, 또는 더 복잡한 스트리밍 조합을 처리해야 할 때 Framed만으로는 충분하지 않다. 다음 장에서는 tokio-stream과 tokio-util의 확장 메커니즘으로 들어가, StreamExt 조합자, StreamMap/JoinSet/TaskTracker, 그리고 CancellationToken이 어떻게 하위 Waker와 스케줄링 메커니즘을 재사용하여 비동기 반복과 작업 관리에 더 상위 수준의 도구를 제공하는지 살펴본다.

모든 코드베이스를 진정으로 이해할 수 있는 책으로

이 장을 다 읽으셨나요? 내 프라이빗 저장소를 위한 아키텍처 책 만들기

Tauri 2 + Rust 로컬 퍼스트 아키텍처. 100% 오프라인 보안, 클라우드 코드 업로드 없음. 불변 커밋 라인 앵커로 정독.

⚡ Tauri 2 · Rust 네이티브 코어 · 100% 오프라인 보안 · 100만 행 이상 검증

CHAPTER 11

제 11 장: Stream 생태계와 도구 계층: tokio-stream과 tokio-util의 확장 메커니즘

Upstream: tokio-rs/tokio · Commit @e800714a · 진행률: 제 11 장 / 총 14 장

지난 장에서 우리는 Framed의 바이트 수준 메커니즘을 분해했다. Decoder는 BytesMut을 프레임으로 자르고, Sink는 프레임을 다시 쓴다. 이로써 비동기 I/O의 추상화 경계가 명확해진다. 하지만 프레임은 데이터의 컨테이너일 뿐이며, 실제 프로토콜 구현은 곧바로 tokio::io와 Framed 모두 해결하지 못하는 세 가지 문제에 직면한다. 비동기 반복 — Framed는 Stream을 구현하지만 Stream에는 poll_next만 있고 next().await, filter, take, merge가 없으며, poll_fn을 직접 작성하는 것은 장황할 뿐만 아니라 취소 안전성에서 함정을 밟기 쉽다. 동적 작업 집합 — 채팅 서비스가 N개의 채널을 동시에 구독해야 하고 채널이 수시로 가입하고 탈퇴하는데, select!의 분기 수는 컴파일 시점에 고정되어 런타임에 증감하는 스트림 집합을 표현할 수 없다. 구조적 취소 — select!는 단일 분기를 취소할 수 있지만, 전체 작업 트리를 중단시키는 일을 전파할 수도 없고 모든 작업이 실제로 종료되기를 기다릴 수도 없다. tokio-stream과 tokio-util은 바로 이 세 가지를 위해 태어났으며, 이들의 핵심 설계 원칙은 별도의 체계를 새로 만들지 않는 것이다. StreamExt의 각 조합자는 단지 poll_next에 대한 래퍼일 뿐이고, StreamMap은 Waker의 등록 의미론을 재사용하며, CancellationToken은 tokio::sync::Notify 위에 직접 세워지고, TaskTracker는 AtomicUsize 하나로 모든 상태를 인코딩한다. 이들을 이해하는 것은 본질적으로 기존 Waker와 스케줄링 메커니즘 위에서 제로 비용 추상화를 하는 방법을 이해하는 것이다. 이 장은 반복, 집합, 취소의 세 계층으로 점진한다. 먼저 StreamExt가 어떻게 poll_next를 조합 가능한 반복자로 바꾸는지 보고, 다음으로 StreamMap과 TaskTracker가 어떻게 동적 집합을 관리하는지 보며, 마지막으로 CancellationToken이 어떻게 트리 하나로 취소 신호를 전체 작업 트리에 전파하는지 본다.

StreamExt: poll_next를 조합 가능한 반복자로 바꾸기

직관적 모델

Stream은(는)Future에 대해, 마치Iterator이(가) 값에 대해 그러하듯:Future은 '하나의 값'을 산출하고,Stream은 '일련의 값'을 산출한다. 하지만Stream은poll_next이 하나의 원시 연산만 정의하며, 마치Iterator이next만 정의한 것과 같다. 만약StreamExt이 없다면, 매번 필터링, 매핑, 절단마다poll_fn클로저를 직접 작성하고Pin을 수동으로 관리해야 한다 — 이것이 바로futurescrate 초기 사용자들이 가장 고통스러워했던 부분이다.StreamExt의 역할은 바로Stream에Iterator과 같은 조합자 생태계를 붙여주는 것이다.

만약 그것이 없다면, 시스템이 직면하는 재앙은 기능 부재가 아니라취소 안전성의 체계적 붕괴이다: 손으로 작성한 모든poll_fn은select!에 의해 취소될 때 이미poll되어 나온 요소 하나를 잃을 수 있다.

데이터 구조와 메모리 레이아웃

StreamExt은확장 trait이며, 자체적으로 데이터를 보유하지 않는다:

📎 tokio-stream/src/stream_ext.rs:106-106

rust
pub trait StreamExt: Stream {

그 모든 메서드는구체적인 조합자 구조체를 반환하며,Box<dyn Stream>이 아니다. 이것이 핵심 설계다:map은Map<Self, F>,filter을 반환하고Filter<Self, F>,take은Take<Self>을 반환한다. 이 구조체들은 모두 힙 할당이 없는 제네릭 래퍼이며, 컴파일러는 전체 체인을 여러 겹의poll_next호출로 인라인할 수 있다.

trait의 blanket impl에 주목하라:

📎 tokio-stream/src/stream_ext.rs:1213-1213

rust
impl<St: ?Sized> StreamExt for St where St: Stream {}

어떤Stream이든 자동으로 모든 조합자를 얻으며, 수동 구현이 필요 없다.?Sized은dyn Stream도 확장 메서드를 누릴 수 있게 해준다.

조합자의 모듈 선언은 이 trait의 완전한 능력 면모를 드러낸다:

📎 tokio-stream/src/stream_ext.rs:4-59

rust
mod all; use all::AllFuture;
mod any; use any::AnyFuture;
mod chain; pub use chain::Chain;
pub(crate) mod collect; use collect::{Collect, FromStream};
mod filter; pub use filter::Filter;
mod filter_map; pub use filter_map::FilterMap;
mod fold; use fold::FoldFuture;
mod fuse; pub use fuse::Fuse;
mod map; pub use map::Map;
mod map_while; pub use map_while::MapWhile;
mod merge; pub use merge::Merge;
mod next; use next::Next;
mod skip; pub use skip::Skip;
mod skip_while; pub use skip_while::SkipWhile;
mod take; pub use take::Take;
mod take_while; pub use take_while::TakeWhile;
mod then; pub use then::Then;
mod try_next; use try_next::TryNext;
mod peekable; pub use peekable::Peekable;

여기서 주목할 만한 구분이 있다:next、try_next、all、any、fold、collect이 반환하는 것은Future(Next、TryNext、AllFuture이다...), 왜냐하면 그것들은 전체 스트림을 하나의 값으로 소비하기 때문이다. 반면map、filter、take등이 반환하는 것은Stream이며, 왜냐하면 그것들은 스트림의 형태를 유지하기 때문이다.next의 반환 타입은Next<'_, Self>이며, 라이프타임 매개변수를 가지는데, 이는 스트림을 빌리기만 하기 때문이다:

📎 tokio-stream/src/stream_ext.rs:144-149

rust
fn next(&mut self) -> Next<'_, Self>
where
    Self: Unpin,
{
    Next::new(self)
}

Self: Unpin제약은 의도적이다:next은 스트림의 소유권을 얻지 않고 빌리기만 하므로, 스트림을Pin할 수 없다. 만약 스트림이!Unpin이라면, 사용자는 먼저Box::pin하거나pin_mut!해야 한다. 문서는 이 트레이드오프를 명확히 지적한다:

📎 tokio-stream/src/stream_ext.rs:116-121

rust
/// Note that because `next` doesn't take ownership over the stream,
/// the [`Stream`] type must be [`Unpin`]. If you want to use `next` with
/// a [`!Unpin`](Unpin) stream, you'll first have to pin the stream. This can
/// be done by boxing the stream using [`Box::pin`] or
/// pinning it to the stack using the `pin_mut!` macro from the `pin_utils`
/// crate.

시나리오 기반 Walkthrough: 한 번의merge의 폴링

merge은 조합자가 Waker를 어떻게 재사용하는지 이해하는 최고의 샘플이다. 두 스트림을 교차로 산출하며,공정성을 보장한다—두 스트림이 동시에 준비되면 교대로 산출한다. 문서는 특별히 체인 호출을 하지 말라고 경고한다merge:

📎 tokio-stream/src/stream_ext.rs:319-321

rust
/// simultaneously, the merge stream alternates between them. This provides
/// some level of fairness. You should not chain calls to `merge`, as this
/// will break the fairness of the merging.

merge의 시그니처는 두 스트림의Item타입이 같을 것을 요구한다:

📎 tokio-stream/src/stream_ext.rs:398-404

rust
fn merge<U>(self, other: U) -> Merge<Self, U>
where
    U: Stream<Item = Self::Item>,
    Self: Sized,
{
    Merge::new(self, other)
}

호출자가.next().await할 때, 실행 흐름은 다음과 같다:

1. Next::poll이Merge::poll_next。

2. Merge을 호출하면 내부적으로 "지난번에 누구 차례였는지"를 나타내는 불리언 플래그를 유지한다. 먼저poll지난번에 산출하지 않은 스트림을 폴링하고, 만약Pending이면 다시poll다른 쪽을 폴링한다.

3. 만약 둘 다Pending,Merge을 반환하면Pending을 반환하지만,두 스트림 각각의 Waker가 이미 등록되어 있으므로—어느 하나라도 준비되면 현재 태스크를 깨운다.

4. 만약 한 스트림이Ready(None)(종료)를 반환하면,Merge은 해당 스트림이 종료되었음을 기록하고, 이후에는poll다른 스트림만 폴링하며, 그것도 종료될 때까지 계속한다.

여기서 핵심은:Merge은 자체적인 Waker 관리 로직이 없으며,cx을 그대로 내부 두 스트림의poll_next。에 전달한다. Waker 등록은 전적으로 하위 스트림이 담당하며,,Merge은 단지 "이번에 누구를 먼저 물어볼지"만 결정한다. 이것이 바로 "하위 Waker 메커니즘을 재사용한다"는 말의 문자 그대로의 의미다.

merge_size_hints보조 함수는 조합자가 용량 힌트를 어떻게 병합하는지 보여준다:

📎 tokio-stream/src/stream_ext.rs:1216-1226

rust
fn merge_size_hints(
    (left_low, left_high): (usize, Option<usize>),
    (right_low, right_high): (usize, Option<usize>),
) -> (usize, Option<usize>) {
    let low = left_low.saturating_add(right_low);
    let high = match (left_high, right_high) {
        (Some(h1), Some(h2)) => h1.checked_add(h2),
        _ => None,
    };
    (low, high)
}

주목할 점은saturating_add과checked_add의 선택이다: 하한은 포화 덧셈(과소평가할지언정 오버플로 panic은 피함)을 사용하고, 상한은 검사 덧셈(어느 하나라도 미지면 전체가 미지)을 사용한다. 이것이size_hint계약의 전형적인 처리 방식이다.

설계 고찰: 취소 안전성과chunks_timeout의 panic 방어

StreamExt의 문서는 모든 메서드에Cancel safety을 표기한다. 예를 들어next의 경우:

📎 tokio-stream/src/stream_ext.rs:123-127

rust
/// # Cancel safety
///
/// This method is cancel safe. The returned future only
/// holds onto a reference to the underlying stream,
/// so dropping it will never lose a value.

next이 취소 안전한 이유는 스트림을 빌리기만 하고 요소를 소비하지 않기 때문이다—Nextfuture가 drop될 때 스트림 자체의 상태는 변하지 않고, 다음번next은 다시poll。

을 호출한다. 하지만 모든 조합자가 취소 안전한 것은 아니다.chunks_timeout은 생성 시점에 매개변수 검증을 수행한다:

📎 tokio-stream/src/stream_ext.rs:1178-1185

rust
#[track_caller]
fn chunks_timeout(self, max_size: usize, duration: Duration) -> ChunksTimeout<Self>
where
    Self: Sized,
{
    assert!(max_size > 0, "`max_size` must be non-zero.");
    ChunksTimeout::new(self, max_size, duration)
}
〔설계 추론 및 아키텍처 트레이드오프〕

#[track_caller]은 panic 위치가 라이브러리 내부가 아닌 호출자를 가리키게 하며,assert!은 생성 시점에max_size == 0을 거부한다. 왜 반드시 생성 시점에 검사해야 하는가? 만약max_size == 0,ChunksTimeout을 허용하면 배치 처리 로직이 "영원히 한 배치를 채우지 못하는" 무한 루프에 빠지거나 빈 배치를 산출하게 되는데, 이런 종류의 버그는 런타임에 극히 찾아내기 어렵다. 생성 시점 panic은 오류를 가장 이른 관측 가능 지점으로 앞당긴다.

timeout과timeout_repeating의 차이도 주목할 만하다:timeout은 타임아웃 후 오류를 반환하지만,내부 스트림을 계속 폴링한다.;timeout_repeating은Interval에 따라 내부 스트림이 값을 산출할 때까지 계속 타임아웃 오류를 산출한다. 문서는 두 가지 예제로 이 차이를 정확히 묘사한다:

📎 tokio-stream/src/stream_ext.rs:985-1001

rust
/// Once a timeout error is received, no further events will be received
/// unless the wrapped stream yields a value (timeouts do not repeat).

📎 tokio-stream/src/stream_ext.rs:1071-1072

rust
/// Timeout errors will be continuously produced at the specified interval
/// until the wrapped stream yields a value.

---

StreamMap: 동적 스트림 집합과 공정 폴링

직관적 모델

select!의 분기 수는 컴파일 시점에 고정된다. 하지만 채팅 서비스가 구독할 채널 수, 크롤러가 추적할 연결 수는 모두 런타임에야 알 수 있다.StreamMap은 "런타임에 추가/삭제 가능한select!"이다: 임의 개수의 스트림을 하나의 집합에 넣고, 매번next이(key, value)을 반환하여 이 값이 어느 스트림에서 왔는지 알려준다. 이것이 없다면 모든 스트림을 하나의mpsc채널에 밀어넣어야 하며, 한 계층의 전달 오버헤드가 추가된다.

데이터 구조와 메모리 레이아웃

StreamMap의 저장 방식은 극히 소박하다—하나의Vec:

📎 tokio-stream/src/stream_map.rs:204-208

rust
#[derive(Debug)]
pub struct StreamMap<K, V> {
    /// Streams stored in the map
    entries: Vec<(K, V)>,
}

문서는 이 선택의 대가를 명확히 설명한다:

📎 tokio-stream/src/stream_map.rs:38-44

rust
/// `StreamMap` is backed by a `Vec<(K, V)>`. There is no guarantee that this
/// internal implementation detail will persist in future versions, but it is
/// important to know the runtime implications. In general, `StreamMap` works
/// best with a "smallish" number of streams as all entries are scanned on
/// insert, remove, and polling. In cases where a large number of streams need
/// to be merged, it may be advisable to use tasks sending values on a shared
/// [`mpsc`] channel.
〔설계 추론 및 아키텍처 트레이드오프〕

왜HashMap을 사용하지 않는가? 왜냐하면StreamMap의 핵심 연산은모든 스트림 폴링이지, 키로 조회하는 것이 아니기 때문이다.Vec의 선형 스캔은 CPU 캐시에 친화적이며,swap_remove은 O(1)이다. 만약HashMap을 사용하면 매번poll_next마다 해시 버킷을 순회해야 하므로 캐시 지역성이 더 나빠진다.insert과remove의 O(n) 스캔은 "소규모 스트림 집합" 가정 하에 수용 가능하다.

insert의 구현은 "먼저 삭제 후 삽입" 의미론을 구현한다:

📎 tokio-stream/src/stream_map.rs:446-454

rust
pub fn insert(&mut self, k: K, stream: V) -> Option<V>
where
    K: Hash + Eq,
{
    let ret = self.remove(&k);
    self.entries.push((k, stream));

    ret
}

remove은swap_remove을 사용하여 삭제된 요소를 마지막 요소와 교환한 후 pop하여 O(n) 이동을 피한다:

📎 tokio-stream/src/stream_map.rs:471-483

rust
pub fn remove<Q>(&mut self, k: &Q) -> Option<V>
where
    K: Borrow<Q>,
    Q: Hash + Eq + ?Sized,
{
    for i in 0..self.entries.len() {
        if self.entries[i].0.borrow() == k {
            return Some(self.entries.swap_remove(i).1);
        }
    }

    None
}

시나리오 기반 Walkthrough: poll_next_entry의 무작위 시작점과 커서 보정

StreamMap의 핵심은poll_next_entry이다. 이것은무작위 시작점부터 폴링을 시작하여 공정성을 보장한다—만약 항상 인덱스 0부터 시작하면 첫 번째 스트림이 뒤의 스트림들을 기아 상태로 만들 것이다:

📎 tokio-stream/src/stream_map.rs:515-550

rust
fn poll_next_entry(&mut self, cx: &mut Context<'_>) -> Poll<Option<(usize, V::Item)>> {
    let start = self::rand::thread_rng_n(self.entries.len() as u32) as usize;
    let mut idx = start;

    for _ in 0..self.entries.len() {
        let (_, stream) = &mut self.entries[idx];

        match Pin::new(stream).poll_next(cx) {
            Poll::Ready(Some(val)) => return Poll::Ready(Some((idx, val))),
            Poll::Ready(None) => {
                // Remove the entry
                self.entries.swap_remove(idx);

                // Check if this was the last entry, if so the cursor needs
                // to wrap
                if idx == self.entries.len() {
                    idx = 0;
                } else if idx < start && start <= self.entries.len() {
                    // The stream being swapped into the current index has
                    // already been polled, so skip it.
                    idx = idx.wrapping_add(1) % self.entries.len();
                }
            }
            Poll::Pending => {
                idx = idx.wrapping_add(1) % self.entries.len();
            }
        }
    }

    // If the map is empty, then the stream is complete.
    if self.entries.is_empty() {
        Poll::Ready(None)
    } else {
        Poll::Pending
    }
}

이 코드에는 세 가지 정묘한 점이 있으며, 하나씩 분석한다:

첫째, 무작위 시작점. thread_rng_n은 스레드 로컬FastRand을 사용하며,xorshift64+알고리즘에 기반한다:

📎 tokio-stream/src/stream_map.rs:765-768

rust
/// Implement `xorshift64+`: 2 32-bit `xorshift` sequences added together.
/// Shift triplet `[17,7,16]` was calculated as indicated in Marsaglia's
/// `Xorshift` paper

fastrand_n은 Lemire의 곱셈 모듈로를 사용하여% n:

📎 tokio-stream/src/stream_map.rs:787-792

rust
pub(crate) fn fastrand_n(&self, n: u32) -> u32 {
    // This is similar to fastrand() % n, but faster.
    // See https://lemire.me/blog/2016/06/27/a-fast-alternative-to-the-modulo-reduction/
    let mul = (self.fastrand() as u64).wrapping_mul(n as u64);
    (mul >> 32) as u32
}

복사swap_remove둘째,이후의 커서 보정.idx인덱스None의 스트림이swap_remove을 반환하여 제거될 때,idx은 마지막 요소를로 옮긴다. 이 옮겨진 요소는이미 폴링되었을 수 있다start(만약 원래 인덱스가idx < start && start <= self.entries.len()이전이라면). 코드는idx = idx.wrapping_add(1) % len으로 이 상황을 감지하고, 그렇다면 건너뛴다(idx == len). 만약 제거된 것이 마지막 요소라면(

), 커서는 0으로 되돌아간다.Poll::Pending셋째,의 의미론.Pending만약 한 바퀴 순회했는데 어떤 스트림도 준비되지 않았고 집합이 비어 있지 않다면,

poll_next을 반환한다. 이때 모든 스트림의 Waker가 이미 등록되어 있으므로, 어느 하나라도 준비되면 깨운다.poll_next_entry은

📎 tokio-stream/src/stream_map.rs:676-683

rust
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
    if let Some((idx, val)) = ready!(self.poll_next_entry(cx)) {
        let key = self.entries[idx].0.clone();
        Poll::Ready(Some((key, val)))
    } else {
        Poll::Ready(None)
    }
}

복사ready!주목할 점은poll_next_entry매크로이다: 만약Pending이poll_next을 반환하면, 전체Pending。K: Clone이 즉시key.clone()。

을 반환한다. 제약은 여기의

next_many에서 온다. 설계 고찰: next_many의 배치 의미론과 취소 안전성StreamMap은

📎 tokio-stream/src/stream_map.rs:581-583

rust
pub async fn next_many(&mut self, buffer: &mut Vec<(K, V::Item)>, limit: usize) -> usize {
    poll_fn(|cx| self.poll_next_many(cx, buffer, limit)).await
}

복사

📎 tokio-stream/src/stream_map.rs:573-578

rust
/// # Cancel safety
///
/// This method is cancel safe. If `next_many` is used as the event in a
/// [`tokio::select!`] statement and some other branch completes first,
/// it is guaranteed that no items were received on any of the underlying
/// streams.

복사next_many왜이 취소 안전한가? 왜냐하면 요소를buffer즉시 호출자가 제공한buffer에 push하고, 내부에 임시 저장하지 않기 때문이다. 만약 future가 drop되면 이미 push된 요소는 여전히buffer안에 있어 손실되지 않는다. 하지만 이것은 또한 의미한다: drop될 때

poll_next_many에 이미 일부 요소가 있을 수 있다—호출자는 이 점을 알아야 한다.poll_next_entry의 루프 구조는

📎 tokio-stream/src/stream_map.rs:597-666

rust
pub fn poll_next_many(
    &mut self,
    cx: &mut Context<'_>,
    buffer: &mut Vec<(K, V::Item)>,
    limit: usize,
) -> Poll<usize> {
    if limit == 0 || self.entries.is_empty() {
        return Poll::Ready(0);
    }

    let mut added = 0;

    let start = self::rand::thread_rng_n(self.entries.len() as u32) as usize;
    let mut idx = start;

    while added < limit {
        // Indicates whether at least one stream returned a value when polled or not
        let mut should_loop = false;

        for _ in 0..self.entries.len() {
            let (_, stream) = &mut self.entries[idx];

            match Pin::new(stream).poll_next(cx) {
                Poll::Ready(Some(val)) => {
                    added += 1;

                    let key = self.entries[idx].0.clone();
                    buffer.push((key, val));

                    should_loop = true;

                    idx = idx.wrapping_add(1) % self.entries.len();

                    if added == limit {
                        break;
                    }
                }
                Poll::Ready(None) => {
                    // Remove the entry
                    self.entries.swap_remove(idx);

                    // Check if this was the last entry, if so the cursor needs
                    // to wrap
                    if idx == self.entries.len() {
                        idx = 0;
                    } else if idx < start && start <= self.entries.len() {
                        // The stream being swapped into the current index has
                        // already been polled, so skip it.
                        idx = idx.wrapping_add(1) % self.entries.len();
                    }
                }
                Poll::Pending => {
                    idx = idx.wrapping_add(1) % self.entries.len();
                }
            }
        }

        if !should_loop {
            break;
        }
    }

    if added > 0 {
        Poll::Ready(added)
    } else if self.entries.is_empty() {
        Poll::Ready(0)
    } else {
        Poll::Pending
    }
}

복사while added < limit외부for과 내부should_loop = true가 "다중 라운드 스캔"을 구성한다: 이전 라운드에 스트림이 값을 산출했으면(limit), 한 라운드 더 스캔하여

📎 tokio-stream/src/stream_map.rs:588-591

rust
/// * `Poll::Pending` if no items are available but the `StreamMap` is not empty.
/// * `Poll::Ready(count)` where `count` is the number of items successfully received and
///   stored in `buffer`. This can be less than, or equal to, `limit`.
/// * `Poll::Ready(0)` if `limit` is set to zero or when the `StreamMap` is empty.

size_hint여러 스트림의 용량 힌트를 집계하는 방법을 보여줍니다:

📎 tokio-stream/src/stream_map.rs:685-701

rust
fn size_hint(&self) -> (usize, Option<usize>) {
    let mut ret: (usize, Option<usize>) = (0, Some(0));

    for (_, stream) in &self.entries {
        let hint = stream.size_hint();

        ret.0 = ret.0.saturating_add(hint.0);

        match (ret.1, hint.1) {
            (Some(a), Some(b)) => ret.1 = a.checked_add(b),
            (Some(_), None) => ret.1 = None,
            _ => {}
        }
    }

    ret
}

와merge_size_hints동일한 패턴: 하한은 포화 덧셈, 상한은 검사 덧셈, 어느 하나라도 미지면 전체가 미지.

아래는 흐름도로poll_next_entry의 의사결정 경로를 묘사합니다:

mermaid
flowchart TD
    start["poll_next_entry(cx)"] --> rand["start = thread_rng_n(len)"]
    rand --> loop{"遍历 len 次?"}
    loop -->|"未完成"| poll["Pin::new(stream).poll_next(cx)"]
    poll -->|"Ready(Some(val))"| ret_val["返回 Ready(Some((idx, val)))"]
    poll -->|"Ready(None)"| remove["entries.swap_remove(idx)"]
    remove --> wrap{"idx == entries.len()?"}
    wrap -->|"是"| set_zero["idx = 0"]
    wrap -->|"否"| check_swap{"idx < start && start <= len?"}
    check_swap -->|"是"| skip["idx = idx.wrapping_add(1) % len"]
    check_swap -->|"否"| loop
    set_zero --> loop
    skip --> loop
    poll -->|"Pending"| advance["idx = idx.wrapping_add(1) % len"]
    advance --> loop
    loop -->|"遍历完成"| empty{"entries.is_empty()?"}
    empty -->|"是"| ret_none["返回 Ready(None)"]
    empty -->|"否"| ret_pending["返回 Pending"]

---

TaskTracker: 단일 AtomicUsize로 모든 상태를 인코딩

직관적 모델

우아한 종료에는 두 가지가 필요합니다:작업에 중지를 통지(CancellationToken가 담당), 그리고작업이 실제로 종료될 때까지 대기(TaskTracker가 담당).TaskTracker은 「작업 카운터 + 종료 스위치」의 결합체와 같습니다: 아직 실행 중인 작업이 있거나close,wait()이 호출되지 않았다면 반환하지 않습니다. 이것이 없다면JoinSet만 사용할 수 있지만,JoinSet은 각 작업의 반환값을 누적하므로 장기 실행 서비스는 OOM이 발생합니다.

데이터 구조와 메모리 레이아웃

TaskTracker은Arc래퍼입니다:

📎 tokio-util/src/task/task_tracker.rs:158-178

rust
pub struct TaskTracker {
    inner: Arc<TaskTrackerInner>,
}

/// Represents a task tracked by a [`TaskTracker`].
#[must_use]
#[derive(Debug)]
pub struct TaskTrackerToken {
    task_tracker: TaskTracker,
}

struct TaskTrackerInner {
    /// Keeps track of the state.
    ///
    /// The lowest bit is whether the task tracker is closed.
    ///
    /// The rest of the bits count the number of tracked tasks.
    state: AtomicUsize,
    /// Used to notify when the last task exits.
    on_last_exit: Notify,
}

이것은 이 장에서 가장 정교한 메모리 레이아웃입니다:하나의AtomicUsize이 「종료 여부」와 「작업 수」를 동시에 인코딩합니다. 최하위 비트는 종료 플래그, 나머지 비트는 작업 수입니다 (작업 카운트가 매번+2이므로 최하위 비트는 항상 0). 이렇게 하면is_closed_and_empty은 단 한 번의 원자적 로드만 필요합니다:

📎 tokio-util/src/task/task_tracker.rs:216-222

rust
fn is_closed_and_empty(&self) -> bool {
    // If empty and closed bit set, then we are done.
    //
    // The acquire load will synchronize with the release store of any previous call to
    // `set_closed` and `drop_task`.
    self.state.load(Ordering::Acquire) == 1
}
〔설계 추론과 아키텍처 트레이드오프〕

state == 1은 「종료 비트가 1, 카운트가 0」을 의미합니다. 왜 두 개의 원자적 변수를 사용하지 않을까요? 두 변수는 두 번의 로드가 필요하고 「두 조건을 동시에 만족」하는 것을 원자적으로 판단할 수 없습니다. 단일 변수 인코딩은is_closed_and_empty을 한 번의Acquire로드로 만들고,wait의 빠른 경로에서 잠금이 필요 없게 합니다.

시나리오 기반 Walkthrough: close와 drop_task의 경쟁

전형적인 시나리오를 고려해봅시다: 메인 스레드가tracker.close()을 호출하고, 동시에 마지막 작업이 종료 중입니다 (TaskTrackerToken::drop이drop_task을 호출). 둘은 동시에 실행될 수 있으며, 누가 먼저든wait()이 깨어날 수 있음을 보장해야 합니다.

먼저set_closed:

📎 tokio-util/src/task/task_tracker.rs:225-249

rust
fn set_closed(&self) -> bool {
    // The AcqRel ordering makes the closed bit behave like a `Mutex<bool>` for synchronization
    // purposes. ...
    let state = self.state.fetch_or(1, Ordering::AcqRel);

    // If there are no tasks, and if it was not already closed:
    if state == 0 {
        self.notify_now();
    }

    (state & 1) == 0
}

fetch_or(1, AcqRel)은 원자적으로 종료 비트를 설정하고 이전 값을 반환합니다. 이전 값이 0이면 (이전에 종료되지 않았고 작업도 없음), 「종료 후 즉시 빈 상태 + 종료 충족」을 의미하므로notify_now을 호출합니다. 반환값(state & 1) == 0은 「이번 호출이 실제로 상태를 변경했음」을 나타냅니다.

다음으로drop_task:

📎 tokio-util/src/task/task_tracker.rs:264-271

rust
fn drop_task(&self) {
    let state = self.state.fetch_sub(2, Ordering::Release);

    // If this was the last task and we are closed:
    if state == 3 {
        self.notify_now();
    }
}

fetch_sub(2, Release)은 카운트를 감소시킵니다. 이전 값이 3이면 (이진수11: 종료 비트 1 + 카운트 1), 「이것이 마지막 작업이고 이미 종료됨」을 의미하므로notify_now。

을 호출합니다. 두 경로의 경쟁 분석:

  • close가 먼저 실행:set_closed은 이전 값2(카운트 1, 미종료)을 보고 통지하지 않습니다. 이후drop_task이 이전 값3을 보고 통지합니다. ✓
  • drop_task가 먼저 실행:drop_task은 이전 값2(카운트 1, 미종료)을 보고 통지하지 않습니다. 이후set_closed이 이전 값0(카운트 0, 미종료)을 보고 통지합니다. ✓
  • 동시 실행:fetch_or과fetch_sub은 원자적이므로, 어떤 교차 순서든 항상 하나는 「종료 + 빈 상태」 조합을 보고 통지합니다. ✓

notify_now에는 간과하기 쉬운Acquire로드가 있습니다:

📎 tokio-util/src/task/task_tracker.rs:274-285

rust
#[cold]
fn notify_now(&self) {
    // Insert an acquire fence. This matters for `drop_task` but doesn't matter for
    // `set_closed` since it already uses AcqRel.
    //
    // This synchronizes with the release store of any other call to `drop_task`, and with the
    // release store in the call to `set_closed`. That ensures that everything that happened
    // before those other calls to `drop_task` or `set_closed` will be visible after this load,
    // and those things will also be visible to anything woken by the call to `notify_waiters`.
    self.state.load(Ordering::Acquire);

    self.on_last_exit.notify_waiters();
}

왜drop_task이Release대신AcqRel을 사용할까요?drop_task의fetch_sub은 「이전 쓰기를 후속 읽기자에게 가시화」만 필요하고 (Release 의미론), 「이전 다른 스레드의 쓰기를 보는 것」 (Acquire 의미론)은 필요하지 않기 때문입니다. 하지만notify_now은 happens-before를 확립하기 위해 Acquire가 필요합니다: 작업 종료 전에 수행한 모든 정리 작업이wait()반환 후의 코드에 가시적임을 보장합니다. 이load의 결과는 버려지며, 순전히 메모리 순서 부작용을 위한 것입니다 — 이것은 Rust 원자적 연산에서 「fence식 로드」의 전형적인 사용법입니다.

설계 사고: wait의 ABA 저항과 TrackedFuture의 drop 의미론

wait은TaskTrackerWaitFuture을 반환하며, 내부적으로Notified:

📎 tokio-util/src/task/task_tracker.rs:318-327

rust
pub fn wait(&self) -> TaskTrackerWaitFuture<'_> {
    TaskTrackerWaitFuture {
        future: self.inner.on_last_exit.notified(),
        inner: if self.inner.is_closed_and_empty() {
            None
        } else {
            Some(&self.inner)
        },
    }
}

복사inner필드에 주목하세요: 생성 시 이미 「종료되고 빈 상태」라면,None,poll으로 직접 설정하여Ready시 즉시 반환합니다. 이것이 빠른 경로입니다.

문서는 ABA 저항을 특별히 강조합니다:

📎 tokio-util/src/task/task_tracker.rs:304-307

rust
/// The `wait` future is resistant against [ABA problems][aba]. That is, if the `TaskTracker`
/// becomes both closed and empty for a short amount of time, then it is guarantee that all
/// `wait` futures that were created before the short time interval will trigger, even if they
/// are not polled during that short time interval.

이 보장은Notify::notified()의 의미론에서 비롯됩니다:Notifiedfuture는 생성 시 「대기자」 신원을 등록하므로,notify_waiters이 그것이poll되기 전에 호출되더라도, 첫poll시 통지를 보게 됩니다.TaskTrackerWaitFuture::poll의 구현:

📎 tokio-util/src/task/task_tracker.rs:697-712

rust
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
    let me = self.project();

    let inner = match me.inner.as_ref() {
        None => return Poll::Ready(()),
        Some(inner) => inner,
    };

    let ready = inner.is_closed_and_empty() || me.future.poll(cx).is_ready();
    if ready {
        *me.inner = None;
        Poll::Ready(())
    } else {
        Poll::Pending
    }
}

매번poll은 먼저is_closed_and_empty()을 확인하고, 그 다음poll Notified을 합니다. 이 순서는 다음을 보장합니다:Notified이 어떤 이유로 깨어나지 않더라도 상태 확인이 안전망 역할을 합니다.

TrackedFuture의 drop 의미론은TaskTracker과JoinSet의 핵심 차이입니다:

📎 tokio-util/src/task/task_tracker.rs:488-494

rust
/// The task is removed from the collection when it is dropped, not when [`poll`] returns
/// [`Poll::Ready`].

이것은 다음을 의미합니다: future가 이미Ready을 반환했더라도,TrackedFuture자체가 아직 drop되지 않았다면,TaskTracker은 작업이 아직 있다고 간주합니다. 문서는 이 설계가 왜 중요한지 설명합니다:

📎 tokio-util/src/task/task_tracker.rs:33-35

rust
/// When a call to [`wait`] returns, it is guaranteed that all tracked tasks have exited and that
/// the destructor of the future has finished running. However, there might be a short amount of
/// time where [`JoinHandle::is_finished`] returns false.

TaskTrackerToken의Drop은 카운트 감소의 트리거 지점입니다:

📎 tokio-util/src/task/task_tracker.rs:670-672

rust
impl Drop for TaskTrackerToken {
    /// Dropping the token indicates to the [`TaskTracker`] that the task has exited.
    #[inline]
    fn drop(&mut self) {
        self.task_tracker.inner.drop_task();
    }
}

TrackedFuture은pin_project!을 통해token과future을 함께 묶어,token의 drop이 자동으로 카운트 감소를 트리거합니다.spawn_blocking은 token을 명시적으로 관리합니다:

📎 tokio-util/src/task/task_tracker.rs:452-464

여기까지 StreamExt는 poll_next를 조합 가능한 반복자로 만들었고, StreamMap과 TaskTracker는 동적 작업 집합에 귀속을 부여했으며, CancellationToken은 트리로 취소 신호를 전체 작업 트리에 전파합니다. 이 세 계층 확장의 공통점은: 새로운 스케줄링 원시 요소를 도입하지 않고, Waker, Notify, 원자적 카운트 같은 기존 메커니즘을 재조합하여 더 높은 수준의 추상화를 만든 것입니다. 하지만 핵심 질문이 떠오릅니다: 이 조합자, 작업 집합, 취소 트리가 같은 스케줄러에서 동시에 실행될 때, 어떤 작업이 오랫동안 양보하지 않아 다른 작업을 기아 상태로 만들지 않도록 어떻게 보장할까요? 다음 장에서는 Tokio의 coop 협력 예산 메커니즘을 깊이 파고들어, 각 작업이 한 스케줄링 주기에서 예산을 어떻게 소비하고, 소진 후 능동적으로 양보하며, budget이 스레드 로컬 저장소에서 어떻게 전달되는지 살펴봄으로써 이 고전적 문제를 해결합니다.

모든 코드베이스를 진정으로 이해할 수 있는 책으로

이 장을 다 읽으셨나요? 내 프라이빗 저장소를 위한 아키텍처 책 만들기

Tauri 2 + Rust 로컬 퍼스트 아키텍처. 100% 오프라인 보안, 클라우드 코드 업로드 없음. 불변 커밋 라인 앵커로 정독.

⚡ Tauri 2 · Rust 네이티브 코어 · 100% 오프라인 보안 · 100만 행 이상 검증

CHAPTER 12

제 12 장: 협력적 스케줄링과 예산: coop 메커니즘이 어떻게 태스크가 스케줄러를 기아 상태로 만드는 것을 방지하는가

Upstream: tokio-rs/tokio · Commit @e800714a · 진행률: 제 12 장 / 총 14 장

지난 장에서 우리는 tokio-stream과 tokio-util이 어떻게 하위 계층의 Waker와 스케줄링 메커니즘을 재사용하여 핵심 기능을 확장하는지 살펴보았다. 그러나 아무리 많은 조합자를 확장하더라도 비동기 런타임의 핵심 모순은 항상 존재한다: 스케줄러는 여러 태스크 사이에 CPU 시간을 공정하게 분배해야 하지만, 태스크 자체는 비선점적이다——일단 어떤 Future의 poll이 실행되기 시작하면 스케줄러는 외부에서 그것을 중단할 수 없다. 만약 어떤 태스크가 단일 poll에서 십만 개의 메시지를 루프로 처리하거나, loop 안에서 영원히 준비된 Future를 반복적으로 await한다면, 그것은 worker 스레드를 독점하여 같은 스레드의 다른 태스크들이 영원히 폴링 기회를 얻지 못하게 한다. 이것이 바로 고전적인 「태스크가 스케줄러를 기아 상태로 만드는」 문제다. Tokio의 해법은 선점이 아니라 협력이다: 각 태스크에 하나의 스케줄링 주기 내에서 제한된 예산을 할당하고, 자원 작업이 예산을 소모하며, 예산이 고갈되면 태스크가 스스로 양보해야 한다. 이 장에서는 이 coop 메커니즘의 구현을 깊이 파고든다.

12.1 예산의 운반체: 스레드 로컬 저장소와 Budget 구조체

〔설계 추론과 아키텍처 트레이드오프〕

스케줄러를 식당의 유일한 서버라고 비유하고, 태스크를 끊임없이 주문하는 손님이라고 한다면, coop 예산은 「각 손님은 최대 N개의 요리만 주문할 수 있다」는 규칙이다——서버는 손님을 강제로 중단할 필요 없이, 손님이 N개를 다 주문한 후에 「잠시 쉬세요, 다음 분을 서빙하겠습니다」라고 말하기만 하면 된다. 이 규칙이 없으면, 수다스러운 손님 하나가 식당 전체를 마비시킬 수 있다.

예산은 두 가지 제약을 충족해야 한다: 첫째, 임의 깊이의poll호출 스택에서 접근할 수 있어야 하며, 매번 인자를 전달할 필요가 없어야 한다; 둘째, 「현재 Tokio 런타임 내에 있는지」를 구분할 수 있어야 한다——런타임 외부에서block_on를 호출할 때는 예산 제약을 받지 않아야 한다. Tokio는스레드 로컬 저장소(TLS)를 사용하여 예산을 운반하고,context모듈을 통해 통합 관리하기로 선택했다.

예산의 핵심 타입은coop::Budget이다. 이 장의 소스 코드 슬라이스가coop.rs의 완전한 정의를 직접 제공하지는 않지만,worker.rs의 사용 지점에서 그 인터페이스 계약을 역추론할 수 있다:

📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:695-795

rust
coop::budget(|| {
    // ... 轮询任务 ...
    task.run();
    // ...
    loop {
        // ...
        if !coop::has_budget_remaining() {
            // 预算耗尽,把 LIFO 任务推回队列
            core.run_queue.push_back_or_overflow(task, ...);
            return ControlFlow::Continue(core);
        }
        // ...
    }
})

여기에 세 가지 핵심 API가 등장한다:coop::budget(closure)는 예산 스코프를 설정하고,coop::has_budget_remaining()는 남은 예산을 조회하며, 그리고 후술할coop::stop()와coop::set()。budget의 의미는: 클로저에 진입할 때 현재 스레드의 예산을 최대값(기본 128)으로 재설정하고, 클로저 실행 동안 모든 자원 작업이 이 할당량을 공유하며, 클로저 종료 시 외부 예산을 복원한다.

〔설계 추론과 아키텍처 트레이드오프〕

예산 값 128은 경험값이다: 정상적인 메시지 처리 루프(예를 들어 한 번의 poll에서 수십 개의 메시지 처리)가 빈번하게 양보를 트리거하지 않을 만큼 충분히 크고, 통제 불능의 루프가 최대 128번의 자원 작업만 실행하면 반드시 양보하여 지연을 수용 가능한 범위로 제어할 만큼 충분히 작다.

Budget는 TLS에서 일반적으로Cell<Option<Budget>>형태로 존재한다.Option의 외부 의미는 「현재 스레드가 Tokio 런타임 컨텍스트에 있는지」이다:None는 런타임 내에 있지 않음을 나타내며(예를 들어 런타임 외부의block_on), 이때 모든 예산 검사는 직접 통과된다.

12.2 예산의 소모 지점: 자원 작업이 어떻게 차감하는가

예산은 공짜로 소모되지 않으며, 오직자원 작업만이 그것을 차감한다. 소위 자원 작업이란 무한 루프로 호출될 수 있는, 외부 세계와 상호작용하는 API를 말한다——channel의send/recv, I/O의 읽기/쓰기,yield_now등. 예를 들어mpsc::Sender::reserve는 모든 전송 경로의 공통 진입점이다:

📎 tokio/src/sync/mpsc/bounded.rs:1272-1311

rust
async fn reserve_inner(&self, n: usize) -> Result<(), SendError<()>> {
    crate::trace::async_trace_leaf().await;

    if n > self.max_capacity() {
        return Err(SendError(()));
    }
    // ... WakeReceiverOnDrop guard ...
    let guard = WakeReceiverOnDrop { chan: &self.chan };
    let result = self.chan.semaphore().semaphore.acquire(n).await;
    // ...
}

reserve_inner는 실제로 세마포어 허가를 획득하기 전에crate::trace::async_trace_leaf()를 거친다. 이것은 단지 tracing처럼 보이는 호출이지만, 실제로는 예산 차감의 마운트 지점 중 하나이다.async_trace_leaf내부에서는coop::poll_proceed같은 함수를 호출한다: 예산이 충분하면 1을 차감하고Proceed를 반환한다; 예산이 고갈되면 「양보」 동작을 등록한다——현재 태스크의 Waker를 스케줄러에 넘기고Pending를 반환하여, 태스크가 이번 poll에서 조기에 종료되게 한다.

이것이 coop의 정묘한 점이다:예산 고갈은 오류를 던지는 것이 아니라, 「양보」를 평범한Pending로 위장한다. 상위 Future는Pending를 보고 자연스럽게 반환하고, 스케줄러는 태스크를 다시 큐에 넣으며, 다음에 스케줄될 때 예산이 이미 재설정되어 태스크는 지난번 중단 지점부터 계속한다. 전체 과정은 비즈니스 코드에 완전히 투명하다.

yield_now는 예산 메커니즘의 가장 직관적인 표현으로, 예산을 소모하지 않고능동적으로 양보를 트리거한다:

📎 tokio/src/task/yield_now.rs:38-60

rust
pub async fn yield_now() {
    let mut yielded = false;
    poll_fn(|cx| {
        ready!(crate::trace::trace_leaf());

        if yielded {
            return Poll::Ready(());
        }

        yielded = true;

        // Don't wake the task immediately, as that would push it right back
        // onto the run queue and it could be polled again before other tasks
        // or the IO/timer driver get a chance to run. Instead, hand the waker
        // to the scheduler, which wakes deferred tasks only after it has run
        // out of ready tasks and polled the driver. When polled from outside
        // a Tokio runtime, the waker is woken immediately.
        context::defer(cx.waker());

        Poll::Pending
    })
    .await
}

주목하라context::defer(cx.waker())이 줄을. 그것은 직접wake하지 않고, Waker를 스케줄러의defer 큐에 넘긴다. 왜인가? 소스 코드 주석이 명확히 설명한다: 즉시 깨우면 태스크가 즉시 실행 큐로 다시 푸시되어, I/O/timer 드라이버가 실행되기 전에 다시 폴링될 수 있어 양보가 의미를 잃는다. defer 큐의 의미는 「현재 worker가 준비된 태스크를 모두 실행하고, 드라이버를 폴링한 후에 이 태스크들을 깨우는 것」이다.

defer 큐는 worker의Context에 정의되어 있다:

📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:247-257

rust
pub(crate) struct Context {
    worker: Arc<Worker>,
    core: RefCell<Option<Box<Core>>>,
    /// Tasks to wake after resource drivers are polled. This is mostly to
    /// handle yielded tasks.
    pub(crate) defer: Defer,
}

defer필드의 주석은 그 용도를 직접 밝힌다: 「mostly to handle yielded tasks」. worker 메인 루프에서 로컬 큐와 스틸 모두 할 일이 없을 때, defer 큐를 검사한다:

📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:613-621

rust
} else {
    // Wait for work
    core = if !self.defer.is_empty() {
        self.park_yield(core)
    } else {
        self.park(core)
    };
    core.stats.start_processing_scheduled_tasks();
}

defer 큐가 비어 있지 않으면, worker는park_yield——0 타임아웃으로 park하며, 이는 I/O와 timer를 구동한 다음 defer에 있는 작업을 깨웁니다. 이로써 "양보"한 작업은 반드시 드라이버가 실행된 후에야 다시 스케줄링됨이 보장됩니다.

12.3 예산 범위의 설정과 복원: run_task와 block_in_place

예산 범위는run_task에서 설정됩니다. 각 작업이 폴링될 때,coop::budget가 전체 폴링 과정을 감쌉니다:

📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:691-704

rust
// Make the core available to the runtime context
*self.core.borrow_mut() = Some(core);

// Run the task
coop::budget(|| {
    // ...
    task.run();
    // ...
})

coop::budget진입 시 TLS의 예산을 최대치로 설정하고, 종료 시 복원합니다. 이는각 작업이 폴링될 때마다 완전히 새로운 예산을 받는다는 것을 의미합니다. 작업 내부에서await를 몇 번이나 하든, 단일poll내에서 소비가 128을 초과하면 강제로 양보됩니다.

하지만 여기에는 미묘한 문제가 있습니다: LIFO slot의 작업은동일한budget클로저 내에서폴링됩니다.run_task의 루프를 보세요:

📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:709-750

rust
let mut lifo_polls = 0;

// As long as there is budget remaining and a task exists in the
// `lifo_slot`, then keep running.
loop {
    let mut core = match self.core.borrow_mut().take() {
        Some(core) => core,
        None => {
            return ControlFlow::Break(());
        }
    };

    let task = match core.lifo_slot.take() {
        Some(task) => task,
        None => {
            self.reset_lifo_enabled(&mut core);
            core.stats.end_poll();
            return ControlFlow::Continue(core);
        }
    };

    if !coop::has_budget_remaining() {
        core.stats.end_poll();
        // Not enough budget left to run the LIFO task, push it to
        // the back of the queue and return.
        core.run_queue.push_back_or_overflow(task, ...);
        debug_assert!(core.lifo_enabled);
        return ControlFlow::Continue(core);
    }
    // ...
}

핵심: LIFO slot의 작업은외부 작업의 예산을 공유합니다. 주석은run_task시작 부분에서 "Tasks from the LIFO slot inherit the 'parent's limits"라고 말합니다. 이는 의도된 설계입니다——만약 각 LIFO 작업이 예산을 재설정한다면, ping-pong 시나리오(작업 A가 B를 깨우고, B가 다시 A를 깨우는 경우)에서 두 작업이 무한히 서로를 스케줄링하고 예산이 영원히 재설정되어 기아 문제가 여전히 남습니다. 예산 공유는 A와 B가 합쳐서 최대 128번의 리소스 작업을 소비한 후 반드시 양보해야 함을 의미합니다.

LIFO slot 자체에는 독립적인 제한기MAX_LIFO_POLLS_PER_TICK:

📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:756-766

rust
// Disable the LIFO slot if we reach our limit
//
// In ping-ping style workloads where task A notifies task B,
// which notifies task A again, continuously prioritizing the
// LIFO slot can cause starvation as these two tasks will
// repeatedly schedule the other. To mitigate this, we limit the
// number of times the LIFO slot is prioritized.
if lifo_polls >= MAX_LIFO_POLLS_PER_TICK {
    core.lifo_enabled = false;
    super::counters::inc_lifo_capped();
}

MAX_LIFO_POLLS_PER_TICK의 값은 3입니다:

📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:263-263

rust
/// Value picked out of thin-air. Running the LIFO slot a handful of times
/// seems sufficient to benefit from locality. More than 3 times probably is
/// over-weighting. The value can be tuned in the future with data that shows
/// improvements.
const MAX_LIFO_POLLS_PER_TICK: usize = 3;

이것은두 번째 방어선입니다: 예산이 아직 소진되지 않았더라도, LIFO slot이 연속 3번 우선되면 비활성화되고 이후 작업은 일반 큐로 갑니다. 예산은 "리소스 작업 총량"을 관리하고, LIFO 제한은 "동일한 두 작업이 서로를 깨우는 횟수"를 관리하며, 둘은 상호 보완적입니다.

예산 범위는block_in_place에서 중요한 예외가 있습니다.block_in_place는 worker core를 다른 스레드로 이관하고, 현재 스레드는 블로킹 상태로 들어갑니다. 블로킹 코드는 예산 제약을 받지 않으므로 반드시일시 중지해야 합니다:

📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:406-417

rust
if had_entered {
    // Unset the current task's budget. Blocking sections are not
    // constrained by task budgets.
    let _reset = Reset {
        take_core,
        budget: coop::stop(),
    };

    crate::runtime::context::exit_runtime(f)
} else {
    f()
}

coop::stop()는 현재 예산을 반환하고 이를None(즉 "런타임 내에 있지 않음")로 설정하며,Reset의Drop는 블로킹 종료 후 복원합니다:

📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:374-397

rust
impl Drop for Reset {
    fn drop(&mut self) {
        with_current(|maybe_cx| {
            if let Some(cx) = maybe_cx {
                if self.take_core {
                    let core = cx.worker.core.take();
                    // ...
                    *cx_core = core;
                }

                // Reset the task budget as we are re-entering the
                // runtime.
                coop::set(self.budget);
            }
        });
    }
}

coop::set(self.budget)는 이전에stop()저장한 예산을 복원합니다. 이렇게 하면block_in_place내의 동기 블로킹 코드는 예산을 소비하지 않고, 예산 소진으로 인해 잘못 양보가 트리거되지도 않습니다; 블로킹 종료 후 작업은 원래의 남은 예산을 가지고 계속 실행됩니다.

아래 그림은 작업이 스케줄링되어 예산 소진으로 양보될 때까지의 전체 제어 흐름을 보여줍니다:

mermaid
flowchart TD
    start["Context::run 主循环"] --> next["core.next_task()"]
    next --> has_task{"有本地任务?"}
    has_task -->|是| run_task["run_task(task, core)"]
    has_task -->|否| steal["core.steal_work()"]
    steal --> stolen{"窃取到任务?"}
    stolen -->|是| run_task
    stolen -->|否| defer_check{"defer 队列非空?"}
    defer_check -->|是| park_yield["park_yield: 驱动 IO/timer 后唤醒"]
    defer_check -->|否| park["park: 阻塞等待"]
    park_yield --> start
    park --> start

    run_task --> budget["coop::budget 建立满额预算"]
    budget --> poll["task.run() 轮询"]
    poll --> lifo_check{"lifo_slot 有任务?"}
    lifo_check -->|否| done["返回 ControlFlow::Continue"]
    lifo_check -->|是| budget_rem{"coop::has_budget_remaining()?"}
    budget_rem -->|否| push_back["push_back_or_overflow 推回队列"]
    push_back --> done
    budget_rem -->|是| lifo_limit{"lifo_polls >= 3?"}
    lifo_limit -->|是| disable["core.lifo_enabled = false"]
    lifo_limit -->|否| poll_lifo["task.run() 轮询 LIFO 任务"]
    disable --> poll_lifo
    poll_lifo --> lifo_check
    done --> start

그림에서 두 가지 양보 경로를 볼 수 있습니다: 예산 소진 시 LIFO 작업을 큐로 되돌리는 것(push_back_or_overflow), 그리고 LIFO 연속 우선 초과 시 LIFO slot을 비활성화하는 것입니다. 둘 다 메인 루프로 돌아가 worker가 다른 작업이나 드라이버를 처리할 기회를 갖게 합니다.

12.4 설계 고찰, 오류 복구 및 프로덕션 함정

왜 TLS를 사용하고 명시적 매개변수 전달을 사용하지 않는가?예산 검사점은 channel, I/O, time 등 여러 모듈 깊숙이 흩어져 있습니다. 만약 명시적으로 전달한다면 모든 API에Budget매개변수가 하나씩 추가되어 전체 공용 인터페이스를 오염시킬 것입니다. TLS는 예산을 비즈니스 코드에 완전히 투명하게 만들며, 대가는 매 검사마다 TLS 접근 오버헤드가 한 번 발생한다는 것입니다. Tokio는#[thread_local]또는 플랫폼 특정 고속 TLS를 사용하여 이 오버헤드를 낮춥니다.

예산 소진과 취소 안전성의 상호작용.예산 소진으로reserve_inner가Pending를 반환할 때, 작업은select!의 특정 분기에 있을 수 있습니다. 이때 다른 분기가 준비되면,select!는 현재 분기를 취소합니다——reserve_inner의WakeReceiverOnDropguard는 drop 시 "세마포어가 닫혔고 유휴 상태"를 확인하고 수신 측을 깨웁니다:

📎 tokio/src/sync/mpsc/bounded.rs:1286-1299

rust
struct WakeReceiverOnDrop<'a, T> {
    chan: &'a chan::Tx<T, Semaphore>,
}

impl<T> Drop for WakeReceiverOnDrop<'_, T> {
    fn drop(&mut self) {
        use chan::Semaphore;

        let semaphore = self.chan.semaphore();
        if semaphore.is_closed() && semaphore.is_idle() {
            self.chan.wake_rx();
        }
    }
}

이 guard의 존재는 다음을 설명합니다: 예산으로 트리거된Pending와 진정한 "허가 없음"Pending는 취소 경로에서 반드시 일관되게 동작해야 하며, 그렇지 않으면 수신 측이 "channel이 닫혔음" 알림을 영원히 받지 못할 수 있습니다.

프로덕션 함정: 예산 소진으로 인한 숨은 지연.흔한 현상은: 특정 작업의 메시지 처리 속도가 갑자기 느려지지만 CPU 사용률은 높지 않은 것입니다. 조사할 때 락 경합이나 I/O를 의심하기 쉽지만, 실제로는 작업이 단일 poll 내에서 128개 이상의 메시지를 처리하여 예산 양보가 트리거되고, 매 양보마다 완전한 "큐로 되돌림 → 재스케줄링 → 드라이버 폴링" 주기를 거치기 때문일 수 있습니다. 메시지 처리 자체가 빠르다면 이 스케줄링 오버헤드가 차지하는 비율이 높을 수 있습니다. 해결책은 대량 처리를 여러spawn작업으로 나누거나, 루프에 명시적으로yield_now。

를 삽입하는 것입니다.block_in_place예산과의 경계.block_in_place는coop::stop()예산을 일시 중지합니다. 하지만 주의할 점:coop::stop()는had_entered가 참일 때만 호출됩니다, 즉 실제로 런타임 worker 스레드에 있을 때만 일시 중지합니다. 만약block_in_place가 런타임 외부에서 호출되면,f()가 직접 실행되고 예산 상태는 변하지 않습니다. 이 분기 판단은maybe_move_runtime에서 완료됩니다:

📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:424-464

rust
with_current(|maybe_cx| {
    match (
        crate::runtime::context::current_enter_context(),
        maybe_cx.is_some(),
    ) {
        (context::EnterRuntime::Entered { .. }, true) => {
            had_entered = true;
        }
        (
            context::EnterRuntime::Entered {
                allow_block_in_place,
            },
            false,
        ) => {
            if allow_block_in_place {
                had_entered = true;
                return Ok(());
            } else {
                return Err(
                    "can call blocking only when running on the multi-threaded runtime",
                );
            }
        }
        (context::EnterRuntime::NotEntered, true) => {
            return Ok(());
        }
        (context::EnterRuntime::NotEntered, false) => {
            return Ok(());
        }
    }
    // ...
})

네 가지 조합은 각각 다음에 대응합니다: worker 스레드 내,block_on의 스레드 풀 진입점, 중첩block_in_place, 런타임 외부. 처음 두 가지만 예산을 일시 중지하고 core를 이관해야 합니다.

〔설계 추론과 아키텍처 트레이드오프〕

예산 값은 구성 불가.소스 코드에서 보면, 예산 최대치는 하드코딩된 상수(128)이며,Builder옵션. 이것은 의도적이다: 예산 값은 스케줄링 공정성과 처리량 간의 트레이드오프에 영향을 미치며, 사용자가 마음대로 조정할 수 있게 하면 "예산이 너무 커서 기아를 초래"하거나 "예산이 너무 작아 스케줄링 오버헤드가 폭발"하는 설정을 쉽게 만들 수 있다. Tokio는 이를 내부 불변량으로 선택했다.

이 장 요약

coop 메커니즘은 세 계층 설계로 비선점 스케줄러의 공정성 문제를 해결한다:

1. 예산 운반체:coop::BudgetTLS에 존재하며,Option외부 계층은 런타임 내부와 외부를 구분하고,coop::budget만액 스코프를 설정하며,coop::stop/coop::set일시정지와 재개를 지원한다(block_in_place시나리오).

2. 소비 지점: 리소스 작업(channel 송수신, I/O,yield_now)은coop::poll_proceed을 통해 예산을 차감하고, 소진 시 "양보"를Pending로 위장하여 비즈니스에 투명하게 만든다.

3. 양보 경로:yield_now는context::defer을 통해 Waker를 defer 큐에 넘겨 드라이버 폴링 이후에만 재스케줄링되도록 보장한다; LIFO slot 작업은 부모 작업 예산을 공유하며,MAX_LIFO_POLLS_PER_TICK = 3의 독립적 속도 제한을 가진다.

이 메커니즘의 핵심 통찰은:공정성은 선점이 필요 없고, "무한 루프"가 유한한 단계 후에 자연스럽게 중단되도록 하면 된다. 예산이 바로 이 "유한한 단계"의 척도다.

이 장 사고와 자가 테스트

Q1: 만약run_task에서coop::budget클로저 내의 LIFO 루프를 매번 LIFO 작업을 폴링하기 전에coop::budget를 호출하여 예산을 재설정하도록 변경하면, ping-pong 시나리오(작업 A가 B를 깨우고, B가 A를 깨움)에서 무슨 일이 발생하는가? 왜 소스 코드는 LIFO 작업이 부모 작업 예산을 공유하도록 선택했는가?

참고 해석: 소스 코드는run_task의 주석에서 "Tasks from the LIFO slot inherit the "parent"'s limits"라고 명확히 설명한다📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:679-682. 만약 각 LIFO 작업이 예산을 재설정하면, A→B→A→B의 ping-pong 시나리오에서 매 폴링마다 만액 예산을 얻어 두 작업이 무한히 서로를 스케줄링할 수 있고, 예산 소진으로 인해 결코 양보하지 않는다. 비록MAX_LIFO_POLLS_PER_TICK = 3의 속도 제한이 3회 후 LIFO slot📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:756-766을 비활성화하지만, LIFO 비활성화 후 작업은 일반 큐로 가고, 큐에 A와 B만 있으면 여전히 교대로 스케줄링되며 단지 LIFO 우선순위를 누리지 못할 뿐이다. 공유 예산은 리소스 작업 총량에서 최후 방어선을 제공한다: A와 B를 합쳐 최대 128회 리소스 작업을 소비하면 반드시 양보하여 다른 작업과 드라이버에 기회를 준다. 두 방어선은 상호 보완적이며 하나도 빠질 수 없다.

Q2: yield_now는context::defer(cx.waker())대신cx.waker().wake_by_ref()을 사용한다. 만약defer를 직접wake로 변경하면, 단일 worker 다중 작업 시나리오에서 한 작업이 루프에서 반복적으로yield_now를 호출하면 어떤 결과가 발생하는가? worker 메인 루프의park_yield분기를 결합하여 분석하라.

참고 해석:yield_now의 주석이 이유를 설명한다: 직접 wake는 작업을 즉시 실행 큐로 되돌려보내며, I/O/timer 드라이버가 실행되기 전에 다시 폴링될 수 있다📎 tokio/src/task/yield_now.rs:49-54. 단일 worker 시나리오에서, 만약 작업이 루프에서 반복적으로yield_now하고 매번 직접 wake하면, worker 메인 루프의next_task가 즉시 이 작업을 가져와 다시 폴링하고,park_yield분기(I/O와 timer 구동 담당)는📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:613-621결코 실행되지 않는다, 왜냐하면 defer 큐가 비어 있고 로컬 큐에 항상 작업이 있기 때문이다. 결과적으로 I/O 이벤트와 timer는 영원히 처리되지 않고, 전체 런타임이 "가짜로 살아있는" 상태가 된다 — 작업은 실행되지만 외부 세계의 이벤트는 진행될 수 없다.defer큐는 양보한 작업이 드라이버 폴링 이후까지 반드시 기다려야 깨어나도록 보장하여, 드라이버에 실행 창을 제공한다.

Q3: block_in_place에서coop::stop()는 예산을None,Reset::drop로 설정하고coop::set(self.budget)에서 복구한다. 만약block_in_place의 클로저f내부에서 다시block_in_place를 호출하면(중첩), 예산 상태는 어떻게 되는가?maybe_move_runtime의 어느 분기가 이 상황을 처리하는가?

참고 해석: 중첩block_in_place은maybe_move_runtime의(context::EnterRuntime::NotEntered, true)분기에 의해 처리된다📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:454-458. 이 분기는 직접return Ok(())하고,had_entered을 설정하지 않으므로, 외부block_in_place의if had_entered판단이 거짓이 되어 다시coop::stop()를 호출하거나 새로운Reset을 생성하지 않는다. 주석은 "This is a nested call to block_in_place (we already exited). All the necessary setup has already been done."라고 설명한다 — 외부 계층은 이미 예산을 일시정지하고 core를 이양했으므로, 내부 계층은 단지 직접f()을 실행하면 된다. 만약 내부 계층이 다시coop::stop()하면, 이미None인 예산을 다시 저장하게 되어,Reset::drop복구 시 잘못된 값(None이 아닌 외부 계층의 원래 예산)으로 복구될 수 있어, 예산이 영구적으로 손실되고 작업의 이후 모든 리소스 작업이 제약을 받지 않게 된다.

coop 메커니즘은 예산 제약을 통해 작업이 리소스 작업 중에 능동적으로 양보하도록 하여, 비선점 모델에서 스케줄링 공정성을 유지한다. 그러나 예산 소진으로 트리거된 Pending은 취소 경로에서 진정한 대기와 일관되게 동작해야 하며, 그렇지 않으면 select! 등의 조합자가 상태 일관성을 깨뜨린다. 다음 장에서는 생산 함정과 경계 조건: 취소 안전성, panic 전파와 종료 순서로 들어가며, 이러한 "겉보기에 무관한 메커니즘이 경계에서 결합되는" 사례를 더 많이 보게 될 것이다.

모든 코드베이스를 진정으로 이해할 수 있는 책으로

이 장을 다 읽으셨나요? 내 프라이빗 저장소를 위한 아키텍처 책 만들기

Tauri 2 + Rust 로컬 퍼스트 아키텍처. 100% 오프라인 보안, 클라우드 코드 업로드 없음. 불변 커밋 라인 앵커로 정독.

⚡ Tauri 2 · Rust 네이티브 코어 · 100% 오프라인 보안 · 100만 행 이상 검증

CHAPTER 13

제 13 장: 생산 함정과 경계 조건: 취소 안전성, panic 전파와 종료 순서

Upstream: tokio-rs/tokio · Commit @e800714a · 진행률: 제 13 장 / 총 14 장

지난 장에서 우리는 coop 협력 예산을 분석했습니다: 각 태스크는 하나의 스케줄링 주기 내에서 제한된 예산만 가지며, 소진되면 반드시 양보해야 하므로 단일 태스크가 다른 태스크를 기아 상태로 만드는 것을 방지합니다. 하지만 예산 메커니즘은 '공정 스케줄링' 문제만 해결했을 뿐, 실제 프로덕션 환경에는 더 은밀한 함정이 있습니다——취소 안전성, panic 전파, 종료 순서입니다. select!가 Future를 취소할 때, 태스크 panic이 포착될 때, Runtime이 종료를 시작할 때, 코드의 경계 동작은 종종 직관과 배치됩니다. 이번 장에서는 취소 안전성부터 시작하여, drop된 Future가 실제로 무엇을 잃는지 먼저 살펴보겠습니다.

13.2 panic 전파: JoinError가 크래시를 포착하는 방법

직관적 모델

Tokio 태스크 panic은 전체 프로세스를 크래시시키지 않으며(panic=abort가 아닌 한), 대신 포착되어JoinError로 패키징되어JoinHandle::await를 통해 반환됩니다. 이는 공장 조립 라인에서 특정 작업장에 사고가 발생했을 때 안전망이 작업자를 잡아주지만 제품은 폐기되는 것과 같습니다——당신이 받는 것은 '사고 보고서'이지 제품이 아닙니다.

데이터 구조와 상태

JoinHandle<T>의Future::Output는super::Result<T>입니다. 즉Result<T, JoinError> 📎 tokio/src/runtime/task/join.rs:325。JoinError는 두 가지 형태를 가집니다: panic과 cancelled. 문서 예제는 panic 시나리오를 보여줍니다:

rust
let join_handle = tokio::spawn(async { panic!("boom"); });
let err = join_handle.await.unwrap_err();
assert!(err.is_panic());

📎 tokio/src/runtime/task/join.rs:121-127

panic이 포착되는 메커니즘은RawTask의 poll 경로에 있습니다: 태스크 poll 시catch_unwind로 감싸고, panic 발생 후 payload를 태스크의 출력 슬롯에 저장하고, 상태를 complete로 표시한 다음, join waker를 깨웁니다.JoinHandle::poll는try_read_output를 통해 읽은 것이Err(JoinError::panic(payload))。

입니다.

mermaid
sequenceDiagram
    participant App as 应用任务
    participant Worker as Worker 线程
    participant Raw as RawTask
    participant JH as JoinHandle

    App->>Worker: spawn(async { panic!("boom") })
    Worker->>Raw: poll 任务 Future
    Raw->>Raw: catch_unwind 捕获 panic
    Raw->>Raw: 存储 panic payload 到输出槽
    Raw->>Raw: state 标记 complete
    Raw->>JH: 唤醒 join waker
    JH->>App: await 返回 Err(JoinError::panic)

복사JoinError핵심 포인트: panic의 payload가 완전히 보존되며,std::error::Error는into_panic()를 구현하여,Box<dyn Any + Send>를 통해downcast_ref::<&str>()를 되찾고,

로 panic 메시지를 추출할 수 있습니다.

설계 고찰과 함정JoinHandle함정 1:UnwindSafe의

rust
impl<T> UnwindSafe for JoinHandle<T> {}
impl<T> RefUnwindSafe for JoinHandle<T> {}

📎 tokio/src/runtime/task/join.rs:176-181

복사T: UnwindSafe이는 무조건적 구현이며,JoinHandle를 요구하지 않습니다. 이유:T,T자체는catch_unwind를 보유하지 않으며, 힙上的 태스크 할당에서 panic 시 이미T에 의해 격리됩니다. 따라서UnwindSafe,JoinHandle가

가 아니더라도 안전합니다.함정 2: panic은 부모 태스크로 자동 전파되지 않습니다.JoinHandle태스크 A가 태스크 B를 spawn했고 B가 panic하면, A가 B의

를 await하지 않는 한 A는 자동으로 알림을 받지 않습니다. A가 await하지 않으면 B의 panic은 조용히 삼켜집니다. 이는 프로덕션 환경에서 가장 은밀한 버그 원인 중 하나입니다.spawn_blocking함정 3:의 panic도 마찬가지로 포착됩니다.catch_unwind블로킹 스레드 풀의 worker도Mutex로 태스크를 감싸며, panic 후 스레드는 죽지 않고 풀로 돌아가 계속 작업을 받습니다. 하지만 블로킹 태스크에서std::sync::Mutex를 보유하고 panic 시 해제하지 않으면 lock poisoning이 발생합니다——이는

의 고유 동작이며, Tokio는 개입하지 않습니다.함정 4: Runtime drop 시의 panic.catch_unwind태스크가 Runtime drop 과정에서 panic하면,

는 여전히 유효하지만, 이 시점에 join waker가 이미 무효화되었을 수 있어 panic payload가 버려집니다. 이는 종료 순서 문제의 하위 집합이며, 다음 절에서 전개합니다.

13.3 종료 순서: 블로킹 스레드와 I/O 리소스 정리

직관적 모델

Runtime 종료는 식당 폐점과 같습니다: 먼저 프런트에서 손님 받기를 중단하고(새 태스크 수락 중지), 주방이 하던 요리를 마무리할 때까지 기다린 다음(비동기 태스크가 다음 yield 지점까지 실행), 마지막으로 외주 도우미가 퇴근하기를 기다립니다(블로킹 스레드 반환). 순서가 틀리면 문제가 발생합니다——예를 들어 도우미를 먼저 내보내면 주방의 요리는 영원히 완성되지 않습니다.

Runtime데이터 구조와 종료 경로

rust
pub struct Runtime {
    scheduler: Scheduler,
    handle: Handle,
    blocking_pool: BlockingPool,
}

📎 tokio/src/runtime/runtime.rs:97-106

Drop복사

rust
impl Drop for Runtime {
    fn drop(&mut self) {
        match &mut self.scheduler {
            Scheduler::CurrentThread(current_thread) => {
                let _guard = context::try_set_current(&self.handle.inner);
                current_thread.shutdown(&self.handle.inner);
            }
            Scheduler::MultiThread(multi_thread) => {
                multi_thread.shutdown(&self.handle.inner);
            }
        }
    }
}

📎 tokio/src/runtime/runtime.rs:506-521

복사Drop주의:scheduler,는blocking_pool。blocking_pool만 처리하며Drop를 명시적으로 처리하지 않습니다.Runtime::drop의 종료는 자체scheduler → handle → blocking_pool에서 발생하며,

반환 후 필드 drop 순서에 의해 트리거됩니다. 필드 drop 순서는 선언 순서입니다:shutdown_timeout. 따라서 블로킹 풀이 마지막에 종료됩니다.

rust
pub fn shutdown_timeout(mut self, duration: Duration) {
    self.handle.inner.shutdown();
    self.blocking_pool.shutdown(Some(duration));
}

📎 tokio/src/runtime/runtime.rs:457-461

는 순서를 명시적으로 제어합니다:handle.inner.shutdown()복사blocking_pool.shutdown(Some(duration))먼저duration。

로 스케줄러와 I/O 드라이버에 중지를 알리고, 그다음

blocking/shutdown.rs로 블로킹 태스크를 기다리며, 최대

rust
pub(super) struct Sender {
    _tx: Arc<oneshot::Sender<()>>,
}

pub(super) struct Receiver {
    rx: oneshot::Receiver<()>,
}

📎 tokio/src/runtime/blocking/shutdown.rs:13-19

블로킹 풀 종료의 저수준 메커니즘Sender는 정교한 oneshot channel을 사용합니다:Arc<oneshot::Sender>복사Sender각 블로킹 worker는Receiver클론(내부는wait)을 보유합니다. 모든 worker가 종료되고 모든

rust
pub(crate) fn wait(&mut self, timeout: Option<Duration>) -> bool {
    use crate::runtime::context::try_enter_blocking_region;

    if timeout == Some(Duration::from_nanos(0)) {
        return false;
    }

    let mut e = match try_enter_blocking_region() {
        Some(enter) => enter,
        _ => {
            if std::thread::panicking() {
                return false;
            } else {
                panic!(
                    "Cannot drop a runtime in a context where blocking is not allowed. \
                    This happens when a runtime is dropped from within an asynchronous context."
                );
            }
        }
    };

    if let Some(timeout) = timeout {
        e.block_on_timeout(&mut self.rx, timeout).is_ok()
    } else {
        let _ = e.block_on(&mut self.rx);
        true
    }
}

📎 tokio/src/runtime/blocking/shutdown.rs:37-70

가 알림을 받습니다.

1. timeout == Some(0)메서드:shutdown_background복사

2. try_enter_blocking_region()단계별 분석:None。

는 즉시 false를 반환합니다——이는

의 경로이며, 기다리지 않습니다.block_on_timeout는 블로킹 영역 진입을 시도합니다. 현재 비동기 컨텍스트에 있으면(예: async 태스크에서 Runtime을 drop),

를 반환합니다.

mermaid
flowchart TD
    start["Runtime::drop 或 shutdown_timeout"] --> sched{"scheduler 类型?"}
    sched -->|CurrentThread| ct["try_set_current + current_thread.shutdown"]
    sched -->|MultiThread| mt["multi_thread.shutdown"]
    ct --> handle_drop["handle 字段 drop"]
    mt --> handle_drop
    handle_drop --> bp_drop["blocking_pool 字段 drop"]
    bp_drop --> bp_wait{"shutdown_timeout 已调用?"}
    bp_wait -->|是| explicit["blocking_pool.shutdown(Some(duration))"]
    bp_wait -->|否| implicit["BlockingPool::drop 默认等待"]
    explicit --> wait_check{"try_enter_blocking_region 成功?"}
    implicit --> wait_check
    wait_check -->|否且在 panic| skip["返回 false 不等待"]
    wait_check -->|否且不在 panic| panic_err["panic: Cannot drop a runtime in async context"]
    wait_check -->|是| block_on["block_on 等待所有 Sender drop"]

4. timeout이 있으면

를 사용하고, 초과 시 false를 반환하며; timeout이 없으면 무한 대기합니다.오류 메시지는 명확하다: 「Cannot drop a runtime in a context where blocking is not allowed」📎 tokio/src/runtime/blocking/shutdown.rs:51-54。해결책은shutdown_background()을 사용하는 것이며, 이는shutdown_timeout(Duration::from_nanos(0)) 📎 tokio/src/runtime/runtime.rs:494-496와 동등하고, 블로킹 작업을 기다리지 않는다.

함정 2:shutdown_background은 블로킹 작업을 누수시킨다.문서는 「this may result in a resource leak (in that any blocking tasks are still running until they return)」라고 명확히 경고한다.📎 tokio/src/runtime/runtime.rs:470-472。블로킹 작업은 자연스럽게 반환될 때까지 계속 실행되지만, Runtime은 이미 drop되었고, 이들이 보유한 리소스는 이미 무효화되었을 수 있다.

함정 3: I/O 리소스가 Runtime drop 후 무효화된다.문서는 「Once the runtime has been dropped, any outstanding I/O resources bound to it will no longer function」이라고 설명한다.📎 tokio/src/runtime/runtime.rs:52-54。is_rt_shutdown_err함수는 바로 이러한 오류를 감지하기 위한 것이다.📎 tokio/src/runtime/runtime.rs:585-593。

함정 4:Drop은 기본적으로 무한 대기한다.문서는 「TheDrop implementation waits forever for this」📎 tokio/src/runtime/runtime.rs:43-44。만약 블로킹 작업이 교착 상태에 빠지면(예: 무한 루프), drop Runtime은 영원히 중단된다. 프로덕션 환경에서는shutdown_timeout으로 상한을 설정해야 한다.

13.4 신호 처리와 다중 Runtime 충돌

직관적 모델

Unix 신호는 프로세스 수준이지만, Tokio의Signal은 Runtime에 바인딩된다. 이는 마치 건물 전체가 하나의 화재 경보 벨을 공유하지만, 각 방에 독립적인 수신기가 설치된 것과 같다——첫 번째로 수신기를 설치한 사람이 벨의 배선 방식을 변경하면, 이후 사람들은 이 변경을 공유할 수밖에 없다.

데이터 구조와 전역 상태

signal_enable은 신호 핸들러를 등록하는 진입점이다:

rust
fn signal_enable(signal: SignalKind, handle: &Handle) -> io::Result<()> {
    let signal = signal.0;
    if signal <= 0 || signal_hook_registry::FORBIDDEN.contains(&signal) {
        return Err(Error::other(format!(
            "Refusing to register signal {signal}"
        )));
    }

    handle.check_inner()?;

    let globals = globals();
    let siginfo = match globals.storage().get(signal as EventId) {
        Some(slot) => slot,
        None => return Err(io::Error::other("signal too large")),
    };

    siginfo
        .init
        .get_or_init(|| {
            unsafe { signal_hook_registry::register(signal, move || action(globals, signal)) }
                .map(|_| ())
                .map_err(|e| e.raw_os_error())
        })
        .map_err(|e| {
            e.map_or_else(
                || Error::other("registering signal handler failed"),
                || Error::from_raw_os_error,
            )
        })
}

📎 tokio/src/signal/unix.rs:266-296

핵심 포인트:

1. signal <= 0 || FORBIDDEN.contains(&signal)은 잘못된 신호를 거부한다.

2. handle.check_inner()은 신호 드라이버가 실행 중인지 확인한다——만약 Runtime이 이미 종료되었다면, 여기서 실패한다.

3. siginfo.init.get_or_init(...)은OnceLock을 사용하여 각 신호가 OS handler에 한 번만 등록되도록 보장한다.get_or_init의 클로저는signal_hook_registry::register을 호출하며, 이는 전역적이고 프로세스 수준의 등록이다.

4. 등록된 handler는action(globals, signal)이며, 두 가지 일을 한다:globals.record_event(signal)이벤트를 기록한 후, pipe에 1바이트를 써서 드라이버를 깨운다.📎 tokio/src/signal/unix.rs:252-259。

다중 Runtime 충돌의 근원

globals()이 반환하는 것은 프로세스 수준의 전역Globals,OsExtraData안의UnixStream쌍도 전역이다:

rust
pub(crate) struct OsExtraData {
    sender: UnixStream,
    pub(crate) receiver: UnixStream,
}

📎 tokio/src/signal/unix.rs:61-64

Default구현은 한 쌍의UnixStream 📎 tokio/src/signal/unix.rs:61-64을 생성한다. 이 pipe는 전역적으로 유일하며, 모든 Runtime의 신호 드라이버가 이를 공유한다.

문제가 발생한다:signal_enable안의handle.check_inner()이 확인하는 것은현재 Runtime의 신호 드라이버이다. 하지만signal_hook_registry::register이 등록한 handler는프로세스 수준이며, 여기에 쓰는 것은전역pipe이다. 만약 Runtime A가 먼저 SIGINT를 등록하고, 그 다음 Runtime B도 SIGINT를 등록하면,get_or_init은 이미 존재하는Ok(())을 직접 반환하고, 중복 등록하지 않는다. 하지만 Runtime B의 신호 드라이버는 전역 pipe에서 데이터를 읽는다——두 Runtime이 동일한 pipe의 바이트를 경쟁하게 된다.

시나리오 기반 Walkthrough: 다중 Runtime 신호 경쟁

mermaid
sequenceDiagram
    participant OS as 操作系统
    participant Handler as 全局 signal handler
    participant Pipe as 全局 UnixStream pipe
    participant RtA as Runtime A 信号驱动
    participant RtB as Runtime B 信号驱动

    Note over RtA: signal(SIGINT) 注册
    RtA->>Handler: signal_hook_registry::register(SIGINT, action)
    Note over RtB: signal(SIGINT) 注册
    RtB->>Handler: get_or_init 返回已有 Ok,不重复注册
    OS->>Handler: 投递 SIGINT
    Handler->>Pipe: write(&[1])
    Pipe->>RtA: 可读事件
    Pipe->>RtB: 可读事件
    Note over RtA,RtB: 两个 Runtime 竞争读取,只有一个能读到字节

설계 사고와 함정

함정 1: 신호 핸들러는 절대 해제되지 않는다.문서는 「Once a signal handler is registered with the process the underlying libc signal handler is never unregistered」라고 명확히 경고한다.📎 tokio/src/signal/unix.rs:379-380。비록Signal인스턴스가 drop되더라도, 이후 신호는 여전히 Tokio에 의해 포착되며, 기본 동작은 복원되지 않는다.📎 tokio/src/signal/unix.rs:338-340。

함정 2: 신호는 병합된다.문서는 「beforepoll is called, all signal notifications are coalesced into one item returned from poll」📎 tokio/src/signal/unix.rs:312-315。만약 10개의 SIGINT를 받았지만 한 번만 poll했다면, 하나의 이벤트만 보게 된다. 이는 Unix 신호 자체의 특성이다(표준 신호는 큐에 쌓이지 않음). Tokio는 추가로 병합하지 않는다.

함정 3: 다중 Runtime에서 신호가 손실될 수 있다.전역 pipe가 여러 Runtime에 의해 경쟁적으로 읽히기 때문에, 하나의 Runtime이 바이트를 읽어가면 다른 하나는 영원히 기다릴 수 있다. 프로덕션 환경에서는 하나의 Runtime에서만 신호를 처리하거나,signal_hook으로 직접 관리해야 한다.

함정 4:signal함수의 panic 조건.문서는 「This function panics if there is no current reactor set, or if thert feature flag is not enabled」📎 tokio/src/signal/unix.rs:398-405。Runtime 외부에서signal()을 호출하면 panic이 발생한다.

함정 5:recv()의 취소 안전성.문서는 「This method is cancel safe. If you use it as a branch intokio::select! and another branch completes first, then it is guaranteed that no signal is lost」📎 tokio/src/signal/unix.rs:423-427。이는 신호 이벤트가 전역EventInfo에 존재하기 때문이며,recv()은 단지 읽기만 하고, 기본 상태를 소비하지 않는다.

설계 사고

이 장의 세 가지 주제는 하나의 근본적인 패턴을 공유한다:상태의 소유권이 취소/종료/신호의 안전성을 결정한다。

  • JoinHandle은 취소 안전하다, 왜냐하면 출력이 힙에 있고, handle은 단지 참조일 뿐이기 때문이다.
  • Runtime 종료 순서는 민감하다, 왜냐하면 블로킹 풀과 스케줄러가 공유되기 때문이다Handle, 순서가 틀리면 데드락이나 panic이 발생한다.
  • 신호는 다중 Runtime 충돌이 발생하는데, handler와 pipe는 프로세스 수준의 전역 상태인 반면Signal은 Runtime 수준 뷰이기 때문이다.

이 패턴을 이해하면, 함정 회피 목록은 세 가지 원칙으로 정리할 수 있다:

1. 취소 안전성 = 상태가 Future 외부에 있음.만약 Future 내부에 버퍼가 있으면, drop 시 데이터가 유실된다.JoinHandle、Signal::recv、tokio::sync::mpsc::Receiver::recv모두 이 조건을 만족한다.

2. 종료 순서 = 의존 방향의 역순.누가 누구에게 의존하는지, 의존받는 쪽을 먼저 닫는다. 스케줄러는 I/O 드라이버에 의존하므로 스케줄러를 먼저 닫고, 블로킹 풀은 독립적이므로 마지막에 닫는다.

3. 전역 상태 = 다중 인스턴스 충돌.모든 프로세스 수준 자원(신호 handler, pipe, 파일 디스크립터 테이블)은 다중 Runtime 하에서 충돌한다. 단일 Runtime으로 제한하거나 외부 동기화를 사용해야 한다.

이 장 요약

이 장 생각해보기와 자가 점검

Q1: 만약JoinHandle::poll에서coop::poll_proceed(cx)을 제거하면, 어떤 시나리오에서 다른 태스크가 기아 상태에 빠지는가? 왜try_read_output자체는 예산을 소비하지 않는가?

참고 해석:coop::poll_proceed(cx)은📎 tokio/src/runtime/task/join.rs:325-325에서 협력 예산을 소비한다. 만약 제거하면, 루프 안에서 반복적으로select!여러JoinHandle를 처리하는 태스크가 한 번의 스케줄링 주기 내에서 모든 handle을 무한히 폴링하고, 영원히Pending로 반환하지 않아 같은 worker의 다른 태스크를 기아 상태에 빠뜨릴 수 있다.try_read_output자체는 예산을 소비하지 않는데, 이는 단순한 메모리 읽기 + 가능한 waker 저장일 뿐이며 I/O나 락 경합이 수반되지 않아 오버헤드가 극히 적기 때문이다. 예산 메커니즘의 설계 의도는 「장시간 실행될 수 있는 연산」을 제약하는 것이지, 매 poll마다 비용을 부과하는 것이 아니다. 주의할 점은coop.made_progress()이ret.is_ready()일 때만📎 tokio/src/runtime/task/join.rs:349-351을 호출한다는 것, 즉 실제로 출력을 얻었을 때만 예산을 반환한다는 것이다 — 이는 「폴링했지만 결과가 없는」 연산이 예산을 누적 소비하는 것을 방지하기 위함이다.

Q2:blocking/shutdown.rs의wait메서드에서, 만약try_enter_blocking_region()이None을 반환하고 현재 panic 중이라면, 왜 계속 기다리는 대신false을 반환하는가? 만약 계속 기다리도록 변경하면 무슨 일이 발생하는가?

참고 해석:try_enter_blocking_region()이None을 반환하는 것은 현재 비동기 컨텍스트에 있으므로📎 tokio/src/runtime/blocking/shutdown.rs:44-57을 블로킹할 수 없음을 나타낸다. 만약 이때 panic 중이라면, 코드는false을 반환하고📎 tokio/src/runtime/blocking/shutdown.rs:47-49을 기다리지 않는다. 이유는: panic 전개 과정에서 다시 panic이 발생하면 프로세스가 abort되기 때문이다(double panic). 만약 계속 기다리도록 변경하면block_on을 호출해야 하는데, 비동기 컨텍스트에서block_on은 panic을 발생시킨다 — panic 전개 중 panic은 프로세스를 즉시 abort시켜 모든 진단 정보를 잃게 된다.false을 반환하면 drop이 계속 완료되어 panic 정보가 보존된다. 이것은 「우아한 성능 저하」 설계이다: 불완전한 종료가 프로세스 크래시보다 낫다.

Q3: Runtime A에서Signal을 생성하여 SIGTERM을 수신하고, 그런 다음Signal을 Runtime B로 이동하여 poll한다고 가정하자.signal_enable의handle.check_inner()은 어느 Runtime을 검사하는가? 만약 Runtime A가 먼저 drop되면, Runtime B의Signal은 여전히 신호를 수신할 수 있는가?

참고 해석:signal_enable은signal()호출 시 실행되며, 이때handle은 Runtime A의📎 tokio/src/signal/unix.rs:398-405。check_inner()이다. 검사하는 것은 Runtime A의 신호 드라이버📎 tokio/src/signal/unix.rs:275。Signal이다. 내부는RxFuture이며, 래핑하는 것은watch::Receiver<()> 📎 tokio/src/signal/unix.rs:366-368이고, 이 receiver는 전역Globals의EventInfo에 등록된다. 만약 Runtime A가 drop되면, 그 신호 드라이버는 전역 pipe에서 데이터 읽기를 중단하지만, 전역 handler는 여전히record_event하고 pipe에 쓴다. Runtime B의 신호 드라이버가 실행 중이라면, pipe 데이터를 읽고EventInfo을 트리거하여Signal의 waker를 깨운다. 따라서 Runtime B의Signal 은 아마도여전히 신호를 수신할 수 있지만, Runtime B에 신호 드라이버가 실행 중인지에 달려 있다. 만약 Runtime B에 신호 드라이버가 없다면(예: signal feature가 활성화되지 않았거나 드라이버가 종료됨), pipe 데이터를 읽는 자가 없어Signal은 영원히 깨어나지 못한다. 이것이 다중 Runtime 신호 처리의 취약성이다.

장말 전환

취소 안전성, panic 전파, 종료 순서, 신호 충돌 — 이 네 가지 문제의 공통 근원은 「상태 소유권」이 비동기 경계에서 모호하다는 것이다. Tokio는 상태를 힙에 배치하고, 참조 카운팅으로 수명 주기를 관리하며,catch_unwind으로 panic을 격리하고, 전역Globals으로 신호 상태를 공유함으로써 엔지니어링적으로 사용 가능한 답을 제시한다. 그러나 이 답들에는 모두 경계 조건이 있으며, 프로덕션 환경에서는 반드시 명시적으로 처리해야 한다.

다음 장에서는 아키텍처 트레이드오프와 미래 진화로 들어간다: io_uring에서 플러거블 드라이버까지. Tokio가 API 안정성을 유지하면서 차세대 I/O 인터페이스를 위해 어떻게 확장 공간을 확보하는지, 그리고 현재 아키텍처에서 어떤 설계 결정이 역사적 부담이고 어떤 것이 선제적 배치인지 살펴볼 것이다.

이로써 우리는 Tokio 프로덕션 환경에서 가장 실수하기 쉬운 경계 지대를 모두 살펴보았다: 취소 안전성이 출력 저장이 힙에 있다는 것에 의존하는 점, try_read_output의 원자성; JoinHandle::drop은 태스크를 취소하지 않고 abort만이 실제로 취소하지만 spawn_blocking에는 효과가 없다는 점; panic이 catch_unwind에 의해 포착된 후 JoinError로 패키징되어 await하지 않으면 조용히 손실된다는 점; Runtime 종료에는 엄격한 순서가 있어 async 컨텍스트에서 drop하면 panic이 발생한다는 점; 시그널 핸들러는 프로세스 전역 상태이며 등록 후 절대 해제되지 않는다는 점. 이러한 규칙 뒤에는 정확성과 성능 사이의 Tokio의 반복적인 트레이드오프가 있다. 다음 장에서는 구체적인 메커니즘을 벗어나 아키텍처 높이에서 이러한 트레이드오프의 유래를 되돌아보고, io_uring, 드라이버 재구성, 커스텀 실행기 인터페이스가 Tokio를 어디로 이끌지 전망할 것이다.

모든 코드베이스를 진정으로 이해할 수 있는 책으로

이 장을 다 읽으셨나요? 내 프라이빗 저장소를 위한 아키텍처 책 만들기

Tauri 2 + Rust 로컬 퍼스트 아키텍처. 100% 오프라인 보안, 클라우드 코드 업로드 없음. 불변 커밋 라인 앵커로 정독.

⚡ Tauri 2 · Rust 네이티브 코어 · 100% 오프라인 보안 · 100만 행 이상 검증

CHAPTER 14

제 14 장: 아키텍처 트레이드오프와 미래 진화: io_uring에서 플러그 가능 드라이버까지

Upstream: tokio-rs/tokio · Commit @e800714a · 진행률: 제 14 장 / 총 14 장

이전 장에서 우리는 취소 안전성, panic 전파, 종료 순서, 시그널 충돌이라는 네 가지 프로덕션 함정을 정리했는데, 이들은 겉보기에는 분산되어 있지만 실은 모두 동일한 아키텍처 문제를 가리킨다: 상태 소유권이 비동기 경계에서 어떻게 명확하게 구분되는가. 그리고 소유권을 구분하는 방식은 런타임 최하층의 세 가지 아키텍처 결정에 의해 결정된다——태스크가 어떻게 스케줄링되는가, I/O 이벤트가 어떻게 분배되는가, 동시성 정확성이 어떻게 검증되는가. 이 장에서는 더 이상 특정 함수의 구현 세부사항에 파고들지 않고, 아키텍처 높이에서 Tokio가 이러한 결정에서의 트레이드오프를 되돌아보며, 공식 문서와 소스 코드에 이미 심어져 있는 진화 단서를 따라 io_uring, 드라이버 재구성, 커스텀 실행기 인터페이스가 Tokio를 어디로 이끌지 살펴본다. 이 장을 읽고 나면 실용적인 질문에 답할 수 있어야 한다: 언제 Tokio를 확장해야 하고, 언제 그것을 우회해야 하는가.

一, 세 가지 역사적 트레이드오프: 왜 지금 이런 모습인가

직관적 모델

Tokio를 10년째 운영 중인 식당이라고 상상해 보자. 주방의 교대 방식(work-stealing), 서빙 담당자의 독립 편제(I/O 드라이버와 스케줄러 분리), 그리고 주방의 위생 검사 제도(loom 동시성 검증)는 모두 개업 첫날부터 설계된 것이 아니라 "손님이 많아지고 요리가 복잡해지는" 과정에서 점진적으로 진화한 것이다. 이러한 진화를 이해해야 어떤 설계가 선견지명 있는 배치이고 어떤 것이 역사적 부담인지 판단할 수 있다.

트레이드오프 1: 전역 큐 대신 work-stealing

〔설계 추론과 아키텍처 트레이드오프〕

전역 큐 구현이 가장 간단하다: 모든 태스크가 하나의Mutex<VecDeque>에 들어가고, worker 스레드가 락을 잡고 태스크를 가져간다. 하지만 락 경합은 코어 수가 늘어날수록 악화되고, 캐시 지역성도 나쁘다——태스크가 어느 코어에서 생성되고 어느 코어에서 실행되는지가 완전히 무작위다.

work-stealing의 트레이드오프는: 각 worker가 로컬 큐를 보유하고,spawn시 우선 로컬 큐에 넣고(무락, 캐시 친화적), 로컬이 비었을 때만 다른 worker 큐의 꼬리에서 훔친다. 대가는 로드 밸런싱에 지연이 있고, 훔치기 자체에 원자적 연산과 메모리 배리어가 필요하다는 것이다. Tokio가 후자를 선택한 이유는 현대 서버가 수십 코어에 달하는 경우가 많아 락 경합 비용이 간헐적 훔치기 오버헤드보다 훨씬 크기 때문이다.

〔설계 추론과 아키텍처 트레이드오프〕

이 결정의 경계 조건은:태스크 입자가 너무 세밀하면 안 된다. 만약 각 태스크가 몇 마이크로초의 작업만 한다면, 훔치기와 스케줄링 오버헤드 비율이 통제 불능이 된다. 이것이 Tokio가spawn_blocking외에도 장기 태스크가 능동적으로yield_now()할 것을 요구하는 이유다——협력적 스케줄링은 본질적으로 work-stealing을 위한 안전망이다.

트레이드오프 2: I/O 드라이버가 스케줄러로부터 독립

이것이 이 장의 소스 자료에서 가장 흥미로운 부분이다.tokio/src/runtime/io/mod.rs의 모듈 구조를 보자:

📎 tokio/src/runtime/io/mod.rs:5-22

rust
mod driver;
use driver::{Direction, Tick};
pub(crate) use driver::{Driver, Handle, ReadyEvent};

mod registration;
pub(crate) use registration::Registration;

mod registration_set;
use registration_set::RegistrationSet;

mod scheduled_io;
use scheduled_io::ScheduledIo;

mod metrics;
use metrics::IoDriverMetrics;

use crate::util::ptr_expose::PtrExposeDomain;
static EXPOSE_IO: PtrExposeDomain<ScheduledIo> = PtrExposeDomain::new();

주목할 점은driver、registration、scheduled_io이 세 개의 독립 모듈이고, 외부에는Driver、Handle、ReadyEvent、Registration이 몇 가지 타입만 노출한다는 것이다.ScheduledIo은pub(crate)의 것이다——그것은PtrExposeDomain에 의해 감싸져 loom 테스트에서 원시 포인터를 동시성 검사에 노출하는 데 사용된다.

〔설계 추론과 아키텍처 트레이드오프〕

왜 I/O 드라이버가 스케줄러에 직접 내장되지 않는가? 둘의 수명 주기와 동시성 모델이 다르기 때문이다. 스케줄러는 "어떤 태스크가 실행되어야 하는가"를 관심 갖고, I/O 드라이버는 "어떤 fd가 준비되었는가"를 관심 갖는다. 만약 결합되면 스케줄링 전략을 조정할 때마다 I/O 경로를 건드려야 하고, 그 반대도 마찬가지다. 더 중요한 것은,block_on단일 스레드 런타임도 I/O 드라이버가 필요하지만 work-stealing 스케줄러는 필요 없다——분리가 두 런타임이 동일한 I/O 구현을 재사용할 수 있게 한다.

트레이드오프 3: loom으로 동시성 모델 검증

tokio/src/loom/mod.rs은 14줄에 불과하지만 Tokio 동시성 정확성의 검증 전략을 드러낸다:

📎 tokio/src/loom/mod.rs:1-14

rust
//! This module abstracts over `loom` and `std::sync` depending on whether we
//! are running tests or not.

#![allow(unused)]

#[cfg(not(all(test, loom)))]
mod std;
#[cfg(not(all(test, loom)))]
pub(crate) use self::std::*;

#[cfg(all(test, loom))]
mod mocked;
#[cfg(all(test, loom))]
pub(crate) use self::mocked::*;

핵심은#[cfg(all(test, loom))]이 조건이다: 동시에test과loom두 cfg를 활성화할 때만mocked모듈로std을 대체한다. 이는 프로덕션 빌드에는 loom 코드가 전혀 없어 런타임 오버헤드가 제로라는 것을 의미한다.

〔설계 추론과 아키텍처 트레이드오프〕

loom의 가치는 "스레드 인터리빙의 모든 가능한 순서"를 열거할 수 있다는 것이다.ScheduledIo안의AtomicUsize의 읽기-수정-쓰기,Waiters연결 리스트의 삽입과 삭제는 실제 하드웨어에서 백만 번을 실행해도 오류가 나지 않을 수 있지만, loom은 몇 초 만에 경쟁 조건을 유발하는 인터리빙을 구성할 수 있다. 대가는 테스트 실행이 느리고 메모리 사용량이 높다는 것이므로, 단위 테스트에만 사용할 수 있고 프로덕션에는 들어갈 수 없다.

설계 사고

이 세 가지 트레이드오프에는 공통된 특징이 있다:모두 "더 복잡하지만 더 확장 가능한" 방식을 선택했고, 복잡성을 내부에 제한했다. work-stealing의 복잡성은 스케줄러에 숨겨져 있고, I/O 기반의 복잡성은ScheduledIo에 숨겨져 있으며, loom의 복잡성은 cfg 조건에 숨겨져 있다. 외부에 노출되는 API는 항상spawn、TcpStream::read이러한 단순한 인터페이스들이다.

〔설계 추론 및 아키텍처 트레이드오프〕

이것이 또한 "언제 Tokio를 확장해야 하는가"를 판단하는 첫 번째 기준이다:만약 당신의 요구사항이 기존 API로 표현될 수 있다면, 내부 구조를 건드리지 마라. 일단 당신이pub(crate)의 타입이나tokio_unstable의 cfg에 의존하기 시작하면, 자신을 Tokio의 내부 구현에 묶어버린 것이며, 업그레이드할 때 대가를 치르게 된다.

---

2. 드라이버 리팩토링: "하나의 waker, 하나의 방향"에서 "임의의 관심 집합"으로

직관적 모델

초기 Tokio I/O 타입에는 강한 제약이 있었다:async fn read(&mut self)은&mut self을 필요로 했다. 이는 식당에 음식 수령 창구가 하나뿐이어서 동시에 한 사람만 줄을 설 수 있는 것과 같다 — waker가 작업에 대응하는 Future 안이 아니라 I/O 리소스 내부에 저장되었기 때문이다.tokio/docs/reactor-refactor.md이 제약의 원인과 리팩토링 방안을 완전히 기록하고 있다.

구 아키텍처의 문제점

문서는 서두에서 바로 문제를 지적한다:

📎 tokio/docs/reactor-refactor.md:16-20

rust
Currently, I/O types require `&mut self` for `async` functions. The reason for
this is the task's waker is stored in the I/O resource's internal state
(`ScheduledIo`) instead of in the future returned by the `async` function.
Because of this limitation, I/O types limit the number of wakers to one per
direction (a direction is either read-related events or write-related events).
〔설계 추론 및 아키텍처 트레이드오프〕

waker를 리소스 내부에 저장한다는 것은 "하나의 방향에 하나의 대기자만 있을 수 있다"는 뜻이다. 만약 동시에 같은TcpStream을 읽고 쓰고 싶다면, 반드시split()을 둘로 나누어 각각 독립적인 waker 슬롯을 가져야 한다. 이것이TcpStream::split()이 존재하는 이유다 — API 설계 선호가 아니라 내부 데이터 구조의 직접적인 제약이다.

새 아키텍처: waker를 Future 안으로 이동

리팩토링의 핵심 아이디어는 "waker를 리소스 상태에서 작업 Future로 이동"하여 각 작업이 여러 waker를 등록할 수 있도록 지원하는 것이다:

📎 tokio/docs/reactor-refactor.md:22-25

rust
Moving the waker from the internal I/O resource's state to the operation's
future enables multiple wakers to be registered per operation. The "intrusive
wake list" strategy used by `Notify` applies to this case, though there are some
concerns unique to the I/O driver.

새로운ScheduledIo구조는 다음과 같다:

📎 tokio/docs/reactor-refactor.md:97-134

rust
#[derive(Debug)]
pub(crate) struct ScheduledIo {
    /// Resource's known state packed with other state that must be
    /// atomically updated.
    readiness: AtomicUsize,

    /// Tracks tasks waiting on the resource
    waiters: Mutex<Waiters>,
}

#[derive(Debug)]
struct Waiters {
    // List of intrusive waiters.
    list: LinkedList<Waiter>,

    /// Waiter used by `AsyncRead` implementations.
    reader: Option<Waker>,

    /// Waiter used by `AsyncWrite` implementations.
    writer: Option<Waker>,
}

// This struct is contained by the **future** returned by `readiness()`.
#[derive(Debug)]
struct Waiter {
    /// Intrusive linked-list pointers
    pointers: linked_list::Pointers<Waiter>,

    /// Waker for task waiting on I/O resource
    waiter: Option<Waker>,

    /// Readiness events being waited on. This is
    /// the value passed to `readiness()`
    interest: mio::Ready,

    /// Should not be `Unpin`.
    _p: PhantomPinned,
}

여기에는 몇 가지 정교한 설계 포인트가 있어 자세히 살펴볼 가치가 있다:

첫째,readiness은AtomicUsize,waiters은Mutex<Waiters>。왜 하나의 잠금으로 둘 다 보호하지 않는가? 왜냐하면readiness의 읽기 작업이 극도로 빈번하고(매번readiness()호출 시 확인해야 함), 쓰기 작업은 mio 이벤트를 받을 때만 발생하기 때문이다. 원자 변수를 사용하여 읽기 경로를 무잠금으로 만드는 것은 전형적인 읽기-쓰기 분리 최적화다.

둘째,Waiter은 침투적 연결 리스트 노드다. pointers: linked_list::Pointers<Waiter>이Waiter자체를 연결 리스트의 일부로 만들어 별도의 노드 할당이 필요 없다._p: PhantomPinned은 명시적으로Unpin을 표시하지 않는다 — 침투적 연결 리스트의 노드 주소가 한 번 이동하면 연결 리스트가 끊어지기 때문이다.

셋째,reader과writer두 개의Option<Waker>은AsyncRead/AsyncWrite을 위해 사용된다.문서는 그 이유를 설명한다:

📎 tokio/docs/reactor-refactor.md:210-213

rust
The `AsyncRead` and `AsyncWrite` traits use a "poll" based API. This means that
it is not possible to use an intrusive linked list to track the waker.
Additionally, there is no future associated with the operation which means it is
not possible to cancel interest in the readiness events.
〔설계 추론 및 아키텍처 트레이드오프〕

이것은 신구 두 메커니즘의 타협적 공존이다:async fn경로는 침투적 연결 리스트를 사용하고(다중 대기자 지원, 취소 가능),poll경로는 고정 슬롯을 사용한다(취소 불가, 하지만 trait 호환). 이러한 "두 메커니즘 병존"은 점진적 리팩토링의 전형적인 대가다.

경쟁 조건과 tick 메커니즘

리팩토링에서 가장 까다로운 문제는 경쟁이다. 문서는 구체적인 교착 상태 시나리오를 제시한다:

📎 tokio/docs/reactor-refactor.md:175-175

rust
If care is not taken, if between `mio_socket.read(buf)` returning and
`clear_readiness(event)` is called, a readiness event arrives, the `read()`
function could deadlock. This happens because the readiness event is received,
`clear_readiness()` unsets the readiness event, and on the next iteration,
`readiness().await` will block forever as a new readiness event is not received.

해결책은 tick 메커니즘을 도입하여readiness이AtomicUsize을 여러 비트 세그먼트로 분할하는 것이다:

📎 tokio/docs/reactor-refactor.md:199-199

code
| shutdown | generation |  driver tick | readiness |
|----------+------------+--------------+-----------|
|   1 bit  |   7 bits   +    8 bits    +  16 bits  |
〔설계 추론 및 아키텍처 트레이드오프〕

이 비트 세그먼트 레이아웃은 "공간으로 정확성을 교환"하는 전형적인 사례다.tick은 매번mio::poll()증가하고,ReadyEvent은 읽을 때의 tick을 운반한다.clear_readiness()은 tick이 일치할 때만 준비 상태를 지운다 — tick이 일치하지 않으면 그 사이에 새 이벤트가 도착한 것이므로 지울 수 없다. 이렇게 하여 "지우기"와 "새 이벤트 도착"의 경쟁을 하나의 원자적 읽기-수정-쓰기 안에서 해소한다.

아래 흐름도는readiness()과clear_readiness()사이의 결정 경로를 묘사한다:

mermaid
flowchart TD
    start["readiness(interest).await"] --> check_ready{"已知 readiness<br/>与 interest 有交集?"}
    check_ready -->|是| ret_event["返回 ReadyEvent<br/>携带当前 tick"]
    check_ready -->|否| wait["注册 Waiter 到<br/>ScheduledIo.waiters"]
    wait --> mio_poll["mio.poll() 收到事件<br/>tick 递增"]
    mio_poll --> notify["遍历 waiters<br/>interest 匹配者唤醒"]
    notify --> ret_event
    ret_event --> do_read["mio_socket.read(buf)"]
    do_read --> read_ok{"read 结果?"}
    read_ok -->|Ok| done["返回 Ok(v)"]
    read_ok -->|WouldBlock| clear["clear_readiness(event)"]
    read_ok -->|其他 Err| err["返回 Err(e)"]
    clear --> tick_match{"event.tick ==<br/>当前 readiness.tick?"}
    tick_match -->|是| clear_ok["清除 readiness 位"]
    tick_match -->|否| skip["跳过清除<br/>保留新事件"]
    clear_ok --> start
    skip --> start

이 그림의 핵심 분기는tick_match에 있다: 만약 tick이 일치하지 않으면,clear_readiness은 반드시 지우기를 포기해야 한다. 그렇지 않으면 방금 도착한 이벤트를 잃어버려 다음 라운드readiness()이 영구적으로 차단된다.

관심 취소와 메모리 누수

침투적 연결 리스트는 새로운 문제를 가져온다: 만약readiness()이 반환한 Future가 조기에 drop되면, 연결 리스트 노드가 반드시 제거되어야 한다. 문서는 명확히 경고한다:

📎 tokio/docs/reactor-refactor.md:144-148

rust
The future returned by `readiness()` uses an intrusive linked list to store the
waker with `ScheduledIo`. Because `readiness()` can be called concurrently, many
wakers may be stored simultaneously in the list. If the `readiness()` future is
dropped early, it is essential that the waker is removed from the list. This
prevents leaking memory.
〔설계 추론 및 아키텍처 트레이드오프〕

이것이 바로 이전 장의 "취소 안전성"이 I/O 계층에서 구현된 것이다.readiness()의 Future는 반드시Drop구현에서 자신을 연결 리스트에서 제거해야 한다. 그렇지 않으면 노드가 영구적으로ScheduledIo에 남아 메모리를 누수시키고, 다음 이벤트 도착 시 잘못 깨워진다.

설계 사고와 프로덕션 함정

왜Vec<Waker>을 사용하지 않고 침투적 연결 리스트를 사용하는가?문서는&Resource구현을 논의할 때 답을 제시한다:

📎 tokio/docs/reactor-refactor.md:228-233

rust
It is only possible to implement `AsyncRead` and `AsyncWrite` for resource types
themselves and not for `&Resource`. Implementing the traits for `&Resource`
would permit concurrent operations to the resource. Because only a single waker
is stored per direction, any concurrent usage would result in deadlocks. An
alternate implementation would call for a `Vec<Waker>` but this would result in
memory leaks.
〔설계 추론 및 아키텍처 트레이드오프〕

Vec<Waker>의 문제는: Future가 drop된 후 해당 waker가 Vec에 남아 위치를 찾아 삭제할 수 없고, 다음 이벤트가 도착할 때에야 "이 waker가 이미 무효화되었다"는 것을 발견할 수 있다는 것이다. 침투적 연결 리스트는 노드 주소가 Future 내부 필드의 주소가 되어 drop 시 정확히 제거할 수 있다.

프로덕션 함정 포인트:TcpStream::by_ref()이 반환한TcpStreamRef은read_waiter과write_waiter두 노드를 보유한다:

📎 tokio/docs/reactor-refactor.md:238-244

rust
struct TcpStreamRef<'a> {
    stream: &'a TcpStream,

    // `Waiter` is the node in the intrusive waiter linked-list
    read_waiter: Waiter,
    write_waiter: Waiter,
}
〔설계 추론 및 아키텍처 트레이드오프〕

이것은TcpStreamRef이 한 번 drop되면 두 waiter 노드가 동시에 무효화된다는 뜻이다. 만약select!안에서by_ref()의 참조를 여러 분기에 걸쳐 공유한다면, 수명 주기를 조심해야 한다 —TcpStreamRef은TcpStream보다 오래 살 수 없고, 여러select!분기 사이에서 동시에 빌려질 수도 없다.

---

3. 사용자 정의 실행기: TokioContext와 "Tokio 우회"의 경계

직관 모델

때로는 Tokio의 스케줄러를 사용하고 싶지 않고, 그저 Tokio의 I/O와 타이머만 빌리고 싶을 때가 있다. 이는 식당에서 먹지 않고 포장 창구만 이용하는 것과 같다.examples/custom-executor.rs이러한 '하이브리드 모드'를 보여준다:futures::executor::ThreadPool로 스케줄링하고, Tokio로 I/O를 한다.

핵심 메커니즘: TokioContext

전체 예제의 핵심은TokioContext이 래퍼 타입에 있다:

📎 examples/custom-executor.rs:51-54

rust
impl ThreadPool {
    fn spawn(&self, f: impl Future<Output = ()> + Send + 'static) {
        let handle = self.rt.handle().clone();
        self.inner.spawn_ok(TokioContext::new(f, handle));
    }
}
〔설계 추론 및 아키텍처 트레이드오프〕

TokioContext::new(f, handle)Future와 Tokio의Handle를 함께 묶는다. 외부 실행기가 이 래핑된 Future를 poll할 때,TokioContext는 먼저 Tokio의 런타임 컨텍스트에 진입하고(스레드 로컬Handle설정), 그 다음 내부의f를 poll한다. 이렇게 하면f에서TcpListener::bind를 호출할 때 Tokio의 I/O 드라이버를 찾을 수 있다.

전체 예제의 구조를 보자:

📎 examples/custom-executor.rs:38-48

rust
static EXECUTOR: Lazy<ThreadPool> = Lazy::new(|| {
    // Spawn tokio runtime on a single background thread
    // enabling IO and timers.
    let rt = tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .unwrap();
    let inner = futures::executor::ThreadPool::builder().create().unwrap();

    ThreadPool { inner, rt }
});
〔설계 추론 및 아키텍처 트레이드오프〕

여기서 Tokio 런타임은 생성되지만에 의해block_on구동되지 않는다—그것은 단지 '존재'하여 I/O 드라이버와 타이머를 제공할 뿐이다. 실제 태스크 스케줄링은futures::executor::ThreadPool가 담당한다. 이 모드에서 Tokio의 워커 스레드는 실제로 유휴 상태(I/O 이벤트 대기)이며, 태스크 실행은 futures의 스레드 풀에서 발생한다.

데이터 흐름: TcpListener::bind의 크로스 실행기 여정

mermaid
sequenceDiagram
    participant App as "应用 (main)"
    participant FE as "futures::ThreadPool"
    participant TC as "TokioContext"
    participant TR as "tokio::Runtime (后台线程)"
    participant IO as "I/O 驱动 (mio)"

    App->>FE: spawn_ok(TokioContext::new(f, handle))
    FE->>TC: poll(cx)
    TC->>TC: enter(handle) 设置线程局部上下文
    TC->>TC: f.poll(cx) 执行 TcpListener::bind
    TC->>TR: 通过 Handle 访问 I/O 驱动
    TR->>IO: Registration::new 注册 fd
    IO-->>TR: 注册完成
    TR-->>TC: 返回 Pending 或 Ready
    TC-->>FE: 返回 poll 结果
    Note over FE,TR: I/O 就绪时,Tokio 驱动唤醒 waker<br/>FE 重新调度该任务

이 시퀀스 다이어그램의 핵심은:태스크의 poll은 futures 스레드 풀에서 발생하지만, I/O 이벤트 대기는 Tokio 백그라운드 스레드에서 발생한다. 둘은Handle와 waker를 통해 연결된다.

설계 고찰: 언제 Tokio를 우회해야 하는가

〔설계 추론 및 아키텍처 트레이드오프〕

이 예제의 존재 자체가 신호다: Tokio의 아키텍처는 'I/O 드라이버만 사용하고 스케줄러는 사용하지 않는' 것을 허용한다. 판단 기준은 세 가지로 요약할 수 있다:

1. 기존 실행기 생태계와 통합해야 한다면(예를 들어 일부 프레임워크가futures::executor를 강제하는 경우),TokioContext를 사용하는 것이 최소 침습적 방안이다.

2. 스케줄링 정책을 완전히 제어해야 한다면(예를 들어 실시간 시스템이 결정적 스케줄링을 요구하는 경우), Tokio의 work-stealing은 요구를 충족하지 못하지만, I/O 드라이버는 여전히 사용할 수 있다.

3. 단지 Tokio의 API가 복잡하다고 느낀다면, 우회해서는 안 된다—TokioContext가 도입하는 크로스 실행기 경계는 새로운 디버깅 난이도를 초래하여 득보다 실이 크다.

프로덕션 함정 포인트:TokioContext모드에서 Tokio 런타임의block_on가 결코 호출되지 않는다는 것은Runtime::shutdown의 정리 로직이 자동으로 트리거되지 않음을 의미한다. 프로그램 종료 전에 명시적으로Runtime를 drop해야 하며, 그렇지 않으면 I/O 드라이버의 백그라운드 스레드가 우아하게 종료되지 않을 수 있다.

io_uring과의 관계

〔설계 추론 및 아키텍처 트레이드오프〕

tokio/src/runtime/io/mod.rs상단의 cfg 조건이 io_uring의 접속 방식을 드러낸다:

📎 tokio/src/runtime/io/mod.rs:1-4

rust
#![cfg_attr(
    not(all(feature = "rt", feature = "net", feature = "io-uring", tokio_unstable)),
    allow(dead_code)
)]

주목할 점은feature = "io-uring"와tokio_unstable가 동시에 나타난다는 것이다. 이는 io_uring 지원이 현재실험적이며, unstable 기능을 함께 활성화해야 컴파일된다는 것을 의미한다.allow(dead_code)는 이러한 기능이 활성화되지 않았을 때 모듈 내 일부 코드가 사용되지 않아 컴파일러가 경고한다는 것을 의미한다—allow로 억제한다.

〔설계 추론 및 아키텍처 트레이드오프〕

io_uring과 epoll의 근본적 차이는: epoll은 '준비 알림'이고, io_uring은 '완료 알림'이다. 전자는 애플리케이션이 직접read/write시스템 콜을 발행해야 하고, 후자는 커널이 직접 I/O를 완료하고 결과를 반환한다. 이는 Tokio의ScheduledIo모델에 큰 충격이다—readiness()의 의미가 io_uring에서는 더 이상 적용되지 않으며, 완전히 새로운 '제출-완료' 추상화가 필요하다. 이것이 io_uring 지원이 오랫동안 unstable에 머물러 있는 이유다: 단순히 백엔드를 추가하는 것이 아니라 전체 I/O 드라이버 추상화 계층을 재구성해야 하기 때문이다.

---

이 장 요약

이 장에서는 아키텍처 관점에서 Tokio의 세 가지 핵심 트레이드오프를 되돌아보고, 세 가지 진화 경로를 전망했다:

역사적 트레이드오프:

  • work-stealing은 스케줄링 복잡성으로 멀티코어 확장성을 얻으며, 경계는 태스크 입자가 너무 세밀하면 안 된다는 것이다;
  • I/O 드라이버는 스케줄러와 독립적이어서block_on와 멀티스레드 런타임이 동일한 I/O 구현을 재사용할 수 있다;
  • loom은 cfg 조건을 통해 프로덕션 빌드에서 완전히 사라지고, 테스트 시에만 스레드 인터리빙을 전수 탐색한다.

드라이버 재구성(reactor-refactor.md):

  • waker를ScheduledIo내부에서 작업 Future로 이동시키고, 침습적 연결 리스트로 다중 대기자를 지원한다;
  • 의 비트 필드 레이아웃(shutdown/generation/tick/readiness)으로AtomicUsize의 경쟁 상태를 해소한다;clear_readiness는 poll 의미론상 침습적 연결 리스트를 사용할 수 없어
  • AsyncRead/AsyncWrite고정 슬롯을 타협안으로 유지한다.reader/writer미래 진화

io_uring은 '제출-완료'라는 새로운 추상화가 필요하며, 현재:

  • 로 보호된다;tokio_unstable는 I/O 드라이버만 사용하고 스케줄러는 사용하지 않는 것을 허용하지만, Runtime 수명 주기를 수동으로 관리해야 한다;
  • TokioContext'확장할 것인가 우회할 것인가'의 판단 기준: 기존 API로 표현할 수 있으면 내부 구조를 건드리지 않는다.
  • 이 장 생각과 자가 점검

Q1:

의ScheduledIo비트 필드 레이아웃에서readiness필드를 8비트에서 4비트로 줄이면 어떤 시나리오에서 오류가 발생하는가?tick의 tick 매칭 로직과 결합하여 분석하라.clear_readiness참고 해석

는 매:tick시mio::poll()를 증가시키고📎 tokio/docs/reactor-refactor.md:185-185。clear_readiness는event.tick == 当前 readiness.tick일 때만 준비 비트를 지운다📎 tokio/docs/reactor-refactor.md:199-199. tick이 4비트만 있으면 매 16번 poll마다 랩어라운드된다. 어떤ReadyEvent가 tick=15를 가지고 있을 때, 그것이clear_readiness이전에 mio가 다시 1번 poll했고, tick이 0으로 되돌아갔다. 이때clear_readinesstick 불일치(15 != 0)를 발견하고, 잘못하여 클리어를 건너뛴다——하지만 실제로는 그 사이에 새로운 이벤트가 도착하지 않았을 수 있으며, 단지 tick이 되돌아간 것뿐이다. 이로 인해 준비 비트가 영구적으로 유지되고, 이후readiness()즉시 반환되지만read여전히WouldBlock상태가 되어 바쁜 루프에 빠진다. 8비트 tick은 정상 부하에서는 충분하지만(256번의 poll 내에 read-clear 주기가 완료됨), 극단적인 고동시성 상황에서는 여전히 되돌아갈 위험이 있으며, 이는 비트 필드 레이아웃의 고유한 한계이다.

Q2: examples/custom-executor.rs에서 Tokio 런타임이 생성되었지만 한 번도block_on되지 않았다. 이때rt.shutdown_timeout()을 호출하면 무슨 일이 발생할까? 왜 이 예제는 호출하지 않기로 선택했을까?

참고 해석:rt.shutdown_timeout()은 모든 태스크가 완료될 때까지 기다리고 I/O 드라이버를 종료한다. 하지만 이 예제에서 태스크는 실제로futures::executor::ThreadPool위에서📎 examples/custom-executor.rs:51-54실행되며, Tokio 런타임에는 태스크가 없다——그것은 I/O 드라이버만 제공한다. 만약shutdown_timeout을 호출하면 (태스크가 없으므로) 즉시 반환되지만, I/O 드라이버의 백그라운드 스레드는 여전히 실행 중일 수 있다. 예제가 호출하지 않기로 선택한 이유는EXECUTOR이Lazy정적 변수이기 때문이며, 프로그램 종료 시 Rust의 정적 소멸 메커니즘에 의해 처리된다. 진짜 함정은: 만약TokioContext이 감싼 Future가 아직 실행 중인데Runtime이 drop되면, Future 내의 I/O 작업이 panic을 일으킨다(런타임 컨텍스트를 찾을 수 없음). 프로덕션 환경에서는 모든TokioContextFuture가 완료된 후에만 Runtime을 drop해야 한다.

Q3: Tokio에 io_uring 기반 I/O 백엔드를 추가한다고 가정하자.reactor-refactor.md의readiness()시맨틱에 따르면, 어떤 부분을 직접 재사용할 수 있고 어떤 부분을 반드시 재작성해야 하는가?

참고 해석: 직접 재사용할 수 있는 것은Registration의 등록 인터페이스와ScheduledIo의waiters연결 리스트 구조이다——이들은 "누가 기다리고 있는가"를 관리하며, 하위 계층이 epoll인지 io_uring인지와 무관하다. 반드시 재작성해야 하는 것은readiness()의 시맨틱이다: epoll에서는 "fd 준비됨"을 반환하지만, io_uring에는 "준비됨"이라는 개념이 없고 "제출된 SQE 완료"만 있을 뿐이다.clear_readiness의 tick 메커니즘도 재설계해야 한다——io_uring의 완료 이벤트는 user_data 식별자를 자체적으로 가지므로, tick으로 신구 이벤트를 구분할 필요가 없다. 가장 근본적인 변경은:readiness()이 반환하는 Future가 io_uring에서는 "SQE를 제출하고 CQE를 기다림"으로 바뀌어야 하며, 이는Waiter구조가 단순한interest이 아니라 SQE 매개변수를 포함해야 함을 의미한다. 이것이 바로 io_uring 지원이tokio_unstable의 보호를 받는📎 tokio/src/runtime/io/mod.rs:1-4이유이기도 하다——그것은 백엔드를 교체하는 것이 아니라, I/O 드라이버의 추상 계약을 변경하는 것이다.

여기까지, 우리는 구체적인 함정에서 아키텍처 트레이드오프로의 상승을 완료했다. 이 책 전체를 돌아보면, Future의 지연 평가에서 스케줄러의 공정성까지, 취소 안전성에서 종료 순서까지, 그리고 이 장의 io_uring과 플러그 가능 드라이버까지, 모든 논의는 하나의 핵심을 둘러싸고 있다: 비동기 경계에서 상태 소유권을 명확히 구분하는 것. Tokio의 아키텍처는 불변하지 않으며, io_uring의 제로 카피 I/O, 드라이버 계층의 디커플링, 커스텀 실행기 인터페이스의 개방이 모두 그것을 더 유연하고 더 효율적인 방향으로 진화시키고 있다. 이 책을 덮을 때, 남겨지길 바라는 것은 API 사용법 더미가 아니라 일련의 판단력이다: 언제 런타임을 신뢰해야 하는지, 언제 하위 계층에 개입해야 하는지, 그리고 프로덕션 환경에서 물어뜯는 조합을 어떻게 피할 것인지. 비동기 Rust 생태계는 여전히 빠르게 성장하고 있으며, 소스 코드와 공식 문서를 추적하는 것이 어떤 결론을 기억하는 것보다 더 중요하다.

모든 코드베이스를 진정으로 이해할 수 있는 책으로

이 장을 다 읽으셨나요? 내 프라이빗 저장소를 위한 아키텍처 책 만들기

Tauri 2 + Rust 로컬 퍼스트 아키텍처. 100% 오프라인 보안, 클라우드 코드 업로드 없음. 불변 커밋 라인 앵커로 정독.

⚡ Tauri 2 · Rust 네이티브 코어 · 100% 오프라인 보안 · 100만 행 이상 검증

복잡한 프로젝트를 이해하기 위해 진정 필요한 것은 한 권의 좋은 책입니다

이 책은 AiReadCode가 공식 저장소를 스캔하여 자동 편찬했으며, 불변 커밋과 FACT 앵커로 뒷받침됩니다.

GitHub에서 스타 ★ 더 많은 도서 보기 →