CHAPTER 01

Capítulo 1: O modelo mental da assincronia: o trio Future, Waker e executor

Projeto relacionado: tokio-rs/tokio · Progresso do livro: capítulo 1 / 14 · Status de verificação: linhas FACT ancoradas em código real

A programação assíncrona em Rust não é uma biblioteca, mas um protocolo em nível de linguagem. O motivo pelo qual o Tokio se tornou um runtime de nível de produção não é porque inventou o Future, mas porque implementa com precisão as condições de contorno de cada contrato desse protocolo. Este capítulo não se apressa em mergulhar no código do scheduler do Tokio, mas primeiro explica a fundo os limites de responsabilidade e o fluxo de controle reverso do "trio" — Future, Waker e Executor. Ao entender como esses três se encaixam, a montagem do Runtime, o agendamento work-stealing e o driver de I/O nos capítulos seguintes terão base para se sustentar.

1.1 Do bloqueio ao polling: por que Rust escolheu poll em vez de callbacks

Modelo intuitivo

Imagine que você pediu em um restaurante um prato que precisa ser preparado na hora. A assincronia baseada em callback (como o estilo inicial do Node.js) equivale a você deixar seu número de telefone e, quando o chef terminar, eleligar ativamente para você— o controle está nas mãos do chef, e seu código apenas responde passivamente. A assincronia baseada em polling (a escolha do Rust) equivale a você receber uma senha de retirada evocê mesmo decidirquando ir à janela perguntar "já está pronto?": se não estiver, faça outra coisa; se estiver, retire.

Essa diferença parece pequena, mas determina a forma de todo o sistema. No modelo de callback, cada operação assíncrona precisa carregar uma closure de "o que fazer após concluir", closures aninhadas em camadas formam o callback hell, e cancelar a operação é extremamente difícil — você não consegue "revogar" um callback já registrado. No modelo de polling, o Future é apenas uma máquina de estados,pollé uma ação pura de consulta; se não for impulsionado, não consome recursos; cancelar é apenas drop, limpo e direto.

O contrato central do modelo de polling

A biblioteca padrão do Rust define a traitFuturecom apenas dois elementos: um métodopolle um tipo associadoOutput. O Tokio não redefine essa trait, mas reutiliza diretamente a implementação da biblioteca padrão. Isso fica claramente evidente no código-fonte:

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

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

Este trecho de código revela um fato importante: quando o recursotracingnão está habilitado, oFutureinterno do Tokio é um alias destd::future::Future, sem qualquer wrapper. Somente quandotracingestá habilitado é queInstrumentedFutureé substituído:

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

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

〔Inferência de design e trade-offs de arquitetura〕

Esse design de "custo zero por padrão, instrumentação sob demanda" é a filosofia consistente do Tokio: o caminho crítico não introduz nenhuma camada extra de abstração, e a observabilidade é adicionada como recurso opcional.InstrumentedFutureA existência de

mostra que a equipe do Tokio considera que o custo de instrumentação do tracing não deve ser arcado por todos os usuários.

pollAs três restrições implícitas do contrato de pollfn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>A assinatura do método

é Pin<&mut Self>. Essa assinatura esconde três contratos; violar qualquer um deles leva a comportamento indefinido ou erro lógico:

Contrato um: Pin garante segurança de autorreferência.significa que, uma vez que um Future é polled, seu endereço de memória não pode mais mudar. Isso ocorre porque blocos async, após compilação, geram uma máquina de estados contendo autorreferências — variáveis locais podem conter referências a outros campos dentro da mesma máquina de estados. Se a movimentação fosse permitida, essas referências ficariam pendentes.pollContrato dois: Pending deve ter wake já registrado.Poll::PendingQuandocx.waker()O Waker foi obtido e guardado, ou já foi registado em alguma fonte de eventos. Caso contrário, o executor nunca saberá quando este Future pode voltar a ser poll, fazendo com que a tarefa fique permanentemente suspensa.

Contrato três: após Ready, não deve voltar a ser poll.Uma vez quepollretornaPoll::Ready, voltar a fazer poll do mesmo Future é um erro lógico (embora não cause UB, o comportamento é indefinido). O executor tem a responsabilidade de, após receber Ready, deixar de agendar essa tarefa.

Destes três contratos, o contrato dois é o ponto mais propenso a erros e é também a razão fundamental para a existência do Waker.

1.2 Waker: o veículo do fluxo de controlo inverso

Modelo intuitivo

O Waker é o «vibrador de recolha de pedido» que o restaurante te dá. Não precisas de ficar junto à janela a perguntar repetidamente «já está?» — isso desperdiçaria o teu tempo. Só precisas de, na primeira vez que vais à janela, entregar o vibrador ao chef (registar o Waker) e depois fazer outras coisas tranquilamente. Quando o prato estiver pronto, o chef carrega no botão, o vibrador vibra (chamawake), recebes o sinal e voltas à janela para levantar o pedido (novo poll).

Sem o Waker, o executor teria apenas duas opções: ou fazer busy polling de todas as tarefas (desperdiçando CPU), ou nunca voltar a fazer poll das tarefas que já retornaram Pending (fazendo as tarefas morrer à fome). O Waker é o único mecanismo para quebrar este impasse.

Layout de memória e design da vtable do Waker

O Waker é um tipo da biblioteca padrão, mas o seu design influenciou diretamente a estrutura de tarefas do Tokio.Wakeré essencialmente um ponteiro gordo: umaRawWakerestrutura, contendo um ponteiro de dados e um ponteiro de vtable.

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 ()),
}
〔Inferência de design e compromissos arquiteturais〕

A genialidade deste design está em:Wakerem si não se importa com o que «despertar» significa concretamente. É apenas um veículo para quatro ponteiros de função. O Tokio pode fornecer um Waker cujawakefunção volta a empurrar a tarefa para a fila de agendamento; enquanto outro runtime (por exemplo, ofuturescrateblock_on) pode fornecer uma implementação de Waker completamente diferente. Este padrão de «dados + vtable» permite que o Waker seja transmitido entre diferentes runtimes sem perder semântica.

wakeA diferença entrewake_by_refewakeé crucial:wake_by_refconsome a propriedade do Waker (após a chamada o Waker é dropado), enquantowake_by_refapenas empresta. O executor normalmente implementawakecomo «marcar a tarefa como pronta e colocá-la na fila», enquanto

, com base nisso, trata adicionalmente da decrementação da contagem de referências. Na estrutura de tarefas do Tokio, o ponteiro de dados do Waker aponta para o cabeçalho de contagem de referências da tarefa; cada clone incrementa a contagem, cada drop decrementa a contagem, e quando a contagem chega a zero a memória da tarefa é libertada.

Sequência completa do despertar

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)

CopiarO ponto-chave deste diagrama é:O Waker é o único canal capaz de alcançar o Executor a partir do Reactor no sentido inverso

. O Reactor não possui qualquer outra informação sobre a tarefa; só sabe «quando este fd estiver pronto, chamar este Waker». Este desacoplamento permite que o driver de I/O seja implementado independentemente do agendador, comunicando ambos apenas através da interface estreita do Waker.

Despertares falsos: a zona cinzenta do contrato

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

A documentação do Tokio reconhece explicitamente a existência de despertares falsos:

〔Inferência de design e compromissos arquiteturais〕pollIsto significa que a implementação de

deve ser capaz de tolerar a situação de «ser novamente pollado sem ter sido despertado». Um Future correto, após retornar Pending, mesmo que nenhum evento tenha ocorrido, ao ser novamente pollado deve retornar Pending em vez de entrar em panic ou produzir resultados errados. Esta restrição parece permissiva, mas na verdade impõe requisitos ao design da máquina de estados: não se pode assumir que «entre dois polls ocorre necessariamente um evento».

1.3 Executor: do Future ao encapsulamento em tarefa

Modelo intuitivo

O Executor é o despachante do restaurante. Tem na mão uma pilha de pedidos (fila de tarefas) e decide qual pedido é feito primeiro e por quem. Quando o vibrador de recolha vibra, ele volta a colocar o pedido correspondente na fila. Sem o despachante, os chefs não saberiam qual prato preparar nem quando mudar de trabalho.Mas as responsabilidades do Executor vão muito além de «fazer poll do Future». Ele tem de resolver três problemas centrais:Gestão do ciclo de vida das tarefas(criação, agendamento, conclusão, cancelamento),Garantia de justiça(evitar que uma tarefa faça as outras morrer à fome),Integração com fontes de recursos

(como os eventos de I/O e de temporizador se transformam em despertares).

Layout de memória da tarefa: do Future à Tasktokio::spawnQuando se chamaTask, o Future passado não é colocado diretamente na fila. É encapsulado numa

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

CopiarAutoBoxEste código resolve um problema muito concreto: se o Future for demasiado grande (mais de 16KB, 2KB em modo debug), inliná-lo diretamente na estrutura Task causaria stack overflow ou desperdício de memória.SHOULD_BOXdecide se deve boxar o Future através da constante de tempo de compilação

〔Inferência de design e compromissos arquiteturais〕

Nos comentários é especialmente realçado «usar constantes associadas em vez de em tempo de execuçãoif」的原因:如果用运行时判断,编译器会为每个T同时实例化两条分支的代码(一条处理T,一条处理Pin<Box<T>>),导致代码膨胀。而用常量分支,单态化收集器会剪掉不可达的分支,只为实际使用的类型生成代码。这是一个典型的「用类型系统替代运行时判断」的优化。

调度公平性:31 与 61 的魔法数字

A documentação do agendador do Tokio define uma garantia formal de justiça:

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

A implementação dessa garantia depende de dois parâmetros-chave. Para o runtime 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

Esses dois números (31 e 61) não foram escolhidos aleatoriamente. 31 é 2 elevado à 5ª potência menos 1, podendo ser verificado rapidamente com operações de bits; 61 serve para garantir que eventos de I/O não sejam indefinidamente adiados — mesmo que a fila de tarefas nunca esteja vazia, a cada 61 agendamentos é obrigatório verificar o I/O.

〔Inferência de design e trade-offs arquiteturais〕

Por que 31 e não 32? Porque o contador começa em 0, incrementa 1 a cada agendamento, e quando atinge 31 dispara a verificação da fila global. Usarcounter & 31 == 31para verificar é mais eficiente quecounter % 32 == 0(embora compiladores modernos otimizem automaticamente). A escolha de 61 é mais sutil: precisa ser grande o suficiente para evitar o custo frequente de chamadas de sistema epoll_wait, e pequeno o suficiente para garantir que a latência de I/O fique dentro de um intervalo aceitável.

Otimização de slot LIFO no runtime multi-thread

O runtime multi-thread adiciona, além da justiça, uma otimização de desempenho — o slot 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

A intuição por trás dessa otimização é: quando uma tarefa desperta outra, a tarefa despertada provavelmente tem dependência de dados com a tarefa atual (como no padrão produtor-consumidor). Colocá-la no slot LIFO permite executá-la imediatamente após a conclusão da tarefa atual, aproveitando dados quentes no cache da CPU.

Mas o slot LIFO tem um mecanismo antiabuso:

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

〔Inferência de design e trade-offs arquiteturais〕

A regra de "desabilitar após três usos consecutivos" serve para evitar que duas tarefas se despertem mutuamente formando um livelock. Se a tarefa A desperta a tarefa B, e B desperta A, sem essa restrição o slot LIFO seria permanentemente ocupado por essas duas tarefas, e as demais nunca seriam agendadas. O limite de três dá às outras tarefas uma oportunidade de se inserir.

Cancelamento de tarefas: a semântica real do abort

JoinHandle::abortO comportamento de muitas vezes é mal interpretado. A documentação deixa claro:

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

Isso significa queabortnão é síncrono. Ele apenas define uma flag, e a tarefa verificará essa flag no próximo.awaite se encerrará por conta própria. Se a tarefa estiver executando um trecho de código intensivo em CPU sem.await,abortnão terá efeito imediato.

Mais sutil ainda:

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

〔Inferência de design e trade-offs arquiteturais〕

A motivação de design dessa semântica é: o cancelamento é uma operação de "melhor esforço". O Tokio não força o encerramento da tarefa (Rust não possui mecanismo seguro de terminação forçada), mas solicita cooperativamente que a tarefa se encerre. Isso é consistente com o design de que tarefasspawn_blockingnão são canceláveis — tarefas bloqueantes não têm.awaitpontos, não podendo verificar a flag de cancelamento.

1.4 Reflexões de design: fronteiras e custos do trio

Por que Future não inclui Executor

A traitFuturedo Rust deliberadamente não inclui informações sobre "como se agendar". Essa é uma decisão de desacoplamento cuidadosamente pensada. Se Future soubesse seu Executor, então:

1. O mesmo Future não poderia ser executado em runtimes diferentes (por exemplo, migrar de Tokio para async-std)

2. Em testes, não seria possível usar um simplesblock_onpara impulsionar

3. Combinadores (comoselect!、join!) não funcionariam entre runtimes

A existência do Waker serve justamente para, mantendo esse desacoplamento, ainda permitir que o Future notifique o Executor. O Waker é um "token de capacidade" — o Future só sabe que "posso chamar isto para solicitar reagendamento", mas não sabe como o agendamento ocorre concretamente.

O custo do agendamento cooperativo

As tarefas do Tokio são cooperativas: a tarefa só cede o controle nos.awaitpontos. Isso significa:

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

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

〔Inferência de design e trade-offs arquiteturais〕

Esse é o custo fundamental do agendamento cooperativo. O sistema operacional pode preemptar threads em qualquer fronteira de instrução, mas o Tokio só pode alternar tarefas nos.awaitpontos. Se uma tarefa executa um loop intensivo em CPU de 10 segundos sem.awaitno meio, todas as outras tarefas na mesma worker thread ficarão bloqueadas por 10 segundos. A estratégia do Tokio é oferecerspawn_blockingeblock_in_place, transferindo esse tipo de trabalho para um pool de threads dedicado. Mas isso é responsabilidade do usuário; o runtime não consegue detectar automaticamente.

Condições de contorno da garantia de justiça

A garantia de justiça do Tokio tem duas premissas: o número total de tarefas tem limite superior, e nenhuma tarefa bloqueia a thread. Essas duas condições são frequentemente violadas em ambientes de produção reais:

  • Se tarefas continuamente criam novas tarefas sem reciclar, o número total de tarefas não tem limite superior, e a garantia de justiça falha
  • Se alguma tarefa executa uma chamada de sistema bloqueante (como I/O de arquivo síncrono), ela bloqueia toda a worker thread
〔Inferência de design e trade-offs arquiteturais〕

É por isso que a documentação do Tokio enfatiza repetidamente "não execute operações bloqueantes em tarefas assíncronas". A garantia de justiça não é uma garantia rígida do runtime, mas uma garantia "sob a premissa de uso correto". O runtime não detecta violações, porque a própria detecção teria custo.

1.5 Resumo do capítulo

Este capítulo estabeleceu três pilares para entender o Tokio:

Future é uma máquina de estados baseada em pull. pollÉ uma ação de consulta pura, retornaPendingDeve ter o wake registrado ao retornarReadyApós retornar, não deve mais ser polled. Tokio reutiliza diretamentestd::future::FutureSem wrapper adicional (a menos que tracing esteja habilitado).

Waker é o único canal de controle de fluxo reverso.Ele alcança independência de runtime através do design de «ponteiro de dados + vtable».wakeConsome a ownership,wake_by_refApenas empresta. Despertares falsos são permitidos, o Future deve tolerá-los.

O Executor é responsável pelo ciclo de vida, justiça e integração de recursos.Ele encapsula o Future em uma Task, através deAutoBoxDecide em tempo de compilação se faz boxing, através dos dois números mágicos 31/61 equilibra o agendamento da fila local e da fila global, e através do slot LIFO otimiza o desempenho em cenários de dependência de dados.

Esses três componentes são desacoplados através de interfaces estreitas: o Future só conhecepollO Waker só conhecewakeO Executor só conhece «poll até Pending ou Ready». É precisamente esse desacoplamento que permite ao Tokio implementar recursos avançados como agendamento work-stealing, integração de driver de I/O e orçamento cooperativo sem modificar a definição do Future.

Reflexão e autoavaliação deste capítulo

Q1: SeAutoBox::SHOULD_BOXA verificação de compile-time constante for alterada para runtimeif size_of::<T>() > THRESHOLDQue impacto isso teria no artefato de compilação? Por que os comentários do Tokio enfatizam especialmente esse ponto?

Análise de referência: De acordo com📎 tokio/src/runtime/mod.rs:657-667Os comentários de, se usar runtimeifO compilador irá, para cadaTInstanciar simultaneamente o código de ambos os ramos — um tratandoTO caso de inlining direto, outro tratandoPin<Box<T>>O caso de. Isso significa que cada tipo de Future spawnado irá gerar duas cópias do código de condução de tarefa (task harness), causando a duplicação do tamanho do binário. Já usando a constante associadaSHOULD_BOXComo ela éTApós ser determinado, torna-se uma constante de compile-time, o coletor de monomorfização irá cortar os ramos inalcançáveis, gerando código apenas para o caminho realmente utilizado. Esta é uma otimização típica de «substituir verificação em runtime pelo sistema de tipos», ao custo deAutoBoxDeve ser uma struct genérica em vez de uma função comum.

Q2: Suponha que uma tarefa empollRetornouPendingMas esqueceu de registrar o Waker. No runtime current-thread e no runtime multi-thread, o que acontece com essa tarefa em cada caso? O Tokio tem algum mecanismo para detectar essa situação?

Análise de referência: De acordo com📎 tokio/src/runtime/mod.rs:306-309O Tokio permite despertares falsos, o que significa que a tarefa pode ser reagendada sem ter sido acordada. Mas isso não significa que esquecer de registrar o Waker seja seguro. No runtime current-thread, se tanto a fila local quanto a fila global estiverem vazias, o runtime entra no estadoparkAguardando eventos de I/O ou timer. Uma tarefa que esqueceu de registrar o Waker nunca será reenfileirada, causando suspensão permanente. No runtime multi-thread, a situação é semelhante, mas se outras tarefas continuarem acordando, essa tarefa pode ser reagendada acidentalmente devido a despertares falsos — mas isso não é confiável. O Tokio não tem mecanismo de detecção em runtime para descobrir «retornou Pending mas não registrou Waker», porque isso exigiria verificar após cada poll se o Waker foi usado, com custo muito alto. Essa é a responsabilidade do implementador do Future.

Q3: A regra do slot LIFO de «desabilitar após três usos consecutivos» é para prevenir qual cenário específico? Se essa restrição fosse removida, em qual padrão de dependência de tarefas outras tarefas sofreriam starvation?

Análise de referência: De acordo com📎 tokio/src/runtime/mod.rs:380-382O slot LIFO é temporariamente desabilitado após três usos consecutivos, até que uma tarefa de origem não-LIFO seja agendada. O cenário que essa regra previne é: duas tarefas se acordando mutuamente formando um loop apertado. Por exemplo, a tarefa A após processar um lote de dados acorda a tarefa B, e a tarefa B após processar acorda imediatamente a tarefa A. Sem o limite de três vezes, A e B ocupariam o slot LIFO para sempre, a thread worker ficaria alternando infinitamente entre essas duas tarefas, e outras tarefas na fila local e global nunca teriam chance de executar. O limite de três vezes garante que após cada três rodadas de «acordar mutuamente», pelo menos uma outra tarefa seja agendada, quebrando o livelock. A escolha desse número é empírica: muito pequeno reduz o ganho da otimização LIFO, muito grande aumenta a latência das outras tarefas.

Até aqui, os limites de responsabilidade e o mecanismo de cooperação entre Future, Waker e Executor estão claros: o Future define a computação, o Waker é responsável pelo despertar, o Executor conduz a execução. Mas um componente individual não pode funcionar sozinho, eles devem ser montados em um ambiente de runtime unificado. No próximo capítulo, vamos rastrear a cadeia completa de montagem de Runtime::new e Builder::build, ver como o scheduler, o driver de I/O, o driver de tempo e o pool de threads bloqueantes são injetados na mesma instância de Runtime, e revelar as diferenças fundamentais entre as duas formas current_thread e multi_thread na fase de montagem.

CHAPTER 02

Capítulo 2: A montagem do Runtime: Como o Builder monta drivers, scheduler e pool de threads

Projeto: tokio-rs/tokio · Progresso do livro: Capítulo 2 / 14 · Status de verificação: Linhas FACT com ancoragem real

DeBuilderparaRuntime: uma jornada completa de montagem

No capítulo anterior, deixamos claras as fronteiras de responsabilidade entre Future, Waker e Executor. Mas um runtime realmente utilizável vai muito além de "um Executor" — ele também precisa de um loop de eventos de I/O, timers, um pool de threads bloqueantes, e esses componentes devem compartilhar o mesmo conjunto de handles e o mesmo ciclo de vida. Este capítulo rastreiaBuilder::builda cadeia completa de montagem, respondendo a uma questão central:Quais componentes existem dentro de umRuntimee como eles são montados e compartilham handles。

O ponto de entrada da montagem do Tokio éBuilder. Ele em si é um contêiner puramente de configuração, todos os seus campos são "declarações de intenção", não possuindo nenhum recurso de runtime. A criação real de recursos ocorre quandobuild()é chamado.

Modelo intuitivo: Builder é a "planta de reforma", Runtime é a "casa após a entrega"

Builderé como uma planta de reforma: você anota nela "quantos quartos (worker_threads)", "se precisa de encanamento (enable_io)", "se precisa de eletricidade (enable_time)", "limite de ajudantes terceirizados (max_blocking_threads)". A planta em si não produz nenhuma entidade. Somente quandobuild()é chamado, a equipe de construção segue a planta, construindo de fato os "quartos" — scheduler, driver, pool de threads — e entregando uma instância deRuntime.

Se não houvesse a camadaBuilder, o usuário teria que manualmente instanciar cada componente, conectar manualmente, tratar manualmente rollback de falhas — qualquer erro de ordem causaria handles pendentes ou vazamento de recursos.BuilderO valor deestá em:。

Separar completamente "configuração" de "construção", permitindo que o processo de construção centralize validação, limpeza de falhas e compartilhamento de handlesBuilderLayout de memória:

Builderpartição de campos deOs campos de:kindpodem ser divididos em quatro grupos por responsabilidade. O primeiro grupo éenable_io / enable_timeForma e interruptores

📎 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,
    // ...
}

determina se o driver correspondente será criado.Copiar:worker_threadsO segundo grupo éOption<usize>,NoneParâmetros do pool de threadsmax_blocking_threadsé

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

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

padrão 512.CopiarO terceiro grupo éOption<Arc<dyn Fn ...>>Hooks de callbackArc, todos sãoBox. Note que usamConfig。

📎 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>,

, porque esses callbacks precisam ser clonados para ode cada thread worker: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,

O quarto grupo éKindHeurísticas de agendamento e semente aleatóriaCopyCopiar

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

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

MultiThreadé um pequeno enumrt-multi-threadcom apenas duas variantes.rtCopiarKindA variantebuild()é controlada pela featurematch. Isso significa que em builds com apenas a featurehabilitada,。

tem apenas uma variante,

Builder::newoenable_iodeenable_timeserá otimizado pelo compilador para um único branch —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,
A filosofia dos valores padrão: por que I/O e time são desabilitados por padrão

é o ponto de entrada comum para todas as construções. Ele define#[tokio::main]eenable_all()。

enable_all()ambos como

📎 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
}

〔Inferência de design e trade-offs arquiteturais〕enable_io()Essa escolha de padrão é intencional: criar o driver de I/O requer solicitar handles epoll/kqueue ao sistema operacional, criar o driver de time requer iniciar a infraestrutura de timers. Se o usuário só quer um scheduler de tarefas puramente computacional (por exemplo, executar lógica async intensiva em CPU), forçar a criação desses drivers é puro desperdício.net、processO macrosignalé "pronto para uso" porque internamente chamatime feature,enable_all()A implementação de

revela como o feature gating afeta a semântica de "tudo ligado".build()Copiar

build()Note quekindsó é chamado quando a feature

📎 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(),
    }
}

está habilitada. Se o usuário habilitou apenas

não abrirá o driver de I/O — porque simplesmente não há código de driver de I/O no artefato compilado.

build_current_thread_runtimeCaminho principal de montagem:build_current_thread_runtime_componentsramificação deRuntime。

📎 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,
    ))
}

em dois caminhos completamente diferentes.build_current_thread_runtime_componentsCopiar

📎 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();

Caminho um: montagem de current_threaddriverem si é muito fino, ele delega para(driver, driver_handle), e então empacota a tupla de três elementos retornada em?CopiarbuildA lógica real de montagem está emErr. Sua ordem de execução é crucial:

CopiarspawnerO primeiro passo criaspawner, retornando um par de

. Note que aqui

📎 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();
retorna

, e neste momento o blocking pool ainda não foi criado, não sendo necessária limpeza.seed_generator_1O segundo passo cria o blocking pool, e imediatamente extrai seuConfigclonado. Esteselect!será injetado no scheduler, dando ao scheduler a capacidade de despachar tarefas bloqueantes para o pool de threads.seed_generator_2O terceiro passo gera dois geradores de semente RNG independentes.CurrentThread::newCopiarrng_seed〔Inferência de design e trade-offs arquiteturais〕

Por que são necessários dois?Configé colocado emCurrentThread::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(),
);

ordem de ramificação aleatória);enable_eager_driver_handoffé passado parafalse。

📎 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,
.

Este comentário aponta a essência dessa opção: ela descreve "como múltiplos workers disputam o driver de I/O", e current_thread tem apenas uma thread, não havendo disputa, portanto é forçado a desativar. Este é um exemplo típico de "semântica de item de configuração fortemente correlacionada com a forma" — o mesmoBuildercampo tem significados diferentes em formas diferentes.

Por fim,CurrentThread::newohandleretornado porscheduler::Handle::CurrentThreadé encapsulado emHandle。

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

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

Ok((scheduler, handle, blocking_pool))

público. Copiar

build_threaded_runtimeCaminho dois: montagem do multi_thread

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

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

Noneé semelhante ao do current_thread, mas há três diferenças essenciais. A primeira é a determinação do número de threads worker:num_cpus()CopiarBuilder::newAqui

é resolvido como

📎 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();

, porque a afinidade de CPU pode mudar entre os dois.max_blocking_threads + worker_threadsA segunda diferença está no cálculo da capacidade do blocking pool:self.max_blocking_threadsCopiar0。

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

rust
let blocking_pool = blocking::create_blocking_pool(self, self.max_blocking_threads, 0);
. Em contraste, o caminho current_thread passa

emax_blocking_threadsCopiarworker_threads〔Inferência de design e trade-offs arquiteturais〕max_blocking_threadsEssa diferença revela a semântica da capacidade do blocking pool: em multi_thread,

é o limite de threads de bloqueio "adicionais", e o limite total real de threads deve somar o número de threads worker. O terceiro parâmetro (current_thread passa 0, multi_thread passaMultiThread::new) é provavelmente uma dica de "número de threads reservadas" ou "número de threads iniciais". Esse design mantém a semântica de

📎 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(),
);

A terceira diferença é quelaunchretorna uma tripla em vez de um par:MultiThread::newCopiarOextra é um "handle de inicialização".

📎 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()e não inicia imediatamente as threads workerlaunch.launch(). A inicialização real ocorre depois:

Copiar

Ohandleentra no contexto de runtime, e entãohandlerealmente faz spawn de todas as threads worker. Esse design de duas fases, "construir primeiro, iniciar depois", é muito crítico.〔Inferência de design e trade-offs arquiteturais〕HandlePor que não é possível iniciar enquanto se constrói? Porque assim que as threads worker iniciam, elas começam imediatamente a fazer poll de tarefas, e as tarefas podem referenciar。_enter. Se

ainda não tiver sido construído, ocorrerá uma race condition de "worker segurando um handle semiacabado". O design de duas fases garante:

Quando todas as threads worker iniciam, odriver::Driver::newcompleto já está prontoErrA guarda garante que as threads worker estejam no contexto de runtime correto no instante da inicialização.

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)"]

A figura abaixo reúne a ordem de montagem, os ramos críticos e os caminhos de erro das duas rotas. Observe que quandoHandlefalha, retorna diretamente

, e nesse momento o blocking pool ainda não foi criado.RuntimeCopiarscheduler、handle、blocking_poolCompartilhamento de handle:handleComo

📎 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,
}

Após a montagem,Arcmantém o trioHandle. Entre eles,Handleé o núcleo compartilhado. Seu interior é um enum:matchCopiardriver():

📎 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(). Isso significa que o clone dematch_flavor!é um incremento barato de contagem de referências, podendo ser distribuído livremente para qualquer thread.

📎 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)
}

. Por exemplodriver()Copiarmatchusa a macromatch_flavor!para eliminar repetição:matchCopiar

Essa macro expande paraHandlecomo oscheduler::Handleacima. Seu valor está em: ao adicionar um novo acessador que precisa ser despachado por forma, basta uma linha de

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

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

.HandleOspawnpúblico é um wrapper fino doblock_on。spawninterno:AutoBoxCopiar

📎 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_BOXque o usuário recebe pode ser clonado entre threads, podesize_of::<F>(), pode

📎 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;
}
demonstra o ramo em tempo de compilação de

:ifCopiarspawn_namedé uma constante associada, obtida pela comparação deFcom um limiar.Pin<Box<F>>Copiar

〔Inferência de design e trade-offs arquiteturais〕

O comentário explica por que usar uma constante associada em vez deem tempo de execução: se fosse uma verificação em tempo de execução,driver -> blocking_pool -> schedulerseria monomorfizado duas vezes (uma para

, uma paralocal_tid), fazendo com que cada future de spawn gerasse duas cópias do task harness, dobrando o tamanho do código. Com o ramo constante, o coletor de monomorfização mantém apenas o ramo realmente alcançado.。build_localReflexões de design: ordem de montagem, recuperação de erros e armadilhas em produçãobuild_current_thread_local_runtimeA ordem é o contrato

📎 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,
    ))
}

não é arbitrária. O driver é criado primeiro, porque é a única etapa que pode falhar por recursos insuficientes do SO e que, após falhar, não exige limpeza de outros componentes. O blocking_pool vem depois do driver e antes do scheduler, porque o scheduler precisa do blocking_spawner. Se a criação do blocking_pool falhar (na prática, é improvável que falhe), o driver será limpo automaticamente via drop.tidO ramoHandlede current_threadcan_spawn_local_on_local_runtimesegue

📎 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,
    }
}
Copiar

EsseLocalRuntimeé armazenado em!Send, e posteriormentelocal_tido usa para validar "se spawn_local está sendo chamado na thread owner":!SendCopiar

〔Inferência de design e trade-offs arquiteturais〕worker_threads(0)Esta é a base da segurança de。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
}

Esta asserção falha na fase de configuração, em vez de esperar pelo build. A vantagem é que o erro é localizado mais cedo, a desvantagem é que, se o número de threads vier de um valor dinâmico do ficheiro de configuração, o utilizador tem de o validar antes de chamar.

Armadilha de produção dois:max_blocking_threadsDefinir demasiado pequeno causa suspensão. A documentação avisa explicitamente:

📎 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`].
〔Inferência de design e compromissos arquiteturais〕

Porque a fila do blocking pool não tem backpressure — as tarefas acumulam-se até haver uma thread disponível. Se todas as threads bloqueantes estiverem à espera de uma operação que «requer uma nova thread bloqueante para ser concluída», ocorre deadlock. A frase da documentação «the queue does not apply any backpressure, it could potentially grow unbounded» é precisamente uma nota sobre este risco.

Armadilha de produção três:UnhandledPanic::ShutdownRuntimeSó suporta current_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
}
〔Inferência de design e compromissos arquiteturais〕

A razão desta limitação é: em multi_thread, «encerrar imediatamente o runtime» exige coordenar a paragem de todas as worker threads, o que tem elevada complexidade de implementação e semântica ambígua (o que acontece às outras tarefas que estão a ser poll?). current_thread tem apenas uma thread, pelo que a semântica de encerramento é clara.

Resumo do capítulo

Este capítulo seguiuBuilder::builda cadeia completa de montagem. Conclusões principais:

1. Builderé um contentor puro de configuração,build()é que cria recursos. A ordem de montagemdriver -> blocking_pool -> scheduleré determinada pela necessidade de recuperação de erros.

2. A diferença entre current_thread e multi_thread não se resume ao número de threads: o cálculo da capacidade do blocking pool é diferente (max_blocking_threads vs max_blocking_threads + worker_threads), multi_thread tem mais umlauncharranque em duas fases,enable_eager_driver_handoffé forçado a encerrar em current_thread.

3. Handleé o núcleo partilhado entre componentes, internamente usaArcpara envolver handles específicos da forma, acedidos uniformemente através dematchoumatch_flavor!macros.

4. AutoBoxusa constantes associadas para decidir em tempo de compilação se o future é boxed, evitando duplicar o tamanho do código.

5. local_tidéLocalRuntimeo ponto de verificação em runtime da segurança.

No próximo capítulo, entraremos no ciclo de vida das tarefas:spawncomo transformar um Future numa entidade agendável,JoinHandlecomo interagir com a máquina de estados da tarefa, e as transições de estado da tarefa entrePENDING / RUNNING / COMPLETE.

Reflexão e autoavaliação deste capítulo

Q1: Se alterar embuild_threaded_runtimeo parâmetro de capacidade decreate_blocking_pooldeself.max_blocking_threads + worker_threadsparaself.max_blocking_threads, em que cenários é que isto causaria inanição de tarefas bloqueantes? Porque é que o caminho current_thread pode passarself.max_blocking_threads?

Análise de referência: De acordo com📎 tokio/src/runtime/builder.rs:2189-2192, o caminho multi_thread passaself.max_blocking_threads + worker_threads, enquanto o caminho current_thread📎 tokio/src/runtime/builder.rs:1765passaself.max_blocking_threads. A raiz da diferença está em que: em multi_thread, as próprias worker threads também executam tarefas bloqueantes (por exemplo,block_in_placeconverte temporariamente uma worker thread em thread bloqueante), pelo que o orçamento total de threads bloqueantes tem de incluir o número de worker threads. Se se alterar para passar apenasself.max_blocking_threads, quandomax_blocking_threadsfor definido como pequeno (por exemplo, 1) e já houver worker threads a ocupar o orçamento emblock_in_place, as novas tarefasspawn_blockingficarão sem threads disponíveis, acumulando-se numa fila sem backpressure, o que fará com que as tarefas async que dependem destas tarefas bloqueantes fiquem permanentemente suspensas. current_thread tem apenas uma thread e não suporta a semântica de conversão de worker deblock_in_place, pelo que não é necessário somar o número de workers.

Q2: MultiThread::newdevolvelauncho handle, quem realmente inicia as worker threads élaunch.launch(). Se removerhandle.enter()esta linha e chamar diretamentelaunch.launch(), o que aconteceria?

Análise de referência: De acordo com📎 tokio/src/runtime/builder.rs:2230-2232, antes do arranque hálet _enter = handle.enter();e só depoislaunch.launch()。handle.enter(). A função deHandle::current()、tokio::spawné definir o contexto thread-local, fazendo com que a thread atual «pareça» estar dentro do runtime. As worker threads, após o arranque, começam imediatamente a fazer poll de tarefas, e o código das tarefas pode chamar_entere outras APIs que dependem do contexto. Se removerlaunch, a definição de contexto no instante de arranque da worker thread pode ficar incompleta (dependendo deHandle::current()se define internamente), e no pior caso o código de inicialização executado na worker thread ao chamarCONTEXT_MISSING_ERRORentrará em panic (launch). Mesmo que_enterdefina internamente o contexto para cada worker,

Q3: AutoBox::<F>::SHOULD_BOXtambém garante que «a própria ação de arranque» ocorre no contexto correto, evitando condições de corrida durante o arranque.if size_of::<F>() > THRESHOLDusa constantes associadas em vez de

em runtime. Suponha que se alterava para verificação em runtime; além de duplicar o tamanho do código, em que situações causaria degradação de desempenho?Análise de referência📎 tokio/src/runtime/mod.rs:657-673: De acordo comifos comentários despawn_named, oTem runtime faria com queTmonomorfizasse cadaPin<Box<T>>duas vezes (Pin<Box<T>>esize_ofuma vez cada). Além de duplicar o tamanho do código, a degradação de desempenho manifesta-se em: 1) maior pressão na cache de instruções (i-cache), porque ambos os conjuntos de código harness têm de residir; 2) o compilador não consegue otimizar «na prática só segue um ramo», e embora a previsão de ramos em runtime seja geralmente precisa, o próprio ramo e as diferenças de alocação de registos entre os dois conjuntos de código acumulam-se; 3) mais subtil ainda,

CHAPTER 03

Capítulo 3: A vida de uma tarefa (Parte 1): como spawn transforma um Future em uma entidade agendável

Projeto: tokio-rs/tokio · Progresso do livro: Capítulo 3 / 14 · Status de verificação: linhas FACT ancoradas em localizações reais

No capítulo anterior, concluímos a montagem do Runtime: o driver de I/O, o driver de tempo, o blocking pool e o agendador são injetados na mesma instânciaRuntimedeHandletornando-se um handle compartilhado para acessar esses componentes entre threads. Mas o runtime montado ainda é apenas um invólucro vazio — ele possui o motor para impulsionar tarefas, mas não tem nenhuma tarefa para impulsionar. A questão que este capítulo responde é exatamente: quando você digitatokio::spawn(async { ... })naquele momento, o blocoasynco que exatamente ele passou para se transformar de um código Rust comum em uma entidade "que pode ser assumida pelo agendador, despertada e aguardada com join". Esta é a primeira metade de "A vida de uma tarefa", focamos no nascimento: partindo deHandle::spawnatravessandonew_taska alocação de contagem de referências, chegando aoCell<T, S>layout de memória, e finalmente vendo como a tarefa é entregue à fila local de algum worker ou à fila de injeção global. A segunda metade (Capítulo 4) só então entrará no loop de agendamento e no ciclo fechado poll/wake.

3.1 Future não é tarefa: o que exatamente um spawn cria

Modelo intuitivo

Pense emFuturecomo uma "receita", e pense na tarefa como "um prato sendo cozinhado na cozinha". A receita em si é estática, copiável e não possui nenhum estado de execução; somente quando a cozinha (o agendador) decide "agora faça este prato", atribui a ele um fogão (worker), um número de pedido (TaskId) e uma saída de prato (JoinHandle), é que ele se torna um "prato em produção". Sem essa camada de empacotamento, o agendador não teria como saber "em que passo este prato está", "quem está esperando por ele", "quem notificar quando estiver pronto" — ele só veria uma receita, incapaz de gerenciar.

Estrutura de dados e layout de memória

Tokio usaTask<S>para representar "uma referência de tarefa possuída pelo runtime", que é um wrapper transparente sobreRawTask:

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

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

#[repr(transparent)]significa queTask<S>eRawTasksão completamente idênticos em memória, sem overhead adicional.PhantomData<S>é apenas uma marcação de tipo em tempo de compilação, marcando a qual tipo de agendador esta tarefa pertenceS。

O que realmente carrega todo o estado da tarefa éCell<T, S>, cujo layout é a pedra fundamental de todo o módulo de tarefas:

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

Os três campos são organizados em ordem "quente-morno-frio".Headersão dados quentes (acessados a cada agendamento, a cada transição de estado),Coresão dados mornos (acessados durante o poll),Trailersão dados frios (acessados apenas na criação e destruição). O comentário afirma explicitamente:Headerdeve ser o primeiro campo, porque a estrutura da tarefa será simultaneamente referenciada por*mut Celle*mut HeaderMais crucial é o alinhamento de linha de cache.📎 tokio/src/runtime/task/core.rs:37-43。

possui uma longa série deCellanexada, escolhendo o número de bytes de alinhamento de acordo com a arquitetura alvo: x86_64/aarch64/powerpc64 usam 128 bytes, arm/mips/sparc/hexagon usam 32 bytes, m68k usa 16 bytes, s390x usa 256 bytes, e o restante usa 64 bytes por padrão#[cfg_attr(..., repr(align(...)))]. O comentário explica por que x86_64 deve usar 128 em vez de 64: a partir do Intel Sandy Bridge, o prefetcher espacial busca de uma vez📎 tokio/src/runtime/task/core.rs:64-125paresde linhas de cache de 64 bytes, então é necessário alinhar a 128 bytes para evitar falso compartilhamento〔Inferência de design e trade-offs arquiteturais〕📎 tokio/src/runtime/task/core.rs:45-53。

O custo dessa estratégia de alinhamento é que cada tarefa desperdiça pelo menos o espaço de uma linha de cache. Mas os bits de estado da tarefa (

) são lidos e escritos com alta frequência por múltiplas threads worker — uma thread define o bit RUNNING durante o poll, outra thread lê o bit NOTIFIED durante o wake — se os bits de estado de duas tarefas caírem na mesma linha de cache, cada transição de estado disparará um vaivém da linha de cache entre os núcleos (cache line ping-pong), com perda de desempenho muito maior que o desperdício de memória. Tokio escolhe trocar espaço por tempo.stateé restringido a 8 tamanhos de ponteiro ou menos:

HeaderCopiar

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

não exceda 64 bytes (8 × 8), podendo assim caber completamente em uma linha em arquiteturas com linha de cache de 64 bytes.HeaderOs campos deHeaderincluem:state: State(bits de estado atômicos),queue_next: UnsafeCell<Option<NonNull<Header>>>(ponteiro de lista encadeada da fila de injeção),vtable: &'static Vtable(tabela de ponteiros de função),owner_id: UnsafeCell<Option<NonZeroU64>>(ID da lista deOwnedTasksà qual pertence),scheduled_at: UnsafeCell<ScheduleLatencyInstant>(medição de latência de agendamento)📎 tokio/src/runtime/task/core.rs:169-198。

Core<T, S>mantém o handle do agendadorscheduler: S, o ID da tarefatask_id: Id, e o mais centralstage: CoreStage<T> 📎 tokio/src/runtime/task/core.rs:148-165。Stageé um enum de três estados:

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

Isto é exatamente a chave para "Future e Output reutilizarem o mesmo bloco de memória": durante a execução da tarefaStage::Runningmantém o future, após a conclusão é substituído no local porStage::Finished(output), e após ser retirado porJoinHandletorna-seStage::Consumed。#[repr(C)]O comentário aponta para uma issue do Miri, indicando que este layout tem requisitos rígidos de correção para código unsafe📎 tokio/src/runtime/task/core.rs:225-229。

Trailerarmazena dados frios:owned: linked_list::Pointers<Header>(OwnedTasksponteiro de lista encadeada),waker: UnsafeCell<Option<Waker>>(waker do consumidor aguardando a conclusão da tarefa),hooks: TaskHarnessScheduleHooks 📎 tokio/src/runtime/task/core.rs:205-213。

Passo a Passo: do spawn à entrada na fila

Vamos inserir um cenário concreto: em um runtime multi_thread, a thread worker A executatokio::spawn(async { 42 })。

Primeiro passo: construir o trio de tarefas. new_taské a única entrada para o nascimento de uma tarefa:

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

Ele chamaRawTask::new::<T, S>para alocarCell, e então deriva três referências a partir do mesmo ponteiroraw:Task(referência owned, geralmente colocada imediatamente emOwnedTasks)、Notified(referência de notificação, entregue ao agendador),JoinHandle(handle de leitura de resultado)📎 tokio/src/runtime/task/mod.rs:347-363. Observe que os três compartilham o mesmoraw, cada um mantendo uma contagem de referências.

Segundo passo: alocarCelle escrever o estado inicial. Cell::newAlocar toda a estrutura no heap:

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

vtablegerado porraw::vtable::<T, S>(), é uma tabela de ponteiros de função monomorfizada paraTeSespecíficos📎 tokio/src/runtime/task/core.rs:260. O future é movido diretamente paraStage::Running, sem boxing adicional.

Terceiro passo: asserção de debug para verificar o layout.Sobdebug_assertions,Cell::newchamará acheckfunção, usandoHeader::get_trailer、Header::get_scheduler、Header::get_id_ptre outras operações de ponteiro baseadas em deslocamentos de vtable, para afirmar uma a uma que "o endereço do campo obtido via header" e "o endereço real do campo" são consistentes📎 tokio/src/runtime/task/core.rs:280-321. Esta é uma autoverificação em tempo de execução da correção dos deslocamentos da vtable.

Quarto passo: despachar para o agendador.O agendador, após receberNotified<S>, chamaSchedule::schedule 📎 tokio/src/runtime/task/mod.rs:315. Sob multi_thread, isso percorrerápush_back_or_overflow, empurrando a tarefa para a fila local do worker atual, transbordando para a fila de injeção quando a fila estiver cheia.

A figura abaixo descreve o fluxo de controle e as ramificações denew_taskaté o enfileiramento:

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

Esta figura revela vários ramos críticos: as asserções de debug só têm efeito em builds de depuração; quando a fila local está cheia, não há transbordamento direto, mas primeiro verifica-se se há ladrões concorrentes (steal != real), e se houver, apenas a tarefa atual é empurrada para a fila de injeção, pois o espaço liberado pelos ladrões logo estará disponível.

Reflexão de design: por que três referências em vez de uma

new_taskretorna três referências, em vez de uma. Este é o núcleo do design de contagem de referências:Taskrepresenta "o runtime possui esta tarefa",Notifiedrepresenta "esta tarefa foi notificada, aguardando agendamento",JoinHandlerepresenta "alguém se importa com seu resultado". Os três têm ciclos de vida independentes —JoinHandlepode ser dropado (a tarefa continua executando, o resultado é descartado),Notifieddesaparece após o poll,Taské liberado após a tarefa completar e ser removida deOwnedTasks. Se houvesse apenas uma referência, não seria possível expressar o estado "a tarefa ainda está rodando mas ninguém faz join".

UnownedTaské outro ramo importante: ele mantémduascontagens de referências, usadas para tarefas blocking (não armazenadas emOwnedTasks)📎 tokio/src/runtime/task/mod.rs:286-295。unownedA funçãomem::forget(task)através demem::forget(notified)eUnownedTask 📎 tokio/src/runtime/task/mod.rs:388-397mescla as duas referências emOwnedTasks. A motivação de design para "duas referências" é: tarefas blocking não têm uma

lista para manter a referência owned, então é necessária uma contagem de referência extra para garantir que a tarefa não seja liberada durante a execução.

3.2 Bits de estado: como um usize codifica todo o ciclo de vida de uma tarefa

Modelo intuitivoImagine o estado da tarefa como um "relatório de exame médico", com várias caixas de seleção independentes: está sendo pollada, foi concluída, foi notificada, foi cancelada, alguém fez join. Tokio não usa múltiplos campos booleanos, mas comprime esses bits de seleção emAtomicUsizeum

. Assim, cada transição de estado requer apenas um CAS, em vez de múltiplos locks. Sem esse design, as transições de estado da tarefa se tornariam aninhamentos de múltiplos locks, e o risco de deadlock e a sobrecarga disparariam.

StateLayout de bits📎 tokio/src/runtime/task/mod.rs:32-53:

  • RUNNINGOs campos de bits deestão completamente definidos na documentação do módulo 📎 tokio/src/runtime/task/mod.rs:37-38。
  • COMPLETE: se a tarefa está sendo pollada ou cancelada.RUNNINGEste bit também serve como o lock da tarefa📎 tokio/src/runtime/task/mod.rs:40-41。
  • NOTIFIED: o future foi completamente concluído e dropado. Uma vez definido, nunca é limpo, e nunca é definido simultaneamente comNotified: se existe atualmente um📎 tokio/src/runtime/task/mod.rs:43。
  • CANCELLEDobjeto📎 tokio/src/runtime/task/mod.rs:45-46。
  • JOIN_INTEREST: a tarefa deve ser cancelada o mais rápido possívelJoinHandle 📎 tokio/src/runtime/task/mod.rs:48。
  • JOIN_WAKER: existe📎 tokio/src/runtime/task/mod.rs:50-51。

: bit de controle de acesso como join handle waker📎 tokio/src/runtime/task/mod.rs:53。

RUNNINGOs bits restantes são usados para contagem de referênciasRUNNINGO fato de📎 tokio/src/runtime/task/mod.rs:130-133bit servir como lock merece ser expandido. A seção Safety da documentação do módulo aponta: qualquer acesso mutável ao future deve ocorrer após modificarRUNNINGbit para obter o lock, garantindo assim acesso exclusivo

. Isso significa que, ao pollar uma tarefa, a thread primeiro faz CAS para definir

JOIN_WAKER, e em caso de sucesso obtém acesso exclusivo ao future; se falhar, significa que outra thread está pollando, e este poll retorna diretamente. Isso funde "exclusão mútua do poll" e "transição de estado" em uma única operação atômica, evitando um mutex separado.wakerProtocolo de controle de acesso do JOIN_WAKERTrailerO bité a parte mais engenhosa de toda a máquina de estados. Ele resolve o problema de:campo (emJoinHandle) ser acessado concorrentemente por duas threads — o runtime, ao completar a tarefa,lêpara acordar o join,📎 tokio/src/runtime/task/mod.rs:75-120:

1. JOIN_WAKERao pollar

escreveJoinHandlepara registrar o waker. A documentação do módulo fornece 7 regras

inicialmente é 0.JoinHandle2. Quando é 0,

tem acesso exclusivo (mutável) ao campo waker.COMPLETE3. Quando é 1,

5. JoinHandletem apenas acesso compartilhado (somente leitura).JOIN_WAKER4. Quando é 1 eJOIN_WAKERé 1, o runtime tem acesso compartilhado (somente leitura) ao campo waker.

6. JoinHandlePara escrever o waker, é necessário: (i) definir com sucessoCOMPLETEpara 0 para obter acesso exclusivo, (ii) escrever o waker, (iii) definir com sucessoJOIN_WAKERpara 1.COMPLETEsó pode modificar

quandoJOIN_INTERESTé 0; o runtime só pode modificar quandoCOMPLETEé 1.

7. SeCOMPLETEé 0 e📎 tokio/src/runtime/task/mod.rs:110-120é 1, o runtime tem acesso exclusivo ao campo waker (para dropar o waker).

A regra 6 implica uma corrida: o passo (i) ou (iii) pode falhar. Se (i) falhar, desiste-se de escrever o waker; se (iii) falhar (outra thread definiu

Tasknesse meio tempo), então o campo waker é limpoUnownedTasko drop decrementa duas vezes:

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_decretornatrueindica que esta é a última referência, e só então libera de fato aCellmemória.ref_dec_twiceéUnownedTaska manifestação direta de manter duas contagens.

Reflexão de design: por que o bit de estado e a contagem de referências compartilham um único atômico

〔Inferência de design e trade-offs arquiteturais〕

Colocar o bit de estado e a contagem de referências no mesmoAtomicUsizeserve para que as duas ações — "decrementar a contagem de referências" e "definir o bit de estado" — possam ser concluídas emum único CASA documentação do módulo, no comentário deSchedule::releasemenciona explicitamente: "o módulo de tarefas processará em lote o ref-dec e a definição de outras opções"📎 tokio/src/runtime/task/mod.rs:302-304. Se o bit de estado e a contagem de referências estivessem em duas variáveis atômicas separadas, então surgiria uma janela entre "liberar a última referência" e "marcar como concluído", exigindo sincronização adicional. Após a fusão,ref_decé possível concluir atomicamente "decrementar a contagem + verificar se chegou a zero", evitando problemas do tipo ABA.

3.3 JoinHandle: como o resultado é devolvido através da fronteira da tarefa

Modelo intuitivo

JoinHandleé como o "comprovante de retirada" que o restaurante lhe dá. Quando a tarefa (cozinha) termina, ela coloca o prato (output) na janela de entrega (Stage::Finished), e então aciona seu pager (waker). Você usa o comprovante para retirar; o comprovante em si não contém o prato, é apenas um ponteiro para a janela de entrega. Se você perder o comprovante (dropJoinHandle), o prato será descartado diretamente (output é dropado), mas a cozinha não para por causa disso.

Estrutura de dados

JoinHandle<T>também é um wrapper transparente sobreRawTask:

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

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

PhantomData<T>marca o tipo de saída.JoinHandle<T>só éT: SendemSend/Sync 📎 tokio/src/runtime/task/join.rs:169-170, o que garante que saídas não-Send não sejam movidas entre threads.

Passo a passo: aguardar um JoinHandle

JoinHandleimplementaFuture, cujopollé o núcleo da devolução do resultado:

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

Observe alguns detalhes:trace_leafé usado para instrumentação de tracing;coop::poll_proceedconsome o orçamento de cooperação (detalhado no capítulo 12);try_read_outputapaga os genéricos via vtable, coloca o valor de retorno na pilha e o passa com*mut ()para📎 tokio/src/runtime/task/join.rs:327-354. Essa técnica de "colocar o valor de retorno na pilha" existe porque funções de vtable não podem genericizar o tipo de retornoT, só podendo escrever de volta por meio de ponteiro bruto.

〔Inferência de design e trade-offs arquiteturais〕

try_read_outputlógica interna (em raw.rs, cujo código-fonte não é fornecido neste capítulo): primeiro verifica o bitCOMPLETE, e se já estiver definido, chamatake_outputpara retirar o resultado deStage::Finished; caso contrário, registracx.waker()no campoTrailer::wakere retornaPending. O processo de registro segue exatamente o protocoloJOIN_WAKERda seção 3.2.

Transferência de posse do resultado

A seção "Non-Send output" da documentação do módulo descreve com precisão as regras de posse do resultado📎 tokio/src/runtime/task/mod.rs:151-170:

  • Quando a tarefa é concluída, o output é colocado emStage, então é executada a transição que "define COMPLETE", e é lido o valor deJOIN_INTERESTnesse instante.
  • SeJOIN_INTERESTfor 0 (semJoinHandle), o output é dropado imediatamente📎 tokio/src/runtime/task/mod.rs:157-158。
  • SeJOIN_INTERESTfor 1,JoinHandleé responsável por limpar o output📎 tokio/src/runtime/task/mod.rs:160-161。

Para output não-Send, a documentação apresenta uma argumentação em três passos: o output é criado na thread que faz poll do future;JoinHandle<Output>também não é Send quando o Output não é Send, então ele também está na thread de spawn; portanto, quandoJoinHandleretira ou dropa o output, não há movimentação entre threads📎 tokio/src/runtime/task/mod.rs:164-170。

O drop do JoinHandle: dois caminhos, rápido e lento

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_fasttenta concluir em um único CAS "limpar o bitJOIN_INTEREST+ decrementar a contagem de referências". Se falhar (por exemplo, a tarefa está sendo concluída e o bit de estado está ocupado), segue o caminho lento dedrop_join_handle_slow. Este é o padrão típico de "caminho rápido otimista + caminho lento pessimista".

Reflexão de design: por que o JoinHandle não mantém o output diretamente

〔Inferência de design e trade-offs arquiteturais〕

SeJoinHandlemantivesse o output diretamente, então o output teria de ser movido para a thread ondeJoinHandleestá quando a tarefa fosse concluída. MasJoinHandlepode ser movido para qualquer thread (desde queT: Send), enquanto a thread de produção do output é a thread de poll. Mantê-lo diretamente causaria a movimentação entre threads de "o output é produzido na thread de poll, mas precisa ser dropado na thread de join", o que, para output não-Send, viola diretamente o sistema de tipos. Tokio opta por deixar o output emCell(Stage::Finished),JoinHandlemantém apenas oCellque aponta paraRawTask, e ao obter o resultado o retira no local viatake_output. Assim, o drop do output ocorre na thread ondeJoinHandleestá, mas sob a premissa de que essa thread é a mesma que a de poll (o que vale no cenário não-Send).

3.4 Fila local: a estrutura produtor-consumidor do work-stealing

Modelo intuitivo

Cada worker tem uma "lista de tarefas privada" (fila local), com capacidade 256. O próprio worker retira tarefas dacabeça(LIFO, aproveitando a localidade de cache), enquanto outros workers roubam tarefas dacauda(FIFO, levando as mais antigas, mais provavelmente já concluídas). Sem a fila local, todas as tarefas se acumulariam na fila global, e cada retirada de tarefa disputaria o lock global, fazendo a escalabilidade multicore desmoronar.

Layout de memória: a separação entre head e 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 bits, se a plataforma suportar u64),tailéAtomicUnsignedShort(32 bits). O comentário explica por que os índices são mais largos do que o necessário: para mitigação de ABA e para distinguir buffers "cheios" e "vazios"📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:37-49。

headempacota internamentedois UnsignedShort:o bit baixo é a "cabeça real" (real head), o bit alto é a "primeira posição sendo processada pelo ladrão" (steal head). Quando ambos são iguais, não há ladrões ativos📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:39-49. Essa compactação de dois valores é a técnica central da fila work-stealing: o ladrão primeiro faz CAS para atualizar o valor steal e "reivindicar" um lote de tarefas; após concluir, avança o valor steal até o valor real, indicando o fim do roubo.

LOCAL_QUEUE_CAPACITYEm modo não-loom é 256, em loom reduz para 4 para testar mais casos limite📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:62-69。MASK = LOCAL_QUEUE_CAPACITY - 1, usado para indexação do buffer circular📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:71。

Passo a Passo: os ramos completos de push_back_or_overflow

Esta é a função mais complexa da fila local; vamos analisá-la ramo a ramo:

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

Três ramos:

1. Com capacidade(tail - steal < CAPACITY):break tail, sai do loop e chamapush_back_finishpara escrever no buffer.

2. Sem capacidade mas com ladrões concorrentes(steal != real): o ladrão liberará espaço, então apenas empurra a tarefa atual para a fila de injeção e retorna imediatamente📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:204-208。

3. Sem capacidade e sem ladrões: chamapush_overflowpara transbordar a segunda metade das tarefas para a fila de injeção📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:209-219. Se o CAS falhar (perder para um ladrão concorrente),push_overflowretornaErr(task), e o loop tenta novamente.

push_back_finishEscreve a tarefa e atualiza 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

ReleaseA ordenação garante que a tarefa escrita seja visível para os ladrões.

push_overflow: por que transbordar a segunda metade

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

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

No transbordamento, retira 128 tarefas. O comentário explica em detalhes por que retirara segunda metadeem vez da primeira metade📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:295-306: ao retirar tarefas da fila de injeção, elas sempre são colocadas na primeira metade. Portanto, se uma tarefa está na segunda metade, pode-se determinar que ela não foi recém-retirada da fila de injeção. Isso garante que "tarefas retiradas da fila de injeção não sejam imediatamente devolvidas à fila de injeção" (pelo menos antes de serem polled uma vez).

CAS reivindica a segunda metade:

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

Atualizaheadde(head, head)para(tail, tail), ou seja, avança simultaneamente steal e real até tail, reivindicando todas as tarefas. Após sucesso, recua tail paratail + NUM_TASKS_TAKEN, indicando que a primeira metade permanece na fila local📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:314-316。

pop e steal_into: os dois caminhos para obter tarefas

popé o worker obtendo tarefas por conta própria (da cabeça, 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

Ramo crítico: sesteal == real(sem ladrões), avança ambos; caso contrário, avança apenas real, mantendo steal inalterado📎 tokio/src/runtime/scheduler/multi_thread/queue.rs:377-384。assert_ne!(steal, next_real)garante que real não seja avançado até a posição de steal, o que corromperia o estado de reivindicação do ladrão.

steal_intoé o caminho de roubo, primeiro verifica se a fila alvo tem espaço suficiente:

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

Se a fila alvo estiver mais da metade cheia, não rouba, evitando transbordar logo após o roubo.

steal_into2é o núcleo do roubo, calcula a quantidade a roubar:

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

Rouba metade (arredondado para cima). Em seguida, CAS atualiza o valor steal de head para reivindicar:

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

Note que aqui apenas o valor real é atualizado (pack(src_head_steal, steal_to)em steal permanece inalterado), avançando real atésteal_to. Isso indica que "estas tarefas foram reivindicadas, outros ladrões não podem tocá-las". Após concluir o roubo, avança steal até 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

O diagrama de sequência abaixo descreve a interação concorrente entre três partes: "produtor push, consumidor pop, ladrão 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"

Reflexão de design: por que a fila local é LIFO e o roubo é FIFO

〔Inferência de design e trade-offs arquiteturais〕

O worker retira da cabeça (LIFO), porque a tarefa recém-inserida provavelmente ainda está no cache da CPU e é mais provavelmente a tarefa "recém-despertada, com dados ainda quentes". O ladrão retira da cauda (FIFO), porque a tarefa mais antiga provavelmente já completou a maior parte do trabalho, e roubá-la alivia mais rapidamente a carga da vítima. Essa combinação de "LIFO local + FIFO roubo" é o design clássico do escalonamento work-stealing, equilibrando localidade de cache e balanceamento de carga.

Até aqui, a tarefa completou sua transformação de Future para entidade escalonável: recebeu contagem de referências, foi colocada noCelllayout de memória, e foi entregue com sucesso à fila local do worker ou à fila de injeção global. Mas colocar a tarefa na fila é apenas o começo; o que realmente a faz funcionar é o loop de escalonamento da thread worker. No próximo capítulo entraremos na segunda metade de "a vida de uma tarefa", rastreando como o worker retira tarefas da fila, chamaFuture::poll, e ao retornarPendingregistra o despertar viaWaker, finalmente disparandoscheduleo reenfileiramento — o caminho completo de chamadas do ciclo fechado "despertar → enfileirar → re-poll", bem como a estratégia work-stealing e a otimização de slots LIFO, serão revelados lá.

CHAPTER 04

Capítulo 4: A vida de uma tarefa (parte 2): loop de escalonamento, poll e o ciclo fechado do despertar

Projeto: tokio-rs/tokio · Progresso do livro: Capítulo 4 / 14 · Status de verificação: linhas FACT com ancoragem real

Da fila à execução: o esqueleto do loop principal do worker

No capítulo anterior enviamos a tarefa para aLocalfila ou fila de injeção global. Mas a fila é apenas uma "lista de afazeres"; o que realmente faz a tarefa rodar é aquele loop infinito na thread worker. Neste capítulo rastreamosContext::run— é o coração de todo o escalonador multithread.

Primeiro, construa a intuição: a thread worker é como um chef, com uma pilha de seus próprios pedidos à frente (run_queue), e ao lado um suporte público de pedidos (inject). O chef primeiro olha o pedido mais próximo à mão (lifo_slot), se não houver, pega da própria pilha; se ainda não houver, vai até a prateleira pública e pega um punhado; se ainda assim não der, vai até a pilha de outro chef e rouba algumas folhas. Só quando tudo estiver vazio ele vai descansar, mas mesmo descansando mantém os ouvidos atentos — assim que chega um pedido, acorda imediatamente.

Sem esse loop, a tarefa, após ser enfileirada, ficaria para sempre na fila,Future::pollnunca seria chamada, e todo o runtime seria um monte de dados mortos.

Layout de memória e campos de estado do Core

Todo o estado mutável do worker está guardado emCore, que éBoxalocado no heap, e passado através deAtomicCell<Core>entreWorkere o thread-localContext.

CoreOs campos-chave de são os seguintes📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:113-167:

  • tick: u32: incrementa a cada iteração do loop, usado para disparar periodicamente a manutenção (maintenance) e a verificação da fila global.
  • lifo_slot: Option<Notified>:Slot LIFO, este é o design mais engenhoso deste capítulo. Quando o worker agenda uma tarefa por conta própria, ele não a coloca emrun_queue, mas sim neste slot, e na próxima vez que for buscar tarefasdá prioridadepara pegar daqui.
  • lifo_enabled: bool: interruptor do slot LIFO, usado para evitar inanição em cenários de ping-pong.
  • run_queue: queue::Local<Arc<Handle>>: fila local, a estruturaLocalanalisada no capítulo anterior.
  • is_searching: bool: indica se o worker está procurando tarefas para roubar.
  • is_shutdown: bool / is_traced: bool: flags de encerramento e rastreamento.
  • park: Option<Parker>: parker, envolto emOptionpara facilitar retirar/colocar de volta sob o borrow checker.
  • global_queue_interval: u32: com que frequência verificar a fila global.
  • rand: FastRand: gerador de números aleatórios rápido, usado para escolher aleatoriamente o ponto de início do roubo.
〔Inferência de design e trade-offs arquiteturais〕

Note quelifo_slotéOption<Notified>e não uma fila — ele armazenaapenas umatarefa. A motivação desse design está claramente explicada nos comentários do código-fonte📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:117-121: tarefas agendadas pelo próprio worker são guardadas neste slot, e o worker verificarun_queue antes de verificar, tendo como efeito "a última tarefa agendada é a próxima a executar" (LIFO). Isso serve para melhorar a localidade, sendo especialmente eficaz para padrões de passagem de mensagens, reduzindo a latência.

Por que LIFO reduz a latência? Considere um cenário típico de passagem de mensagens: a tarefa A, após processar uma mensagem, acorda a tarefa B; B, após processar, acorda A novamente. Se, depois que A acorda B, B executar imediatamente, os dados de que B precisa provavelmente ainda estarão no cache da CPU (porque A acabou de acessá-los). Se B for jogada para o final da fila, esperando dezenas de tarefas à frente terminarem, o cache já terá sido completamente substituído.

Mas LIFO tem risco de inanição. O código-fonte usaMAX_LIFO_POLLS_PER_TICK = 3para limitar📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:263-263: a cada tick, no máximo 3 priorizações do slot LIFO; ultrapassando isso, ele é desabilitado, dando chance a outras tarefas de executar.

Walkthrough do loop principal: um ciclo completo de agendamento

Vamos considerar um cenário concreto: o worker 0 acabou de acordar depark,run_queuetem 5 tarefas,lifo_slottem 1 tarefa, e a fila global tem 3 tarefas.

A entrada do loop principal éContext::run 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:570-642. Ele primeiro reinicializalifo_enabled(porque o core pode ter sido roubado porblock_in_place, e o estado precisa ser restaurado)📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:571-573, e então entra no loopwhile !core.is_shutdown.

A cada iteração do loop, quatro coisas são feitas:

Primeiro passo: tick e manutenção. core.tick()incrementa o contador📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:587. Em seguida,self.maintenance(core)verificatick % event_interval == 0, e se sim, chamapark_yieldpara acionar I/O e timers com timeout 0📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:809-826。

Segundo passo: obter tarefa. core.next_task(&self.worker)é a lógica central de obtenção de tarefas📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1090-1156. Ela se divide em dois caminhos:

  • Quandotick % global_queue_interval == 0,dá prioridadepara pegar da fila global; se não conseguir, pega da fila local📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1091-1098. Isso serve para evitar que tarefas na fila global morram de inanição.
  • Caso contrário,dá prioridadepara pegar tarefas locais📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1090-1156。

A obtenção local de tarefas é feita pornext_local_task, que📎 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())
}

primeiro o slot LIFO, depois a cabeça da fila (pop LIFO). É isso que o capítulo anterior chamou de "LIFO local".

Se a fila local estiver vazia mas a fila global não, o workerem lotepuxa tarefas da fila global📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1110-1154. O cálculo do tamanho do lotené bastante criterioso:min(inject.len() / remotes.len() + 1, cap), ondecappor sua vez pegamin(remaining_slots, max_capacity / 2). Os comentários do código-fonte explicam por que limitar à metade da capacidade da fila📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1120-1131: garantir que as tarefas puxadas caiam naprimeira metadeda fila local, de modo que, mesmo que ocorra overflow depois, essas tarefas não sejam empurradas de volta para a fila global (o overflow afeta apenas a segunda metade).

Terceiro passo: executar a tarefa.Após obter a tarefa, chamarun_task 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:647-796. Esta é a função mais complexa do capítulo, que detalharemos na próxima seção.

Quarto passo: roubar ou park.Senext_taskretornaNone, significa que não há trabalho nem local nem globalmente, então chamasteal_work 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1167-1195. Se o roubo falhar, entra emparkoupark_yield 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:613-621。

Todo o fluxo de controle é o seguinte:

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: o ciclo fechado entre poll e o slot LIFO

run_taské onde a tarefa realmente époll, e também o ponto de fechamento do ciclo "acordar → enfileirar → poll novamente".

A primeira coisa ao entrar na função éassert_owner 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:648, converterNotifiedemTask, e ao mesmo tempo afirmar que a thread atual é de fato a owner desta tarefa (asserção de debug).

Em seguidatransition_from_searching 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:652— se o worker estava antes no estado de busca, agora que encontrou a tarefa, deve sair do estado de busca e possivelmente acordar outros workers em park.

Depois vem o envoltório chave de 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();
    }
})

Este trecho de código revela o ciclo fechado completo do slot LIFO:task.run()executaFuture::poll, e durante o poll, se a tarefa acordar a si mesma ou a outra tarefa,schedule_localcoloca a nova tarefa emlifo_slot 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1396-1408. Após o poll retornar, o loop verifica imediatamentelifo_slot, e se houver tarefa, continua executando —sem voltar ao loop principal, fazendo polls consecutivos diretamente dentro do mesmo budget.

É assim que "acordar → enfileirar → poll novamente" se manifesta no caminho LIFO: ao acordar, a tarefa é colocada emlifo_slot, e após o poll retornar, ela é imediatamente retirada e sofre poll de novo, formando um ciclo fechado e estreito.

Note oself.core.borrow_mut().take()deNoneno branch📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:716-724: se o core foi roubado (por exemplo, se dentro da tarefa foi chamadoblock_in_place), o worker deve retornarControlFlow::Break(()), fazendo com queContext::runsaia. Isto éblock_in_placePontos de interação com o loop de agendamento.

Caminho de despertar: como o Waker dispara o reenfileiramento

QuandoFuture::pollretornaPendinga tarefa precisa registrar umWakere ser despertada quando o evento estiver pronto. A implementação deWakerdo Tokio é extremamente enxuta — é apenas um ponteiro bruto para aHeaderda tarefa mais uma vtable.

waker_refConstróiWakerRef 📎 tokio/src/runtime/task/waker.rs:11-34envolvendoManuallyDropcomWakerpara evitar decrementar a contagem de referências no drop. A vtable é estática📎 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);

As quatro funções apenas restauram o ponteiro bruto paraHeadere então chamam o método correspondente deRawTask📎 tokio/src/runtime/task/waker.rs:70-116Por exemplo,wake_by_refeventualmente chamaraw.wake_by_ref() 📎 tokio/src/runtime/task/waker.rs:106-116。

wake_by_refA semântica é: mudar o estado da tarefa dePENDINGparaSCHEDULEDe, se a transição for bem-sucedida (ou seja, se antes era de fato PENDING), chamarSchedule::schedulepara reenfileirar a tarefa.

Para o agendador multithread,schedulea implementação deHandle::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();
    });
}

A lógica se divide em dois ramos:

  • Se a thread atual for um worker desse agendador e estiver com o core, segue porschedule_local 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1385-1417— coloca no slot LIFO ou na fila local.
  • Caso contrário (despertar de uma thread externa, ou core roubado), segue porpush_remote_taskempurra para a fila de injeção global enotify_parked_remotedesperta um worker estacionado📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1379-1383。

schedule_localInternamente também se divide em dois ramos📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1385-1417: se foryieldou o LIFO estiver desabilitado, empurra para o final derun_queue; caso contrário, coloca emlifo_slote empurra a tarefa que estava no slot para o final da fila.

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 e unpark: atomicidade entre máquina de estados e despertar

O worker precisa estacionar quando não tem trabalho, mas park/unpark é onde as condições de corrida mais acontecem. O Tokio resolve com uma máquina de estadosAtomicUsizemaisCondvarcomo fallback.

InnerOs campos de📎 tokio/src/runtime/scheduler/multi_thread/park.rs:31-43:state: AtomicUsize、mutex: Mutex<()>、condvar: Condvar、shared: Arc<Shared>. Há quatro constantes de estado📎 tokio/src/runtime/scheduler/multi_thread/park.rs:36-45:

  • EMPTY = 0: não estacionado.
  • PARKED_CONDVAR = 1: estacionado em uma condvar.
  • PARKED_DRIVER = 2: estacionado no driver de I/O.
  • NOTIFIED = 3: já foi despertado.

Esta é uma máquina de estados explícita; usamos ela para desenhar o diagrama de estados (este é o único lugar do capítulo que atende aos critérios de admissão destateDiagram-v2— de fato existem essas quatro constantes de estado no código-fonte):

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)"

unparkA implementação de📎 tokio/src/runtime/scheduler/multi_thread/park.rs:277-290usaswapem vez de CAS; o comentário do código-fonte explica o motivo📎 tokio/src/runtime/scheduler/multi_thread/park.rs:277-290: é necessário executar uma operação release para que a thread estacionada observe as escritas anteriores ao unpark, então mesmo que o state já sejaNOTIFIEDé preciso escrever uma vez.

parkprimeiro tenta consumir uma notificação existente📎 tokio/src/runtime/scheduler/multi_thread/park.rs:132-149: se o CAS deNOTIFIED -> EMPTYfor bem-sucedido, significa que já foi despertado antes, então retorna diretamente sem bloquear. Caso contrário, tenta obter o lock do driver; se conseguir, estaciona no driver; se não conseguir, usa a condvar como fallback📎 tokio/src/runtime/scheduler/multi_thread/park.rs:143-148。

park_condvarHá uma verificação dupla clássica em📎 tokio/src/runtime/scheduler/multi_thread/park.rs:162-180: primeiro CASEMPTY -> PARKED_CONDVAR; se falhar e forNOTIFIED, significa que fomos despertados antes de definir o estado, então é obrigatórioswap(EMPTY)para sincronizar a escrita do unpark📎 tokio/src/runtime/scheduler/multi_thread/park.rs:167-177. O comentário enfatiza especialmente: mesmo sabendo que éNOTIFIEDainda é preciso ler uma vez, porque o unpark pode ter sido chamado novamente depois que lemosNOTIFIED.

unpark_condvarO comentário de📎 tokio/src/runtime/scheduler/multi_thread/park.rs:292-307aponta a armadilha clássica da condvar: a thread estacionada define o estadoPARKEDe realmentewaithá uma janela entre os dois; se um notify ocorrer nesse intervalo, ele será ignorado. A solução é que a thread que estaciona mantémmutexnesse momento, e a thread que faz unpark primeirodrop(self.mutex.lock())adquire o lock (esperando assim que a thread estacionada o libere), e entãonotify_one。

Reflexão de design: por que o slot LIFO é um slot único e não uma fila

〔Inferência de design e trade-offs arquiteturais〕

O design de slot único é um trade-off deliberado. Se fosse uma fila, cada despertar exigiria enfileirar e cada retirada de tarefa exigiria desenfileirar, com custo maior; além disso, a fila acumularia várias tarefas, quebrando a hipótese de localidade de "a tarefa despertada mais recentemente executa primeiro". A semântica do slot único é "lembrar apenas a mais recente"; a tarefa expulsa vai para a fila comum — o que se encaixa exatamente na lei dos retornos decrescentes de localidade: a tarefa mais recente é a mais quente, a segunda vem depois, e da terceira em diante o benefício se torna muito pequeno.

MAX_LIFO_POLLS_PER_TICK = 3Esse número mágico📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:263-263também é um valor empírico. O comentário do código-fonte diz que "executar algumas vezes o slot LIFO parece suficiente para se beneficiar da localidade; mais de 3 vezes pode dar peso excessivo". Isso evita que o cenário ping-pong em que A desperta B e B desperta A faça outras tarefas passarem fome.

Outro design digno de nota é a estratégia de "busca pela metade" desteal_work:📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1158-1160: somente quando menos da metade dos workers estão buscando é que um novo worker realmente tenta roubar. Isso evita a disputa de CAS causada por todos os workers tentando roubar freneticamente ao mesmo tempo.transition_to_searchingcoordenaidle.transition_worker_to_searching()por meio de📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1197-1203。

O roubo começa de um ponto aleatório📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1172-1174, percorre todos os remotes, pula a si mesmo📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1179-1182, chamasteal_intopara tentar roubar. Após todas as falhas, recorre à fila global📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1197-1203。

Resumo do capítulo

O loop principal do workerContext::runé o coração do agendador: após cada tick, primeiro pega uma tarefa (slot LIFO → fila local → fila global); se conseguir,run_taskexecuta o poll; se não conseguir, tenta roubar; se o roubo falhar, estaciona.run_taskO loop LIFO interno comprime "despertar → enfileirar → poll novamente" dentro do mesmo budget, formando um ciclo fechado de baixa latência.Wakeré um ponteiro bruto mais uma vtable estática,wake_by_refdisparaschedulepor meio de transições de estado, e decide entre fila local ou fila global conforme a thread atual seja ou não o mesmo worker.park/unparkusa uma máquina atômica de quatro estados mais condvar como fallback, resolvendo a condição de corrida clássica de perda de despertar.

No próximo capítulo deixaremos o agendador e entraremos no mundo de I/O: como o Reactor traduz eventos do epoll emWakerdespertares, fazendo com queAsyncFddePendingse torneReady。

Reflexões e autoavaliação deste capítulo

Q1: Se mudarmosnext_local_taskpara primeiro pegarrun_queueEm seguida, peguelifo_slot, quais seriam as consequências em cenários com intensa troca de mensagens?

Análise de referência:next_local_taskA implementação atual éself.lifo_slot.take().or_else(|| self.run_queue.pop()) 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:1158-1160, primeiro pega o slot LIFO. Se, ao contrário, pegar primeirorun_queue, então tarefas recém-despertadas, cujos dados ainda estão quentes, seriam executadas depois de outras tarefas na fila. No padrão de troca de mensagens A→B→A, após B ser despertada, ela não seria executada imediatamente, mas esperaria que as outras tarefas na fila terminassem; nesse momento, os dados escritos por A podem já ter sido expulsos do cache da CPU, e o ganho de localidade seria perdido. Mais grave ainda,lifo_slotas tarefas em esperariam até querun_queuefosse esvaziado para serem executadas, aumentando significativamente a latência. O comentário no código-fonte📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:117-121indica explicitamente que essa ordem é para "melhorar a localidade, beneficiar-se do padrão de troca de mensagens e reduzir a latência".

Q2: park_condvar, se removermosErr(NOTIFIED)do branchself.state.swap(EMPTY, SeqCst), mantendo apenasreturn, qual seria o problema?

Análise de referência: o código-fonte, no branchErr(NOTIFIED), executalet old = self.state.swap(EMPTY, SeqCst) 📎 tokio/src/runtime/scheduler/multi_thread/park.rs:167-177. O comentário explica📎 tokio/src/runtime/scheduler/multi_thread/park.rs:168-173: unpark pode ter sido chamado novamente depois que lemosNOTIFIED, e é necessário executar uma operação acquire para sincronizar com aquele unpark, a fim de observar todas as escritas anteriores a ele. Se apenasreturnsem swap, o state permaneceria emNOTIFIED, e no próximo park o CASNOTIFIED -> EMPTYteria sucesso e retornaria imediatamente (consumindo uma notificação já expirada), mas pior: a escrita release do unpark não seria sincronizada, e a thread em park poderia não ver os dados escritos antes do unpark, causando problemas de visibilidade de memória. Este é um bug duplo típico de "wakeup perdido + ordenação de memória".

Q3: run_task, quandoself.core.borrow_mut().take()retornaNone, por que retornaControlFlow::Break(())em vez deContinue?

Análise de referência:self.core.borrow_mut().take()retornarNonesignifica que o core já foi roubado📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:716-724. A única forma de o core ser roubado é uma tarefa internamente chamarblock_in_place, que através demaybe_move_runtimeretira o core decx.coree o entrega para uma nova thread📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:473-497. Nesse momento, a thread atual já não detém capacidade de agendamento; se retornasseContinue,Context::run, continuaria o loop e chamariacore.next_task()e outros métodos que precisam do core, mas o core já não está emself.core, causando panic ou inconsistência de estado. RetornarBreakfaz com queContext::rundiretamentereturn 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:594-597, devolvendo o controle para a funçãorun, que trata o restante (por exemplo,cx.defer.wake() 📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:564). O comentário também explica📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:719-721: neste momento não se pode chamarreset_lifo_enabled, porque o core foi roubado, e o ladrão tratará isso no topo deContext::run.

CHAPTER 05

Capítulo 5: Notificação de prontidão de I/O: como o Reactor traduz eventos epoll em despertar via Waker

Projeto: tokio-rs/tokio · Progresso do livro: Capítulo 5 / 14 · Status de verificação: linhas FACT com ancoragem real

No capítulo anterior, rastreamos o loop principal da thread worker: a tarefa é pollada, ao retornar Pending o Waker é armazenado em algum lugar, e quando o evento fica pronto o Waker é acionado e a tarefa é reenfileirada. Mas onde exatamente é esse "algum lugar"? Como o Waker é recuperado quando um evento epoll chega? É exatamente isso que o Reactor responde. Primeiro, vamos construir um modelo intuitivo: imagine todo o mecanismo de notificação de prontidão de I/O como o sistema de chamada de pedidos de um restaurante — o cliente (tarefa), após fazer o pedido, não fica esperando parado na janela, mas pega um pager (Waker) e volta ao seu lugar; a cozinha (epoll do kernel), após preparar o pedido, a recepção (Reactor) encontra o pager correspondente pelo número do pedido (Token) e aperta o botão. Sem esse sistema, cada tarefa só poderia fazer polling no socket, queimando a CPU; ou usar threads bloqueantes para esperar, uma conexão por thread, o que não escala. O Reactor do Tokio é composto por três arquivos em uma estrutura de três camadas, com responsabilidades estritamente separadas: driver.rs é o corpo do loop de eventos, possui o mio::Poll, responsável por chamar poll() para bloquear aguardando eventos do kernel e traduzir eventos em leituras/escritas no ScheduledIo; registration.rs é o handle de registro voltado ao usuário, que o TcpStream mantém internamente, oferecendo APIs como poll_read_ready / poll_write_ready; scheduled_io.rs é o slot de estado de cada fd, armazenando bits de prontidão de leitura/escrita e a lista de Wakers, sendo a ponte entre eventos e tarefas. A relação de montagem dos módulos pode ser consultada em tokio/src/runtime/io/mod.rs:5-16: driver exporta Driver, Handle, ReadyEvent; registration exporta Registration; scheduled_io exporta ScheduledIo. A figura abaixo ancora o fluxo de dados completo a ser rastreado neste capítulo: TcpStream → Registration → ScheduledIo → Handle/Driver → kernel → de volta ao ScheduledIo → Waker. A seguir, vamos destrinchar camada por camada.

Camada de driver:DrivereHandledivisão de responsabilidades

Modelo intuitivo

Driveréa única entidade que possuimio::Poll, e só pode ser acessado porem uma única thread — este é o requisito de exclusividade do loop de eventos. Já&mutéHandleum ponto de registro clonável e compartilhável entre threads可克隆、可跨线程共享的注册入口, qualquer thread que queira registrar um novo fd passa por ele. Sem essa divisão, seria necessário ou adicionarmio::Pollum lock (competindo a cada registro), ou fazer com que todos os registros voltem para a thread do driver (introduzindo uma fila de mensagens entre threads). O Tokio escolhe fazer com queHandledetenha diretamentemio::Registryum clone de , permitindo que as operações de registro ocorram concorrentemente, e apenas a espera real por eventos exija exclusividade.

Layout de memória e campos

Primeiro, vejamosDriveros campos de📎 tokio/src/runtime/io/driver.rs:25-38:

  • signal_ready: bool: se um evento de sinal Unix chegou, usado para o driver de signal.
  • events: mio::Events: buffer principal de eventos, reutilizado entre chamadas deturn, evitando alocação a cada vez.
  • events_busy: Option<mio::Events>:Buffer dedicado para poll não bloqueante, existente apenas quandomax_io_events_per_busy_tickestá definido.
  • poll: mio::Poll: encapsulamento da fila de eventos do kernel.

Agora vejamosHandle 📎 tokio/src/runtime/io/driver.rs:41-75:

  • registry: mio::Registry:mio::Poll::registry()o clone de , usado pararegister/deregister。
  • registrations: RegistrationSet: o conjunto de todos os registros ativos, responsável por alocarTokeneScheduledIo。
  • synced: Mutex<registration_set::Synced>: estado de sincronização que protegeRegistrationSet.
  • waker: mio::Waker: usado para acordar de qualquer thread o driver bloqueado emturn.
  • metrics: IoDriverMetrics: contabiliza o número de fds e de eventos prontos.

Aqui há um design crucial:events_busyA existência de📎 tokio/src/runtime/io/driver.rs:25-38serve para resolvero problema de que o poll não bloqueante engole eventos. O comentário📎 tokio/src/runtime/io/driver.rs:189-190deixa claro: se os eventos retirados pelo poll não bloqueante permanecerem no buffer principal, o próximo poll não os verá; usando um buffer separado, os eventos não processados permanecem na fila do kernel e serão retornados novamente no próximo poll.

Passo a passo: uma execução deturn

turné a função central do driver📎 tokio/src/runtime/io/driver.rs:184-261. Suponha que uma thread worker descubra que não há tarefas para executar e chamepark → turn(handle, None)para bloquear e esperar:

Primeiro passo: afirma que não houve shutdown📎 tokio/src/runtime/io/driver.rs:185, e libera os registros pendentes de limpeza📎 tokio/src/runtime/io/driver.rs:187。release_pending_registrationsVerificaneeds_release(), e se houver, chamaregistrations.release() 📎 tokio/src/runtime/io/driver.rs:336-340。

Segundo passo: escolhe o buffer de eventos📎 tokio/src/runtime/io/driver.rs:191-194. Semax_waitfor zero eevents_busyexistir, usa o buffer busy; caso contrário, usa o buffer principal.

Terceiro passo: chamaself.poll.poll(events, max_wait) 📎 tokio/src/runtime/io/driver.rs:198. Este é o ponto onde realmente se bloqueia em epoll_wait. O tratamento de erros é contido:Interruptedé ignorado diretamente (interrupção por sinal é normal)📎 tokio/src/runtime/io/driver.rs:200, sob WASIInvalidInputtambém é ignorado📎 tokio/src/runtime/io/driver.rs:201-205, outros erros causam panic diretamente📎 tokio/src/runtime/io/driver.rs:206。

Quarto passo: percorre os eventos📎 tokio/src/runtime/io/driver.rs:211-233. Para cadaevent:

  • setoken == TOKEN_WAKEUP(valor 0)📎 tokio/src/runtime/io/driver.rs:214, não faz nada — isto éunparkusado para interromper o bloqueio.
  • Setoken == TOKEN_SIGNAL(valor 1)📎 tokio/src/runtime/io/driver.rs:216, definesignal_ready = true。
  • Caso contrário, é um evento de I/O normal📎 tokio/src/runtime/io/driver.rs:218-231: convertemio::Readypara oReadydo Tokio, usaEXPOSE_IO.from_exposed_addr(token.0)para restaurar o token ao ponteiro*const ScheduledIo, entãoset_readiness(Tick::Set, |curr| curr | ready)acumula os bits de prontidão, e em seguidaio.wake(ready)dispara oWaker。

na direção correspondenteEXPOSE_IOAquiPtrExposeDomain<ScheduledIo> 📎 tokio/src/runtime/io/mod.rs:21-22é umusize, que "expõe" o ponteiro como ummio::Tokencomo📎 tokio/src/runtime/io/driver.rs:222-225. O comentário de segurançaexplica por que essa conversão unsafe é segura: o ponteiro não será liberado antes de ser desregistrado do mioeArc<ScheduledIo>o driver não fizer mais poll concorrente, e o driver detém a propriedade de

Quinto passo: processa a fila de conclusão do io_uring (apenas Linux + tokio_unstable)📎 tokio/src/runtime/io/driver.rs:235-258, incluindo o loop de flush quando há overflow de CQ.

Sexto passo: acumula métricas📎 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)"]

Reflexão de design: por queHandledeve determio::Waker

unpark 📎 tokio/src/runtime/io/driver.rs:280-283chamaself.waker.wake(). Estemio::WakeremDriver::newusaTOKEN_WAKEUPpara registrar📎 tokio/src/runtime/io/driver.rs:124. Quando o driver está bloqueado empoll.poll(), outra thread chamandounparkinsere um eventoTOKEN_WAKEUPno epoll,pollretorna imediatamente, e ao percorrer, ao ver esse token, simplesmente o ignora📎 tokio/src/runtime/io/driver.rs:214-215。

〔Inferência de design e trade-offs arquiteturais〕

Este mecanismo é usado emderegister_source📎 tokio/src/runtime/io/driver.rs:315-334: após desregistrar um source, seregistrations.deregisterretornar true (indicando que esta é a última referência), entãounpark(). Por quê? Porque o driver pode estar bloqueado empollesperando eventos deste fd, e o fd já foi desregistrado, então o kernel não gerará mais eventos; é necessário acordar ativamente o driver para que ele reexamine o conjunto de registros e possa sair do bloqueio. Caso contrário, o driver dormirá até o timeout demax_wait, atrasando o shutdown.

Outro detalhe:deregister_sourcechama primeiroself.registry.deregister(source) 📎 tokio/src/runtime/io/driver.rs:322, depois limparegistrations 📎 tokio/src/runtime/io/driver.rs:315-334. O comentário📎 tokio/src/runtime/io/driver.rs:320-321diz "Cleanup ALWAYS happens" — mesmo que o deregister na camada do SO falhe, o estado interno deve ser limpo, e só então o erro do SO é retornado📎 tokio/src/runtime/io/driver.rs:336-340. Este é o padrão típico delimpeza de recursos tem prioridade sobre propagação de erros.

Camada de registro:RegistrationcomoWakerarmazena emScheduledIo

Modelo intuitivo

Registrationéo contrato entre a tarefa e o fd. Ele detém duas coisas: umscheduler::Handle(usado para acessar o runtime quando necessário), e umArc<ScheduledIo>(o slot de estado do fd). Quando a tarefa chamapoll_read_ready,RegistrationentregaWakeraScheduledIopara guardar; quando o driver recebe um evento, retiraScheduledIodeWakerpara acordar.

Layout de memória e campos

Registrationtem apenas dois campos📎 tokio/src/runtime/io/registration.rs:46-54:

  • handle: scheduler::Handle: o handle do runtime, com o comentário📎 tokio/src/runtime/io/registration.rs:46-54dizendo "TODO: this can probably be moved into ScheduledIo", indicando que o autor acredita que a posição deste campo pode ser otimizada.
  • shared: Arc<ScheduledIo>: estado compartilhado,Arcgarante que tanto o driver quanto a tarefa possam acessá-lo.
〔Inferência de design e trade-offs arquiteturais〕

Note queRegistrationimplementa manualmenteSendeSync 📎 tokio/src/runtime/io/registration.rs:57-58. Por que é necessário unsafe impl? Porquescheduler::Handleinternamente pode conter campos que não sãoSend/Sync(comoRc), mas o cenário de uso deRegistrationexige que ele possa atravessar threads. O comentário de documentação📎 tokio/src/runtime/io/registration.rs:28-33fornece a restrição crucial:O chamador deve garantir que no máximo duas tarefas usem o mesmoRegistrationconcorrentemente, uma para leitura e uma para escrita. Violar essa restrição, embora seja seguro em termos de memória, causará perda de notificações e suspensão de tarefas.

Step-by-Step:poll_read_readyA cadeia de chamadas de

Suponha que a tarefa emTcpStream::poll_readdescobre que o socket não tem dados, é necessário registrar interesse de leitura. A cadeia de chamadas éTcpStream::poll_read_priv → PollEvented::poll_read → Registration::poll_read_io → poll_io → poll_ready。

poll_readyé o núcleo📎 tokio/src/runtime/io/registration.rs:155-171:

Primeiro passo:trace_leaf() 📎 tokio/src/runtime/io/registration.rs:160, usado para tracing e instrumentação.

Segundo passo:coop::poll_proceed(cx) 📎 tokio/src/runtime/io/registration.rs:155-171. Este é o mecanismo de orçamento cooperativo que será explicado no Capítulo 12. Se o orçamento se esgotar, retornaPendinge registra umWakerespecial, fazendo a tarefa ser reagendada na próxima rodada.

Terceiro passo:self.shared.poll_readiness(cx, direction) 📎 tokio/src/runtime/io/registration.rs:155-171. Este é o local onde realmente interage comScheduledIo: verifica o bit de prontidão atual; se já estiver pronto, retorna imediatamenteReady; caso contrário, armazenacx.waker()no slot de direção correspondente deScheduledIo, e retornaPending。

Quarto passo: verificaev.is_shutdown 📎 tokio/src/runtime/io/registration.rs:155-171. Se o runtime estiver sendo encerrado, retornaRUNTIME_SHUTTING_DOWN_ERROR。

Quinto passo:coop.made_progress() 📎 tokio/src/runtime/io/registration.rs:169, marca o consumo de orçamento e retorna o evento de prontidão.

poll_ioadiciona uma camada de loop de retry sobrepoll_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)),
    }
}

Aqui se refletereadiness é uma dica, não uma garantiaa ideia central de:poll_readydiz que é legível, mas quando realmenteread()pode retornarWouldBlock(por exemplo, outra thread leu os dados primeiro). Nesse caso, é necessárioclear_readiness(ev) 📎 tokio/src/runtime/io/registration.rs:187limpar o bit de prontidão e então repetir o loop aguardando novamente. Se não limpar, a tarefa entrará em um busy loop de "acha que é legível → read falha → acha que é legível de novo".

Reflexão de design:try_ioeasync_ioa divisão de responsabilidades

try_io 📎 tokio/src/runtime/io/registration.rs:194-213é a versão síncrona: primeiroready_event(interest)verifica o bit de prontidão; se estiver vazio, retorna diretamenteWouldBlock 📎 tokio/src/runtime/io/registration.rs:194-213; caso contrário, executaf(), sef()retornarWouldBlockentão limpa o bit de prontidão📎 tokio/src/runtime/io/registration.rs:207-210. Elenão registra Waker, adequado paratry_readcenários do tipo "tenta uma vez e sai".

async_io 📎 tokio/src/runtime/io/registration.rs:225-245é a versão assíncrona:readiness(interest).awaitregistra o Waker e aguarda, então ao executarf(),WouldBlocklimpa o bit de prontidão e faz o loop. Note que dentro do loop ele também chamacoop::poll_proceed 📎 tokio/src/runtime/io/registration.rs:233, para evitar esgotar o orçamento em muitasWouldBlocktentativas de retry.

Armadilhas em produção:Droplimpeza de Waker em

Registration::drop 📎 tokio/src/runtime/io/registration.rs:253-262chamaself.shared.clear_wakers(). O comentário📎 tokio/src/runtime/io/registration.rs:253-262explica o motivo:ScheduledIoarmazenado emWakerpode conterArc<driver::Inner>, edriver::Innerpor sua vez contémScheduledIo, formando uma referência circular. Limpar o Waker é um meio de quebrar o ciclo. Mas o comentário também admite que é uma "imperfect solution" — seRegistrationem si for armazenado emWaker, o ciclo ainda existe. Este é o problema discutido em tokio-rs/tokio#3481.

〔Inferência de design e trade-offs arquiteturais〕

O comportamento em produção é: se muitas conexões forem dropadas mas o runtime não sair, a memória não será recuperada imediatamente, até o próximoclear_wakersou runtime shutdown. Para serviços de conexão longa, isso geralmente não é problema; mas para cenários de conexão curta com criação/destruição de alta frequência, é preciso ficar atento ao momento de recuperação deScheduledIo.

DeTcpStream::readatéWakera cadeia completa de despertar

Modelo intuitivo

Agora vamos conectar as três camadas. O usuário chamaTcpStreamem.read().await, o que na prática executaAsyncRead::poll_read → PollEvented::poll_read → Registration::poll_read_io. Quando os dados não chegam,Wakeré armazenado emScheduledIo; quando o epoll reporta legibilidade, o driver retiraScheduledIodeWakere desperta, a tarefa é reagendada, e ao pollar novamentepoll_readinessdescobre que o bit de prontidão já está setado, retornando diretamenteReady,read()com sucesso.

Step-by-Step: uma espera de leitura completa

Fase 1: registrar interesse。TcpStream::new 📎 tokio/src/net/tcp/stream.rs:166-169chamaPollEvented::new(connected), que internamente chamaRegistration::new_with_interest_and_handle 📎 tokio/src/runtime/io/registration.rs:73-81, e entãohandle.driver().io().add_source(io, interest) 📎 tokio/src/runtime/io/registration.rs:73-81。

add_source 📎 tokio/src/runtime/io/driver.rs:288-312faz três coisas:

1. registrations.allocate(&mut synced.lock())aloca umScheduledIo, obtémtoken 📎 tokio/src/runtime/io/driver.rs:293-294。

2. self.registry.register(source, token, interest.to_mio())registra📎 tokio/src/runtime/io/driver.rs:298no kernel. Se falhar,deveremover oScheduledIorecém-alocado do conjunto📎 tokio/src/runtime/io/driver.rs:300-303, caso contrário vaza.

3. metrics.incr_fd_count()conta📎 tokio/src/runtime/io/driver.rs:309。

Fase 2: aguardar prontidão. A tarefa pollaTcpStream::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_readiness. Nesse momento, se não estiver pronta,Wakerarmazena emScheduledIoo slot de leitura, retornaPending。

Fase 3: evento chega. Oturndo driver obtém o eventopoll.poll()de📎 tokio/src/runtime/io/driver.rs:198, e ao iterar executa para cada evento de fdio.set_readiness(Tick::Set, |curr| curr | ready)eio.wake(ready) 📎 tokio/src/runtime/io/driver.rs:228-229。wakeinternamente retira oWakerda direção correspondente e chamawake()。

Fase 4: reagendamento da tarefa。Waker::wake()reenfileira a tarefa na fila local do worker (explicado no capítulo anterior). O worker polla a tarefa novamente,poll_readinessdescobre que o bit de prontidão já está setado, retornaReady,read()com sucesso.

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() 成功返回数据"

Ramificações importantes:assume_readyotimização

TcpStream::new_accepted 📎 tokio/src/net/tcp/stream.rs:174-181é uma otimização que vale a pena notar.acceptO socket retornado por é naturalmente gravável e geralmente já contém o primeiro lote de bytes do par. Se esperar pelo primeiro evento do driver, sob alta carga esse evento pode ficar atrás de todos os eventos de conexões já estabelecidas, causando latência. Entãonew_acceptedchama diretamenteassume_ready(Ready::READABLE | Ready::WRITABLE) 📎 tokio/src/net/tcp/stream.rs:174-181。

assume_readyo comentário de📎 tokio/src/runtime/io/registration.rs:103-105diz: "A wrong guess costs oneWouldBlock, which clears the readiness again." — o custo de errar é apenas umWouldBlock,poll_ioo loop de limpa o bit de prontidão e aguarda novamente. Este é um design depalpite otimista + correção rápida.

Reflexão de design: por que o driver de I/O é desacoplado do scheduler

〔Inferência de design e trade-offs arquiteturais〕

Pela estrutura do código-fonte,Drivere as threads worker são separadas:Driveré colocado em algum local dedicado do runtime (geralmente a threadblock_onou uma thread de I/O dedicada), enquanto as threads worker mantêm apenasHandle. Esse desacoplamento traz vários benefícios:

1. Registro sem lock:Handlemantémmio::Registryclonado, qualquer worker pode registrar novos fds concorrentemente, sem precisar voltar à thread do driver.

2. Centralização da espera por eventos: apenas uma thread bloqueia emepoll_wait, evitando o problema de thundering herd de múltiplas threads pollando o mesmo epoll fd simultaneamente.

3. Caminho de despertar curto: após receber o evento, o driver opera diretamenteScheduledIoe chamaWaker::wake(),wake()internamente empurra a tarefa para a fila do worker, sem necessidade de passagem de mensagem entre threads.

O custo é queScheduledIoprecisa lidar com acesso concorrente (set_readinessepoll_readinesspodem ocorrer simultaneamente), o que é resolvido por operações atômicas e locks internos.

Armadilhas em produção:is_shutdowneRUNTIME_SHUTTING_DOWN_ERROR

poll_readyverificaev.is_shutdown 📎 tokio/src/runtime/io/registration.rs:155-171, se verdadeiro retornagone() 📎 tokio/src/runtime/io/registration.rs:265-267, ou sejaRUNTIME_SHUTTING_DOWN_ERROR。

〔Inferência de design e trade-offs arquiteturais〕

O significado dessa verificação é: quando o runtime está sendo encerrado, o drivershutdown 📎 tokio/src/runtime/io/driver.rs:174-182irá percorrer todos os registos e chamario.shutdown(), definiris_shutdowne acordar todos os que esperam. Se este sinalizador não for verificado, a tarefa pode ainda tentar ler o socket depois de o runtime já ter parado o agendamento, causando comportamento indefinido ou bloqueio. Em ambiente de produção, se viresRUNTIME_SHUTTING_DOWN_ERROR, normalmente significa que há tarefas ainda em execução depois de o runtime ter sido dropado — verifica se háspawntarefas que não foram corretamente joined.

Outra armadilha éderegister_sourceounpark 📎 tokio/src/runtime/io/driver.rs:328. Se o driver estiver bloqueado empoll, e nesse momento o últimoRegistrationfor dropado,unparkirá acordar o driver. Mas se o driver não estiver bloqueado (por exemplo, estiver a processar outros eventos),unparkapenas faz com que o próximoturnretorne imediatamente📎 tokio/src/runtime/io/driver.rs:280-283. Esta semântica está descrita nos comentários da documentação deHandle::unpark.

Reflexão de design: os três compromissos-chave do Reactor

Compromisso um:Tokenusar ponteiros em vez de índices。EXPOSE_IO.from_exposed_addr(token.0) 📎 tokio/src/runtime/io/driver.rs:220Tratarmio::Tokendiretamente como o endereço de*const ScheduledIo. Isto evita manter uma tabela de mapeamento deToken → ScheduledIo, e a pesquisa é O(1) e sem locks. O custo é que a segurança depende de uma gestão rigorosa do ciclo de vida: o ponteiro só pode ser libertado depois de ser desregistado e de o driver já não fazer poll📎 tokio/src/runtime/io/driver.rs:222-225。

Compromisso dois: dois slots Waker de leitura e escrita。RegistrationA documentação📎 tokio/src/runtime/io/registration.rs:24-26diz «A registration instance represents two separate readiness streams» — leitura e escrita têm cada uma umWakerslot independente. Isto permite que tarefas de leitura e de escrita do mesmo socket se registem separadamente, sem interferirem entre si. Mas o comentáriopoll_read_readyde📎 tokio/src/net/tcp/stream.rs:549-552alerta: chamarpoll_read_ready/poll_read/poll_peekvárias vezes só preserva oWakerda última chamada — a direção de leitura tem apenas um slot.

Compromisso três:events_busyo buffer independente de. O teste📎 tokio/src/runtime/io/driver.rs:364-386verifica este comportamento:Driver::new(16, Some(2))cria um driver com capacidade busy de 2, regista 5 sources legíveis, e oturnnão bloqueante obtém apenas 2 eventos📎 tokio/src/runtime/io/driver.rs:375-376, ficando os restantes 3 na fila do kernel, e o próximoturnbloqueante obtém📎 tokio/src/runtime/io/driver.rs:379-380. Isto evita que um poll não bloqueante consuma todos os eventos de uma vez e cause fome nos polls seguintes.

Resumo deste capítulo

Este capítulo seguiu a cadeia completa do Reactor por trás deTcpStream::read:

  • Camada de driver:Driverdetém exclusivamentemio::Poll,turne bloqueia à espera de eventos, usaEXPOSE_IOpara restaurarTokenpara um ponteiroScheduledIo, chamaset_readiness + wakepara acionarWaker。Handlefornece um ponto de entrada de registo que pode atravessar threads,unparké usado para interromper o bloqueio.
  • Camada de registo:RegistrationmantémArc<ScheduledIo>,poll_readyverifica os bits de prontidão ou armazena emWaker,poll_iousaWouldBlockum ciclo de retry para lidar com falsos positivos,try_io/async_ioserve separadamente cenários síncronos e assíncronos.
  • Camada de estado:ScheduledIoé o slot de estado do fd, armazena os bits de prontidão de leitura e escrita e os doisWakerslots, e é a única ponte entre eventos e tarefas.

Reflexão e autoavaliação deste capítulo

Q1: Se empoll_ioo ramoWouldBlockdeself.clear_readiness(ev)for removido, em que cenário isso causaria um busy-loop na tarefa? Porquê?

Análise de referência:poll_ioO ciclo📎 tokio/src/runtime/io/registration.rs:173-192def()chamaWouldBlockquandoclear_readiness(ev) 📎 tokio/src/runtime/io/registration.rs:187。evretornapoll_readyé oReadyEventretornado porclear_readiness, contendo os bits de prontidão atuais.ScheduledIoremove estes bits de

.poll_ready → poll_readinessSe não forem limpos, na próxima chamada do ciclo aScheduledIo, os bits antigos de «legível» ainda permanecem empoll_readiness,Readyretornará imediatamentef()(porque os bits de prontidão não estão vazios), e depoisread()executa novamenteWouldBlock, e se o socket realmente não tiver dados, retorna outra vezPending, e o ciclo continua. Como os bits de prontidão nunca são limpos, este ciclo nunca entra em

, e a tarefa fica permanentemente a consumir CPU em polling.RegistrationCenário de acionamento: várias tarefas partilham a direção de leitura do mesmo socket (embora a documentação📎 tokio/src/runtime/io/registration.rs:28-33detry_readdiga no máximo duas tarefas, a direção de leitura tem apenas um slot), oupoll_readeread()são usados em conjunto. Mais comum ainda: depois de o epoll reportar legível, outra thread lê os dados primeiro, e oWouldBlockda tarefa atual retorna

Q2: add_source, e nesse momento é obrigatório limpar os bits de prontidão, caso contrário haverá retry infinito.registry.registerPorque é que emregistrations.remove, quando

falha, se deve chamar:add_source 📎 tokio/src/runtime/io/driver.rs:288-312? O que aconteceria se não fosse chamado?registrations.allocateAnálise de referênciaScheduledIo 📎 tokio/src/runtime/io/driver.rs:293primeiroregistry.registeraloca📎 tokio/src/runtime/io/driver.rs:298, depoisScheduledIoregista no kernelRegistrationSet. Se o registo falhar,

já foi alocado mas não tem nenhum fd associado; se não for removido, ficará para sempre em📎 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_ioO comentário

removediz explicitamente: «we should remove the📎 tokio/src/runtime/io/driver.rs:300-303.» — isto é uma fuga de memória.ScheduledIoA chamadaRegistrationSeté envolvida num bloco unsafe, porqueRegistrationSetfaz parte deToken, e a operação de remoção precisa de garantir que não há outras referências. Consequências da fuga:allocatecresce continuamente,

Q3: deregister_sourceespaço é desperdiçado, e eventualmente pode causar falha deunpark()ou esgotamento de memória. Em cenários de criação/destruição de conexões em alta frequência (como servidores de conexões curtas), se a taxa de falha de registo for elevada (por exemplo, esgotamento de fd), a fuga acelera o esgotamento de recursos.registrations.deregisterEm

, porque é que:deregister_source 📎 tokio/src/runtime/io/driver.rs:315-334só é chamado quandoregistry.deregister(source)retorna true? Que problemas haveria se fosse chamado incondicionalmente?📎 tokio/src/runtime/io/driver.rs:322Análise de referênciaregistrations.deregisterA lógica de📎 tokio/src/runtime/io/driver.rs:315-334é: primeirounpark() 📎 tokio/src/runtime/io/driver.rs:328。

registrations.deregisterdesregistaScheduledIodo kernel, depoispolllimpa o estado internounpark, e se retornar true entãomio::Wakerretornar true significa que esta é a última referência,TOKEN_WAKEUPé realmente removido. Nesse momento o driver pode estar bloqueado em📎 tokio/src/runtime/io/driver.rs:280-283à espera de eventos deste fd, mas o fd já foi desregistado, e o kernel já não produzirá eventos.pollatravés de

insere um eventounparkno epollScheduledIo, fazendo com queTcpStreamretorne imediatamente, e o driver reexamina o conjunto de registos e pode sair do bloqueio.split后读写两半),每次 drop 一个半都会唤醒 driver,增加 CPU 开销。更严重

Neste capítulo, desmontamos como o Reactor traduz eventos epoll em despertar do Waker: partindo do poll_read_ready do TcpStream, passando pelo registro e consulta do Registration, chegando aos bits de prontidão e slots de Waker do ScheduledIo, e então o Driver localiza e dispara o despertar com base no Token durante o loop de eventos. Os designs principais incluem: Token como ponteiro para busca O(1), slots duplos de Waker para leitura e escrita suportando separação de leitura/escrita concorrente, buffer independente events_busy para evitar inanição de eventos, e assume_ready para otimizar o cenário de accept com suposição otimista. Até aqui, o ciclo de notificação de prontidão de I/O está completo. Mas o runtime assíncrono ainda precisa lidar com outro tipo de "prontidão" — o tempo. No próximo capítulo, analisaremos a implementação de tokio::time::sleep e timeout: como os temporizadores são inseridos na roda do tempo, como a roda do tempo é hierarquizada por tempo de expiração, e como o driver calcula o timeout do próximo park e dispara as tarefas expiradas. Você verá a abstração unificada de que "tempo também é um evento de I/O", e como start_paused e test clock tornam o tempo controlável em testes.

CHAPTER 06

Capítulo 6: Acionamento por tempo: como a roda do tempo, Sleep e timeout são despertados

Projeto: tokio-rs/tokio · Progresso do livro: Capítulo 6 / 14 · Status de verificação: linhas FACT com ancoragem real

No capítulo anterior, rastreamos a cadeia completa de TcpStream::read e vimos como o ScheduledIo traduz eventos de prontidão de fd do epoll em despertar do Waker. Mas o runtime assíncrono ainda precisa lidar com outro tipo de "prontidão": um Future de sleep(100ms) deve ser despertado após 100ms. Esse tipo de evento não vem de um fd do kernel, mas do "tempo em si". A escolha de design do Tokio é tratar o tempo também como um evento de I/O: a struct Driver tem apenas um campo park: IoStack, que reutiliza o mecanismo park/unpark do driver de I/O. Quando a roda do tempo calcula o "próximo instante de expiração", o driver chama park_timeout para fazer a thread dormir até esse instante; após ser despertada, retira os itens expirados da roda do tempo e dispara seus Wakers. Assim, o scheduler precisa apenas de uma entrada unificada de park para aguardar simultaneamente os dois tipos de eventos: "fd pronto" e "temporizador expirado". Este capítulo responde a três perguntas: como os temporizadores são inseridos na roda do tempo? Como a roda do tempo é hierarquizada por tempo de expiração? Como o driver calcula o timeout do próximo park e dispara as tarefas expiradas?

I. Roda do tempo: estrutura hierárquica de hash com seis níveis de 64 slots

Modelo intuitivo

Imagine um relógio mecânico: o ponteiro dos segundos dá uma volta e move o ponteiro dos minutos, que dá uma volta e move o ponteiro das horas. Se houvesse apenas um ponteiro de segundos, para representar "12 dias depois" seria preciso contar 1 milhão de marcações; com hierarquia, o ponteiro dos segundos cuida da precisão dentro de 64 segundos, o dos minutos dentro de 64 minutos, o das horas dentro de 64 horas — cada nível precisa apenas de 64 slots para cobrir até 2 anos no futuro.

Sem hierarquia, inserir um temporizador distante exigiria percorrer O(N) ou usar um array gigantesco. A roda do tempo usa "hierarquização por tempo de expiração" para reduzir inserção e disparo a aproximadamente O(1).

Layout de memória e campos

WheelOs campos principais de são apenas três📎 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(ou seja, 64 slots por nível)📎 tokio/src/runtime/time/wheel/mod.rs:45-47。MAX_DURATION = 1 << (6 * 6) = 1 << 36milissegundos, cerca de 2 anos📎 tokio/src/runtime/time/wheel/mod.rs:50。

A granularidade dos seis níveis, conforme comentário da documentação, é📎 tokio/src/runtime/time/wheel/mod.rs:22-40:

NívelGranularidade do slotCobertura
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é uma lista encadeada intrusiva (LinkedList<TimerShared>), que armazena itens já retirados da roda e aguardando disparo do Waker. Note que éLinkedListem vez deVec: o item em si está embutido emTimerShared, e inserção/remoção não requerem alocação.

Orientado a cenário: inserir um sleep de 100ms

Quandosleep(100ms)é pollado pela primeira vez,Sleep::poll_elapsedconstróiTimer::newe chamainit 📎 tokio/src/time/sleep.rs:436-440。initque finalmente chamaHandle::reregister, e então chamaWheel::insert。

insertO primeiro passo é verificar se já expirou📎 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));
}

Sewhenjá caiu antes deelapsed(por exemplo, o deadline já passou), retorna diretamenteElapsed, e o chamador dispara imediatamente o temporizador.

Caso contrário, calcula em qual nível o item deve ser colocado📎 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é o núcleo do algoritmo de hierarquização📎 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
}

Aqui usa-seelapsed ^ whenem vez dewhen - elapsed, uma técnica engenhosa: o bit mais significativo do XOR reflete "a partir de qual bit dois timestamps começam a diferir", ou seja, "qual granularidade é necessária para distingui-los".| SLOT_MASKforça os 6 bits inferiores para 1, evitando queilog2calcule um nível pequeno demais quando cai no mesmo slot.ilog2() / 6mapeia a largura de bits para o número do nível. Se o resultado do XOR excederMAX_DURATION(ou seja, mais de 2 anos), é forçado para o nível mais alto — isso é o "fudge the timer into the top level".

Para um sleep de 100ms, supondo queelapsedesteja próximo de 0,when ≈ 100,elapsed ^ when ≈ 100,ilog2(100) = 6,6 / 6 = 1, portanto cai na camada 1 (granularidade de 64ms). Isso significa que ele esperará em algum slot da camada 1 até que o tempo avance até a borda desse slot para então ser rebaixado para a camada 0.

Rebaixamento em cascata: process_expiration

Quandopoll(now)avança o tempo,Wheel::pollchama em loopnext_expirationeprocess_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é responsável por "rebaixar" as entradas expiradas de uma camada para a próxima, ou (na camada 0) marcá-las como 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é crucial: ela verifica se o deadline real da entrada já foi atingido. Se atingido, retornaOk(()), a entrada entra napendinglista encadeada; se ainda não chegou (apenas a borda do slot foi atingida), retornaErr(expiration_tick), a entrada é reinserida em uma camada mais fina.

Note um ponto enfatizado nos comentários📎 tokio/src/runtime/time/wheel/mod.rs:219-228: é necessário primeiro retirar todas as entradas do slot inteiro antes de processá-las, porque algumas entradas podem ser reinseridas no mesmo slot (isso acontece quando o tempo de inserção excedeMAX_DURATION, causando wraparound). Se inserir enquanto retira, pode entrar em loop infinito.

Cálculo do próximo instante de expiração

next_expirationVarre da camada inferior para a superior, retornando o primeiro ponto de expiração não vazio📎 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
}

Sependingnão estiver vazio, significa que há entradas expiradas aguardando disparo, retorna imediatamente oelapsedatual como deadline (assim o driver fará park com timeout 0 e voltará imediatamente para processar). Caso contrário, varre camada por camada, retornando o deadline do primeiro slot com conteúdo.debug_assertValida um invariante: camadas superiores não podem ter pontos de expiração mais cedo que a camada atual.

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() 返回"]

---

II. O loop de park do Driver: conectando o timing wheel à pilha de I/O

Modelo intuitivo

O timing wheel em si não "anda sozinho". Ele precisa de um loop externo que pergunte repetidamente: "Quando é a próxima expiração?" e então durma até esse momento, acordando depois para avançar o tempo. Esse loop é oDriver::park_internal. Ele traduz "a próxima expiração do timing wheel" em uma duração depark_timeout, entregando à pilha de I/O subjacente para dormir.

Sem esse loop, os timers nunca seriam disparados — o timing wheel é apenas uma estrutura de dados estática, precisa de alguém para "girá-lo".

Estrutura de dados: Driver e InnerState

Drivertem apenas um campopark: IoStack 📎 tokio/src/runtime/time/mod.rs:90-93. O estado real está emHandle, distinguindo a implementação tradicional da experimental através do enumInner. O📎 tokio/src/runtime/time/mod.rs:95-127da implementação tradicional contém dois camposInnerStateCópia📎 tokio/src/runtime/time/mod.rs:130-136:

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

next_wakeem vez deNonZeroU64aninhamento deOption<u64>, para aproveitar a otimização de niche —Option<NonZeroU64>eu64têm o mesmo tamanho. Ele registra "até qual tick o driver promete acordar", usado parareregisterao determinar se precisaunpark。

is_shutdowné umAtomicBoolindependente, os comentários explicam por que separá-lo do Mutex📎 tokio/src/runtime/time/mod.rs:90-93:Handleprecisa verificaris_shutdownsem travar o mutex. Esta é uma otimização típica de "muitas leituras, poucas escritas" — shutdown acontece apenas uma vez, mas a verificação pode ser frequente.

Orientado a cenários: o fluxo completo de um park

park_internalé o núcleo📎 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());
}

Análise passo a passo:

1. Adquirir lock, ler próxima expiração:lock.wheel.next_expiration_time()retornaOption<u64>, ou seja, o próximo tick de expiração. Ao mesmo tempo, escreve emlock.next_wake, parareregisterdeterminar se precisa de unpark.

2. Liberar lock:drop(lock)deve ocorrer antes do park, caso contrário outras threads não poderão inserir timers durante o park.

3. Calcular duração do park:when.saturating_sub(now)obtém o número de ticks restantes,tick_to_durationconverte paraDuration. Os comentários indicam que na prática arredonda para cima até 1ms📎 tokio/src/runtime/time/mod.rs:228-230, evitando que sleeps em microssegundos sejam tratados pelo SO como duração zero.

4. Tratar limit: se o chamador passoulimit(comopark_timeouttimeout explícito), pegamin(limit, duration), garantindo que não durma demais.

5. Caso especial: seduration == 0(já expirado), usapark_timeout(0)para retornar imediatamente, sem realmente dormir.

6. Sem timers: senext_wakeforNone, comlimitfazpark_thread_timeout(limit), caso contráriopark。

7. infinito:handle.process(clock)Processar após acordar

avança o timing wheel e dispara entradas expiradas.

processprocess_at_time: disparar entradas expiradasprocess_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();
}

Cópia

  • Alguns pontos-chave: 📎 tokio/src/runtime/time/mod.rs:301-309Proteção contra retrocesso do temponow < wheel.elapsed(): seInstant, significa que o relógio retrocedeu. Os comentários indicam que isso normalmente não deveria acontecer (Rust garantenowmonotônico), mas acontece em VMs Linux hospedadas no Windows, porque std confia erroneamente que o relógio de hardware é monotônico. A proteção é fixarelapsed。
  • em:WakeListDespertar em lote!can_push()coleta Wakers, e quando enche (📎 tokio/src/runtime/time/mod.rs:319), libera temporariamente o lock, desperta um lote, e readquire o lock. Os comentários enfatizam que isso evita deadlock
  • . Se chamar o Waker segurando o lock, e o Waker tentar operar no timing wheel (por exemplo, reregistrar um timer), ocorrerá deadlock.Atualizar next_wakepoll_at(): após processar, recalculanext_wake。

, atualiza

reregister: reregistro e unparkSleep::resetQuandoreregisteré chamado, o timer precisa ser reregistrado.📎 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();
    }
}

Cópianext_wakeLógica-chave: após inserção bem-sucedida, se o novo instante de expiração for mais cedo queunpark.unpark(), chama

para acordar o driver. Isso porque o driver pode estar dormindo até um momento mais tardio, precisando ser acordado antecipadamente para recalcular a duração do park.unparkNote queé chamadosegurando o lockwaker.wake(), enquantoé chamadoapós liberar o lock📎 tokio/src/runtime/time/mod.rs:441. Os comentários explicamunpark: é necessário liberar o lock antes de chamar o Waker para evitar deadlock. Mas

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()

---

Cópia

III. Sleep e Timeout: a camada de API visível ao usuário

SleepModelo intuitivo.awaité o Future que o usuárioTimeoutÉ um adaptador que envolve outro Future. Eles próprios não gerenciam a roda temporal, apenas traduzem o "deadline" em tick, delegando paraTimereHandle。

Layout de memória do Sleep

SleepUsapin_project!macro para definir📎 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>e com#[pin]: antes do primeiro poll éNone, oTimersó é criado e registrado no primeiro poll. Essa "inicialização preguiçosa" evita acessar o runtime no momento dasleep()chamada——sleep()pode ser chamado fora do runtime, desde que o registro real ocorra apenas no.await.

PinnedDropA implementação garante que o timer seja cancelado no 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);
        }
    }
}

Fluxo completo do poll_elapsed

poll_elapsedÉSleepo núcleo do📎 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
}

Passo a passo:

1. Verificação do orçamento de coop:poll_proceed(cx)Consome um orçamento de cooperação. Se o orçamento se esgotar, retornaPendinge cede o controle de execução. Este é o mecanismo do Tokio para evitar que uma única tarefa inane as outras.

2. Criação preguiçosa do Timer: setimeréNone, convertedeadlineem tick, criaTimere chamainitpara registrar na roda temporal.

3. Delega para Timer::poll_elapsed: a verificação real de expiração é feita porTimer.

4. Marca progresso após sucesso:coop.made_progress()indica que este poll teve progresso real.

Poll do Timeout: primeiro poll do valor, depois poll do delay

TimeoutA ordem de poll do📎 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,
    }
}

Copiar📎 tokio/src/time/timeout.rs:24-26O comentário afirma explicitamenteOk: o future é pollado primeiro, e só depois a expiração é verificada. Portanto, se o future completar sem yield, ele pode retornar

poll_delaymesmo após exceder o timeout. Esta é uma escolha de design, não um bug.📎 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()
    }
}

CopiarpollLógica: se ao entrar emPendingainda houver orçamento, mas após o poll do value o orçamento se esgotar, significa que o value consumiu o orçamento. Nesse caso, se pollar o delay com orçamento restrito, o delay pode retornarwith_unconstrainedimediatamente, impossibilitando determinar se o timeout foi atingido. Por isso usa-se📎 tokio/src/time/timeout.rs:243-246。

para suspender temporariamente a restrição de orçamento. O comentário chama isso de "pathological cases"

timeoutTratamento de overflow do deadline do timeoutchecked_addA função usa📎 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,
    },
}

CopiarInstant::now() + durationSedelayoverflow (duration extremamente grande),Nonetorna-sePoll::Pending 📎 tokio/src/time/timeout.rs:222, e o poll retorna

---

diretamente. Isso equivale a "nunca expirar", um comportamento de degradação razoável.

Reflexões de design e armadilhas em produção elapsed ^ whenPor que usar XOR em vez de subtração para calcular o nível?when - elapsedO bit mais significativo deelapsedreflete diretamente "a partir de qual bit os dois timestamps diferem", que é exatamente a medida de "quão grossa a granularidade precisa ser". A subtraçãowhenquandoilog2está próximo de

tem todos os bits altos em 0, 📎 tokio/src/runtime/time/mod.rs:301-309calcularia um nível muito pequeno. O XOR lida naturalmente com cenários de wraparound.InstantNecessidade da proteção contra retrocesso temporalInstant: Rust garante quenow = lock.wheel.elapsed()é monotônico, mas o SO subjacente pode não garantir. Em uma VM Linux hospedada no Windows, a std confia no relógio de hardware, causando retrocesso deset_elapsed. Tokio usa

para clampear, evitando falha de assert em 📎 tokio/src/runtime/time/mod.rs:319Despertar em lote e deadlockSleep::reset: chamar Waker enquanto se mantém o lock da roda temporal é perigoso——o Waker pode disparar o re-poll da tarefa, que por sua vez chamaWakeList, tentando adquirir novamente o lock da roda temporal, causando deadlock.

next_wakeO mecanismo de lote de 📎 tokio/src/runtime/time/mod.rs:130-136:Option<NonZeroU64>libera temporariamente o lock quando cheio, sendo o padrão "callback fora do lock".u64Otimização de niche doNonetem o mesmo tamanho queNonZeroU64::new(t).unwrap_or_else(|| NonZeroU64::new(1).unwrap()), pois 0 é usado como niche de📎 tokio/src/runtime/time/mod.rs:221. Mas tick 0 é um valor válido, então o código usa

process_expirationpara mapear 0 para 1 📎 tokio/src/runtime/time/wheel/mod.rs:219-228. Este é um tratamento de borda sutil: tick 0 é tratado como tick 1, causando no máximo 1ms de despertar extra.MAX_DURATIONO "pegar antes de processar" do

Timeout: é necessário retirar todas as entradas do slot antes de processá-las, pois entradas que excedem 📎 tokio/src/time/timeout.rs:24-26fazem wraparound e são reinseridas no mesmo slot. Se inserir enquanto retira, haveria loop infinito.OkArmadilha da ordem de poll dotimeout: o future é pollado primeiro, a expiração é verificada depois. Se o future for CPU-intensivo e não fizer yield, ele pode retornar

---

mesmo após exceder o timeout. Em produção, não dependa de

para forçar a interrupção de futures não cooperativos.

1. Resumo do capítulo(WheelEste capítulo desmontou a estrutura de três camadas do driver de tempo do Tokio:elapsed ^ whenRoda temporalpending): estrutura hierárquica de hash com seis níveis de 64 slots, usando a largura de bits deprocess_expirationpara determinar o nível da entrada, com inserção e disparo aproximadamente O(1).

2. Driver(Driver::park_internalA lista encadeada armazena entradas expiradas,next_expiration_timeé responsável pelo afundamento nível a nível.park_timeout): traduz oprocess_at_timeda roda temporal em duração

3. , reutilizando o park/unpark da pilha de I/O.(Sleep / Timeout):SleepAvança a roda temporal após o despertar, dispara Wakers em lote, e trata retrocesso temporal e proteção contra deadlock.TimerAPI do usuárioTimeoutCriawith_unconstrainedpreguiçosamente e registra,

polla o value primeiro e depois o delay, usandonext_wakepara tratar cenários de esgotamento de orçamento.reregisterO design central é "tempo também é um evento de I/O": o driver tem apenas um ponto de entrada park, aguardando simultaneamente fd pronto e timer expirado.unparkregistra o instante de despertar prometido,

ao inserir um timer mais cedo,Mutex、Semaphoredesperta o driver para recalcular.

No próximo capítulo entraremos nas primitivas de sincronização:

comoWheel::inserte canais implementam espera assíncrona. Você verá como eles reutilizam o mecanismo Waker deste capítulo, e como "contagem de permissões" e "fila de espera" cooperam.if when <= self.elapsedReflexões e autoavaliação deste capítuloif when < self.elapsed(remover o sinal de igual),em quais cenários isso faria com que o timer nunca fosse disparado?

Análise de referência:when == self.elapsedindica que o instante de expiração do timer é exatamente igual ao tempo atual já avançado. O código original usa<=para classificá-lo comoElapsed, e o chamador dispara imediatamente📎 tokio/src/runtime/time/wheel/mod.rs:96-98. Se for alterado para<, este item será inserido na camada calculada porlevel_for(elapsed, when). Comoelapsed ^ when == 0,masked = 0 | SLOT_MASK = 63,ilog2(63) = 5,5 / 6 = 0, ele cai na camada 0. Mas onext_expirationda camada 0 retornará um slotdeadline >= elapsed, e a condição deWheel::polléexpiration.deadline <= now. Senow == elapsed, a condição é satisfeita,process_expirationretirará esse item,mark_pending(elapsed)verificará se o deadline real foi atingido — neste momentowhen == elapsed,mark_pendingretornaOk, e o item entra em pending. Portanto, na prática ainda será disparado, mas com uma volta extra. O risco real está em: seelapsedjá avançou para depois dewhen(when < elapsed), o código original retornaElapsede dispara imediatamente; após a alteração, ele é inserido em um slot que já passou,next_expirationpode retornardeadline < elapsed,set_elapsed, e o assertelapsed <= whenfalhará com panic📎 tokio/src/runtime/time/wheel/mod.rs:253-264. Portanto, esse sinal de igual é a fronteira crítica que evita a falha do assert.

Q2: process_at_timeEmWakeList, depois quedrop(lock)fica cheio, por quewake_all()novamentelocke então refazer o

? Se esse drop for removido, em quais cenários de concorrência ocorreria deadlock?:WakeListAnálise de referência📎 tokio/src/runtime/time/mod.rs:318-325coleta Wakers; quando fica cheio, é necessário acordar um lote para liberar espaçoself.inner.lock(). Sewaker.wake()for chamado enquantoSleep::resetestá sendo mantido, a tarefa acordada pode rodar imediatamente em outra thread (ou no scheduler da mesma thread), chamarSleep::poll_elapsedouHandle::reregister, e então chamarreregister, e a primeira coisa queself.inner.lock() 📎 tokio/src/runtime/time/mod.rs:405faz éstd::sync::Mutex. Comoprocess_at_timenão é reentrante, a mesma thread sofrerá deadlock; mesmo em threads diferentes, haverá bloqueio até queprocess_at_timelibere o lock, enquantowake_allestá esperando📎 tokio/src/runtime/time/mod.rs:319retornar, formando espera circular. O comentário diz explicitamente "To avoid deadlock, we must do this with the lock temporarily dropped"while let Some(entry) = lock.wheel.poll(now). Ao refazer o lock após o drop, o estado da timing wheel pode ter sido modificado por outra thread (por exemplo, um novo timer inserido), então

Q3: Timeout::pollcontinuará retirando itens do novo estado, o que é seguro.had_budget_beforeEmhas_budget_now, por que a combinação de(true, false)ewith_unconstrainedsó é usada quando "há orçamento na entrada e não há orçamento após o poll do value"(false, true)? Se fosse o contrário

, o que aconteceria?:had_budget_beforeAnálise de referência📎 tokio/src/time/timeout.rs:208-208,has_budget_nowregistra📎 tokio/src/time/timeout.rs:239。(true, false)antes do poll do value; registrapoll_proceeddepois do poll do value.Pendingsignifica que o orçamento foi esgotado durante o poll do value, indicando que o value é um "consumidor de orçamento". Nesse caso, se o delay fosse pollado com orçamento restrito,with_unconstrainedretornaria imediatamente📎 tokio/src/time/timeout.rs:247。(false, true), o delay nunca seria realmente verificado e a detecção de timeout falharia. Portanto, usa-sewith_unconstrainedpara remover temporariamente a restrição.(false, false)não pode acontecer — o orçamento só pode ser consumido, não restaurado (a menos que hajaPendingexplícito, mas não há aqui).poll_proceedsignifica que não havia orçamento na entrada; nesse caso, o poll do value pode já ter retornado(true, true)(porque

falhou), e o delay também é pollado com orçamento restrito; ambos ficam pending, como esperado.

CHAPTER 07

Voltar ao topo ↑

Próximo capítulo: Capítulo 7 → · Capítulo 7: Primitivas de sincronização: como Mutex, Semaphore e canais implementam espera assíncrona · Projeto: tokio-rs/tokio

Progresso do livro: Capítulo 7 / 14

Status de verificação: linhas FACT com ancoragem real

O capítulo anterior revelou como o tempo é abstraído como um tipo de evento de I/O, fazendo com que timers e prontidão de fd compartilhem o mesmo ponto de espera park/unpark. No entanto, quando múltiplas tarefas competem pelo mesmo lock ou trocam mensagens por canais, o objeto de espera não é mais um fd ou um relógio, mas sim a mudança de estado de outra tarefa. Este capítulo entra na família tokio::sync para descobrir onde exatamente um lock().await ou recv().await armazena o Waker ao bloquear, e como ele é reagendado ao ser despertado.

std::sync::MutexPor que o Mutex assíncrono não pode reutilizar a implementação de stdlock()Modelo intuitivo: de "ocupar o lugar" para "ceder o assento"Ode, quando o lock está ocupado,bloqueia a thread atualPending— a thread é suspensa pelo sistema operacional até que o lock seja liberado. Isso é desastroso em um runtime assíncrono: uma worker thread pode estar conduzindo centenas ou milhares de tarefas ao mesmo tempo; se ela bloquear esperando por um lock, todas as outras tarefas que ela carrega param. A exigência central do Mutex assíncrono é: ao esperar pelo lock,

ceder a threadMutex, registrar em uma fila o fato de que "estou esperando por este lock", e então retornarConstruído inteiramente sobre semáforos。

Estrutura de dados e layout de memória

Mutex<T>Os campos de são minimalistas:

📎 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>,
}

Os três campos desempenham cada um o seu papel:sé umsemáforo com contagem de permissões igual a 1,céUnsafeCell<T>dados protegidos envolvidos por . Note que aquisemaphoreébatch_semaphoreum alias de📎 tokio/src/sync/mutex.rs:3-3, ou seja, a implementação subjacente, e nãosync::Semaphoreaquela camada de encapsulamento pública.

MutexGuard<'a, T>por sua vez contém apenas uma referência aMutex:

📎 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>,
}

Há aqui um design crucial:MutexGuard não mantém o objeto de permissão do semáforo, mantém apenas&Mutex. A ação de libertar o lock ocorre emDrop, chamando diretamenteself.lock.s.release(1) 📎 tokio/src/sync/mutex.rs:959-961. Isto difere deSemaphorePermitque mantémpermits: usizecontagem e a devolve no Drop — o Mutex tem sempre uma contagem de permissões igual a 1, não precisa de contagem.

Send/SyncAs fronteiras de merecem uma análise separada:

📎 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 {}

Syncrequer apenasT: Sende nãoT: Sync— o que é razoável, pois o acesso mutuamente exclusivo garante que apenas uma thread pode tocar emTao mesmo tempo, transferir a propriedade deTentre threads (Send) é suficiente, não é necessário queTem si seja partilhável (Sync). É precisamente isto que permite aMutex<T>transformar umSyncque não éTnumSync.

Passo a Passo: a viagem completa de umlock().await

Cenário: a tarefa A chamamutex.lock().await, estando o lock livre.

Primeiro passo,lock()constrói um bloco async, internamente primeiroself.acquire().await, após sucesso constróiMutexGuard 📎 tokio/src/sync/mutex.rs:434-443。

Segundo passo,acquire()delega diretamente ao semáforo:

📎 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!())Esta linha de comentário revela a restrição de design: o Mutex nunca fecha explicitamente o semáforo e mantém-no em exclusivo, portantoacquirenunca retornaErr. Isto exclui ao nível do tipo o caminho de erro "semáforo fechado".

Terceiro passo, se o lock estiver ocupado,s.acquire(1)retornaPending, o Waker da tarefa atual é registado na fila de espera do semáforo.Onde está guardado o Waker?A resposta está embatch_semaphorena fila de espera de (o ficheiro não é explorado no material fonte deste capítulo, mas o seu papel é: cada esperante mantém um Waker, em fila FIFO).

Quarto passo, quando a tarefa B que detém o lock o liberta,MutexGuard::dropchamas.release(1) 📎 tokio/src/sync/mutex.rs:965-975, o semáforo entrega a permissão ao primeiro da fila e acorda o seu Waker, a tarefa A é reagendada,acquireretornaOk, construindoMutexGuard。

Todo o fluxo pode ser descrito pelo seguinte diagrama de sequência:

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

Reflexão de design: justiça FIFO e segurança de cancelamento

A documentação declara explicitamente que o Mutex do Tokio garante FIFO📎 tokio/src/sync/mutex.rs:20-22. Esta justiça vem da semântica de fila do semáforo subjacente. O custo da justiça é: umlockcancelado (por exemplo, ao perder emselect!) faz com queperca a posição na fila 📎 tokio/src/sync/mutex.rs:415-419. Isto não é um bug, mas uma consequência inevitável da fila FIFO — cancelar significa remover da fila, um novolocktem de voltar a entrar na fila.

Outro design contra-intuitivo énão envenenar(no poisoning)。std::sync::Mutexmarca como poisoned quando a thread que detém o lock entra em panic, elocksubsequentes retornamErr. O Mutex do Tokio não faz isto: quando o detentor do lock entra em panic, o lock é libertado normalmente📎 tokio/src/sync/mutex.rs:122-125. A documentação avisa que, se o panic for capturado, os dados protegidos podem ficar num estado inconsistente. É um compromisso pragmático em cenários assíncronos — um panic numa tarefa assíncrona normalmente significa terminação da tarefa, e o mecanismo de envenenamento só acrescentaria complexidade.

MutexGuard::mapA série de métodos merece menção. Permite degradar todo oMutexGuard<T>para umMappedMutexGuard<U>que protege apenas um subcampo. Na implementação, primeiro usa um closure para calcular o ponteiro do subcampodata, depois através deskip_dropdesmonta o guard original numMutexGuardInnerque não dispara Drop, e finalmente constrói um novo guard📎 tokio/src/sync/mutex.rs:869-883。skip_dropusandoManuallyDrop + ptr::readpara transferir a propriedade do campo, evitando queDropseja chamado duas vezes📎 tokio/src/sync/mutex.rs:827-836. Esta é a técnica clássica em Rust de "transferir propriedade sem disparar o destrutor".

Semaphore: como a contagem de permissões e a fila de espera implementam backpressure

Modelo intuitivo: lugares de estacionamento

O semáforo é como um parque de estacionamento:acquireé entrar de carro, se houver lugar entra, se não houver fica à espera à porta;releaseé sair de carro, ao libertar um lugar notifica o primeiro carro da fila para entrar. O número de permissões é o total de lugares,acquire_many(n)é um carro grande que ocupa n lugares.

Estrutura de dados e layout de memória

O públicoSemaphoreé apenas um fino encapsulamento do subjacentebatch_semaphore::Semaphore:

📎 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>mantém a referência ao semáforo e a contagem de permissões:

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

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

permitsO campo é a chave para entenderforget/merge/split.forgetcolocapermitsa zero📎 tokio/src/sync/semaphore.rs:1193-1195, assim no Drop devolve 0 permissões — equivalente a "consumir permanentemente" essas permissões.splitcorta n permissões das atuais para o novo permit📎 tokio/src/sync/semaphore.rs:1260-1271。mergefunde a contagem de outro permit, e afirma que ambos vêm do mesmo semáforo📎 tokio/src/sync/semaphore.rs:1230-1240。

〔Inferência de design e compromissos arquiteturais〕

MAX_PERMITSéusize::MAX >> 3 📎 tokio/src/sync/semaphore.rs:476-479. Porquê deslocar 3 bits à direita? O subjacentebatch_semaphoreprecisa de codificar flags de estado (como a flag de fecho) nos bits altos, por isso limita o número de permissões disponíveis aos bits baixos, deixando os bits altos para flags. Esta é a técnica comum de comprimir "contagem + estado" num únicousize.

Passo a Passo: o fluxo de permissões de acquire e release

Cenário: semáforo com 2 permissões iniciais, tarefa Aacquire(), tarefa Bacquire_many(2)。

acquire()delega all_sem.acquire(1), após sucesso constróiSemaphorePermit { permits: 1 } 📎 tokio/src/sync/semaphore.rs:614-631。acquire_many(2)semelhante, mas passa 2📎 tokio/src/sync/semaphore.rs:661-679。

Se as permissões forem insuficientes,ll_sem.acquire(n)retornaPending, o Waker entra na fila. Há aqui um detalhe de justiça: a documentação indica que, se o primeiro da fila for umacquire_many(5)e restarem apenas 3 permissões, mesmo que atrás haja umacquire(1)que possa ser satisfeito imediatamente, tem de esperar — porque o carro grande à frente ocupa a fila📎 tokio/src/sync/semaphore.rs:19-24. Este é o custo do FIFO estrito, que evita a fome.

O caminho de libertação está no Drop:

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

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

add_permitsdelega all_sem.release(n) 📎 tokio/src/sync/semaphore.rs:568-570, o subjacente devolve as permissões à fila de espera e acorda os esperantes que conseguem reunir permissões suficientes.

Em termos de ordenação de memória, a documentação dá garantias fortes: acquire, release e close são todosAcqReloperações, totalmente ordenadas entre si, equivalentes a uma única variável atómicaAcqRel 📎 tokio/src/sync/semaphore.rs:35-42。Isso significa que a escrita de "escrever os dados primeiro e depois liberar a permissão" é visível para a tarefa que "adquire a permissão depois" — o semáforo pode transferir dados com segurança entre tarefas.

Reflexão de design: close e backpressure

close()Faz com que todos os waiters recebamAcquireError, e subsequentementetry_acquireretornaClosed 📎 tokio/src/sync/semaphore.rs:1161-1163. Esta é a base do encerramento gracioso: quando o lado receptor não precisa mais de dados, o close do semáforo permite que todos os senders bloqueados falhem e retornem imediatamente, em vez de esperar para sempre.

A essência do backpressure fica mais clara no mpsc. Na próxima seção veremos que o controle de capacidade do mpsc é implementado com um semáforo cujo número de permissões é igual ao tamanho do buffer.

Família de canais: diferentes trade-offs entre fila de waiters e despertar por Waker

Modelo intuitivo: quatro tipos de canais, quatro estratégias de espera

oneshoté um "envelope descartável" — só pode enviar uma carta, o sender não espera (sendé síncrono), o receiverawaitespera a carta.mpscé uma "esteira transportadora limitada" — o sender espera quando a esteira está cheia, o receiver espera quando está vazia, e a capacidade é controlada por semáforo.broadcastewatchsão um "alto-falante de broadcast" — um sender, múltiplos receivers, mas os dois tratam o "atraso" de maneiras completamente diferentes.

O material do código-fonte desta seção foca emoneshotempsc::bounded, vamos destrinchá-los um por um.

oneshot: handshake minimalista codificado com bits de estado

oneshotA estruturaInnerde é o núcleo para entender seu design:

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

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

stateé umAtomicUsize, que codifica todo o estado do canal com flags de bits. As quatro flags são definidas no final do arquivo:

📎 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_taskerx_tasksãoTasktipos, internamente éUnsafeCell<MaybeUninit<Waker>> 📎 tokio/src/sync/oneshot.rs:411-411. ObserveMaybeUninit— o Waker pode não estar inicializado, e se é válido é determinado pelo bitstateemRX_TASK_SET/TX_TASK_SET📎 tokio/src/sync/oneshot.rs:396-399。

A essência deste design:VALUE_SENTO bit não apenas indica "o valor foi enviado", mas também determina a quem pertence o direito de acesso aUnsafeCell. O comentário é muito claro📎 tokio/src/sync/oneshot.rs:1491-1496: seVALUE_SENTestiver setado,UnsafeCellsó pode ser acessado pelo receiver; se não estiver setado, só pode ser acessado pelo sender. Assim, um único bit atômico implementa transferência de ownership sem lock, evitando locks adicionais.

sendO fluxo de

📎 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(())
}

Primeiro escreve o valor emUnsafeCell(neste momentoVALUE_SENTnão está setado, o receiver não acessará), depois chamacomplete()para tentar setarVALUE_SENT。complete()é um loop 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)
}

Por que usar CAS em vez de um simplesfetch_or? O comentário explica claramente📎 tokio/src/sync/oneshot.rs:1517-1529: se o canal já estiverCLOSED, entãonão podesetarVALUE_SENTnovamente. Porque uma vez setado, o receiver pensará que pode acessarUnsafeCell, mas neste momento o sender está prestes a pegar o valor de volta (consume_value), e ambos acessando ao mesmo tempo causaria data race. Portanto, o loop CAS faz break antecipado ao encontrarCLOSED, sem setar.

complete()Após retornar, se o set foi bem-sucedido eRX_TASK_SETjá estava setado, desperta o receiver:

📎 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
}

Opoll_recvdo receiver é o núcleo da máquina de estados:

📎 tokio/src/sync/oneshot.rs:1317-1384

Ele primeiro carrega o estado; seis_complete()então retorna diretamenteconsume_value; seis_closed()retornaErr; caso contrário entra no branch de "registrar Waker". Ao registrar, primeiro verificais_rx_task_set(); se já estiver setado ewill_wakedeterminar que é o mesmo Waker, não seta novamente; se for diferente, primeiro unset e depois set. Aqui há um tratamento sutil de corrida: após o unset, se descobrir queis_complete()se tornou verdadeiro, é precisosetar a flag de volta 📎 tokio/src/sync/oneshot.rs:1342-1344, caso contrário o Waker vazará no Drop (porque o Drop depende da flag para decidir se deve dropar o Waker).

Esse padrão de "unset e depois set novamente" também aparece empoll_closed📎 tokio/src/sync/oneshot.rs:839-848, e é a técnica padrão do oneshot para lidar com despertares concorrentes.

mpsc::bounded: backpressure guiado por semáforo

O controle de capacidade do mpsc é totalmente delegado ao semáforo.channelA função cria um semáforo com número de permissões igual ao 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é um wrapper interno do mpsc, que mantém ao mesmo tempo o semáforo subjacente ebound(capacidade máxima)📎 tokio/src/sync/mpsc/bounded.rs:176-179。boundé usado para consultamax_capacity, enquantoavailable_permitsfornece a capacidade atual📎 tokio/src/sync/mpsc/bounded.rs:591-593。

O caminho de enviosendprimeiroreservee depoissend:

📎 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)),
    }
}

reserveInternamente chamareserve_inner(1), que primeiro verifican > max_capacitye retorna erro diretamente, depoisacquire(n) 📎 tokio/src/sync/mpsc/bounded.rs:1272-1311. Aqui há um guardWakeReceiverOnDropengenhoso:

📎 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();
        }
    }
}

O comentário explica a motivação📎 tokio/src/sync/mpsc/bounded.rs:1279-1285: sereservefor cancelado após obter permissões parciais (por exemplo,select!perde), oAcquiresubjacente devolverá essas permissões no Drop, masnãonotificará o receiver comoPermitfaria. Se neste momento o canal já estiver fechado e ocioso, o receiver pode nunca receber a notificação de "canal fechado". Este guard adiciona esse despertar no Drop. Em caso de sucesso, usamem::forget(guard)para cancelar o guard📎 tokio/src/sync/mpsc/bounded.rs:1306-1306, porque o caminho de sucesso temPermitassumindo a responsabilidade de notificação.

PermitO Drop de

📎 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::sendCopiarmem::forgetusa📎 tokio/src/sync/mpsc/bounded.rs:1721-1728。

para pular o Drop, evitando devolver permissõesrecvO caminho de recepçãopoll_fnusachan.recv(cx) 📎 tokio/src/sync/mpsc/bounded.rs:243-246。poll_recvpara envolver📎 tokio/src/sync/mpsc/bounded.rs:650-652e delega diretamentechan. A lógica real da fila de espera está no módulochan::Rx(não abordado neste capítulo), mas pode-se inferir: o Waker do receiver fica emsend, e é despertado quando o sender faz

try_sendmostra o caminho não bloqueante:

📎 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_acquireOs dois tipos de erro deClosedmapeiam precisamente paraFulle

, distinguindo "canal fechado" e "buffer cheio".

Reflexão de design: cancel safety e perda de mensagens📎 tokio/src/sync/mpsc/bounded.rs:776-784:sendA documentação do mpsc enfatiza repetidamente cancel safetyselect!Quandoperde em, a mensagem será descartadareserve. Para evitar perda, é preciso usarPermitpara obtersende depoisPermit— porquesendjá reservou a capacidade,

recvé síncrono e não será interrompido.📎 tokio/src/sync/mpsc/bounded.rs:199-204é cancel-saferecv: seselect!perder emrecv, garante que nenhuma mensagem foi consumida. Isso porque opoll_recvdeReady,Pendingsó retorna

oneshotquando realmente obtém a mensagemReceivere não mexe na fila.📎 tokio/src/sync/oneshot.rs:246-251Ooneshotdesendcomo Future também é cancel-safeErr. Mas atenção:

o

Armadilha um: usar Mutex assíncrono para proteger dados puros.A documentação recomenda explicitamente📎 tokio/src/sync/mutex.rs:26-36: se o que está protegido são dados puros (sem.awaitrequisitos), usarstd::sync::Mutexouparking_loté mais rápido. O custo do Mutex assíncrono está nas operações atômicas do semáforo e no possível agendamento de tarefas. Só quando for necessário.awaitdurante a posse do lock (por exemplo, acessar uma conexão de banco de dados com o lock em mãos), deve-se usar Mutex assíncrono.

Armadilha dois: manter o lock através de.awaitcausando deadlock.Esta é a armadilha mais perigosa do Mutex assíncrono. Se a tarefa A, após adquirir o lock,.awaitum evento que requer a conclusão da tarefa B, e a tarefa B está esperando por esse lock, ocorre deadlock.std::sync::MutexO guard deSendnão é.await(em tarefas móveis), o compilador impede manter através deSend 📎 tokio/src/sync/mutex.rs:314-314; mas o guard do Mutex assíncrono é

, o compilador não te impede, você mesmo precisa garantir que não se forme espera circular.reserveArmadilha três:send。 Permitesquecer o Drop de📎 tokio/src/sync/mpsc/bounded.rs:1732-1745O Drop devolve o permit

, então não vaza capacidade. Mas se o canal já estiver fechado e ocioso, o Drop acorda o receptor — esse despertar é necessário, caso contrário o receptor pode nunca receber a notificação de fechamento.oneshotArmadilha quatro:pollOPending。pode falsamente📎 tokio/src/sync/oneshot.rs:236-242A documentação explicapoll: mesmo que a mensagem já tenha sido enviada,Pendingpode retornar

. Isso não é um bug, mas um fenômeno normal em condições de corrida — o chamador será acordado para tentar novamente, a mensagem não será perdida, apenas atrasada.forget_permitsArmadilha cinco: forget_permits(n)A semântica de📎 tokio/src/sync/semaphore.rs:576-578Tenta reduzir n permits, retornando a quantidade efetivamente reduzida

. Não bloqueia, nem acorda esperadores — simplesmente "engole" permits. Usado para encolher dinamicamente a capacidade do semáforo.

Resumo do capítulotokio::syncEste capítulo revelouo padrão central de。

  • Mutex: todas as primitivas de espera assíncrona são construídas sobre "fila de esperadores + despertar por Waker", e a implementação concreta da fila varia conforme o cenárioMutexGuardreutiliza um semáforo com contagem de permits igual a 1,release(1)mantém apenas referências, no Drop
  • Semaphore, FIFO justo mas sem envenenamento.SemaphorePermité contagem de permits + fila de espera,permitsusaforget/merge/split,MAX_PERMITScontagem para suportar
  • oneshotdeslocamento à direita de 3 bits reserva espaço para flags de estado.AtomicUsizeusa um únicoVALUE_SENTflag de bits para codificar o estado,UnsafeCellbits simultaneamente determinamCLOSEDa quem pertence o direito de acesso, o loop CAS previne definir após
  • mpsc::bounded.WakeReceiverOnDropusa um semáforo com número de permits igual ao buffer para implementar backpressure,

guard lida com a compensação de despertar em caso de cancelamento.

Reflexões e autoavaliação do capítuloset_completeQ: Se mudarmosfetch_or(VALUE_SENT)o loop CAS de

para um simples:set_complete, em quais cenários de concorrência ocorreria uma condição de corrida de dados?fetch_orAnálise de referência📎 tokio/src/sync/oneshot.rs:1517-1529A razão deVALUE_SENTusar loop CAS em vez deCLOSEDestá escrita nos comentáriosfetch_or: é necessário verificarclose()antes de definirCLOSED 📎 tokio/src/sync/oneshot.rs:1569-1574. Se mudarmos parasendincondicionalfetch_or(VALUE_SENT), considere esta sequência temporal: o receptor primeiro chamaVALUE_SENTdefineCLOSED, o emissor em seguidapoll_recvescreve o valor eis_complete(). Neste momentoconsume_valuee📎 tokio/src/sync/oneshot.rs:1325-1330são definidos simultaneamente, ocomplete()do receptor vêprev.is_closed()como verdadeiro, chamaráconsume_valuepara pegar o valor📎 tokio/src/sync/oneshot.rs:1300-1315; e oUnsafeCelldo emissor após retornar, porqueCLOSEDé verdadeiro, chamaráVALUE_SENTpara pegar o valor de volta

Q: reserve_inner. Ambos os lados acessamWakeReceiverOnDropsimultaneamente, condição de corrida de dados. O loop CAS ao descobrirmem::forgetfaz break antecipado, não defineforget, garantindo assim o invariante "após fechamento, o emissor tem acesso exclusivo".

Oguard em📎 tokio/src/sync/mpsc/bounded.rs:1290-1298na rota de sucesso usaacquire(n)para pular, o que aconteceria se removêssemos esseOk?PermitAnálise de referênciaPermit: a lógica de Drop do guard é "se o semáforo já estiver fechado e ocioso, acordar o receptor"reserve_inner. Na rota de sucesso,is_idleretornaPermit, o chamador obtém o permit e construirámem::forget, sendoforgetresponsável pelas notificações subsequentes. Se não removermos o guard, o guard ao ser Dropado no retorno da função, verificará extra uma vez "fechado e ocioso" — mas neste momento o permit já está em posse do chamador deacquire, o semáforo não está ocioso (Oké falso), então na prática não haverá despertar duplicado. Mas mais crucial é a clareza semântica: a responsabilidade de despertar na rota de sucesso deve ser inteiramente dePermit, o guard só lida com a compensação da rota de "cancelamento/falha".

expressa claramente a intenção de "esta rota não precisa de guard". Se removermosMutexGuarde por acaso o semáforo estiver no estado limítrofe de "fechado e ocioso" (por exemplo,SemaphorePermitretorna

mas o permit ainda não foi assumido por), pode ocorrer um despertar supérfluo — embora não cause erro, desperdiça um agendamento.MutexGuardQ: Se mudarmos&Mutexpara manter o objeto de permit do semáforo (comoself.lock.s.release(1) 📎 tokio/src/sync/mutex.rs:959-961faz), quais problemas seriam introduzidos?MutexGuard::mapAnálise de referênciaMappedMutexGuard: Atualmente📎 tokio/src/sync/mutex.rs:869-883mantém apenasMappedMutexGuard, no Drop chama&Semaphore. Se mudássemos para manter o objeto de permit, vários problemas surgiriam. Primeiro,📎 tokio/src/sync/mutex.rs:190-199a série de métodos precisa desmontar o guard emself.s.release(1) 📎 tokio/src/sync/mutex.rs:1252-1262, protegendo apenas o subcampoMappedMutexGuard. No design atual,permits: usizesó precisa manterMutexGuarde o ponteiro do subcampoSend/Sync, no Dropunsafe impl. Se o guard mantivesse o objeto de permit, no map seria necessário transferir a propriedade do objeto de permit, e📎 tokio/src/sync/mutex.rs:260-263o layout dos campos seria mais complexo. Segundo, o objeto de permit geralmente carregamap。

contagem, para Mutex essa contagem é sempre 1, é redundante. Terceiro,tokio::syncospawn_blockingdeblock_onjá controla precisamente

O local de armazenamento do Waker varia conforme a primitiva: Mutex/Semaphore armazenam na fila de espera do semáforo subjacente, oneshot armazena nos campos tx_task/rx_task do Inner, mpsc armazena nas filas de envio e recebimento do módulo chan. Mas o mecanismo de despertar é unificado: quando o estado muda, o Waker é retirado e wake_by_ref é chamado, e o executor reagenda a tarefa. Até aqui, a espera e o despertar dentro das primitivas assíncronas estão claramente visíveis. No entanto, nem todo código pode ser tornado assíncrono — o próximo capítulo explorará como usar spawn_blocking para fazer a ponte com operações bloqueantes, e como block_on impulsiona Futures em contextos não assíncronos.

CHAPTER 08

Capítulo 8: Bloqueio e ponte: o limite entre o pool de threads spawn_blocking e block_on

Projeto: tokio-rs/tokio · Progresso do livro: Capítulo 8 / 14 · Status de verificação: linhas FACT com ancoragem real

No capítulo anterior vimos que a razão pela qual o Mutex assíncrono e os canais conseguem não ocupar uma thread enquanto esperam é que eles armazenam o Waker na fila de espera e, quando a condição é satisfeita, o despertador reagenda a tarefa. Mas tudo isso pressupõe que a tarefa possa ceder ativamente a thread quando está em Pending. Assim que o código chama std::fs::read, libsqlite3 ou um loop de compressão puramente em CPU, ele monopoliza a worker thread até retornar, e durante esse período todas as outras tarefas nessa thread ficam famintas. A solução do Tokio é terceirizar esse tipo de trabalho para um pool de threads bloqueantes independente e usar block_on para impulsionar Futures em contextos não assíncronos. Este capítulo disseca esses dois limites.

8.1 Layout de memória do pool de threads bloqueantes: Inner e fila com implementação dupla

Modelo intuitivo:spawn_blockingO pool de threads é como o "pool de ajudantes terceirizados" de um restaurante. Os garçons de salão (worker threads) só cuidam de anotar pedidos e servir pratos; quando encontram um prato que precisa de cozimento lento, escrevem uma ordem de serviço e a jogam na janela de entrega da cozinha (fila), e os ajudantes (threads bloqueantes) pegam a ordem na janela. Sem esse pool, o garçom teria que cozinhar pessoalmente e o restaurante inteiro pararia.

Estrutura central. Todo o pool é mantido porBlockingPoolque armazena apenas duas coisas: umSpawnerclonável (ponto de entrada de envio) e umshutdown_rx(receptor do sinal de encerramento)📎 tokio/src/runtime/blocking/pool.rs:20-23。Spawnerinternamente éArc<Inner>, todos os remetentes compartilham o mesmo estado📎 tokio/src/runtime/blocking/pool.rs:26-28。

Inneré todo o estado do pool, e os campos merecem ser examinados um a um📎 tokio/src/runtime/blocking/pool.rs:77-104:

  • inner_impl: InnerImpl: implementação de fila + notificação + topologia de lock, é um enum comLockedeShardedduas variantes📎 tokio/src/runtime/blocking/pool.rs:107-110. Esta é a abstração mais crucial deste capítulo — ela unifica "fila de lock único" e "fila fragmentada" sob uma única interface.
  • thread_cap: usize: limite superior do número de threads, ou seja,max_blocking_threads。
  • scheduler_threads: usize: número de worker threads do scheduler, usado para deduzir nas métricas, fazendo com quenum_blocking_threadsconte apenas threads bloqueantes📎 tokio/src/runtime/blocking/pool.rs:455-460。
  • keep_alive: Duration: tempo de vida de threads ociosas, padrãoKEEP_ALIVE = 10s 📎 tokio/src/runtime/blocking/pool.rs:231。
  • metrics: SpawnerMetrics: três contadores atômicos —num_threads、num_idle_threads、queue_depth 📎 tokio/src/runtime/blocking/pool.rs:31-35。
〔Inferência de design e trade-offs arquiteturais〕

Por que usar contadores atômicos em vez de campos dentro do lock? num_idle_threadsé lido no caminho quente despawn_task(para decidir se é necessário despertar uma thread ociosa); se ele estivesse escondido emMutex, cada envio precisaria primeiro adquirir o lock e depois ler. Ao torná-loMetricAtomicUsize, o caminho de envio pode fazer uma verificação rápida sem manter o lock da fila. O custo é que não há garantia de atomicidade entre esses contadores e o estado da fila, então o código usa o contadornum_notifypara compensar — veja abaixo.

Estado de gerenciamento de threads。ThreadManagementStateé extraído separadamente para ser reutilizado pelas duas implementações de fila📎 tokio/src/runtime/blocking/pool.rs:135-150:

  • shutdown: bool: flag de encerramento.
  • shutdown_tx: Option<shutdown::Sender>: cada worker thread mantém uma cópia clonada; após todas serem dropadas,shutdown_rxrecebe a notificação.
  • last_exiting_thread: Option<JoinHandle<()>>: handle da última thread que saiu por timeout.
  • worker_threads: HashMap<usize, JoinHandle<()>>: handles de todos os workers vivos.
  • worker_thread_index: usize: alocador monotonicamente crescente de ID de thread.

last_exiting_threadA motivação de design está claramente escrita nos comentários: uma thread que sai por timeout fará join na última thread que saiu por timeout, evitando falsos positivos do Valgrind📎 tokio/src/runtime/blocking/pool.rs:135-150。worker_timed_outé exatamente a implementação desse join encadeado — ela remove seu próprio handle e troca olast_exiting_threadantigo, retornando-o ao chamador para fazer join📎 tokio/src/runtime/blocking/pool.rs:172-178。

Encapsulamento de tarefa. O que é armazenado na fila éTask, que envolve umUnownedTask<BlockingSchedule>e uma flagMandatoryque decide se, no encerramento, essa tarefa é descartada ou executada à força:📎 tokio/src/runtime/blocking/pool.rs:187-191。Mandatorychamashutdown_or_run_if_mandatoryemNonMandatory, e chamashutdown()emMandatory. Esta é a diferença entrerun() 📎 tokio/src/runtime/blocking/pool.rs:223-228(não forçado) espawn_blocking(forçado, usado por fs)spawn_mandatory_blockingLayout de memória da implementação de lock único📎 tokio/src/runtime/blocking/pool.rs:233-265。

é a topologia mais primitiva: um。LockedImplmais umMutex<LockedInner>dentro estáCondvar 📎 tokio/src/runtime/blocking/pool.rs:113-116。LockedInnereVecDeque<Task>、num_notify: u32. Observe quethread_mgmt_state 📎 tokio/src/runtime/blocking/pool.rs:118-124enum_notifyestão sob o mesmo lock, enquantothread_mgmt_stateé uma quantidade atômica fora do lock — esse layout híbrido de "parte do estado dentro do lock, parte fora" é exatamente a raiz de todas as sutilezas de concorrência posteriores.num_idle_threads8.2 Caminho de envio: de spawn_blocking ao despertar de thread

Cenário

: uma tarefa assíncrona chama, o que acontece neste momento?tokio::task::spawn_blocking(move || heavy_compute(data))Primeiro passo: decisão de boxing e construção da tarefa

primeiro mede o tamanho da closure。Spawner::spawn_blocking, depois decide, com base emfn_size, se deve colocar a closure emAutoBox::<F>::SHOULD_BOX(boxing)Box. Esta é a estratégia genérica do Tokio de "boxing automático para Futures grandes": quando a closure é grande demais, ela é boxada para evitar o inchaço da struct de tarefa.📎 tokio/src/runtime/blocking/pool.rs:359-389Entra em

, primeiro aloca o ID da tarefa, depois usaspawn_blocking_innerpara envolver a closure em um Future, e por fim usablocking_taskpara construirtask::unownedeUnownedTask. Observe que aqui é retornado umJoinHandle 📎 tokio/src/runtime/blocking/pool.rs:440-449tupla dupla — o handle e o resultado do envio são retornados separadamente.(JoinHandle<R>, Result<(), SpawnError>)Segundo passo: três tratamentos do resultado do envio

. Voltando a, faz match emspawn_blocking:spawn_result: normal, retorna o handle.📎 tokio/src/runtime/blocking/pool.rs:381-388:

  • Ok(()):正常,返回句柄。
  • Err(ShuttingDown):não entra em panic, ainda retorna o handle. O comentário explica que isso é por consideração de compatibilidade — o handle nunca será resolvido, mas o chamador não entrará em panic porque o runtime está sendo encerrado.
  • Err(NoThreads(e)): o SO não consegue criar a thread e ninguém no pool a assume, então entra diretamente em panic.

Terceiro passo: decisão de enfileiramento e despertar。spawn_taskpassa aon_no_idleclosure paraInnerImpl::spawn_task, e a implementação concreta decide quando chamá-la📎 tokio/src/runtime/blocking/pool.rs:462-506. VejaLockedImpl::spawn_taska seção crítica de📎 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();
}

Aqui há dois pontos-chave. Primeiro, a verificação de encerramento ocorre antes do enfileiramento, e mesmo que a tarefa sejaMandatorytambém diretamenteshutdown()— o comentário explica: ela só foi agendada depois do início do encerramento, então descartá-la é legal📎 tokio/src/runtime/blocking/pool.rs:614-620. Segundo, a decisão de despertar depende denum_idle_threadsfora do lock: se for 0, chamaon_no_idlepara tentar iniciar uma nova thread; caso contrário, decrementa o contador de ociosas e incrementanum_notify、notify_one。

num_notifyPor que precisa existir?PorqueCondvarpode produzir despertar espúrio (spurious wakeup). Se usar apenasnotify_onesem contar, uma thread despertada espuriamente pensará por engano que há tarefa disponível, descobrirá que a fila está vazia e voltará a dormir, enquanto a thread realmente despertada pode nunca receber a notificação.num_notifytransforma o "despertar legítimo" em um token contável: o lado que publica+1, e o lado despertado só emnum_notify != 0considera o despertar legítimo e-1 📎 tokio/src/runtime/blocking/pool.rs:674-684。

Quarto passo: iniciar nova thread。on_no_idleA closure executa📎 tokio/src/runtime/blocking/pool.rs:462-506mantendo o lock da fila. Primeiro verificanum_threads == thread_cap, e ao atingir o limite retorna diretamenteOk(())— a tarefa permanece na fila aguardando threads existentes, e isso é backpressure. Caso contrário, clonashutdown_tx, chamaspawn_threadpara criar a thread e, em caso de sucesso, incrementanum_threads, incrementaworker_thread_index, insere o handle emworker_threads。

spawn_threadusathread::Builderpara definir o nome da thread e o tamanho da pilha, e então faz spawn de uma closure: entra no contexto do runtimert.enter(), chamainner.run(id), e por fim dropshutdown_tx 📎 tokio/src/runtime/blocking/pool.rs:508-528。

Tolerância a falhas na criação de threads do SO。spawn_threadpode falhar. O código classifica o erro📎 tokio/src/runtime/blocking/pool.rs:488-500: se forWouldBlock(erro temporário, determinado poris_temporary_os_thread_error) e já houver threads bloqueadas no pool, então📎 tokio/src/runtime/blocking/pool.rs:750-752ignora silenciosamente— a tarefa acabará sendo retirada por alguma thread atualmente ocupada. Caso contrário, retorna, o que acaba levando a panic.SpawnError::NoThreadsResumindo o caminho de publicação com um grafo de fluxo de controle das decisões:

Copiar

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"]

Modelo intuitivo

: cada thread bloqueada é um "ajudante de plantão". Com demanda, trabalha continuamente (BUSY); sem demanda, cochila (IDLE); se cochilar além de, encerra o turno (saída por timeout). Sem recuperação por timeout, o pool manteria permanentemente todas as threads criadas no pico, desperdiçando memória e custo de agendamento do kernel.keep_aliveEstrutura do loop principal

é um loop。LockedImpl::run_worker, internamente alternando entre as duas fases BUSY e IDLE'main. Observação: aqui BUSY/IDLE são📎 tokio/src/runtime/blocking/pool.rs:642-735fasesdentro do loop, não estados de um enum explícito, então abaixo descrevemos com fluxograma em vez de diagrama de estados.Fase BUSY

: o loop internoretira tarefas continuamentewhile let Some(task) = locked.queue.pop_front(). Após obter, decrementa📎 tokio/src/runtime/blocking/pool.rs:655-661drop do lockqueue_depth,, executa, e readquire o lock. O passo de drop do lock é crucial — uma tarefa bloqueante pode demorar muito, e nunca se deve executá-la segurando o lock.task.run()Fase IDLE

: a fila esvaziou, incrementa, definenum_idle_threads, e então entra no loop de esperais_counted_idle = true. O núcleo é📎 tokio/src/runtime/blocking/pool.rs:663-696, e após retornar verifica três coisas:condvar.wait_timeout(locked, keep_alive): despertar legítimo. Decrementa

1. num_notify != 0, definenum_notify(porque o lado que publicou já decrementouis_counted_idle = false), break de volta para BUSYnum_idle_threads2. Não encerrado e timeout: chama📎 tokio/src/runtime/blocking/pool.rs:674-684。

para obter o handle da thread que saiu anteriormente,worker_timed_outsai do loopbreak 'main3. Caso contrário, é despertar espúrio, continua esperando.📎 tokio/src/runtime/blocking/pool.rs:689-693。

Esvaziamento da fila no encerramento

. Sefor verdadeiro, entra na lógica de esvaziamentothread_mgmt_state.shutdown: retira tarefas uma a uma, drop do lock, chama📎 tokio/src/runtime/blocking/pool.rs:698-710— tarefas não forçadas são descartadas, tarefas forçadas executam normalmente. Depois break para sair do loop principal.task.shutdown_or_run_if_mandatory()Limpeza na saída

. Antes de a thread sair, decrementa. Senum_threads 📎 tokio/src/runtime/blocking/pool.rs:714for verdadeiro, também decrementais_counted_idle, e usanum_idle_threadspara afirmar que não houve underflowassert_ne!(prev_idle, 0). Essa asserção é uma proteção em tempo de depuração: se📎 tokio/src/runtime/blocking/pool.rs:716-726contabilizar errado, aqui entrará imediatamente em panic em vez de deixar o erro se propagar silenciosamente.num_idle_threadsPor fim, se estiver encerrando e

(a última thread),num_threads == 0desperta o iniciador do encerramento que pode estar esperandonotify_one. Retorna📎 tokio/src/runtime/blocking/pool.rs:728-730, ejoin_on_threadfaz join antes de sairInner::runHandshake de encerramento📎 tokio/src/runtime/blocking/pool.rs:755-771。

primeiro chama。BlockingPool::shutdownpara obter todos os handles dos workersbegin_shutdowndefine a flag de encerramento, drop📎 tokio/src/runtime/blocking/pool.rs:310-312。LockedImpl::begin_shutdowndesperta todas as threads em esperashutdown_tx、notify_all. Depois📎 tokio/src/runtime/blocking/pool.rs:740-745bloqueia aguardandoshutdown_rx.wait(timeout)A implementação de📎 tokio/src/runtime/blocking/pool.rs:324。

shutdown::Receiver::waité bastante cuidadosa📎 tokio/src/runtime/blocking/shutdown.rs:37-70: primeiro trata o caminho rápido detimeout == 0retornando diretamente false; depois chamatry_enter_blocking_region()para entrar na região de bloqueio e, se falhar e no momento estiver em panic, retorna false; caso contrário, entra em panic com a mensagem "não é possível dar drop no runtime em contexto assíncrono"📎 tokio/src/runtime/blocking/shutdown.rs:44-57. Por fim, conforme o timeout, chamablock_on_timeoutoublock_onpara impulsionar aquele oneshot.

shutdown_txO mecanismo deArc<oneshot::Sender<()>>é: cada thread worker mantém um clone de📎 tokio/src/runtime/blocking/shutdown.rs:12-14. Depois que todas as threads saem, todos os clones são dropados,Arca contagem chega a zero,oneshot::Senderé dropado,Receiverrecebe a notificação. Esse é o padrão clássico de "após todos os Sender serem dropados, o Receiver é despertado".

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: impulsionar um Future em contexto não assíncrono

Modelo intuitivo:block_oné a "porta principal" do runtime. Ela transforma a thread atual em um executor temporário, repetidamente fazendo poll no Future recebido até concluir. Sem ela,maina função não conseguiria iniciar nenhum código assíncrono.

Entrada e boxing。Runtime::block_ontambém primeiro mede o tamanho, decide porSHOULD_BOXseBox::pin, e então entra emblock_on_inner 📎 tokio/src/runtime/runtime.rs:343-350。block_on_innerHá dois trechos de trace com compilação condicional (taskdump e tracing), entãoself.enter()entra no contexto do runtime e, por fim, despacha conforme o tipo de scheduler📎 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),
}

Os dois schedulersblock_onA semântica é diferente, a documentação deixa isso bem claro📎 tokio/src/runtime/runtime.rs:302-320:

  • Agendador multithread: O Future é executado no contexto do driver de I/O e do temporizador,block_onapós o retorno, as tarefas já spawnadas continuam a ser executadas.
  • Agendador de thread atual:block_onpode ser chamado concorrentemente por múltiplas threads, o primeiro chamador obtém a propriedade do driver de I/O e do temporizador, e as outras threads "se conectam" a ele. Após o primeiroblock_onterminar, as outras threads podem "roubar" o driver.block_onapós o retorno, as tarefas já spawnadas são suspensas, e chamar novamenteblock_onirá restaurá-las.

Restrição crítica: não pode ser chamado em contexto assíncrono. A documentação deixa explícito queblock_onchamar em um contexto de execução assíncrono causará panic📎 tokio/src/runtime/runtime.rs:321-324. A razão é direta:block_onbloqueia a thread atual até que o Future seja concluído; se a thread atual for ela mesma uma worker thread, bloqueará todo o executor — que é exatamentespawn_blockingo problema que se pretende resolver, portanto os dois são mutuamente exclusivos.

Caminho de encerramento。Runtime::dropdespacha de acordo com o tipo de agendador📎 tokio/src/runtime/runtime.rs:506-521: o agendador de thread atual precisa primeirotry_set_currententrar no contexto e então shutdown (garantindo que as tarefas sejam dropadas no contexto de runtime); o agendador multithread faz shutdown diretamente (as worker threads já estão no contexto).shutdown_timeoutPrimeiro fecha o agendador e depois fecha o pool de bloqueio📎 tokio/src/runtime/runtime.rs:457-461,shutdown_backgroundé equivalente ashutdown_timeout(Duration::from_nanos(0)) 📎 tokio/src/runtime/runtime.rs:494-496。

Reflexões de design, recuperação de erros e armadilhas em produção

Por quespawn_blockingdeShuttingDownnão causa panic? 📎 tokio/src/runtime/blocking/pool.rs:383-384O comentário diz que é por consideração de compatibilidade.spawn_blockingretornaJoinHandleem vez deResult, pois se causasse panic no encerramento, transformaria o estado previsível de "o runtime está encerrando" em um crash. Retornar um handle que nunca resolve faz com que o chamadorawaitfique suspenso para sempre — mas nesse momento o runtime já está encerrado, e todo oblock_ontambém sairá, então na prática não haverá vazamento permanente.

max_blocking_threadsA semântica de backpressure de. O valor padrão é muito grande (512), porquespawn_blockingé frequentemente usado para I/O de arquivos. Mas a documentação alerta: ao executar tarefas intensivas de CPU, deve-se usar um semáforo para limitar a concorrência, caso contrário serão criadas muitas threads📎 tokio/src/task/blocking.rs:94-100. Ao atingir o limite, as tarefas ficam na fila, formando backpressure — mas note que esse backpressure atua apenas no pool de bloqueio, não faz backpressure para o agendador assíncrono.

spawn_blockingnão é cancelável. A documentação deixa explícito:abortnão tem efeito sobre tarefas de bloqueio que já começaram a executar; a tarefa continuará até o fim📎 tokio/src/task/blocking.rs:106-120. Apenas tarefas que ainda não começaram podem ser impedidas por abort. No encerramento, o runtime aguardará todas as tarefas de bloqueio já iniciadas,shutdown_timeoute após o timeout essas threads vazarão.

num_idle_threadsA armadilha de contabilidade de。is_counted_idleA existência da flag indica que essa contagem é muito propensa a erros. O lado que envia decrementa ao acordarnum_idle_threads, e o lado acordado, ao vernum_notify != 0, defineis_counted_idle = false, evitando decremento duplicado📎 tokio/src/runtime/blocking/pool.rs:679-682. Se esse caminho tiver um bug,assert_ne!(prev_idle, 0)causará panic ao sair📎 tokio/src/runtime/blocking/pool.rs:722-725. Em produção, se você vir "num_idle_threadsunderflowed on thread exit", significa que a lógica de contabilidade do pool foi corrompida.

〔Inferências de design e trade-offs arquiteturais〕

last_exiting_threadO custo do join encadeado. Uma thread que sai por timeout fará join na thread que saiu por timeout anteriormente📎 tokio/src/runtime/blocking/pool.rs:172-178. Isso forma uma cadeia de join: cada thread que sai precisa esperar a anterior realmente terminar. Em cenários de criação/destruição de alta frequência de threads de bloqueio, essa cadeia pode crescer, causando acúmulo de atraso na saída de threads. Este é um trade-off feito para evitar falsos positivos do Valgrind; o impacto em produção normal é limitado, mas merece atenção sob cargas com timeouts frequentes de threads.

InnerImplO significado da abstração de enum. O comentário explica queLockeda variante tem comportamento idêntico ao anterior à refatoração, enquantoShardeda variante reserva um slot simétrico para futuras filas concorrentes📎 tokio/src/runtime/blocking/pool.rs:537-539。spawn_task、run_worker、begin_shutdownos três métodos despacham através do enum📎 tokio/src/runtime/blocking/pool.rs:548-582. Esse design de "despacho por enum + seção crítica própria por variante" faz com que, ao adicionar novas topologias de fila, não seja necessário alterar o chamador.

Resumo do capítulo

Este capítulo desmontou as duas fronteiras do Tokio para acomodar código síncrono.spawn_blockingentrega closures a um pool de threads de bloqueio independente:Innermantém fila, limite de threads, tempo de vida e métricas atômicas;LockedImplusa lock único +Condvarpara implementar a fila,num_notifycontador compensa wakeups falsos; o worker alterna entre BUSY/IDLE, e após timeout de ociosidade sai via join encadeado;max_blocking_threadsao atingir o limite, as tarefas entram na fila formando backpressure.block_onpor sua vez, impulsiona Futures em contexto não assíncrono; os agendadores multithread e de thread atual têm semânticas diferentes, e é estritamente proibido chamá-lo em contexto assíncrono. O caminho de encerramento, através deshutdown_txdeArccom contagem zerada, disparaoneshot, realizando o handshake de "acordar o iniciador do encerramento após todas as workers saírem".

Reflexões e autoavaliação do capítulo

Q1: Se emLockedImpl::spawn_taska verificação deif metrics.num_idle_threads() == 0fosse alterada para sempre verdadeira (ou seja, chamaron_no_idletoda vez), o que aconteceria em cenários de envio altamente concorrente? Por quê?

Análise de referência:on_no_idleverificanum_threads == thread_cap, e se o limite não foi atingido, cria uma nova thread📎 tokio/src/runtime/blocking/pool.rs:471-487. Se a verificação fosse sempre verdadeira, mesmo havendo threads ociosas tentaria iniciar novas threads, fazendo o número de threads disparar atéthread_cap. Mais grave ainda, threads ociosas não seriam acordadas pornotify_one(porque seguiu o ramoon_no_idleem vez do ramoelsedenum_notify += 1; notify_one 📎 tokio/src/runtime/blocking/pool.rs:627-636), e as tarefas na fila poderiam ficar sem ninguém para processá-las, até que alguma nova thread inicie e descubra que a fila não está vazia. Isso causaria um estado de falsa paralisia de "threads lotadas mas tarefas ainda na fila". O sentido da verificação original é exatamente: quando há threads ociosas, priorizar acordá-las, evitando criação desnecessária de threads.

Q2: LockedImpl::run_workerNa fase BUSY, antes de executartask.run(), faz-sedrop(locked) 📎 tokio/src/runtime/blocking/pool.rs:657-658. Se essedropfosse removido, em qual cenário ocorreria deadlock?

Análise de referência:task.run()executa a closure do usuário, e dentro da closure é totalmente possível chamar novamentespawn_blockingpara enviar uma nova tarefa. O caminho de envioLockedImpl::spawn_taskfaz como primeira coisaself.mutex.lock() 📎 tokio/src/runtime/blocking/pool.rs:612. Se o worker mantivesse o lock ao executar a closure, o envio dentro da closure tentaria adquirir o mesmo lock, estd::sync::MutexNão reentrante, deadlock direto. Além disso, manter o lock durante a execução de tarefas longas bloqueia todas as operações de obtenção de tarefas dos outros submitters e workers; mesmo sem deadlock, isso serializa todo o pool.drop(locked)É obrigatório.

Q3: shutdown::Receiver::waitEmtry_enter_blocking_region()falha e está atualmente em panic, retorna false; caso contrário, entra em panic📎 tokio/src/runtime/blocking/shutdown.rs:44-57. Por que tratar especialmente durante o panic? Se removermos esse branch, em quais cenários surgiriam problemas?

Análise de referência:try_enter_blocking_regionA falha significa que estamos atualmente em um contexto assíncrono, onde bloqueio não é permitido. Em condições normais, deveria entrar em panic para avisar o usuário que "não se pode dar drop no runtime em contexto assíncrono". Mas se a thread atual já está em panic (std::thread::panicking()é verdadeiro), entrar em panic novamente causaria um panic duplo, e o comportamento padrão do Rust é abortar o processo diretamente. Cenário: o usuário dá drop em um Runtime dentro de uma tarefa assíncrona, e essa tarefa já está em panic por outro motivo; nesse momento, o shutdown acionado pelo drop causaria um segundo panic. Retornar false faz o shutdown desistir de esperar, evitando o abort do processo e preservando a chance de o usuário ver a informação original do panic. Esse é um tratamento típico de "panic safety".

O pool de threads bloqueantes e o block_on delimitam as fronteiras de capacidade do runtime assíncrono: o primeiro isola o trabalho que não pode ceder a thread em threads dedicadas, e o segundo permite que pontos de entrada não assíncronos também dirijam Futures. Mas essas duas fronteiras muitas vezes não são escritas à mão no código — no próximo capítulo entraremos no mundo das macros, para ver como #[tokio::main], select! e join! geram esse código de runtime em tempo de compilação.

CHAPTER 09

Capítulo 9: A magia das macros: o código gerado por trás de #[tokio::main], select! e join!

Projeto: tokio-rs/tokio · Progresso do livro: Capítulo 9 / 14 · Status de verificação: linhas FACT com ancoragem real

No capítulo anterior vimosblock_one como o pool de threads bloqueantes delimita as fronteiras de capacidade do runtime assíncrono, mas os usuários quase nunca escrevem essas fronteiras à mão — eles escrevem#[tokio::main]、select!、join!, deixando a macro expandir esse código boilerplate em tempo de compilação. As macros são a primeira camada de açúcar que o Tokio oferece ao usuário e também o lugar onde o código de runtime é realmente gerado em tempo de compilação. Este capítulo foca notokio-macroscrate e emtokio/src/macros/select.rs, desmontando os três caminhos de expansão de macros mais usados, com foco em responder a uma pergunta: depois da expansão da macro, como é a cadeia de chamadas real, e por que a semântica de cancel safety deselect!precisa ser especialmente cautelosa.

9.1 #[tokio::main]: reescrevendo async fn como Runtime::block_on

Modelo intuitivo:#[tokio::main]é como uma "procuração de reforma". Você entrega um apartamento inacabado (async fn main), e ela instala a hidráulica e a elétrica para você (construindo o Runtime), coloca portas e janelas (enable_all), e por fim move seus móveis originais (o corpo da função) para dentro. Sem ela, cadamainteria que escrever manualmenteBuilder::new_multi_thread().enable_all().build().unwrap().block_on(...), e o código boilerplate afogaria a lógica de negócio.

Estruturas de dados e layout de memória

A macro em si não produz estruturas de dados em runtime, mas a configuração que ela analisa é colocada em duas structs.Configurationé um "acumulador mutável em tempo de parsing", com todos os campos sendoOption, porque os parâmetros de atributo podem estar ausentes, podem se repetir, podem ser inválidos📎 tokio-macros/src/entry.rs:74-84. Observe queworker_threads、start_paused、unhandled_panictodos carregamSpan— isso serve para, ao reportar erro, localizar o erro na linha que o usuário escreveu, e não dentro da macro📎 tokio-macros/src/entry.rs:74-84。FinalConfigpor outro lado, é o "resultado imutável após validação",flavornão é maisOption, porquebuild()já usoudefault_flavorcomo fallback📎 tokio-macros/src/entry.rs:55-62。

RuntimeFlavortem apenas três variantes:CurrentThread、Threaded、Local 📎 tokio-macros/src/entry.rs:10-14。from_strdá intencionalmente mensagens amigáveis para nomes legados:single_threadindica que deveria se chamarcurrent_thread,basic_schedulerindica que foi renomeado,threaded_schedulerindica que foi renomeado📎 tokio-macros/src/entry.rs:17-27. Esse é um design típico da macro como "primeira superfície de contato do usuário": a mensagem de erro é a documentação.

Fluxo de expansão passo a passo

Cenário: o usuário escreve#[tokio::main(flavor = "multi_thread", worker_threads = 4)] async fn main() { ... }。

Primeiro passo,maina entrada primeiro analisa o item como umItemFn 📎 tokio-macros/src/entry.rs:577-580personalizado. EsseItemFnnão ésyn::ItemFn, mas um parser implementado pelo próprio Tokio, e o motivo está nos comentários: ele não quer analisar recursivamente toda a instrução, apenas fazer um parsing leve de "bufferizar por token tree e dividir ao encontrar ponto e vírgula"📎 tokio-macros/src/entry.rs:720-764. Isso evita o custo de construir uma AST completa para o corpo da função dentro da macro.

Segundo passo,build_configvalida se a palavra-chaveasyncexiste; se faltar, reporta "theasync keyword is missing" 📎 tokio-macros/src/entry.rs:346-349. Em seguida, percorre os parâmetros de atributo, despachandoworker_threads、flavor、start_paused、crate、unhandled_panic、namepara o setter correspondente📎 tokio-macros/src/entry.rs:369-399. Observe quecore_threadsé explicitamente rejeitado com a indicação de que foi renomeado📎 tokio-macros/src/entry.rs:379-382。

Terceiro passo,Configuration::buildfaz validação de consistência entre campos. Aqui há três restrições principais:worker_threadssó permitemulti_thread 📎 tokio-macros/src/entry.rs:197-217;start_pausedsó permitecurrent_thread/local 📎 tokio-macros/src/entry.rs:219-229;unhandled_panictambém só permitecurrent_thread/local 📎 tokio-macros/src/entry.rs:231-241. Se o usuário escolhermulti_threadmas a featurert-multi-threadnão estiver habilitada, a mensagem de erro varia conforme o flavor tenha sido especificado explicitamente ou não📎 tokio-macros/src/entry.rs:209-216。

Quarto passo,parse_knobsgera o código. Primeiro removeasyncness 📎 tokio-macros/src/entry.rs:441, depois escolhe o ponto de partida do builder conforme o flavor:CurrentThread/LocalusaBuilder::new_current_thread(),ThreadedusaBuilder::new_multi_thread() 📎 tokio-macros/src/entry.rs:468-477。LocalA particularidade é que a chamada de build ébuild_local(Default::default())em vez debuild() 📎 tokio-macros/src/entry.rs:479-483. Em seguida, encadeia conforme necessário.worker_threads(#v)、.start_paused(#v)、.unhandled_panic(...)、.name(#v) 📎 tokio-macros/src/entry.rs:485-497。

Quinto passo, gera o corpo final da função. O núcleo élast_block:return #rt.enable_all().#build.expect("Failed building the Runtime").block_on(body) 📎 tokio-macros/src/entry.rs:509-522. Observe aquelereturnexplícito, cujo comentário aponta para tokio-rs/tokio#4636, para corrigir um problema de inferência de tipos📎 tokio-macros/src/entry.rs:508。

Sexto passo, o corpo da função é empacotado comoasync #bodye passa por verificação de tipos. No caminho não-test, se o tipo de retorno não for!e não contiverimpl Trait, insere-seif false { let _: &dyn Future<Output = #output_type> = &body; }para fazer uma asserção em tempo de compilação📎 tokio-macros/src/entry.rs:551-571. No caminho test, usa-sepin!fixar o body na pilha e convertê-lo emPin<&mut dyn Future>, o comentário explica que isso é para reduzirblock_ono custo de compilação da instanciação genérica📎 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"]

Reflexões de design e armadilhas em produção

mainetestcompartilhamparse_knobs, mas o flavor padrão é diferente:testpadrãoCurrentThread,mainpadrãoThreaded 📎 tokio-macros/src/entry.rs:91-94. Isso explica por que#[tokio::test]é single-thread por padrão — testes geralmente não precisam de múltiplos núcleos, e single-thread é mais fácil de reproduzir.

Uma armadilha fácil de ignorar: após a expansão da macro, cada chamada da função cria um novo Runtime. A documentação alerta explicitamente que, se a função for chamada com frequência, deve-se usar Builder para reutilizar o Runtime📎 tokio-macros/src/lib.rs:31-35. Usar#[tokio::main]em uma função comum é legal, mas cada chamada paga o custo de construção do Runtime.

Outra armadilha écraterenomeação. Quando o usuáriouse tokio as tokio1, otokio::runtime::Buildergerado por padrão dentro da macro não encontrará o caminho, sendo necessário explicitamentecrate = "tokio1" 📎 tokio-macros/src/lib.rs:239-264。parse_knobsemcrate_patho valor padrão deIdent::new("tokio", ...) 📎 tokio-macros/src/entry.rs:456-462é

, e é exatamente essa a raiz do erro no cenário de renomeação.

9.2 select!: polling multi-branch, bitmask e justiça aleatória:select!Modelo intuitivopoll_fné como um "garçom que observa várias janelas de retirada ao mesmo tempo". A janela que servir primeiro é de onde ele leva o prato, e a fila das outras janelas é descartada. Sem ele, o usuário teria que escrever manualmente

para colocar vários Futures em uma tupla e fazer poll um por um, além de lidar sozinho com a lógica de "quando um branch fica pronto, os outros devem ser descartados".

select!Estrutura de dados e layout de memória__tokio_select_utilapós a expansão gera um módulo localOut, dentro do qual há um enumMask 📎 tokio/src/macros/select.rs:615-619。Oute um alias de tipo_0、_1os nomes das variantes sãoDisabled……um por branch, mais um📎 tokio-macros/src/select.rs:33-39。Maskque representa que todos os branches falharamu8o tipo subjacente deu16é escolhido dinamicamente pelo número de branches: ≤8 usau32, ≤16 usau64, ≤32 usa📎 tokio-macros/src/select.rs:17-31, ≤64 usaselect!, e acima de 64 entra em panic direto

. Esse bitmask éfutureso estado central deIntoFuture::into_future: o i-ésimo bit sendo 1 significa que o i-ésimo branch foi desabilitado.📎 tokio/src/macros/select.rs:654-656Todos os Futures são armazenados em uma tuplafutures_init, e cada elemento passa primeiro porinto_futureconversão📎 tokio/src/macros/select.rs:641-646. Observe que aqui primeiro se constróilet mut futures = &mut futures;e depois, um a um,poll_fn, e o comentário explica que isso é para aproveitar a extensão do tempo de vida de temporários📎 tokio/src/macros/select.rs:658-662。

. Em seguida,

rebaixa a tupla para uma referência mutável, evitando que a closureselect! { v = stream1.next() => ..., v = stream2.next() => ..., else => break }。

tome possebiased;Fluxo de polling passo a passostart=0 📎 tokio/src/macros/select.rs:801-803Contextualizando o cenário:startPrimeiro passo, correspondência da regra de entrada da macro. Se houverthread_rng_n(BRANCHES) 📎 tokio/src/macros/select.rs:805-809prefixo,📎 tokio/src/macros/select.rs:61-65。

; caso contrário(skip) pat = fut, if cond => handler,é uma expressão aleatóriaskip. É daí que vem a justiça mencionada na documentação de "escolher aleatoriamente um branch para verificar primeiro"_Segundo passo, normalização. O tt-muncher normaliza cada branch para a forma📎 tokio/src/macros/select.rs:770-793。skip,futures_init.$($skip)*é uma sequência decount!, com comprimento igual ao número de branches anteriores àquele branch

é usado tanto para gerar o acesso ao campo da tuplaif $c, quanto paradisabled |= 1 << index 📎 tokio/src/macros/select.rs:631-636calcular o índice do branch.$futTerceiro passo, avaliação das pré-condições. Para o📎 tokio/src/macros/select.rs:39-41。

de cada branch, se for false, entãopoll_fn. Atenção: mesmo que o branch esteja desabilitado, suaready!(poll_budget_available(cx))expressão ainda será avaliada, apenas não será feito pollPending 📎 tokio/src/macros/select.rs:664-667Quarto passo, entrar na closureselect!. Primeiro verificar o orçamento de cooperação:

, se o orçamento se esgotar, retorna diretamentefor i in 0..BRANCHES,branch = (start + i) % BRANCHES 📎 tokio/src/macros/select.rs:680-685. Isso garante quedisabled & mask == masknão monopolize o worker.continue 📎 tokio/src/macros/select.rs:694-699Quinto passo, loopPin::new_unchecked. Para cada branch: primeiro verificar📎 tokio/src/macros/select.rs:701-707, se já estiver desabilitado entãoReady(out); caso contrário, retirar o Future da tupla e envolvê-lo comdisabled |= mask(a segurança depende de o Future estar na pilha e não ser movido)📎 tokio/src/macros/select.rs:710-730。

; fazer poll nele,outentão primeiro$binde depois corresponder ao padrãoPoll::Ready(Out::_i(out)) 📎 tokio/src/macros/select.rs:727-733Sexto passo, correspondência de padrão. Secontinuecorresponder a📎 tokio/src/macros/select.rs:44-47。

, retornais_pending; se não corresponder,Pendingcontinua o polling dos outros branches — é exatamente isso que o passo 5 da documentação diz: "se o padrão não corresponder, desabilita o branch atual"Out::Disabled 📎 tokio/src/macros/select.rs:740-745Sétimo passo, fim do loop. Sematch outputfor verdadeiro, retornaOut::_i, caso contrário todos os branches falharam, retornaDisabled. Oelseexterno📎 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

para o handler correspondente,

mapeia paraVec<bool>?expressãodisabled |= maskCopiarselect!Reflexões de design e armadilhas em produção

Por que usar bitmask em vez debitmask é um único inteiro na pilha, sem alocação no heap, eselect!é uma única instrução. ParaSome(v) = stream.next() => ...no hot path, isso evita acesso ao heap a cada iteração.stream.next()Por que desabilitar o branch quando o padrão não corresponde?NoneEssa é a diferença chave entre📎 tokio/src/macros/select.rs:198-223。

e uma "race simples". Considere:select!, seread_exact、read_to_end、write_allretornar📎 tokio/src/macros/select.rs:119-124(fim do stream), o padrão não corresponde, o branch é permanentemente desabilitado, evitando polling infinito em um stream já encerrado. O exemplo da documentação depende exatamente dessa semântica para coletar dois streams até que ambos terminemMutex::lock、Semaphore::acquireO verdadeiro significado de cancel safety📎 tokio/src/macros/select.rs:126-133Assim que um branch fica pronto, os Futures dos outros branches são dropados. Se o Future dropado já consumiu dados mas ainda não retornou, os dados são perdidos. A documentação lista explicitamente.awaitcomo não cancel safe.await, enquanto📎 tokio/src/macros/select.rs:135-139。

if, por causa da justiça de fila, o cancelamento perde a posição na fila. Método de julgamento: procureif !sleep.is_elapsed()pontos, se reiniciar a função emsleepainda for correto, então é cancel safeis_elapsed()A armadilha de corrida nas pré-condiçõeswhile: a documentação dá um exemplo clássico de erro — usarselect!guard📎 tokio/src/macros/select.rs:336-376branch, masifpode se tornar true entre a verificação desleepebreak 📎 tokio/src/macros/select.rs:378-405。

biased;, fazendo com que o timeout seja perdido. A forma correta é remover📎 tokio/src/macros/select.rs:67-74, deixar o branchbiased;sempre participar do polling, e após o timeout📎 tokio/src/macros/select.rs:75-81。

o custo de

: o RNG aleatório tem custo de CPU, e alguns cenários precisam de ordem de polling determinística:join!. Masselect!deixa a responsabilidade da justiça para o usuário: se um branch estiver sempre pronto, os branches seguintes sofrerão starvationReady9.3 join! e as restrições de engenharia da expansão de macrospoll_fnModelo intuitivo

é como "esperar ao mesmo tempo que todas as entregas cheguem". Diferente de

join!A expansão de também é baseada em tuplas que armazenam Futures, mas o estado não é uma máscara de bits, e sim uma tupla de "valores concluídos". Após cada Future ser concluído, seu valor é extraído e armazenado na tupla de resultados, e o slot correspondente é marcado como concluído. Diferente deselect!,join!não faz drop de Futures não concluídos — ele deve esperar que todos os Futures sejam concluídos para retornar.

Fluxo Passo a Passo

join!A lógica de polling de compartilha o esqueleto de "tupla armazena Future +select!driver" compoll_fn, mas a semântica é oposta:select!é "retorna assim que qualquer um estiver pronto",join!é "retorna somente quando todos estiverem prontos". A cada rodada de poll, percorre todos os Futures não concluídos; se qualquer um retornarPending, o todoPending; se todosReady, agrega e retorna.

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"]

Reflexões de design e armadilhas em produção

join!A semântica de cancelamento seguro de é diferente deselect!:join!Quando é dropado, todos os Futures não concluídos também serão dropados, o que igualmente pode perder dados. Mas comojoin!não cancela ativamente nenhum branch, ele não faz comoselect!que "cancela este branch porque outro branch ficou pronto". O risco real está emjoin!ser cancelado como um todo peloselect!externo ou por timeout.

join!A diferença entre etry_join!merece atenção:try_join!retorna imediatamente quando qualquer Future retornaErr, cancelando os demais Futures, portanto herdaselect!o risco de cancelamento seguro de .

Reflexões de design

A macro como fronteira de um gerador de código em tempo de compilação。#[tokio::main]Coloca a validação de configuração em tempo de compilação; combinações ilegais (comomulti_thread + start_paused) falham diretamente na compilação, em vez de panic em runtime. Essa é a vantagem central da macro em relação ao Builder: erro antecipado.

Arquitetura híbrida de macro declarativa + macro procedural。select!O corpo de émacro_rules!, mas dois pontos-chave da lógica são delegados a macros procedurais:select_priv_declare_output_enumgera o enumOute o tipoMasklimpa o📎 tokio-macros/src/lib.rs:658-660,select_priv_clean_patternno padrãoref/mut 📎 tokio-macros/src/lib.rs:666-668. Por quê? O comentário explica: macros declarativas têm dificuldade em gerar código que "seleciona dinamicamente o tipo inteiro conforme o número de branches", e também em fazer limpeza em nível de token na posição de padrão📎 tokio/src/macros/select.rs:577-579。

clean_patternA necessidade de。select!fazoutcorresponder ao padrão na forma&out; se o usuário escrever📎 tokio/src/macros/select.rs:727, isso se tornaref vcausando erro de tipo.&ref vremove recursivamenteclean_pattern, bem como oby_ref、mutabilitydo padrãoReference. Este é o compromisso que a macro faz entre a "intuição do usuário" e o "borrow checker".mutability 📎 tokio-macros/src/select.rs:68-73📎 tokio-macros/src/select.rs:100-103A realidade de engenharia do limite de 64 branches

As três macros escrevem manualmente regras de correspondência de 0 a 64。count!、count_field!、select_variant!. O comentário diz francamente "I'm not happy about it either"📎 tokio/src/macros/select.rs:821-1017📎 tokio/src/macros/select.rs:1021-1217📎 tokio/src/macros/select.rs:1221-1414. Este é o preço de macros declarativas não poderem fazer aritmética: só é possível mapear para inteiros codificando pela quantidade de tokens.📎 tokio/src/macros/select.rs:816-817Resumo do capítulo

Reflexões e autoavaliação do capítulo

O

Q1: select!dedisabledA máscara de bits é reinicializada paraselect!a cada entrada emDefault::default() 📎 tokio/src/macros/select.rs:627. Se essa linha for movida para dentro do closurepoll_fn, o que aconteceria no cenário de "chamar select! em loop e algum padrão de branch não corresponder"?

Análise de referência:disabledSe inicializado dentro do closure, cada poll o reinicializaria, fazendo com que branches desabilitados na rodada anterior por incompatibilidade de padrão voltem a participar do polling. ConsidereSome(v) = stream.next() => ...estreamjá encerrado (retornaNone); após a incompatibilidade de padrão, esse branch deveria permanecer permanentemente desabilitado. Sedisabledfor reinicializado, o próximo poll fará poll novamente desse stream já encerrado; se o stream não for fused (ou seja, após encerrar, um novo poll pode causar panic ou comportamento indefinido), haverá problema. Mesmo que o stream seja fused, também desperdiça CPU fazendo poll repetidamente de um stream que sempre retornaNone. A documentação diz explicitamente "Re-entering select! due to a loop clears the disabled state"📎 tokio/src/macros/select.rs:37-38, referindo-se a reentrar na macroselect!(uma nova rodada do loop), e não a múltiplos polls dentro do mesmoselect!.disableddeve ser inicializado fora do closure para manter o estado entre múltiplos polls da mesma chamada deselect!.

Q2: select!Após poll retornarReady(out), executa primeirodisabled |= maske depois corresponde ao padrão📎 tokio/src/macros/select.rs:720-730. Sedisabled |= maskfor removido, o que aconteceria no cenário em que o padrão não corresponde e esse Future retorna imediatamenteReadya cada poll?

Análise de referência: após removerdisabled |= mask, seoutnão corresponder a$bind, o código seguecontinuee continua fazendo polling dos outros branches. Mas na próxima vez quepoll_fnfor chamado (por exemplo, após outro branch retornarPendinge houver novo poll), esse branch ainda não estará desabilitado e será pollado novamente. Se esse Future retornar imediatamenteReadya cada poll e o valor não corresponder ao padrão, forma-se um livelock de "poll -> Ready -> não corresponde -> continue -> outros branches Pending -> retorna Pending -> novo poll -> novamente Ready -> ...", com CPU em busy-wait.disabled |= maské marcado imediatamente apósReady, garantindo que, mesmo se o padrão não corresponder, esse branch não seja pollado novamente. Note que a marcação ocorre antes da correspondência de padrão, então tanto "Ready mas padrão não corresponde" quanto "Ready e padrão corresponde" desabilitam o branch — o primeiro para evitar livelock, o segundo para evitar consumo duplicado.

Q3: parse_knobsInsereif false { let _: &dyn Future<Output = #output_type> = &body; }no caminho não-test para fazer verificação de tipo📎 tokio-macros/src/entry.rs:557-561, mas pula a verificação para tipos que retornam!ou contêmimpl Trait. Por que📎 tokio-macros/src/entry.rs:551-556precisa ser pulado? O que aconteceria se a verificação fosse forçada?impl TraitAnálise de referência

Na posição de retorno é um "tipo opaco"; o compilador não permite forçá-lo a:impl Trait, porque&dyn Future<Output = impl Trait>exige um tipo concreto, enquantodyn 要求具体类型,而 impl TraitO tipo concreto não é visível fora da função. Se forçar a inserção de uma verificação, ocorrerá um erro como "the size for values of typeimpl Futurecannot be known at compilation time" ou "cannot be made into an object". O tipo de retorno!é análogo:!pode ser forçado a converter para qualquer tipo, mas&dyn Future<Output = !>o próprioOutput = !pode acionar problemas de recurso instável do never type. O custo de pular a verificação é: se o usuário escreveuasync fn main() -> impl Traitmas o tipo de retorno real não corresponde aimpl Trait, o erro só será exposto emblock_on, e a mensagem de erro pode não ser tão clara quanto uma verificação explícita. Este é o trade-off entre "integridade da verificação em tempo de compilação" e "limitações do sistema de tipos".

A macro assume do usuário o código boilerplate e a validação em tempo de compilação, mas o que ela gera ainda são Futures comuns e chamadaspoll. No próximo capítulo, deixaremos o mundo de compilação das macros e entraremos na camada de abstração de I/O em tempo de execução, para ver comoAsyncRead/AsyncWritedivide o fluxo de bytes em frames, e comoFramedo framework de codec funciona corretamente sob as restrições de cancelamento seguro deselect!.

#[tokio::main]A essência é "análise de configuração + geração de cadeia Builder +block_onencapsulamento", a validação de configuração é concluída em tempo de compilação, e o flavor determina o ponto de partida do builder e o método build.select!O núcleo é "tupla armazena Future + bitmask registra desabilitados + ponto de partida aleatório garante justiça", se o padrão não corresponder, o branch é desabilitado, e a segurança de cancelamento depende de se o Future descartado pode ser reiniciado em.await.join!eselect!compartilham o esqueleto mas têm semânticas opostas: o primeiro espera que todos terminem, o segundo retorna assim que qualquer um estiver pronto. Os três juntos demonstram o trade-off central do design de macros do Tokio: entregar o código boilerplate e a validação em tempo de compilação às macros, e deixar a complexidade da semântica em tempo de execução (especialmente a segurança de cancelamento) para o usuário entender explicitamente. Depois de entender como as macros geram código em tempo de execução, a próxima pergunta natural é: quando esses códigos realmente começam a ler e escrever fluxos de bytes, que abstrações o Tokio oferece? O Capítulo 10 analisaráAsyncRead/AsyncWritee o framework de codec, para ver comoBufReader/BufWriterreduz chamadas de sistema, comocopy_bidirectionalimpulsiona o encaminhamento bidirecional, e comoFrameddivide o fluxo de bytes em frames, respondendo assim "onde está a fronteira da abstração de I/O assíncrono".

CHAPTER 10

Capítulo 10: Abstração de I/O em streaming: AsyncRead/AsyncWrite e framework de codec

Projeto: tokio-rs/tokio · Progresso do livro: Capítulo 10 / 14 · Status de verificação: linhas FACT com âncoras reais

O capítulo anterior desmontou o processo de expansão do tokio-macros, vimos como #[tokio::main], select!, join! assumem do usuário o código boilerplate e a validação em tempo de compilação. Mas o que as macros geram ainda são Futures comuns e chamadas poll — quando esses Futures realmente começam a ler e escrever bytes, as abstrações de baixo nível fornecidas pelo Tokio são apenas dois traits: AsyncRead e AsyncWrite. O problema deles é serem "baixo nível demais": um poll_read apenas garante "leu alguns bytes", não garante "leu uma mensagem completa". E a grande maioria dos protocolos (HTTP, Redis, gRPC, RPC personalizado) é orientada a "frames" e não a "fluxos de bytes". A pergunta central que este capítulo quer responder é: onde deve ser traçada a fronteira da abstração de I/O assíncrono? A resposta do Tokio tem duas camadas: tokio::io fornece traits e utilitários no nível de fluxo de bytes (BufReader/BufWriter/copy_bidirectional), e o framework codec do tokio-util fornece, sobre isso, adaptação Stream/Sink no nível de frames (Framed/LengthDelimitedCodec). Entender a divisão de trabalho dessas duas camadas é entender "por que quase todas as implementações de protocolo começam com Framed".

I. AsyncRead/AsyncWrite: por que não é possível reutilizar diretamente std::io::Read

Modelo intuitivo

std::io::Read::readé "retirada bloqueante": você fica na janela e, se a mercadoria não chegou, espera indefinidamente, e a thread é suspensa.AsyncRead::poll_readé "retirada com senha de retirada": você pergunta "já está pronto?", se não estiver (Poll::Pending) vai fazer outra coisa, e ao mesmo tempo deixa um Waker para o sistema te avisar quando a mercadoria chegar. Sem esse trait, todo I/O assíncrono teria que registrar manualmenteepolle mapear Waker — que é exatamente o que o Reactor do Capítulo 5 faz, eAsyncReadé a fachada unificada que ele expõe para as camadas superiores.

Estrutura de dados e layout de memória

AsyncReadA definição é extremamente enxuta, com apenas um método:

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

Os três parâmetros têm suas particularidades.self: Pin<&mut Self>em vez de&mut self: porqueAsyncReadfrequentemente é mantido por Futures gerados porasync fn, e um Future, uma vez polled, não pode ser movido (auto-referência),Piné um contrato imposto pelo compilador.cx: &mut Context<'_>carrega o Waker, é o canal de transmissão do "retirador de refeição".buf: &mut ReadBuf<'_>é o encapsulamento do Tokio para&mut [u8]— ele registra simultaneamente "comprimento preenchido" e "capacidade não inicializada", evitandostd::io::ReadAquele tipo de ambiguidade de "retornar o número de bytes lidos mas o buffer pode estar não inicializado".

A documentação lista explicitamente três semânticas de retorno📎 tokio/src/io/async_read.rs:15-32:Ready(Ok(()))indica que os dados foram escritosbuf, a quantidade lida é determinada pelo incremento do comprimento deReadBuf::filled; se o incremento for 0, ou é EOF, ou ébuf.remaining() == 0(buffer com capacidade zero);Pendingindica que atualmente não é legível mas o despertar já foi registrado;Ready(Err(e))é um erro de I/O subjacente. Aqui há uma armadilha facilmente ignorada:"quantidade lida igual a 0" não é equivalente a EOF— se o chamador passar um buffer de capacidade zero,poll_readretornará imediatamenteReady(Ok(()))mas nada foi lido. Se a camada superior tratar "0 bytes" como EOF, julgará erroneamente que a conexão foi fechada.

Walkthrough orientado por cenário: ler um trecho de bytes de&[u8]Considere a implementação mais simples — para

o&[u8]deAsyncRead:

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

Análise passo a passo:self.len()é o comprimento restante da slice não lida,buf.remaining()é a capacidade restante do buffer de destino, pegue o menor valor entre os doisamt。split_at(amt)divida a slice em "oaa ser copiado nesta vez" e "ob」。buf.put_slice(a)restante a ser lido"acopieReadBufpara*self = be avance seu ponteiro filled.&[u8]avance a própria slice para a parte restante — esta é a chave deselfcomo "cursor": após cada poll,Ready(Ok(()))aponta para a parte não lida. Por fim retornePending。

, porque a slice de memória está sempre "pronta", nunca_cxObserve quePendingé ignorado: fontes de dados em memória não precisam de Waker. Isso contrasta com sockets de rede — estes, quando não há dados, retornam

io::Cursor<T>e registram interesse de legibilidade.📎 tokio/src/io/async_read.rs:113-134A implementação deposition()adiciona uma camada extra de verificação de limitespos > slice.len(): primeiro obtenhaReady(Ok(())), se📎 tokio/src/io/async_read.rs:113-134(posição fora dos limites) retorne diretamenteCursorsem panicset_position. Este é um design defensivo:

a position de

AsyncReadpode ser definida para qualquer valor porBox<T>、&mut T、Pin<P>externo, e tratar fora dos limites como "já lido até o fim" é mais compatível com a semântica de I/O do que panic.deref_async_read!Reflexão de design: propagação de macros deref e Pin📎 tokio/src/io/async_read.rs:64-70fornecePin::new(&mut **self).poll_read(cx, buf)paraPin<&mut Box<T>>implementações de encaminhamento. As duas primeiras geramPin<&mut T>através da macroPin<P>, o núcleo é📎 tokio/src/io/async_read.rs:87-93— desreferenciarcrate::util::pin_as_deref_mut(self)paraPin<&mut Pin<P>>e então encaminhar.Pin<&mut P::Target>A implementação dePiné mais sutil

: ela chama

, projetandoBox<dyn AsyncRead>、&mut Tcomopoll_read. Esta camada de projeção é necessária, caso contrárioPinaninhados causariam incompatibilidade de tipos.

---

〔Inferência de design e trade-offs arquiteturais〕

A motivação de design aqui é "abstração de custo zero": implementações de encaminhamento permitem que

copy_bidirectionale outros tipos wrapper não precisem escrever manualmentecopy, mantendo ao mesmo temposelect!semanticamente correto. O custo é que cada camada de encaminhamento introduz uma chamada indireta, que o compilador geralmente consegue eliminar por inlining.select!II. copy_bidirectional: a máquina de estados do encaminhamento bidirecionalcopy_bidirectionalModelo intuitivo

é um "garçom bidirecional": ele observa simultaneamente as duas direções A→B e B→A, e assim que um lado lê dados, escreve no lado oposto. Sem ele, implementar um proxy TCP exigiria escrever manualmente dois

Futures e combiná-los com

rust
enum TransferState {
    Running(CopyBuffer),
    ShuttingDown(u64),
    Done(u64),
}

📎 tokio/src/io/util/copy_bidirectional.rs:10-14

Running(capítulo 9) faria com que dados "lidos pela metade e então cancelados" fossem perdidos.CopyBufferusa uma máquina de estados explícita para preservar os estados intermediários de "ler-escrever-fechar", tornando-se cancel safe.ShuttingDown(u64)Estrutura de dados e layout de memóriaDone(u64)O núcleo é um enum de três estados:cópia。

CopyBuffercontémcopy.rs(incluindo buffer de 8KB e contadores de leitura/escrita), indicando "transferindo dados".DEFAULT_BUF_SIZEcarrega o número de bytes já copiados, indicando "o lado de leitura já atingiu EOF, fechando o lado de escrita".📎 tokio/src/io/util/copy_bidirectional.rs:76-88indica "fechamento concluído, registrando o número final de bytes". Este enum é a chave da cancel safety:CopyBuffera qualquer momento em que for dropado, o estado é preservado no enum, e o próximo poll pode continuar do ponto de interrupção

vem de

copy_bidirectional_impl, o tamanho padrão é determinado porpoll_fn(8KB)

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

independente, portanto o custo de memória é 16KB.transfer_one_directionWalkthrough orientado por cenário: o ciclo de vida completo de um encaminhamento bidirecionalPoll。ready!usaPendingpara combinar as máquinas de estado das duas direções:cópiaObserve a ordem de chamada de📎 tokio/src/io/util/copy_bidirectional.rs:143-144: primeiro avance a→b, depois avance b→a, ambos retornamready!A macro retorna imediatamente quando qualquer direção não terminouDone(count)— mas

transfer_one_directiono estado da outra direção já foi avançadoloop. É exatamente isso que o comentário enfatiza

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

Runningretorne antecipadamente, a outra direção ainda retornarápoll_copyno próximo poll, sem perder progresso.ShuttingDown。ShuttingDownInternamente,poll_shutdowné umDone。Done, avançando por estado:

cópia

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))"]

chama

, que internamente faz um loop "lê um bloco, escreve um bloco" até EOF no lado de leitura ou bloqueio no lado de escrita. Em EOF, retorna o total já copiado e o estado muda para

chamatransfer_one_directionpara fechar o lado de escrita (enviar FIN), e após concluir muda paraasync fnretorna diretamente o contador.CopyBufferO fluxograma abaixo mostra a lógica de avanço e os ramos de erro da máquina de estados unidirecional:copy_bidirectionalcópiaReflexão de design: por que usar uma máquina de estados explícita em vez de async fn〔Inferência de design e trade-offs arquiteturais〕async fnSeselect!fosse escrito comoTransferState, o compilador geraria um Future cujo estado interno (poll_fn, contador já copiado) ficaria oculto na máquina de estados gerada. Isso não é problema no uso unidirecional, mas

precisa, empoll_copyum mesmo ciclo de pollErravançar simultaneamente as duas direções — se usar dois?mais📎 tokio/src/io/util/copy_bidirectional.rs:32, quando uma direção terminar a outra será dropada, e seu buffer interno e contador serão perdidos, violando a cancel safety. Um📎 tokio/src/io/util/copy_bidirectional.rs:67-70explícito expõe o estado na pilha,e a cada nova entrada o estado ainda está lá, garantindo "recuperação do ponto de interrupção após cancelamento".No tratamento de erros,copy_bidirectionalo

copy_bidirectional_with_sizesretornado por📎 tokio/src/io/util/copy_bidirectional.rs:99-125será propagado imediatamente para cima através depoll_copySempre retornaReady(Ok(0))é erroneamente considerado EOF, formando um busy loop.

---

Três, Framed: dividindo o fluxo de bytes em frames

Modelo intuitivo

Framedé a "máquina de salsichas": a montante é um fluxo contínuo de água (AsyncRead/AsyncWrite), a jusante são segmentos de salsicha cortados (Stream<Item = Frame> / Sink<Frame>)。Decoderé responsável por "cortar um segmento do fluxo de água",Encoderé responsável por "embrulhar um segmento em fluxo de água". Se não houverFramed, cada implementação de protocolo teria que escrever manualmente "gerenciamento de buffer + tratamento de meio pacote + divisão de pacotes colados" — exatamente o trabalho repetitivo que o framework codec visa eliminar.

Estrutura de dados e layout de memória

Framedem si é apenas um invólucro fino:

rust
pub struct Framed<T, U> {
    #[pin]
    inner: FramedImpl<T, U, RWFrames>
}

📎 tokio-util/src/codec/framed.rs:38-41

O estado real está emFramedImpldostate: RWFrames, contendoread: ReadFrameewrite: WriteFrameduas partes.ReadFrameOs campos dewith_capacitysão visíveis em📎 tokio-util/src/codec/framed.rs:107-126:eof: bool(se a extremidade de leitura está em EOF),is_readable: bool(se o interesse de leitura já foi registrado),buffer: BytesMut(buffer de leitura),has_errored: bool(se já ocorreu erro, para evitar leitura repetida).WriteFrameCampos📎 tokio-util/src/codec/framed.rs:119-122:buffer: BytesMut(buffer de escrita),backpressure_boundary: usize(limite de backpressure).

backpressure_boundaryé a chave do mecanismo de backpressure: quando o buffer de escrita excede esse limite,poll_readyretornaráPendingaté que os dados sejam descarregados, aplicando assim backpressure aoSinka montante. Por padrão é igual acapacity 📎 tokio-util/src/codec/framed.rs:121, pode ser ajustado através deset_backpressure_boundaryajustar📎 tokio-util/src/codec/framed.rs:271-273。

Walkthrough orientado a cenários: lendo um frame do socket

FramedOStreamda implementação apenas encaminha paraFramedImpl::poll_next 📎 tokio-util/src/codec/framed.rs:309-311. A lógica real está emFramedImpl(este capítulo não fornece esse arquivo, mas pode-se inferir a cadeia de chamadas a partir daFramedinterface):

1. poll_nextPrimeiro verificaread.bufferse já existe um frame completo (chamandocodec.decode);

2. SedecoderetornarSome(frame), produz diretamente, sem tocar no I/O subjacente;

3. Se retornarNone(meio pacote), verificaread.eof: se já está em EOF e o buffer não está vazio, indica que há dados residuais que não podem ser decodificados, retorna erro ouNone;

4. Caso contrário, chama oAsyncRead::poll_readsubjacente para ler mais bytes emread.buffer;

5. Os bytes lidos tentam novamentedecode, em loop até produzir um frame ouPending。

Esta ordem de "primeiro decode depois read" é importante: garante queum read pode produzir múltiplos frames(pacotes colados), eum frame pode abranger múltiplos reads(meio pacote).is_readableO flag evita registrar repetidamente o interesse de leitura — se o poll anterior já registrou e não está pronto, desta vez retorna diretamentePendingsem chamar repetidamente o subjacente.

SinkCadeia de chamadas da implementação📎 tokio-util/src/codec/framed.rs:315-338:start_sendchamacodec.encode(item, &mut write.buffer)para codificar o frame no buffer de escrita;poll_flushdescarregawrite.bufferpara oAsyncWrite;poll_readysubjacente verificawrite.buffer.len() >= backpressure_boundary, se exceder o limite, faz flush primeiro e depois retorna pronto.

O diagrama de sequência abaixo mostraFrameda colaboração entre componentes em uma ida e volta de "ler frame - escrever frame":

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(()))

Cancel safety: aviso da documentação do Framed

FramedA documentação de📎 tokio-util/src/codec/framed.rs:23-30:SinkExt::sendlista especificamente a semântica de cancel safetyselect!Se emfor completado primeiro por outro branch,a mensagem é garantidamente não enviada, mas a mensagem em si é perdidasend— porquepoll_readyinternamente primeirostart_senddepoispoll_ready, se emitemestágio for dropado,StreamExt::nextjá foi consumido mas não codificado. Enquanto

é cancel safe: ele apenas mantém uma referência ao stream subjacente, drop não perderá frames já decodificados.

〔Inferência de design e trade-offs arquiteturais〕read.bufferEsta assimetria origina-se da diferença entre os caminhos de leitura e escrita: o estado do caminho de leitura (Framed) é mantido dentro denext, ser dropado apenas abandona a ação de "obter frame", o buffer não é afetado; o estado do caminho de escrita (itempendente de envio) está nasendpilha de Future, drop significa perda. Em código de produção, se usarselect!dentro desend, deve garantir que a mensagem possa ser reenviada ou aceitar a perda.

Reflexão de design:into_partsemap_codec

Framedforneceminto_parts/from_partspara "trocar codec mas manter o buffer"📎 tokio-util/src/codec/framed.rs:290-298 📎 tokio-util/src/codec/framed.rs:155-166。map_codecé implementado com base neste par de métodos📎 tokio-util/src/codec/framed.rs:221-234: primeirointo_partsseparaio/codec/read_buf/write_buf, depois usamapfunção para converter o codec, finalmentefrom_partsrecombina. Este design permite preservar dados já bufferizados durante atualizações de protocolo (como mudar de texto claro para TLS), evitando releitura.

FramedPartsO_priv: ()campo📎 tokio-util/src/codec/framed.rs:373-375é a técnica de "struct não exaustiva": campos privados impedem construção direta externa, forçando o uso denew/from_parts, permitindo assim adicionar campos no futuro sem quebrar compatibilidade.

---

Quatro, LengthDelimitedCodec: máquina de estados para codec com prefixo de comprimento

Modelo intuitivo

LengthDelimitedCodecé uma faca especializada para "cortar salsicha por comprimento": assume que cada frame tem um campo de comprimento de bytes fixos antes dele, primeiro lê o comprimento depois lê o payload. Sem ele, implementar um protocolo com prefixo de comprimento exigiria escrever manualmente a máquina de estados "ler 4 bytes → analisar comprimento → ler N bytes → loop" — exatamente o que seuDecodeStateinterno faz.

Estrutura de dados e layout de memória

rust
pub struct LengthDelimitedCodec {
    builder: Builder,
    state: DecodeState,
}

enum DecodeState {
    Head,
    Data(usize),
}

📎 tokio-util/src/codec/length_delimited.rs:451-457

DecodeStateé uma máquina de estados explícita:Headindica "está lendo o campo de comprimento",Data(n)indica "já analisou o comprimento n, está lendo o payload". Este estado persiste entredecodechamadas, portantoem cenários de meio pacote o progresso não é perdido。

Buildermantém toda a configuração📎 tokio-util/src/codec/length_delimited.rs:416-435:max_frame_len(padrão 8MB),length_field_len(padrão 4 bytes),length_field_offset(padrão 0),length_adjustment(padrão 0),num_skip(padrãoNone, ou sejaoffset + len)、length_field_is_big_endian(padrão true).

Walkthrough orientado a cenários: decodificando um frame com prefixo de comprimento

decodeé a entrada da máquina de estados:

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

HeadNo estado, chamadecode_head. Se retornarNone(dados insuficientes), retorna diretamenteOk(None)aguardando mais dados; se retornarSome(n), o estado muda paraData(n)。DataNo estado, pega n diretamente. Depois chamadecode_data(n, src): se o buffer já tem n bytes,split_to(n)corta o frame, o estado volta paraHead, e reserva espaço para o próximo cabeçalho de frame; caso contrário retornaNoneaguardando.

decode_headé a lógica central de análise:

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

Análise passo a passo: primeiro verificasrc.len() >= head_len, se insuficiente retornaNone 📎 tokio-util/src/codec/length_delimited.rs:499-502. UsaCursorpara envolversrca fim deadvance/get_uintoperar sem consumir o buffer original.advance(length_field_offset)Pula o prefixo do cabeçalho📎 tokio-util/src/codec/length_delimited.rs:517. Lê conforme endiannessfield_leno valor de comprimento de bytes📎 tokio-util/src/codec/length_delimited.rs:520-524。

Defesa crítica: sen > max_frame_len, retorna imediatamenteInvalidDataerro📎 tokio-util/src/codec/length_delimited.rs:526-531. Isso impede que um par malicioso envie um frame com "campo de comprimento de 4GB" causando esgotamento de memória — esta é a superfície de ataque DoS mais clássica de protocolos com prefixo de comprimento.

O ajuste de comprimento usachecked_sub/checked_addem vez de operação bruta📎 tokio-util/src/codec/length_delimited.rs:537-541, retorna em caso de overflowInvalidInputerro em vez de panic.get_num_skip()Retornanum_skipou o padrãooffset + len 📎 tokio-util/src/codec/length_delimited.rs:1070-1073, ignorando o restante do cabeçalho. Por fim,reserve(n.saturating_sub(src.len()))reserva espaço para o payload📎 tokio-util/src/codec/length_delimited.rs:559——usa-sesaturating_subporquesrcpode já conter parte do payload.

O fluxograma abaixo mostradecodeo caminho de decisão completo:

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))"]

Reflexão de design: recorte e proteção contra overflow de max_frame_len

Builder::adjust_max_frame_lenAo construir o codec,max_frame_lené recortado para o valor máximo que o campo de comprimento pode representar📎 tokio-util/src/codec/length_delimited.rs:1075-1081。max_allowed_frame_lencalculamax_length_field_value + length_adjustment 📎 tokio-util/src/codec/length_delimited.rs:1083-1089, ondemax_length_field_valueusachecked_shlpara tratarlength_field_len == 8o overflow de deslocamento em📎 tokio-util/src/codec/length_delimited.rs:1091-1096. Esse recorte impede configurações contraditórias como "campo de comprimento de 2 bytes mas max_frame_len definido como 1MB" — 2 bytes representam no máximo 65535, e após o recorte max_frame_len passa a ser 65535.

Proteção simétrica no caminho de codificação:encodeverifican > max_frame_lenretornaInvalidInput 📎 tokio-util/src/codec/length_delimited.rs:607-607, o ajuste de comprimento também usachecked_add/checked_sub 📎 tokio-util/src/codec/length_delimited.rs:620-631. Note que a direção do ajuste na codificação é oposta à da decodificação: na decodificação é "comprimento lido ± adjustment = comprimento do payload", na codificação é "comprimento do payload ∓ adjustment = campo de comprimento escrito"📎 tokio-util/src/codec/length_delimited.rs:620-624。

〔Inferência de design e trade-offs arquiteturais〕

Esse design simétrico de "somar na decodificação, subtrair na codificação" serve para unificar a semântica delength_adjustment: ele representa "a diferença entre o valor do campo de comprimento e o comprimento do payload". Quando o campo de comprimento do protocolo inclui o cabeçalho (como no Example 3),adjustment = -2, na decodificaçãon - (-2) = n + 2obtém o comprimento do payload, na codificaçãopayload - (-2) = payload + 2escreve de volta no campo de comprimento.

---

Reflexão de design: os três níveis da fronteira de abstração

Revisando este capítulo, a abstração de I/O do Tokio apresenta uma estrutura clara de três camadas:

Primeira camada: traits de fluxo de bytes (AsyncRead/AsyncWrite). Promete apenas "ler/escrever alguns bytes", sem garantir fronteiras de frame. Esta é a interface mínima, que qualquer fonte de I/O (socket, arquivo, slice de memória) pode implementar. O custo é que a camada superior precisa lidar sozinha com pacotes parciais/colados.

Segunda camada: utilitários de fluxo de bytes (BufReader/BufWriter/copy_bidirectional). Fornece, sobre os traits, capacidades genéricas como "reduzir chamadas de sistema" e "encaminhamento bidirecional".copy_bidirectionalA máquina de estados explícita de

demonstra como a "cancel safety" é implementada na camada de utilitários — o estado é mantido na pilha, não dentro do Future.Framed/Decoder/Encoder)Terceira camada: adaptação de frames (Stream<Frame>/Sink<Frame>. Eleva o fluxo de bytes aLengthDelimitedCodec, permitindo que a implementação do protocolo se preocupe apenas com "codificação/decodificação de frames" em vez de "gerenciamento de buffer".DecodeStateé o exemplo padrão desta camada, e suamax_frame_lenmáquina de estados e

proteção são padrões que todo protocolo com prefixo de comprimento deveria reutilizar.

〔Inferência de design e trade-offs arquiteturais〕tokio-utilA divisão nessas três camadas não é acidental: ela corresponde a três gradientes de "vazamento de abstração". Quanto mais baixo o nível, mais genérico mas mais difícil de usar; quanto mais alto, mais fácil de usar mas mais especializado. O Tokio escolheu colocar o "frame" como cidadão de primeira classe emtokioem vez do núcleo detokio, porque a definição de frame varia por protocolo——tokio-utilfornece apenas fluxo de bytes,Decoder/Encoder。

---

fornece o framework de frames, e protocolos específicos (HTTP/Redis/gRPC) implementam

  • AsyncRead::poll_readem seus respectivos cratesPin<&mut Self> + Context + ReadBufResumo do capítulostd::io::Read::readusaReady(Ok(()))três parâmetros em vez de
  • copy_bidirectional, transformando "espera bloqueante" em "registrar Waker + retornar Pending".TransferStatee quando a quantidade lida é 0, é preciso distinguir EOF de buffer de capacidade zero.Running/ShuttingDown/Doneusaselect!enum de três estados (
  • Framed) para salvar o estado intermediário, permitindo que o encaminhamento bidirecional se recupere mesmo sob cancelamento deAsyncRead/AsyncWrite. Quando ocorre erro, parte dos dados pode ser perdida.Stream/Sink,ReadFrame/WriteFrameadaptaSinkExt::sendparaStreamExt::nextgerenciando separadamente buffers de leitura/escrita e backpressure.
  • LengthDelimitedCodecnão é cancel safe (perda de mensagens),DecodeState(Head/Data(n)é cancel safe.max_frame_lenusachecked_add/checked_sub) máquina de estados para lidar com pacotes parciais,

protege o campo de comprimento contra DoS,

Q1: copy_bidirectionalprotege contra overflow de ajuste.transfer_one_directionReflexões e autoavaliação do capítuloTransferState::ShuttingDownNoready!(w.as_mut().poll_shutdown(cx))?de*state = TransferState::Done(*count), se o

do branch:poll_shutdownfor alterado para diretamenteDone(pulando o shutdown), em quais cenários a conexão do par não conseguirá fechar normalmente?readAnálise de referênciaShuttingDownA função de📎 tokio/src/io/util/copy_bidirectional.rs:35-39é enviar um pacote FIN ao par, notificando "não tenho mais dados do meu lado". Se pulá-lo e ir direto parapoll_shutdown, o lado de escrita não será fechado, e o par ficará esperando dados indefinidamente, formando uma "conexão half-open" — o par pode bloquear para sempre emPendingaté o timeout. Em cenários de proxy TCP, isso causa vazamento de conexões: o cliente já desconectou, mas a conexão do proxy com o backend permanece. No código-fonte, a existência do estadoready!serve

Q2: LengthDelimitedCodec::decode_headjustamente para garantir o fechamento explícito do lado de escrita após EOF. Note queif n > self.builder.max_frame_len as u64em si pode retornar📎 tokio-util/src/codec/length_delimited.rs:526-531(como buffer de envio cheio), então é preciso usar0xFFFFFFFFpara aguardar em vez de ignorar.length_adjustmentEm

, se a verificaçãodenfor removidausize, que consequências um cliente malicioso enviando um cabeçalho de frame com campo de comprimentodecode_data。decode_data(4GB) causaria? Por que essa verificação deve vir antes desrc.len() < n?NoneAnálise de referênciadecode_head: removida a verificação,src.reserve(n.saturating_sub(src.len())) 📎 tokio-util/src/codec/length_delimited.rs:559seria convertido paralength_adjustmente passado paralength_adjustmentverifica-2retorna0xFFFFFFFF - 2, mas ochecked_subno final de

Até aqui, esclarecemos as duas camadas de abstração do Tokio entre fluxos de bytes e quadros de mensagem: tokio::io é responsável pelo transporte de bytes, e o framework codec do tokio-util é responsável pela segmentação de quadros e codificação/decodificação. O motivo pelo qual Framed se torna o ponto de partida para implementações de protocolo é justamente porque encapsula a necessidade de alta frequência de "ler uma mensagem completa" em uma adaptação reutilizável de Stream/Sink. Mas quadros são apenas contêineres de dados; quando o protocolo precisa lidar com conjuntos dinâmicos de tarefas, cancelamento estruturado ou composições de streaming mais complexas, apenas Framed não é suficiente. O próximo capítulo entrará nos mecanismos de extensão do tokio-stream e tokio-util, para ver como os combinadores do StreamExt, StreamMap/JoinSet/TaskTracker e CancellationToken reutilizam o Waker subjacente e o mecanismo de agendamento, fornecendo ferramentas de nível superior para iteração assíncrona e gerenciamento de tarefas.

CHAPTER 11

Capítulo 11: Ecossistema Stream e camada de ferramentas: mecanismos de extensão do tokio-stream e tokio-util

Projeto pertencente: tokio-rs/tokio · Progresso do livro: Capítulo 11 / 14 · Status de verificação: Linhas FACT com ancoragem real

No capítulo anterior, desmontamos o mecanismo em nível de bytes do Framed: o Decoder divide BytesMut em quadros, o Sink escreve os quadros de volta, e a fronteira de abstração de I/O assíncrono torna-se clara. Mas quadros são apenas contêineres de dados; implementações reais de protocolo imediatamente encontram três problemas que nem tokio::io nem Framed resolvem: iteração assíncrona — Framed implementa Stream, mas Stream só tem poll_next, sem next().await, filter, take, merge; escrever poll_fn manualmente é verboso e propenso a erros de segurança de cancelamento; conjuntos dinâmicos de tarefas — um serviço de chat precisa se inscrever simultaneamente em N canais, que entram e saem a qualquer momento, enquanto o número de ramos do select! é fixo em tempo de compilação, incapaz de expressar conjuntos de streams que aumentam ou diminuem em tempo de execução; cancelamento estruturado — select! pode cancelar um único ramo, mas não pode propagar a parada de toda a árvore de tarefas, nem esperar que todas as tarefas realmente terminem. tokio-stream e tokio-util nasceram exatamente para essas três coisas, e seu princípio de design chave é não começar do zero: cada combinador do StreamExt é apenas um wrapper sobre poll_next, StreamMap reutiliza a semântica de registro do Waker, CancellationToken é construído diretamente sobre tokio::sync::Notify, e TaskTracker codifica todo o estado com um AtomicUsize. Entendê-los é, essencialmente, entender como fazer abstrações de custo zero sobre os mecanismos existentes de Waker e agendamento. Este capítulo progride em três camadas: iteração, coleções e cancelamento: primeiro veremos como StreamExt transforma poll_next em um iterador componível, depois como StreamMap e TaskTracker gerenciam coleções dinâmicas, e finalmente como CancellationToken usa uma árvore para propagar sinais de cancelamento por toda a árvore de tarefas.

StreamExt: transformando poll_next em um iterador componível

Modelo intuitivo

Streamé paraFuture, assim comoIteratoré para valores:Futureproduz "um valor",Streamproduz "uma sequência de valores". MasStreamdefine apenaspoll_nextcomo primitiva, assim comoIteratordefine apenasnext. SemStreamExt, cada filtragem, mapeamento ou truncamento exigiria escrever manualmente closures depoll_fne gerenciar manualmentePin— isso é exatamente o ponto mais doloroso para os primeiros usuários do cratefutures.StreamExtO papel deStreamé equiparIteratorcom um ecossistema de combinadores como

. Sem ele, o desastre que o sistema enfrenta não é falta de funcionalidade, mascolapso sistêmico da segurança de cancelamento: cadapoll_fnescrito manualmente pode, ao ser cancelado porselect!, perder um elemento jápollproduzido.

Estrutura de dados e layout de memória

StreamExté umatrait de extensão, que não armazena dados por si só:

📎 tokio-stream/src/stream_ext.rs:106-106

rust
pub trait StreamExt: Stream {

Todos os seus métodos retornam umastruct concreta de combinador, em vez deBox<dyn Stream>. Este é o design chave:mapretornaMap<Self, F>,filterretornaFilter<Self, F>,takeretornaTake<Self>. Essas structs são wrappers genéricos sem alocação no heap, e o compilador pode inline toda a cadeia em camadas de chamadaspoll_next.

Note o blanket impl da trait:

📎 tokio-stream/src/stream_ext.rs:1213-1213

rust
impl<St: ?Sized> StreamExt for St where St: Stream {}

QualquerStreamobtém automaticamente todos os combinadores, sem necessidade de implementação manual.?Sizedpermite quedyn Streamtambém desfrute de métodos de extensão.

A declaração de módulo dos combinadores revela a superfície completa de capacidades desta 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;

Há uma distinção digna de nota aqui:next、try_next、all、any、fold、collectretornaFuture(Next、TryNext、AllFuture...), porque consomem todo o stream em um valor; enquantomap、filter、takeetc. retornamStream, porque mantêm a forma do stream.nextO tipo de retorno deNext<'_, Self>é

📎 tokio-stream/src/stream_ext.rs:144-149

rust
fn next(&mut self) -> Next<'_, Self>
where
    Self: Unpin,
{
    Next::new(self)
}

Self: UnpinCopiarnextA restrição dePiné intencional:!Unpinnão obtém a posse do stream, apenas empresta, portanto não podeBox::pino stream. Se o stream forpin_mut!, o usuário deve primeiro

📎 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.

. A documentação aponta explicitamente este trade-off:mergepolling de

mergeé o melhor exemplo para entender como os combinadores reutilizam o Waker. Ele intercala a produção de dois streams egarante justiça— se ambos os streams estiverem prontos ao mesmo tempo, alterna a produção. A documentação alerta explicitamente para não encadear chamadasmerge:

📎 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.

mergeexige que ambos os streams tenham oItemmesmo tipo:

📎 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)
}

Quando o chamador.next().await, o fluxo de execução é o seguinte:

1. Next::pollchamaMerge::poll_next。

2. Mergemantém internamente um sinalizador booleano de "quem foi o último". Primeiropollo stream que não produziu na última vez; sePending, entãopollo outro.

3. Se ambosPending,MergeretornamPending, masos Wakers de ambos os streams já estão registrados— qualquer um que fique pronto acordará a tarefa atual.

4. Se um stream retornaReady(None)(fim),Mergeregistra que esse stream terminou e, a partir daí, sópollo outro stream, até que ele também termine.

O ponto-chave aqui é:Mergenão tem lógica própria de gerenciamento de Waker; ele passacxcomo está para os dois streams internospoll_next。O registro do Waker é totalmente responsabilidade dos streams subjacentes,Mergeapenas decide "a quem perguntar primeiro desta vez". Esse é o sentido literal de "reutilizar o mecanismo de Waker subjacente".

merge_size_hintsA função auxiliar mostra como os combinadores combinam dicas de capacidade:

📎 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)
}

Observe a escolha entresaturating_addechecked_add: o limite inferior usa adição saturante (prefira subestimar a estourar com panic), o limite superior usa adição verificada (se qualquer um for desconhecido, o todo é desconhecido). Essa é a forma típica de lidar com o contrato desize_hint.

Reflexão de design: cancel safety echunks_timeoutproteção contra panic de

StreamExtA documentação deCancel safetyanotanextem cada método. Tomando

📎 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.

nextcloneNexté cancel safe porque apenas empresta o stream, não consome elementos —nextquando o future é dropado, o estado do próprio stream não muda, e na próximapoll。

irá rechunks_timeoutMas nem todos os combinadores são cancel safe.

📎 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)
}
clone

#[track_caller]〔Inferência de design e trade-offs arquiteturais〕assert!faz a posição do panic apontar para o chamador em vez de para dentro da biblioteca,max_size == 0rejeitamax_size == 0,ChunksTimeoutjá na fase de construção. Por que é obrigatório verificar na construção? Se permitir

timeouta lógica de batching detimeout_repeatingcairia em um loop infinito de "nunca acumular um lote completo" ou produziria lotes vazios, e esse tipo de bug é extremamente difícil de localizar em tempo de execução. O panic na construção antecipa o erro para o ponto observável mais cedo possível.timeoutA diferença entree;timeout_repeatingtambém merece atenção:Intervalretorna um erro após o timeout, mas

📎 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.

---

, até que o stream interno produza um valor. A documentação descreve precisamente essa diferença com dois exemplos:

clone

select!cloneStreamMapStreamMap: coleção dinâmica de streams e polling justoselect!Modelo intuitivonextO número de ramos de(key, value)é fixo em tempo de compilação. Mas o número de canais que um serviço de chat precisa assinar, ou o número de conexões que um crawler precisa rastrear, só é conhecido em tempo de execução.mpscé exatamente um

que pode ser adicionado/removido em tempo de execução: ele coloca qualquer quantidade de streams em uma coleção, e cada

StreamMapretornaVec:

📎 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)>,
}

canal, adicionando uma camada extra de overhead de encaminhamento.

📎 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.
O armazenamento de

é extremamente simples — umHashMapcloneStreamMapA documentação explica explicitamente o custo dessa escolha:clone〔Inferência de design e trade-offs arquiteturais〕VecPor que não usarswap_remove? Porque a operação central deHashMapépoll_nextfazer polling de todos os streamsinsert, e não busca por chave.removeA varredura linear de

inserté amigável ao cache da CPU, e

📎 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, cadaswap_removeteria que percorrer os buckets de hash, com localidade de cache pior.

📎 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
}

e

StreamMapé aceitável sob a premissa de "coleções pequenas de streams".poll_next_entryA implementação dereflete a semântica de "remover antes de inserir":clone

📎 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
    }
}

para trocar o elemento removido com o último elemento e então fazer pop, evitando movimentação O(n):

clone thread_rng_nWalkthrough orientado a cenários: ponto de partida aleatório e correção de cursor em poll_next_entryFastRandO núcleo dexorshift64+é

📎 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_num ponto de partida aleatório% 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
}

cloneswap_removeEste trecho de código tem três sutilezas; vamos destrinchá-las uma a uma:Primeiro, o ponto de partida aleatório.idxusaNonethread-localswap_remove, baseado no algoritmoidx:cloneusa a multiplicação e módulo de Lemire em vez destartcloneidx < start && start <= self.entries.len()Segundo,idx = idx.wrapping_add(1) % lena correção do cursor apósidx == len.

Quando o stream no índicePoll::Pendingretornae é removido,Pendingmove o último elemento para

poll_next. Esse elemento movido podepoll_next_entryjá ter sido pollado

📎 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)
    }
}

). O código usaready!para detectar esse caso e, se for, pula ele (poll_next_entry). Se o removido for o último elemento (Pending), o cursor volta para 0.poll_nextTerceiro,Pending。K: Clonea semântica dekey.clone()。

.

next_manySe percorrer um ciclo inteiro sem nenhum stream pronto, e a coleção não estiver vazia, retornaStreamMap. Nesse momento, os Wakers de todos os streams já estão registrados; qualquer um que fique pronto acordará.

📎 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.

Observe a macronext_many: seretornabuffer, todo obufferretorna imediatamentebufferA restrição vem daqui

poll_next_manyReflexão de design: semântica em lote de next_many e cancel safetypoll_next_entryé a versão em lote de

📎 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
    }
}

clonewhile added < limitSua garantia de cancel safety é crucial:forcloneshould_loop = truePor quelimité cancel safe? Porque ele faz

📎 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_hintA implementação de mostra como agregar dicas de capacidade de múltiplos streams:

📎 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
}

O mesmo padrão demerge_size_hints: saturação do limite inferior com adição, verificação do limite superior com adição, e se qualquer um for desconhecido, o todo é desconhecido.

A seguir, um fluxograma descreve o caminho de decisão depoll_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: codificando todo o estado com um único AtomicUsize

Modelo intuitivo

O encerramento gracioso requer duas coisas:Notificar as tarefas para pararem(CancellationTokené responsável), eAguardar que as tarefas realmente terminem(TaskTrackeré responsável).TaskTrackeré como uma fusão de "contador de tarefas + interruptor de encerramento": enquanto houver tarefas em execução, ou enquanto não for chamado,close,wait()não retornará. Sem ele, você só poderia usarJoinSet, masJoinSetacumularia o valor de retorno de cada tarefa, e um serviço de longa duração sofreria OOM.

Estrutura de dados e layout de memória

TaskTrackeré um wrapper deArc:

📎 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,
}

Este é o layout de memória mais engenhoso deste capítulo:Um únicoAtomicUsizecodifica simultaneamente "se está encerrado" e "contagem de tarefas". O bit menos significativo é o flag de encerramento, e os demais bits são a contagem de tarefas (porque a contagem de tarefas é incrementada de+2a cada vez, o bit menos significativo é sempre 0). Assim,is_closed_and_emptyprecisa de apenas um carregamento atômico:

📎 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
}
〔Inferência de design e trade-offs arquiteturais〕

state == 1significa "bit de encerramento = 1, contagem = 0". Por que não usar duas variáveis atômicas? Duas variáveis exigiriam dois carregamentos e não permitiriam determinar atomicamente "as duas condições satisfeitas ao mesmo tempo". A codificação em uma única variável faz com queis_closed_and_emptyseja um único carregamentoAcquire, e no caminho rápido dewaitnão requer bloqueio.

Walkthrough orientado a cenários: a corrida entre close e drop_task

Considere um cenário típico: a thread principal chamatracker.close(), enquanto a última tarefa está saindo (TaskTrackerToken::dropchamadrop_task). Ambos podem ser concorrentes, e é preciso garantir que, independentemente de quem vier primeiro,wait()possa ser despertado.

Vejamos primeiroset_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)define atomicamente o bit de encerramento e retorna o valor antigo. Se o valor antigo for 0 (não encerrado antes e sem tarefas), isso significa "após o encerramento, satisfaz imediatamente vazio + encerrado", então chamanotify_now. O valor de retorno(state & 1) == 0indica "esta chamada realmente alterou o estado".

Vejamos agoradrop_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)decrementa a contagem. Se o valor antigo for 3 (binário11: bit de encerramento 1 + contagem 1), isso significa "esta é a última tarefa e já está encerrado", então chamanotify_now。

Análise de corrida dos dois caminhos:

  • close executa primeiro:set_closedvê o valor antigo2(contagem 1, não encerrado), não notifica. Em seguida,drop_taskvê o valor antigo3, notifica. ✓
  • drop_task executa primeiro:drop_taskvê o valor antigo2(contagem 1, não encerrado), não notifica. Em seguida,set_closedvê o valor antigo0(contagem 0, não encerrado), notifica. ✓
  • Concorrência:fetch_orefetch_subsão atômicos; independentemente da ordem de intercalação, sempre haverá um que verá a combinação "encerrado + vazio" e notificará. ✓

notify_nowHá em um carregamentoAcquirefacilmente ignorado:

📎 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();
}

Por quedrop_taskusaReleaseem vez deAcqRel? Porque odrop_taskdefetch_subsó precisa "tornar as escritas anteriores visíveis para leitores subsequentes" (semântica Release), e não precisa "ver as escritas anteriores de outras threads" (semântica Acquire). Masnotify_nowprecisa de Acquire para estabelecer happens-before: garantir que todo o trabalho de limpeza feito antes da saída da tarefa seja visível para o código após o retorno dewait(). O resultado desteloadé descartado, puramente por seu efeito colateral de ordenação de memória — este é um uso típico de "carregamento estilo fence" em operações atômicas do Rust.

Reflexão de design: a resistência a ABA de wait e a semântica de drop de TrackedFuture

waitretorna umTaskTrackerWaitFuture, que internamente mantémNotified:

📎 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)
        },
    }
}

Observe o campoinner: se no momento da criação já estiver "encerrado e vazio", define diretamente comoNone,polle retorna imediatamenteReady. Este é o caminho rápido.

A documentação enfatiza especialmente a resistência a 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.

Esta garantia vem da semântica deNotify::notified():Notifiedo future registra sua identidade de "esperante" no momento da criação; mesmo quenotify_waitersseja chamado antes de ele serpoll, ele verá a notificação no primeiropoll.TaskTrackerWaitFuture::pollA implementação de

📎 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
    }
}

CopiarpollA cadais_closed_and_empty(), primeiro verificapoll Notified, depoisNotified. Esta ordem garante que: mesmo que

TrackedFuturepor algum motivo não seja despertado, a verificação de estado também serve como fallback.TaskTrackerA semântica de drop deJoinSeté a diferença central entre

📎 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`].

:ReadyCopiarTrackedFutureIsso significa: mesmo que o future já tenha retornadoTaskTracker, desde que

📎 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.

TaskTrackerTokenconsidera que a tarefa ainda está ativa. A documentação explica por que este design é importante:DropCopiar

📎 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();
    }
}

TrackedFuturedepin_project!é o ponto de disparo do decremento da contagem:tokenCopiarfutureempacotatokenespawn_blockingatravés de

📎 tokio-util/src/task/task_tracker.rs:452-464

, e o drop de

CHAPTER 12

Capítulo 12: Agendamento cooperativo e orçamento: como o mecanismo coop impede que tarefas matem o agendador

Projeto: tokio-rs/tokio · Progresso do livro: Capítulo 12 / 14 · Status de verificação: linhas FACT com ancoragem real

No capítulo anterior, vimos como tokio-stream e tokio-util reutilizam o Waker e o mecanismo de agendamento subjacentes para estender as capacidades centrais. Mas não importa quantos combinadores sejam criados, a contradição central do runtime assíncrono sempre existe: o agendador precisa distribuir o tempo de CPU de forma justa entre várias tarefas, e as tarefas em si não são preemptivas — uma vez que o poll de um Future começa a executar, o agendador não consegue interrompê-lo externamente. Se uma tarefa processa cem mil mensagens em loop dentro de um único poll, ou faz await repetidamente em um Future sempre pronto dentro de um loop, ela monopoliza a worker thread e faz com que outras tarefas na mesma thread nunca tenham chance de serem polladas. Esse é o clássico problema da "tarefa que mata o agendador". A solução do Tokio não é preempção, mas cooperação: cada tarefa recebe um orçamento limitado dentro de um ciclo de agendamento, operações de recursos consomem esse orçamento, e quando ele se esgota a tarefa deve ceder voluntariamente. Este capítulo aprofunda a implementação desse mecanismo coop.

12.1 O portador do orçamento: armazenamento local de thread e a estrutura Budget

〔Inferência de design e trade-offs arquiteturais〕

Se compararmos o agendador ao único garçom de um restaurante, e as tarefas a clientes que não param de pedir pratos, então o orçamento coop é a regra de "cada cliente pode pedir no máximo N pratos" — o garçom não precisa interromper o cliente à força, basta dizer "descanse um pouco enquanto atendo o próximo" depois que o cliente atinge N pedidos. Sem essa regra, um cliente tagarela pode paralisar o restaurante inteiro.

O orçamento precisa satisfazer duas restrições: primeiro, ele deve poder ser acessado a partir de uma pilha de chamadaspollde qualquer profundidade, sem precisar passar parâmetros camada por camada; segundo, ele deve conseguir distinguir "se atualmente estamos dentro do runtime do Tokio" — fora do runtime, ao chamarblock_onnão deve estar sujeito à restrição de orçamento. O Tokio escolheu usararmazenamento local de thread (TLS)para carregar o orçamento, e gerenciá-lo de forma unificada através do módulocontext.

O tipo central do orçamento écoop::Budget. Embora o trecho de código-fonte deste capítulo não forneça diretamente a definição completa decoop.rs, a partir dos pontos de uso deworker.rsé possível inferir seu contrato de interface:

📎 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);
        }
        // ...
    }
})

Aqui aparecem três APIs principais:coop::budget(closure)estabelece um escopo de orçamento,coop::has_budget_remaining()consulta o orçamento restante, e, como veremos adiante,coop::stop()ecoop::set()。budgetA semântica é: ao entrar no closure, o orçamento da thread atual é redefinido para um valor cheio (padrão 128); durante a execução do closure, todas as operações de recursos compartilham essa cota; ao sair do closure, o orçamento externo é restaurado.

〔Inferência de design e trade-offs arquiteturais〕

O valor de orçamento 128 é um valor empírico: é grande o suficiente para que um loop normal de processamento de mensagens (por exemplo, processar algumas dezenas de mensagens em um poll) não dispare cedências com frequência; e pequeno o suficiente para que um loop descontrolado execute no máximo 128 operações de recursos antes de ser obrigado a ceder, mantendo a latência em uma faixa aceitável.

BudgetNo TLS, normalmente existe na forma deCell<Option<Budget>>.OptionA semântica externa deNoneé "se a thread atual está no contexto do runtime do Tokio":block_onindica que não está dentro do runtime (por exemplo,

fora do runtime), e nesse caso todas as verificações de orçamento são liberadas diretamente.

12.2 Os pontos de consumo do orçamento: como as operações de recursos o debitamO orçamento não é consumido do nada; somenteoperações de recursossend/recvo debitam. As chamadas operações de recursos são aquelas APIs que interagem com o mundo externo e podem ser chamadas em loops infinitos — oyield_nowde channel, leitura e escrita de I/O,mpsc::Sender::reserveetc. Tomando

📎 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_innerCopiarcrate::trace::async_trace_leaf()Antes de realmente adquirir a permissão do semáforo,async_trace_leafpassa porcoop::poll_proceed. Essa chamada, que aparentemente serve apenas para tracing, na verdade é um dos pontos de ancoragem do débito de orçamento.ProceedInternamente,Pendingchama uma função do tipo

: se o orçamento for suficiente, debita 1 e retorna; se o orçamento estiver esgotado, registra uma ação de "ceder" — entrega o Waker da tarefa atual ao agendador, retornaPending, e faz a tarefa terminar antecipadamente neste poll.PendingÉ aqui que está a sutileza do coop:

yield_nowo esgotamento do orçamento não lança erro, mas disfarça a "cedência" como umcomum. O Future superior, ao ver:

📎 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
}

é a expressão mais direta do mecanismo de orçamento: ele não consome orçamento, mascontext::defer(cx.waker())dispara ativamente a cedênciawakeCopiarObserve a linha. Ela não chama

diretamente, mas entrega o Waker àContextfila defer

📎 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,
}

deferA fila defer é definida no

📎 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();
}

Se a fila de defer não estiver vazia, o worker chamapark_yield——com timeout 0 para park, o que impulsiona I/O e timer, e então desperta as tarefas em defer. Isso garante que a tarefa que "cedeu" seja reagendada somente após o driver ter executado.

12.3 Estabelecimento e restauração do escopo de orçamento: run_task e block_in_place

O escopo de orçamento é estabelecido emrun_task. Quando cada tarefa é pollada,coop::budgetenvolve todo o processo de poll:

📎 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::budgetNa entrada, define o orçamento no TLS como cheio; na saída, restaura. Isso significa quecada tarefa recebe um orçamento totalmente novo a cada poll. Independentemente de quantasawaitoperações de recursos a tarefa execute internamente, desde que em um únicopollo consumo exceda 128, ela será forçada a ceder.

Mas há um problema sutil aqui: tarefas no LIFO slot são polladasdentro do mesmobudgetclosure. Veja o loop derun_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);
    }
    // ...
}

Ponto-chave: tarefas no LIFO slotcompartilham o orçamento da tarefa externa. O comentário no início derun_taskjá diz: "Tasks from the LIFO slot inherit the "parent"'s limits". Isso é um design intencional — se cada tarefa LIFO resetasse o orçamento, então no cenário ping-pong (tarefa A desperta B, B desperta A), as duas tarefas se agendariam mutuamente infinitamente, o orçamento nunca seria resetado, e o problema de starvation continuaria. Compartilhar o orçamento significa que A e B juntas consomem no máximo 128 operações de recursos, após o que devem ceder.

O próprio LIFO slot também tem um limitador independenteMAX_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_TICKO valor de é 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;

Esta éa segunda linha de defesa: mesmo que o orçamento ainda não tenha se esgotado, o LIFO slot será desabilitado após ser priorizado 3 vezes consecutivas, e as tarefas subsequentes vão para a fila normal. O orçamento controla o "total de operações de recursos", o limitador LIFO controla o "número de vezes que o mesmo par de tarefas se desperta mutuamente", os dois são complementares.

O escopo de orçamento tem uma exceção importante emblock_in_place.block_in_placetransfere o worker core para outra thread, e a thread atual entra em estado de bloqueio. Código bloqueante não está sujeito ao orçamento, então é necessáriopausaro orçamento:

📎 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()retorna o orçamento atual e o define comoNone(ou seja, "fora do runtime"),ResetoDropde restaura após o término do bloqueio:

📎 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)restaura o orçamento previamentestop()salvo. Assim,block_in_placecódigo bloqueante síncrono dentro de não consome orçamento, nem dispara falsamente uma cessão por esgotamento de orçamento; após o término do bloqueio, a tarefa continua a execução com o orçamento restante original.

A figura abaixo mostra o fluxo de controle completo desde o agendamento da tarefa até a cessão por esgotamento de orçamento:

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

Na figura, podem-se ver dois caminhos de cessão: quando o orçamento se esgota, a tarefa LIFO é empurrada de volta para a fila (push_back_or_overflow), e quando o LIFO é priorizado consecutivamente além do limite, o LIFO slot é desabilitado. Ambos retornam ao loop principal, dando ao worker a oportunidade de processar outras tarefas ou o driver.

12.4 Reflexões de design, recuperação de erros e armadilhas em produção

Por que usar TLS em vez de passagem explícita de parâmetros?Os pontos de verificação de orçamento estão espalhados profundamente em vários módulos como channel, I/O, time, etc. Se passados explicitamente, cada API precisaria de um parâmetroBudgetadicional, poluindo toda a interface pública. O TLS torna o orçamento completamente transparente para o código de negócio, ao custo de um acesso TLS por verificação. O Tokio usa#[thread_local]ou TLS rápido específico da plataforma para reduzir essa sobrecarga.

Interação entre esgotamento de orçamento e cancel safety.Quando o esgotamento de orçamento faz com quereserve_innerretornePending, a tarefa pode estar em algum branch deselect!. Se nesse momento outro branch estiver pronto,select!cancela o branch atual —reserve_inneroWakeReceiverOnDropguard de verifica no drop se "o semáforo está fechado e ocioso" e desperta o receptor:

📎 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();
        }
    }
}

A existência deste guard mostra que: oPendingdisparado pelo orçamento e o verdadeiro "sem permissão"Pendingdevem se comportar de forma consistente no caminho de cancelamento, caso contrário o receptor pode nunca receber a notificação de "channel fechado".

Armadilha em produção: latência oculta causada por esgotamento de orçamento.Um fenômeno comum é: uma tarefa de repente fica mais lenta para processar mensagens, mas o uso de CPU não é alto. Ao investigar, é fácil suspeitar de contenção de lock ou I/O, mas na verdade pode ser que a tarefa tenha processado mais de 128 mensagens em um único poll, disparando cessão de orçamento, e cada cessão passa por um ciclo completo de "empurrar de volta para a fila → reagendar → poll do driver". Se o processamento de mensagens em si é rápido, essa sobrecarga de agendamento pode representar uma proporção alta. A solução é dividir o processamento em lote em múltiplasspawntarefas, ou inserir explicitamenteyield_now。

no loop. Fronteira entre orçamento eblock_in_place.Vimos anteriormente queblock_in_placefazcoop::stop()pausar o orçamento. Mas atenção:coop::stop()só é chamado quandohad_enteredé verdadeiro, ou seja, somente pausa quando está de fato na thread do worker do runtime. Seblock_in_placefor chamado fora do runtime,f()executa diretamente, e o estado do orçamento não muda. Essa verificação de branch é feita emmaybe_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(());
        }
    }
    // ...
})

As quatro combinações correspondem a: dentro da thread do worker,block_onentrada do thread pool deblock_in_place, aninhado

, fora do runtime. Apenas as duas primeiras precisam pausar o orçamento e transferir o core.

〔Inferência de design e trade-offs arquiteturais〕O valor do orçamento não é configurável.Builderopção. Isso é intencional: o valor do orçamento afeta o equilíbrio entre justiça de agendamento e throughput; se os usuários pudessem ajustá-lo livremente, seria fácil configurar algo como "orçamento grande demais causando starvation" ou "orçamento pequeno demais causando explosão de overhead de agendamento". O Tokio escolhe tratá-lo como uma invariante interna.

Resumo do capítulo

O mecanismo coop resolve o problema de justiça do agendador não preemptivo com um design de três camadas:

1. Portador do orçamento:coop::Budgetexiste no TLS,Optiona camada externa distingue dentro e fora do runtime,coop::budgetestabelece um escopo com cota cheia,coop::stop/coop::setsuporta pausa e retomada (block_in_placecenários).

2. Pontos de consumo: operações de recursos (envio/recepção em channel, I/O,yield_now) através decoop::poll_proceeddecrementam o orçamento; ao esgotá-lo, disfarçam o "ceder" comoPending, de forma transparente para o negócio.

3. Caminho de cessão:yield_nowatravés decontext::deferentrega o Waker à fila defer, garantindo que só haja reagendamento após o driver fazer polling; tarefas no LIFO slot compartilham o orçamento da tarefa pai e têmMAX_LIFO_POLLS_PER_TICK = 3de limitação independente.

A percepção-chave desse mecanismo é:justiça não exige preempção, basta fazer com que o "loop infinito" seja interrompido naturalmente após um número finito de passos. O orçamento é justamente a medida desse "número finito de passos".

Reflexões e autoavaliação do capítulo

Q1: Se emrun_taskocoop::budgetloop LIFO dentro do closure fosse alterado para chamarcoop::budgetpara redefinir o orçamento antes de cada polling de tarefa LIFO, o que aconteceria no cenário ping-pong (tarefa A acorda B, B acorda A)? Por que o código-fonte escolhe fazer as tarefas LIFO compartilharem o orçamento da tarefa pai?

Análise de referência: o código-fonte emrun_taskcomenta explicitamente "Tasks from the LIFO slot inherit the "parent"'s limits"📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:679-682. Se cada tarefa LIFO redefinisse o orçamento, então no cenário ping-pong A→B→A→B, cada polling obteria cota cheia de orçamento, e as duas tarefas poderiam se agendar mutuamente indefinidamente, nunca cedendo por esgotamento de orçamento. EmboraMAX_LIFO_POLLS_PER_TICK = 3a limitação desative o LIFO slot após 3 vezes📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:756-766, depois de desativar o LIFO as tarefas passam pela fila normal; se houver apenas A e B na fila, elas ainda serão alternadamente agendadas, apenas sem a prioridade LIFO. O orçamento compartilhado, porém, garante no total de operações de recursos: A e B juntos podem consumir no máximo 128 operações de recursos antes de precisarem ceder, dando oportunidade a outras tarefas e ao driver. As duas linhas de defesa são complementares e ambas indispensáveis.

Q2: yield_nowusacontext::defer(cx.waker())em vez decx.waker().wake_by_ref(). Suponha que se alteredeferparawakediretamente; no cenário de worker único com múltiplas tarefas, o que aconteceria se uma tarefa chamasse repetidamenteyield_nowem um loop? Analise em conjunto com o branchpark_yielddo loop principal do worker.

Análise de referência:yield_nowos comentários explicam o motivo: wake direto empurraria a tarefa imediatamente de volta à fila de execução, podendo fazer com que ela fosse pollada novamente antes de o driver de I/O/timer rodar📎 tokio/src/task/yield_now.rs:49-54. No cenário de worker único, se a tarefa repetidamenteyield_nowem um loop e a cada vez fizesse wake direto, onext_taskdo loop principal do worker pegaria imediatamente essa tarefa e faria polling de novo,park_yieldo branch (responsável por acionar I/O e timer)📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:613-621nunca seria executado, porque a fila defer estaria vazia e a fila local sempre teria tarefas. O resultado é que eventos de I/O e timers nunca seriam processados, e todo o runtime ficaria "falso vivo" — as tarefas rodam, mas eventos do mundo externo não conseguem avançar.deferA fila garante que uma tarefa que cedeu só seja acordada depois do polling do driver, dando assim uma janela de execução ao driver.

Q3: block_in_placeemcoop::stop()define o orçamento comoNone,Reset::dropemcoop::set(self.budget)restaura. Se dentro do closureblock_in_placedefhouver novamente uma chamada ablock_in_place(aninhada), como ficaria o estado do orçamento?maybe_move_runtimeQual branch de

trata esse caso?Análise de referênciablock_in_place: aninhamento demaybe_move_runtimeé tratado pelo(context::EnterRuntime::NotEntered, true)branch em📎 tokio/src/runtime/scheduler/multi_thread/worker.rs:454-458. Esse branch diretamentereturn Ok(()), sem definirhad_entered, portanto oblock_in_placedoif had_enteredexterno é avaliado como falso e não chama novamentecoop::stop()nem cria um novoReset. O comentário explica "This is a nested call to block_in_place (we already exited). All the necessary setup has already been done." — a camada externa já pausou o orçamento e transferiu o core; a camada interna só precisa executar diretamentef(). Se a camada interna chamasse novamentecoop::stop(), salvaria de novo um orçamento que já éNone,Reset::drope na restauração poderia restaurar um valor errado (Noneem vez do orçamento original da camada externa), causando perda permanente do orçamento, e todas as operações de recursos subsequentes da tarefa ficariam sem restrição.

O mecanismo coop, por meio da restrição de orçamento, faz as tarefas cederem ativamente durante operações de recursos, mantendo assim a justiça de agendamento sob o modelo não preemptivo. Mas o Pending disparado pelo esgotamento do orçamento deve se comportar de forma consistente com uma espera real no caminho de cancelamento; caso contrário, combinadores como select! quebrarão a consistência de estado. O próximo capítulo entrará em armadilhas de produção e condições de contorno: cancel safety, propagação de panic e ordem de shutdown; veremos mais casos desse tipo, em que "mecanismos aparentemente não relacionados se acoplam nas bordas".

CHAPTER 13

Capítulo 13: Armadilhas de produção e condições de contorno: cancel safety, propagação de panic e ordem de shutdown

Projeto: tokio-rs/tokio · Progresso do livro: Capítulo 13 / 14 · Status de verificação: linhas FACT com ancoragem real

No capítulo anterior, analisamos o orçamento de cooperação do coop: cada tarefa tem apenas um orçamento limitado dentro de um ciclo de agendamento e, quando esgotado, deve ceder, evitando assim que uma única tarefa faça as outras passarem fome. Mas o mecanismo de orçamento resolve apenas o problema de "agendamento justo"; em ambientes de produção reais há também uma categoria mais sutil de armadilhas — segurança de cancelamento, propagação de panic e ordem de encerramento. Quando select! cancela um Future, quando um panic de tarefa é capturado, quando o Runtime começa a encerrar, o comportamento de fronteira do código muitas vezes contradiz a intuição. Este capítulo começa pela segurança de cancelamento e primeiro examina o que exatamente se perde em um Future descartado.

13.2 Propagação de panic: como JoinError captura falhas

Modelo intuitivo

Um panic de tarefa do Tokio não faz o processo inteiro falhar (a menos que panic=abort), mas é capturado, empacotado comoJoinErrore retornado viaJoinHandle::awaitÉ como um acidente em uma estação da linha de montagem de uma fábrica: a rede de segurança segura o trabalhador, mas o produto é descartado — você recebe um "relatório de acidente" em vez do produto.

Estrutura de dados e estados

JoinHandle<T>OFuture::Outputdesuper::Result<T>éResult<T, JoinError> 📎 tokio/src/runtime/task/join.rs:325。JoinErrorou seja,

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

CopiarRawTaskO mecanismo pelo qual o panic é capturado está no caminho de poll decatch_unwindquando a tarefa faz poll, ela é envolvida porJoinHandle::pollapós o panic ocorrer, o payload é armazenado no slot de saída da tarefa, o estado é marcado como complete e então o join waker é acordado.try_read_outputO que é lido viaErr(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)

CopiarJoinErrorPonto-chave: o payload do panic é preservado integralmente,std::error::Errorimplementainto_panic()e é possível recuperarBox<dyn Any + Send>viadowncast_ref::<&str>()e então extrair a mensagem do panic com

Reflexões de design e armadilhas

Armadilha 1:JoinHandleOUnwindSafede

rust
impl<T> UnwindSafe for JoinHandle<T> {}
impl<T> RefUnwindSafe for JoinHandle<T> {}

📎 tokio/src/runtime/task/join.rs:176-181

CopiarT: UnwindSafeEsta é uma implementação incondicional e não exigeJoinHandleMotivo:T,Tem si não mantémcatch_unwindna alocação de tarefa no heap, durante o panic já foi isolado porTPortanto, mesmo queUnwindSafe,JoinHandlenão seja

também é seguro.Armadilha 2: panic não se propaga automaticamente para a tarefa pai.JoinHandleSe a tarefa A fez spawn da tarefa B e B sofreu panic, A não será notificada automaticamente, a menos que A tenha feito await no

de B. Se A não fez await, o panic de B é silenciosamente engolido. Esta é uma das fontes de bugs mais sutis em ambientes de produção.spawn_blockingArmadilha 3:O panic decatch_unwindtambém é capturado.MutexOs workers do pool de threads bloqueantes também envolvem a tarefa comstd::sync::Mutexapós o panic a thread não morre, mas volta ao pool para continuar pegando trabalho. Porém, se você mantém

em uma tarefa bloqueante e não o libera durante o panic, isso causa envenenamento de lock — este é o comportamento inerente dee o Tokio não interfere.catch_unwindArmadilha 4: panic durante o drop do Runtime.

Se uma tarefa sofre panic durante o drop do Runtime,

ainda tem efeito, mas nesse momento o join waker pode já estar inválido e o payload do panic será descartado. Este é um subconjunto do problema de ordem de encerramento, que será expandido na próxima seção.

13.3 Ordem de encerramento: limpeza de threads bloqueantes e recursos de I/O

Modelo intuitivo

RuntimeO encerramento do Runtime é como fechar um restaurante: primeiro a recepção para de aceitar clientes (para de aceitar novas tarefas), depois espera a cozinha terminar os pratos em andamento (tarefas assíncronas executam até o próximo ponto de yield) e, por fim, espera os ajudantes terceirizados terminarem (threads bloqueantes retornam). Se a ordem estiver errada, surgem problemas — por exemplo, se os ajudantes forem dispensados primeiro, os pratos da cozinha nunca ficarão prontos.

rust
pub struct Runtime {
    scheduler: Scheduler,
    handle: Handle,
    blocking_pool: BlockingPool,
}

📎 tokio/src/runtime/runtime.rs:97-106

DropOs três campos de

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

CopiarDropImplementação descheduler,Copiarblocking_pool。blocking_poolObservação:Droptrata apenas deRuntime::dropnão trata explicitamente descheduler → handle → blocking_poolO encerramento de

ocorre em seu próprioshutdown_timeoutapós

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

Portanto, o pool bloqueante é o último a ser encerrado.handle.inner.shutdown()Masblocking_pool.shutdown(Some(duration))controla explicitamente a ordem:duration。

Copiar

blocking/shutdown.rsPrimeiro

rust
pub(super) struct Sender {
    _tx: Arc<oneshot::Sender<()>>,
}

pub(super) struct Receiver {
    rx: oneshot::Receiver<()>,
}

📎 tokio/src/runtime/blocking/shutdown.rs:13-19

espera pelas tarefas bloqueantes, no máximoSenderMecanismo de baixo nível do encerramento do pool bloqueanteArc<oneshot::Sender>usa um engenhoso oneshot channel:SenderCopiarReceiverCada worker bloqueante mantém umwaitclone (internamente é

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

são dropados,

1. timeout == Some(0)recebe a notificação.shutdown_backgroundMétodo

2. try_enter_blocking_region()CopiarNone。

Análise passo a passo:

retorna false diretamente — este é o caminho deblock_on_timeoutsem esperar.

tenta entrar na região bloqueante. Se estiver atualmente em um contexto assíncrono (por exemplo, drop do Runtime dentro de uma tarefa async), retorna

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. Com timeout, usa

e retorna false ao expirar; sem timeout, espera indefinidamente.A mensagem de erro é bem clara: «Cannot drop a runtime in a context where blocking is not allowed»📎 tokio/src/runtime/blocking/shutdown.rs:51-54. A solução é usarshutdown_background(), que é equivalente ashutdown_timeout(Duration::from_nanos(0)) 📎 tokio/src/runtime/runtime.rs:494-496, sem esperar por tarefas bloqueantes.

Armadilha 2:shutdown_backgroundvai vazar tarefas bloqueantes.A documentação avisa explicitamente «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. As tarefas bloqueantes continuarão a correr até retornarem naturalmente, mas o Runtime já foi dropado, e os recursos que elas detêm podem já ter expirado.

Armadilha 3: Recursos de I/O tornam-se inválidos após o drop do Runtime.A documentação indica «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_errA função serve precisamente para detetar este tipo de erro📎 tokio/src/runtime/runtime.rs:585-593。

Armadilha 4:Dropespera indefinidamente por defeito.A documentação refere «TheDrop implementation waits forever for this」📎 tokio/src/runtime/runtime.rs:43-44. Se uma tarefa bloqueante ficar presa (por exemplo, num ciclo infinito), o drop do Runtime ficará suspenso permanentemente. Em produção, deve usar-seshutdown_timeoutpara definir um limite.

13.4 Tratamento de sinais e conflitos entre múltiplos Runtimes

Modelo intuitivo

Os sinais Unix são ao nível do processo, mas oSignaldo Tokio está vinculado ao Runtime. É como se todo o edifício partilhasse um único alarme de incêndio, mas cada sala tivesse o seu próprio recetor — a primeira pessoa a instalar um recetor alterou a forma como o alarme está ligado, e as seguintes só podem partilhar essa alteração.

Estruturas de dados e estado global

signal_enableé o ponto de entrada para registar handlers de sinais:

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

Pontos-chave:

1. signal <= 0 || FORBIDDEN.contains(&signal)rejeita sinais inválidos.

2. handle.check_inner()verifica se o driver de sinais está em execução — se o Runtime já foi encerrado, isto falhará.

3. siginfo.init.get_or_init(...)usaOnceLockpara garantir que cada sinal regista apenas uma vez o handler do SO.get_or_initO closure de chamasignal_hook_registry::register, que é um registo global, ao nível do processo.

4. O handler registado éaction(globals, signal), que faz duas coisas:globals.record_event(signal)regista o evento e depois escreve um byte no pipe para acordar o driver📎 tokio/src/signal/unix.rs:252-259。

A raiz dos conflitos entre múltiplos Runtimes

globals()O que é retornado é o global ao nível do processoGlobals,OsExtraDataOUnixStreamdentro de também é global:

rust
pub(crate) struct OsExtraData {
    sender: UnixStream,
    pub(crate) receiver: UnixStream,
}

📎 tokio/src/signal/unix.rs:61-64

DefaultA implementação cria um parUnixStream 📎 tokio/src/signal/unix.rs:61-64. Este pipe é globalmente único, e todos os drivers de sinais dos Runtimes o partilham.

O problema surge:signal_enabledentro dehandle.check_inner()verifica oRuntime atualdo driver de sinais. Mas o handler registado porsignal_hook_registry::registeréao nível do processo, e escreve noglobalpipe. Se o Runtime A registar SIGINT primeiro, e depois o Runtime B também registar SIGINT,get_or_initretornará diretamente oOk(())existente, sem registar novamente. Mas o driver de sinais do Runtime B lerá dados do pipe global — os dois Runtimes competirão pelos bytes do mesmo pipe.

Walkthrough orientado a cenários: competição de sinais entre múltiplos Runtimes

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 竞争读取,只有一个能读到字节

Reflexões de design e armadilhas

Armadilha 1: O handler de sinais nunca é desinstalado.A documentação avisa explicitamente «Once a signal handler is registered with the process the underlying libc signal handler is never unregistered»📎 tokio/src/signal/unix.rs:379-380. Mesmo que a instânciaSignalseja dropada, os sinais subsequentes continuarão a ser capturados pelo Tokio, e o comportamento padrão não será restaurado📎 tokio/src/signal/unix.rs:338-340。

Armadilha 2: Os sinais são coalescidos.A documentação indica «beforepoll is called, all signal notifications are coalesced into one item returned from poll」📎 tokio/src/signal/unix.rs:312-315. Se receber 10 SIGINT mas só fizer poll uma vez, verá apenas um evento. Esta é uma característica dos próprios sinais Unix (sinais padrão não são enfileirados), o Tokio não faz coalescência adicional.

Armadilha 3: Em múltiplos Runtimes, os sinais podem perder-se.Como o pipe global é lido competitivamente por vários Runtimes, um Runtime pode consumir o byte enquanto outro nunca o recebe. Em produção, deve tratar-se os sinais num único Runtime, ou usarsignal_hookpara gerir manualmente.

Armadilha 4:signalCondições de panic da função.A documentação indica «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. Chamarsignal()fora do Runtime causará panic.

Armadilha 5:recv()Cancel safety deA documentação garante «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. Isto porque os eventos de sinais residem no globalEventInfo,recv()apenas lê, não consome o estado subjacente.

Reflexões de design

Os três temas deste capítulo partilham um padrão subjacente:A propriedade do estado determina a segurança de cancelamento/encerramento/sinais。

  • JoinHandleé cancel-safe, porque a saída está no heap e o handle é apenas uma referência.
  • A ordem de encerramento do Runtime é sensível, porque o pool de bloqueio e o scheduler partilhamHandle, a ordem errada causará deadlock ou panic.
  • Sinais com múltiplos Runtime entram em conflito, porque o handler e o pipe são estados globais em nível de processo, enquantoSignalé uma visão em nível de Runtime.

Depois de entender esse padrão, a lista de armadilhas a evitar pode ser resumida em três princípios:

1. Cancelamento seguro = estado fora do Future.Se o Future tiver um buffer interno, o drop perderá dados.JoinHandle、Signal::recv、tokio::sync::mpsc::Receiver::recvTodos satisfazem essa condição.

2. Ordem de encerramento = ordem inversa da direção de dependência.Quem depende de quem, encerre primeiro o dependido. O scheduler depende do driver de I/O, então encerre o scheduler primeiro; o pool de bloqueio é independente, encerre por último.

3. Estado global = conflito entre múltiplas instâncias.Qualquer recurso em nível de processo (signal handler, pipe, tabela de descritores de arquivo) entrará em conflito sob múltiplos Runtime; ou restrinja a um único Runtime, ou use sincronização externa.

Resumo do capítulo

Reflexões e autoavaliação do capítulo

Q1: Se removermosJoinHandle::polldecoop::poll_proceed(cx), em quais cenários isso faria outras tarefas passarem fome? Por quetry_read_outputem si não consome orçamento?

Análise de referência:coop::poll_proceed(cx)consome o orçamento de cooperação em📎 tokio/src/runtime/task/join.rs:325-325. Se for removido, uma tarefa que repetidamenteselect!múltiplosJoinHandleem um loop pode fazer polling infinito de todos os handles em um único ciclo de agendamento, nunca retornandoPending, fazendo assim outras tarefas no mesmo worker passarem fome.try_read_outputem si não consome orçamento, porque é apenas uma leitura de memória + possível armazenamento de waker, não envolve I/O nem disputa de lock, e o custo é extremamente baixo. A intenção de design do mecanismo de orçamento é restringir "operações que podem rodar por muito tempo", e não cobrar a cada poll. Observe quecoop.made_progress()só chamaret.is_ready()quando📎 tokio/src/runtime/task/join.rs:349-351, ou seja, só devolve o orçamento quando realmente obtém saída — isso é para evitar que operações de "fez polling mas não teve resultado" acumulem consumo de orçamento.

Q2:blocking/shutdown.rsNo métodowaitdetry_enter_blocking_region(), seNoneretornarfalsee atualmente estiver em panic, por que escolher retornar

em vez de continuar esperando? O que aconteceria se fosse alterado para continuar esperando?:try_enter_blocking_region()Análise de referênciaNoneretornar📎 tokio/src/runtime/blocking/shutdown.rs:44-57indica que atualmente estamos em contexto assíncrono e não é permitido bloquearfalse. Se neste momento estiver em panic, o código escolhe retornar📎 tokio/src/runtime/blocking/shutdown.rs:47-49sem esperarblock_on. A razão é: um novo panic durante o unwinding de panic faz o processo abortar (double panic). Se fosse alterado para continuar esperando, seria necessário chamarblock_on, e em contexto assíncronofalsecausaria panic — panic durante o unwinding de panic aborta diretamente o processo, perdendo todas as informações de diagnóstico. Retornar

permite que o drop continue até o fim, preservando as informações de panic. Este é um design de "degradação graciosa": um encerramento incompleto é melhor do que o processo quebrar.SignalQ3: Suponha que você criouSignalno Runtime A para escutar SIGTERM, e então moveusignal_enablepara o Runtime B para fazer poll.handle.check_inner()Dentro deSignal, qual Runtime é verificado? Se o Runtime A for dropado primeiro, o

no Runtime B ainda poderá receber sinais?:signal_enableAnálise de referênciasignal()é executado quandohandleé chamado; neste momento📎 tokio/src/signal/unix.rs:398-405。check_inner()é do Runtime A📎 tokio/src/signal/unix.rs:275。SignalO que é verificado é o driver de sinais do Runtime ARxFutureInternamente éwatch::Receiver<()> 📎 tokio/src/signal/unix.rs:366-368, envolvendoGlobals, e este receiver é registrado no globalEventInfoemrecord_event. Se o Runtime A for dropado, seu driver de sinais para de ler dados do pipe global, mas o handler global ainda faráEventInfoe escreverá no pipe. Se o driver de sinais do Runtime B também estiver em execução, ele lerá os dados do pipe e dispararáSignal, acordando assim o waker deSignal . Portanto, ono Runtime B podeSignalainda receber sinais, mas depende de o Runtime B ter um driver de sinais em execução. Se o Runtime B não tiver driver de sinais (por exemplo, se o feature signal não estiver habilitado ou o driver já estiver fechado), ninguém lerá os dados do pipe,

e nunca haverá wakeup. Essa é a fragilidade do tratamento de sinais com múltiplos Runtime.

Transição de fim de capítulocatch_unwindCancelamento seguro, propagação de panic, ordem de encerramento, conflito de sinais — a raiz comum desses quatro problemas é a ambiguidade da "propriedade de estado" nas fronteiras assíncronas. O Tokio, ao colocar o estado no heap, gerenciar o ciclo de vida com contagem de referências, isolar panic comGlobalse compartilhar estado de sinais com

global, oferece respostas utilizáveis em engenharia. Mas todas essas respostas têm condições de contorno, e o ambiente de produção deve tratá-las explicitamente.

Com isso, percorremos as áreas limítrofes mais propensas a erros no ambiente de produção do Tokio: a segurança de cancelamento depende de que a saída seja armazenada no heap e da atomicidade de try_read_output; JoinHandle::drop não cancela a tarefa, apenas abort realmente cancela, mas é ineficaz para spawn_blocking; panic capturado por catch_unwind é empacotado como JoinError e, se não for aguardado com await, é silenciosamente perdido; o encerramento do Runtime segue uma ordem estrita, e fazer drop em contexto async causa panic; signal handler é estado global em nível de processo e, uma vez registrado, nunca é desinstalado. Por trás dessas regras estão as repetidas ponderações do Tokio entre correção e desempenho. No próximo capítulo, sairemos dos mecanismos concretos para revisar, do ponto de vista arquitetural, a origem dessas ponderações e vislumbrar para onde io_uring, a refatoração dos drivers e a interface de executores personalizados levarão o Tokio.

CHAPTER 14

Capítulo 14: Ponderações arquiteturais e evolução futura: de io_uring a drivers plugáveis

Projeto pertencente: tokio-rs/tokio · Progresso do livro: Capítulo 14 / 14 · Status de verificação: linhas FACT com ancoragem real

No capítulo anterior, examinamos quatro tipos de armadilhas de produção: segurança de cancelamento, propagação de panic, ordem de encerramento e conflitos de sinais. Embora pareçam dispersas, todas apontam para o mesmo problema arquitetural: como a propriedade de estado é claramente delimitada nas fronteiras assíncronas. E a forma de delimitar a propriedade é determinada precisamente por três decisões arquiteturais no nível mais baixo do runtime — como as tarefas são escalonadas, como os eventos de I/O são distribuídos e como a correção de concorrência é verificada. Este capítulo não mergulha nos detalhes de implementação de uma função específica, mas se posiciona no nível arquitetural para revisar as escolhas do Tokio nessas decisões e, seguindo as pistas de evolução já plantadas na documentação oficial e no código-fonte, ver para onde io_uring, a refatoração dos drivers e a interface de executores personalizados levarão o Tokio. Ao final deste capítulo, você deverá ser capaz de responder a uma questão prática: quando estender o Tokio e quando contorná-lo.

I. Três ponderações históricas: por que é assim hoje

Modelo intuitivo

Imagine o Tokio como um restaurante que já funciona há dez anos. A forma de organizar os turnos da cozinha (work-stealing), a estrutura independente dos garçons (separação entre driver de I/O e escalonador) e o sistema de inspeção sanitária da cozinha (verificação de concorrência com loom) não foram projetados no primeiro dia de funcionamento, mas evoluíram gradualmente no processo de "mais clientes, pratos mais complexos". Compreender essas evoluções é o que permite julgar quais designs são planejamento visionário e quais são fardos históricos.

Ponderação 1: work-stealing em vez de fila global

〔Inferência de design e ponderação arquitetural〕

A implementação com fila global é a mais simples: todas as tarefas entram em umaMutex<VecDeque>, e as threads worker disputam o lock para pegar tarefas. Mas a contenção de lock piora com o aumento do número de núcleos, e a localidade de cache é ruim — em qual núcleo uma tarefa é criada e em qual é executada é completamente aleatório.

A escolha do work-stealing é: cada worker mantém uma fila local,spawnprioriza entrar na fila local (sem lock, amigável ao cache), e só quando a local está vazia vai roubar do final da fila de outro worker. O custo é que o balanceamento de carga tem latência, e o roubo em si exige operações atômicas e barreiras de memória. O Tokio escolheu o segundo porque servidores modernos facilmente têm dezenas de núcleos, e o custo da contenção de lock é muito maior do que o custo ocasional do roubo.

〔Inferência de design e ponderação arquitetural〕

A condição de contorno dessa decisão é:a granularidade das tarefas não pode ser muito fina. Se cada tarefa executa apenas alguns microssegundos de trabalho, a proporção do custo de roubo e escalonamento fica fora de controle. É também por isso que o Tokio, além despawn_blocking, exige que tarefas longas façamyield_now()ativamente — o escalonamento cooperativo, em essência, serve como rede de segurança para o work-stealing.

Ponderação 2: driver de I/O independente do escalonador

Este é o ponto mais instigante do material de código-fonte deste capítulo. Veja a estrutura de módulos detokio/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();

Observe quedriver、registration、scheduled_iosão três módulos independentes, e externamente expõem apenas os tiposDriver、Handle、ReadyEvent、Registration.ScheduledIoépub(crate)— ele é envolvido porPtrExposeDomain, usado para expor ponteiros brutos à verificação de concorrência sob testes com loom.

〔Inferência de design e ponderação arquitetural〕

o runtime single-thread também precisa do driver de I/O, mas não precisa do escalonador work-stealing — a separação permite que os dois runtimes reutilizem a mesma implementação de I/O.block_on 单线程运行时也需要 I/O 驱动,但不需要 work-stealing 调度器——分离让两种运行时能复用同一套 I/O 实现。

Ponderação 3: loom para verificação de modelo de concorrência

tokio/src/loom/mod.rstem apenas 14 linhas, mas revela a estratégia de verificação de correção de concorrência do 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::*;

O ponto-chave está na condição#[cfg(all(test, loom))]: somente quando ambos os cfgtesteloomestão ativados é que o módulomockedsubstituistd. Isso significa que builds de produção não contêm código do loom, com zero overhead em tempo de execução.

〔Inferência de design e ponderação arquitetural〕

O valor do loom está em enumerar exaustivamente "todas as ordens possíveis de intercalação de threads". Como emScheduledIo, a leitura-modificação-escrita deAtomicUsize,WaitersA inserção e remoção em listas encadeadas podem ser executadas um milhão de vezes em hardware real sem erros, mas o loom consegue construir em segundos uma intercalação que dispara a condição de corrida. O custo é a lentidão na execução dos testes e o alto consumo de memória, por isso só pode ser usado em testes unitários, nunca em produção.

Reflexões de design

Esses três trade-offs têm uma característica em comum:Todos escolheram a solução "mais complexa, porém mais escalável", e limitaram a complexidade ao interior. A complexidade do work-stealing está escondida no escalonador, a complexidade do I/O driver está escondida emScheduledIo, e a complexidade do loom está escondida nas condições de cfg. A API exposta externamente é semprespawn、TcpStream::readessas interfaces simples.

〔Inferências de design e trade-offs arquiteturais〕

Este também é o primeiro critério para julgar "quando se deve estender o Tokio":Se a sua necessidade pode ser expressa pela API existente, não mexa nas estruturas internas. Assim que você começar a depender dospub(crate)tipos detokio_unstableou dos cfg de

---

, significa que você se amarrou à implementação interna do Tokio, e pagará um preço nas atualizações.

Dois, refatoração do driver: de "um waker, uma direção" para "qualquer conjunto de interesses"

Modelo intuitivoasync fn read(&mut self)Os primeiros tipos de I/O do Tokio tinham uma limitação rígida:&mut selfexigiatokio/docs/reactor-refactor.md. É como um restaurante com apenas uma janela de retirada, onde só uma pessoa pode estar na fila por vez — porque o waker era armazenado dentro do recurso de I/O, e não no Future correspondente à operação.

documenta completamente a causa dessa limitação e o plano de refatoração.

As dores da arquitetura antiga

📎 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).
Copiar

〔Inferências de design e trade-offs arquiteturais〕TcpStreamArmazenar o waker dentro do recurso significa que "uma direção só pode ter um esperante". Se você quiser ler e escrever no mesmosplit()ao mesmo tempo, terá que dividirTcpStream::split()em duas metades, cada uma com seu próprio slot de waker. É por isso que

existe — não é uma preferência de design de API, mas uma restrição direta da estrutura de dados interna.

Nova arquitetura: mover o waker para dentro do Future

📎 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.

CopiarScheduledIoA nova estrutura

📎 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,
}

Copiar

Aqui há vários pontos de design engenhosos que merecem ser detalhados:readinessPrimeiro,AtomicUsize,waiterséMutex<Waiters>。éreadinessPor que não usar um lock para proteger ambos? Porque as operações de leitura dereadiness()são extremamente frequentes (toda chamada de

precisa verificar), enquanto as operações de escrita só ocorrem ao receber eventos do mio. Usar variáveis atômicas para deixar o caminho de leitura sem lock é uma otimização típica de separação entre leitura e escrita.WaiterSegundo, pointers: linked_list::Pointers<Waiter>é um nó de lista intrusiva.Waiterfaz com que_p: PhantomPinnedem si se torne parte da lista, sem necessidade de alocar nós extras.Unpinmarca explicitamente que ele não pode ser

— porque, uma vez que o endereço do nó de uma lista intrusiva se move, a lista se quebra.readerTerceiro,writereOption<Waker>doisAsyncRead/AsyncWritesão parausar.

📎 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.
Copiar

〔Inferências de design e trade-offs arquiteturais〕async fnEsta é uma coexistência de compromisso entre os dois mecanismos, antigo e novo:pollO caminho

usa lista intrusiva (suporta múltiplos esperantes, cancelável),

o caminho

📎 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.

Condições de corrida e o mecanismo de tickreadinessO problema mais espinhoso da refatoração são as condições de corrida. O documento fornece um cenário concreto de deadlock:AtomicUsizeCopiar

📎 tokio/docs/reactor-refactor.md:199-199

code
| shutdown | generation |  driver tick | readiness |
|----------+------------+--------------+-----------|
|   1 bit  |   7 bits   +    8 bits    +  16 bits  |
este

em múltiplos segmentos de bits:tickCopiarmio::poll()〔Inferências de design e trade-offs arquiteturais〕ReadyEventEste layout de segmentos de bits é um caso clássico de "trocar espaço por correção".clear_readiness()incrementa a cada

,readiness()carrega o tick no momento da leitura.clear_readiness()só limpa o estado de prontidão quando o tick corresponde — se o tick não corresponde, significa que novos eventos chegaram nesse meio-tempo, e não se pode limpar. Assim, a condição de corrida entre "limpar" e "chegada de novo evento" é dissolvida em uma única leitura-modificação-escrita atômica.

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

etick_match:clear_readinessCopiarreadiness()O ramo crítico deste diagrama está em

: se o tick não corresponde,

deve abandonar a limpeza, caso contrário perderá o evento recém-chegado, causando bloqueio permanente no próximoreadiness().

📎 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.
A lista intrusiva traz um novo problema: se o Future retornado por

for dropado antecipadamente, o nó da lista deve ser removido. O documento alerta explicitamente:readiness()CopiarDrop〔Inferências de design e trade-offs arquiteturais〕ScheduledIoIsto é exatamente o reflexo da "cancel safety" do capítulo anterior na camada de I/O.

O Future de

deve se remover da lista na implementação deVec<Waker>, caso contrário o nó permanecerá para sempre em, vazando memória e ainda sendo erroneamente acordado na próxima chegada de evento.&ResourceReflexões de design e armadilhas em produção

📎 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.
e sim uma lista intrusiva?

Vec<Waker>A documentação, ao discutir a implementação de

, dá a resposta::TcpStream::by_ref()CopiarTcpStreamRef〔Inferências de design e trade-offs arquiteturais〕read_waiterO problema dewrite_waiteré: depois que o Future é dropado, o waker correspondente permanece no Vec sem poder ser localizado e removido, só se descobre "este waker já é inválido" na próxima chegada de evento. A lista intrusiva faz com que o endereço do nó seja o endereço do campo interno do Future, permitindo remoção precisa no drop.

📎 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,
}
O

retornado porTcpStreamRefmantémselect!eby_ref()dois nós:TcpStreamRefCopiarTcpStream〔Inferências de design e trade-offs arquiteturais〕select!Isto significa que, uma vez que

---

seja dropado, os dois nós waiter se invalidam simultaneamente. Se você usar

Modelo intuitivo

Às vezes você não quer usar o escalonador do Tokio, apenas aproveitar seu I/O e timers. É como não querer comer no restaurante, mas apenas usar a janela de delivery.examples/custom-executor.rsdemonstra esse "modo híbrido": usarfutures::executor::ThreadPoolpara escalonamento e Tokio para I/O.

Mecanismo central: TokioContext

A chave de todo o exemplo está noTokioContexttipo wrapper:

📎 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));
    }
}
〔Inferência de design e trade-offs arquiteturais〕

TokioContext::new(f, handle)vincula o Future aoHandledo Tokio. Quando o executor externo faz poll desse Future wrapper,TokioContextprimeiro entra no contexto de runtime do Tokio (definindo oHandlethread-local), depois faz poll dofinterno. Assim, quandofchamaTcpListener::bind, consegue encontrar o driver de I/O do Tokio.

Veja a estrutura de todo o exemplo:

📎 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 }
});
〔Inferência de design e trade-offs arquiteturais〕

Aqui o runtime do Tokio é criado masnão éblock_onimpulsionado——ele apenas "existe", fornecendo driver de I/O e timers. O escalonamento real de tarefas é feito pelofutures::executor::ThreadPool. Nesse modo, as threads worker do Tokio na verdade ficam ociosas (aguardando eventos de I/O), e a execução de tarefas ocorre no pool de threads do futures.

Fluxo de dados: a jornada跨-executor de um 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 重新调度该任务

O ponto-chave deste diagrama de sequência é:o poll da tarefa ocorre no pool de threads do futures, mas a espera por eventos de I/O ocorre nas threads de background do Tokio. Os dois se conectam através doHandlee do waker.

Reflexão de design: quando contornar o Tokio

〔Inferência de design e trade-offs arquiteturais〕

A própria existência deste exemplo já é um sinal: a arquitetura do Tokio permite "usar apenas o driver de I/O, sem o escalonador". Os critérios de decisão podem ser resumidos em três:

1. Se você precisa integrar com um ecossistema de executor existente(por exemplo, alguns frameworks exigemfutures::executor), usarTokioContexté a solução de menor intrusão.

2. Se você precisa de controle total sobre a estratégia de escalonamento(por exemplo, sistemas de tempo real exigem escalonamento determinístico), o work-stealing do Tokio não atende, mas seu driver de I/O ainda é utilizável.

3. Se você só acha a API do Tokio complicada, então não deveria contorná-la——TokioContexta fronteira跨-executor introduzida traz novas dificuldades de depuração, o custo não compensa.

Armadilhas em produção:TokioContextNo modo , oblock_ondo runtime do Tokio nunca é chamado, o que significa que a lógica de limpeza doRuntime::shutdownnão será acionada automaticamente. Você deve fazer drop explícito doRuntimeantes de o programa encerrar, caso contrário as threads de background do driver de I/O podem não fechar graciosamente.

Relação com io_uring

〔Inferência de design e trade-offs arquiteturais〕

tokio/src/runtime/io/mod.rsA condição cfg no topo do revela como o io_uring é integrado:

📎 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)
)]

Note quefeature = "io-uring"etokio_unstableaparecem simultaneamente. Isso significa que o suporte a io_uring atualmente éexperimental, sendo necessário habilitar a feature unstable ao mesmo tempo para compilar.allow(dead_code)indica que: quando essas features não estão habilitadas, parte do código no módulo não será usada, e o compilador emitirá avisos——suprimidos comallow.

〔Inferência de design e trade-offs arquiteturais〕

A diferença fundamental entre io_uring e epoll é: epoll é "notificação de prontidão", io_uring é "notificação de conclusão". O primeiro exige que a aplicação inicie a chamada de sistemaread/writepor conta própria, o segundo tem o kernel concluindo o I/O diretamente e retornando o resultado. Isso é um enorme impacto para o modeloScheduledIodo Tokio——readiness()a semântica de não se aplica mais sob io_uring, sendo necessária uma abstração totalmente nova de "submissão-conclusão". É por isso que o suporte a io_uring permanece unstable por tanto tempo: não é simplesmente adicionar um backend, mas sim refatorar toda a camada de abstração do driver de I/O.

---

Resumo do capítulo

Este capítulo revisou, do ponto de vista arquitetural, os três trade-offs centrais do Tokio, e vislumbrou três caminhos de evolução:

Trade-offs históricos:

  • work-stealing troca complexidade de escalonamento por escalabilidade multi-core, com a fronteira de que a granularidade das tarefas não pode ser muito fina;
  • o driver de I/O é independente do escalonador, permitindo queblock_one o runtime multi-thread reutilizem a mesma implementação de I/O;
  • loom desaparece completamente em builds de produção via cfg, exaurindo intercalações de threads apenas em testes.

Refatoração do driver(reactor-refactor.md):

  • mover o waker de dentro doScheduledIopara o Future de operação, usando lista intrusiva para suportar múltiplos waiters;
  • usar o layout de bits doAtomicUsize(shutdown/generation/tick/readiness) para eliminar a corrida doclear_readiness;
  • AsyncRead/AsyncWritepor ter semântica de poll que não permite lista intrusiva, mantémreader/writerslots fixos como compromisso.

Evolução futura:

  • io_uring precisa de uma nova abstração de "submissão-conclusão", atualmente protegida portokio_unstable;
  • TokioContextpermite usar apenas o driver de I/O sem o escalonador, mas exige gerenciamento manual do ciclo de vida do Runtime;
  • o critério para decidir "estender ou contornar": se puder ser expresso com a API existente, não toque nas estruturas internas.

Reflexão e autoavaliação do capítulo

Q1: NoScheduledIodoreadinesslayout de bits, se o campotickfosse reduzido de 8 bits para 4 bits, em que cenário ocorreria erro? Analise combinando com a lógica de correspondência de tick doclear_readiness.

Análise de referência:tickincrementamio::poll()a cada📎 tokio/docs/reactor-refactor.md:185-185。clear_readinesse só limpa os bits de prontidão quandoevent.tick == 当前 readiness.tick. Se o tick tiver apenas 4 bits, então a cada 16 polls ocorrerá wraparound. Suponha que um certo📎 tokio/docs/reactor-refactor.md:199-199carregue tick=15, e quando ele forReadyEvent 携带 tick=15,在它被 clear_readinessAnteriormente, mio fez poll mais 1 vez, e o tick voltou a 0. Neste momentoclear_readinessdescobre que o tick não corresponde (15 != 0), e irá incorretamente saltar a limpeza — mas na realidade pode não ter chegado nenhum evento novo durante o período, apenas o tick deu a volta. Isto fará com que os bits de prontidão sejam preservados permanentemente, e subsequentementereadiness()retorna imediatamente masreadcontinuaWouldBlock, entrando num busy loop. O tick de 8 bits é suficiente sob carga normal (completa um ciclo read-clear dentro de 256 polls), mas sob concorrência extremamente alta ainda existe risco de wrap-around, esta é a limitação inerente do layout de bitfields.

Q2: examples/custom-executor.rs, o runtime do Tokio é criado mas nuncablock_on. Se neste momento chamarrt.shutdown_timeout(), o que acontecerá? Porque é que este exemplo escolhe não chamar?

Análise de referência:rt.shutdown_timeout()irá esperar que todas as tarefas terminem e fechar o driver de I/O. Mas neste exemplo, as tarefas na realidade executam emfutures::executor::ThreadPoolsobre📎 examples/custom-executor.rs:51-54, não há tarefas no runtime do Tokio — ele apenas fornece o driver de I/O. Se chamarshutdown_timeout, ele retornará imediatamente (porque não há tarefas), mas a thread de background do driver de I/O pode ainda estar em execução. O exemplo escolhe não chamar, porqueEXECUTORéLazyvariável estática, tratada pelo mecanismo de destruição de estáticos do Rust quando o programa termina. A verdadeira armadilha está em: seTokioContexto Future envolvido ainda está em execução, eRuntimeé dropado, então as operações de I/O dentro do Future irão panic (não encontram contexto de runtime). Em ambiente de produção é obrigatório garantir que todos osTokioContextFuture terminem antes de dropar o Runtime.

Q3: Suponha que quer adicionar um backend de I/O baseado em io_uring ao Tokio. De acordo comreactor-refactor.mdemreadiness()a semântica, que partes podem ser diretamente reutilizadas, e quais devem ser reescritas?

Análise de referência: O que pode ser diretamente reutilizado éRegistrationa interface de registo eScheduledIoawaitersestrutura de lista ligada — elas gerem "quem está à espera", independentemente de por baixo ser epoll ou io_uring. O que deve ser reescrito éreadiness()a semântica: sob epoll retorna "fd pronto", sob io_uring não existe o conceito de "pronto", apenas "SQE submetido completou".clear_readinesso mecanismo de tick também precisa de ser redesenhado — os eventos de conclusão do io_uring trazem identificação user_data própria, não precisam de tick para distinguir eventos novos de antigos. A alteração mais fundamental é:readiness()o Future retornado sob io_uring deve tornar-se "submeter SQE e esperar CQE", isto significa queWaitera estrutura precisa de transportar parâmetros SQE, e não apenasinterest. Isto também é a razão pela qual o suporte a io_uring está protegido portokio_unstableproteção📎 tokio/src/runtime/io/mod.rs:1-4— não é substituir o backend, mas alterar o contrato de abstração do driver de I/O.

Até aqui, completámos a escalada de armadilhas concretas para compromissos arquiteturais. Revendo todo o livro, desde a avaliação preguiçosa de Future até à justiça do scheduler, desde cancel safety até à ordem de encerramento, e neste capítulo io_uring e drivers plugáveis, todas as discussões giram em torno de um núcleo: dividir claramente a propriedade de estado nas fronteiras assíncronas. A arquitetura do Tokio não é imutável, o I/O zero-copy do io_uring, o desacoplamento da camada de driver, a abertura da interface de executor personalizado, tudo está a impulsioná-lo numa direção mais flexível e eficiente. Quando fechar este livro, espero que o que fique não seja um monte de utilizações de API, mas um conjunto de capacidade de julgamento: saber quando confiar no runtime, quando intervir na camada inferior, e como evitar em ambiente de produção aquelas combinações que mordem. O ecossistema de Rust assíncrono continua a crescer rapidamente, manter o acompanhamento do código-fonte e da documentação oficial é mais importante do que memorizar qualquer conclusão.

Compreender qualquer projeto complexo, na verdade só precisa de um bom livro

Este "Tokio 源码深度解读:从 Future 到生产级异步运行时" foi compilado automaticamente pelo AiReadCode através da análise do repositório oficial de código aberto. Independentemente de enfrentar uma obra famosa de código aberto com centenas de milhares de linhas, ou um projeto complexo interno de uma empresa, pode gerar com um clique uma monografia dedicada igualmente clara e organizada.

Descarregar gratuitamente o cliente AiReadCode Explorar mais bons livros de código aberto →
🇨🇳 中文 · 🇺🇸 EN · 🇯🇵 日本語 · 🇰🇷 한국어 · 🌐 繁中 · 🇪🇸 ES · 🇩🇪 DE · 🇫🇷 FR · 🇧🇷 PT · 🇷🇺 RU