tokio-console: Debug de Tasks Async em Rust | Rust Brasil

Use tokio-console para depurar tasks, locks, wakes e latência em aplicações Rust assíncronas. Guia com console-subscriber, Tokio unstable e produção segura.

Para depurar uma aplicação Tokio com tasks lentas, locks disputados ou futuros que parecem “travados”, use tokio-console junto de console-subscriber. A combinação permite observar tasks, recursos assíncronos, wakes, tempo ocupado e períodos de inatividade em uma TUI. O fluxo mínimo é: instalar tokio-console, inicializar console_subscriber::init(), compilar com --cfg tokio_unstable, executar o serviço e abrir o console em outro terminal.

O tokio-console é especialmente útil quando logs tradicionais dizem apenas que uma requisição começou, mas não explicam por que ela nunca terminou. Ele não substitui tracing e logs estruturados, métricas ou profiling de CPU. Seu papel é mais específico: mostrar como o runtime Tokio está agendando e despertando o trabalho assíncrono.

Este guia apresenta a configuração mínima, um exemplo de contenção com Mutex, como interpretar os principais sinais, quando usar instrument, quais armadilhas geram falsos diagnósticos e como adotar o console sem abrir uma interface de debug perigosa em produção.

Resposta rápida: instalação e uso

EtapaComando ou códigoObjetivo
Instalar a TUIcargo install --locked tokio-consoleCriar o comando tokio-console
Adicionar subscribercargo add console-subscriberExportar dados do runtime
Inicializarconsole_subscriber::init();Ativar coleta e servidor do console
Compilar instrumentadoRUSTFLAGS="--cfg tokio_unstable" cargo runHabilitar eventos detalhados do Tokio
Abrir a interfacetokio-consoleConectar e inspecionar tasks
Tornar o cfg repetível.cargo/config.tomlEvitar depender de uma variável manual

Antes de automatizar, confira tokio-console --help e a documentação da versão de console-subscriber instalada. A instrumentação usa tokio_unstable, portanto uma atualização de Tokio merece validação explícita no projeto.

O problema: async pode esconder onde o tempo foi gasto

Código assíncrono divide trabalho em futuros que avançam quando o executor os consulta. Quando um futuro não pode continuar — porque espera rede, timer, canal ou lock — ele devolve Poll::Pending. Mais tarde, um waker avisa ao runtime que vale a pena consultar aquele futuro novamente.

Esse modelo evita bloquear uma thread para cada conexão, mas cria perguntas diferentes das encontradas em código síncrono:

  • a task está esperando I/O legitimamente ou perdeu um wake?
  • ela foi despertada milhares de vezes sem produzir progresso?
  • passou muito tempo ocupada porque executou trabalho bloqueante?
  • está parada em um Mutex disputado?
  • o sistema criou tasks demais para uma operação simples?
  • uma task foi criada e nunca encerrada?
  • o gargalo está no runtime ou em uma dependência externa?

Um log como este não responde:

INFO iniciando processamento do pedido 42

Se não houver mensagem de conclusão, você sabe que algo ficou no caminho. O tokio-console ajuda a restringir a investigação à task, ao recurso e ao padrão de agendamento envolvidos.

Para revisar os fundamentos antes da ferramenta, consulte async/await em profundidade e o guia completo de Tokio.

Como o tokio-console funciona

A arquitetura tem duas partes:

  1. console-subscriber dentro da aplicação recebe eventos de instrumentação e disponibiliza os dados de diagnóstico;
  2. tokio-console no terminal conecta-se ao processo instrumentado e renderiza a TUI.

O subscriber se integra ao ecossistema tracing. Além de eventos normais da aplicação, ele usa a instrumentação emitida pelo Tokio quando o binário é compilado com:

--cfg tokio_unstable

O nome “unstable” merece atenção: ele não significa necessariamente que você precisa compilar o projeto com Rust nightly. Significa que essas APIs e garantias de diagnóstico não fazem parte da estabilidade normal do Tokio. Fixe versões quando a previsibilidade for importante e teste a atualização antes de promovê-la.

Configuração mínima em um projeto

Crie um binário de exemplo:

cargo new console-demo
cd console-demo
cargo add tokio --features full
cargo add console-subscriber
cargo install --locked tokio-console

No src/main.rs:

use std::time::Duration;

#[tokio::main]
async fn main() {
    console_subscriber::init();

    let worker = tokio::spawn(async {
        loop {
            tokio::time::sleep(Duration::from_secs(1)).await;
            println!("worker ativo");
        }
    });

    tokio::time::sleep(Duration::from_secs(30)).await;
    worker.abort();
}

Execute a aplicação com a instrumentação:

RUSTFLAGS="--cfg tokio_unstable" cargo run

Em outro terminal:

tokio-console

A interface deve listar a task principal, o worker criado por tokio::spawn e recursos associados aos timers. Se o console não conectar, confirme:

  • se console_subscriber::init() executa antes dos spawn relevantes;
  • se a aplicação foi realmente recompilada com tokio_unstable;
  • se o processo continua rodando;
  • se host e porta foram alterados por configuração;
  • se firewall, container ou namespace de rede bloqueiam a conexão.

Configurando tokio_unstable sem depender do shell

Para um projeto dedicado a diagnóstico local, você pode colocar a flag em .cargo/config.toml:

[build]
rustflags = ["--cfg", "tokio_unstable"]

Essa opção é simples, mas afeta todos os builds feitos com aquela configuração. Em um serviço de produção, pode ser melhor manter a instrumentação em um alias, script ou perfil operacional explícito, para não confundir o binário normal com o binário de diagnóstico.

Exemplo de script local:

#!/usr/bin/env bash
set -euo pipefail
RUSTFLAGS="${RUSTFLAGS:-} --cfg tokio_unstable" cargo run --bin api

Se o repositório já define RUSTFLAGS para linker, CPU target ou lints, não sobrescreva a variável sem revisar. Flags de compilação também afetam cache: mudar RUSTFLAGS pode provocar recompilação ampla e reduzir o reaproveitamento do sccache.

Exemplo prático: encontrando contenção em Mutex

Considere uma aplicação que protege estado compartilhado e, por engano, mantém o lock durante uma operação lenta:

use std::{sync::Arc, time::Duration};
use tokio::sync::Mutex;

#[tokio::main]
async fn main() {
    console_subscriber::init();

    let contador = Arc::new(Mutex::new(0_u64));

    for id in 0..20 {
        let contador = Arc::clone(&contador);

        tokio::spawn(async move {
            let mut valor = contador.lock().await;
            *valor += 1;

            // Problema didático: mantém o lock durante a espera.
            tokio::time::sleep(Duration::from_millis(500)).await;

            println!("task {id}: contador = {valor}");
        });
    }

    tokio::time::sleep(Duration::from_secs(15)).await;
}

O programa não possui um deadlock clássico, mas serializa vinte tasks atrás do mesmo lock. No console, você pode observar muitas tasks aguardando o recurso enquanto apenas uma progride por vez.

A correção é reduzir a seção crítica:

let valor_atual = {
    let mut valor = contador.lock().await;
    *valor += 1;
    *valor
}; // lock liberado aqui

tokio::time::sleep(Duration::from_millis(500)).await;
println!("task {id}: contador = {valor_atual}");

A lição não é “Mutex assíncrono é ruim”. O problema é manter exclusividade durante um .await que não precisa do estado protegido. O console torna esse padrão visível; a revisão do código explica por que ele acontece.

Como ler os sinais sem adivinhar

A interface evolui, mas os conceitos principais permanecem. Observe a relação entre os sinais, não um número isolado.

Tasks vivas por tempo demais

Uma task antiga pode ser perfeitamente legítima: servidor, consumer de fila, monitor ou loop de manutenção. Investigue quando:

  • a quantidade cresce continuamente;
  • tasks de request sobrevivem muito depois do timeout esperado;
  • a task deveria encerrar após uma operação curta;
  • o estado mostra espera em um recurso que nunca progride.

Antes de chamar isso de vazamento, identifique a responsabilidade da task. Loops de background são longevos por projeto.

Busy time alto

Tempo ocupado elevado sugere que a task passou muito tempo sendo consultada ou executando trabalho. Pode indicar:

  • loop sem espera suficiente;
  • CPU pesada dentro de uma task async;
  • processamento síncrono grande entre dois .await;
  • polling excessivo causado por wakes frequentes.

Para CPU pesada, mova trabalho bloqueante ou computacional para uma estratégia apropriada, como spawn_blocking, Rayon ou serviço separado, conforme o caso. Depois confirme com profiling; o guia de performance em produção mostra quando usar flamegraph, Criterion e métricas.

Idle time alto

Tempo ocioso não é automaticamente ruim. Uma task que espera uma mensagem por minutos está cumprindo sua função. Torna-se suspeito quando o contrato previa conclusão rápida e nenhuma dependência externa justifica a espera.

Muitos wakes

Wakes frequentes com pouco progresso podem apontar para:

  • canal ou stream excessivamente “barulhento”;
  • loop que se autoacorda;
  • seleção de futuros que recria trabalho continuamente;
  • recurso disputado por muitas tasks;
  • polling provocado por integração defeituosa.

Não otimize apenas para reduzir o contador. Correlacione wakes, busy time, código executado e throughput real.

Recursos e operações assíncronas

Recursos instrumentados ajudam a enxergar timers, locks, semáforos e outras primitivas. Uma espera longa em Semaphore, por exemplo, pode significar que o limite de concorrência está correto e o tráfego aumentou — ou que permissões não estão sendo liberadas após erros.

Dê nomes e contexto às tasks

Tasks anônimas tornam qualquer sessão de diagnóstico mais difícil. O tracing permite instrumentar futuros com spans que carregam campos úteis.

use tracing::{info_span, Instrument};

for pedido_id in 1..=10 {
    tokio::spawn(
        async move {
            processar_pedido(pedido_id).await;
        }
        .instrument(info_span!("processar_pedido", pedido_id)),
    );
}

Com spans consistentes, você consegue correlacionar a task ao pedido, tenant, fila ou componente sem registrar dados sensíveis. Evite colocar token, e-mail completo, conteúdo de mensagem ou segredo em nomes e campos de diagnóstico.

A combinação recomendada é:

  • nome da operação, estável e com baixa cardinalidade;
  • identificador técnico, quando permitido pela política de privacidade;
  • spans internos para banco, HTTP, fila e cache;
  • métricas agregadas para observar a tendência;
  • tokio-console para uma sessão interativa quando a tendência vira incidente.

Veja também logging e observabilidade em Rust e OpenTelemetry em produção.

Integrando console-subscriber com tracing normal

Uma aplicação real provavelmente já usa tracing-subscriber para logs. Nesse cenário, não inicialize dois subscribers globais independentes. Monte as camadas em um único registry, conforme as APIs suportadas pelas versões instaladas.

Exemplo conceitual:

use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

fn init_observability() {
    let console_layer = console_subscriber::spawn();
    let fmt_layer = tracing_subscriber::fmt::layer();

    tracing_subscriber::registry()
        .with(console_layer)
        .with(fmt_layer)
        .init();
}

A forma exata de configurar filtros, retenção, endereço e capacidade depende da versão. O ponto arquitetural é: um subscriber global composto por camadas, e não múltiplas chamadas concorrentes a .init().

Também separe os filtros. Um filtro agressivo para logs não deve remover eventos necessários ao console. Da mesma forma, habilitar tracing extremamente detalhado de todas as dependências pode gerar ruído e overhead.

tokio-console versus outras ferramentas

FerramentaPergunta principalMelhor uso
tokio-consoleO que as tasks e recursos do runtime estão fazendo agora?Locks, wakes, tasks pendentes, excesso de spawn
tracingQual caminho lógico uma operação percorreu?Requests, filas, banco, contexto de negócio
MétricasO sistema está piorando ao longo do tempo?Latência, erros, saturação, filas, SLO
FlamegraphOnde a CPU foi consumida?Hot paths e trabalho computacional
cargo-llvm-covQuais caminhos foram exercitados nos testes?Lacunas de testes e regressões
MiriA execução interpretada encontra certas violações de memória?Código unsafe e undefined behavior

Não use o console para provar que uma API ficou rápida. Faça o diagnóstico, aplique a correção e valide com métricas e benchmark. Para cobertura, consulte cargo-llvm-cov; para comportamento de memória, veja Miri.

Um roteiro de investigação de task travada

Quando o alerta é “requests penduram depois de alguns minutos”, siga um roteiro reproduzível:

  1. Defina o sintoma: rota, percentil de latência, taxa de erro e janela de tempo.
  2. Reproduza em ambiente controlado: carga, conjunto de features e configuração semelhantes.
  3. Ative o console: de preferência por configuração explícita e acesso restrito.
  4. Filtre as tasks relevantes: procure idade, estado, busy time e recursos aguardados.
  5. Correlacione com spans: identifique request, worker ou dependência.
  6. Inspecione locks e limites: Mutex, RwLock, Semaphore, pool de conexões e canais.
  7. Procure trabalho bloqueante: chamadas síncronas, compressão, parsing grande, filesystem e CPU.
  8. Aplique uma alteração pequena: reduza seção crítica, limite spawn ou mova CPU.
  9. Repita a carga: compare console, métricas e latência.
  10. Crie regressão: teste, benchmark ou alerta que impeça o retorno do problema.

Esse método evita a correção supersticiosa de “aumentar o número de threads do Tokio”. Mais threads podem esconder contenção temporariamente, mas não consertam um lock mantido durante I/O ou uma fila sem backpressure.

Produção: use com controles, não como painel público

O console é uma interface de diagnóstico. Trate-o como uma superfície operacional sensível.

Restringir rede

Prefira bind local, rede privada, sidecar ou túnel autenticado. Não publique a porta diretamente na internet. Dados de tasks e spans podem revelar arquitetura, nomes internos e padrões de tráfego.

Ativação explícita

Uma abordagem segura é habilitar a camada somente quando uma variável de configuração está presente:

fn console_habilitado() -> bool {
    std::env::var("TOKIO_CONSOLE_ENABLED").as_deref() == Ok("1")
}

A montagem do subscriber pode então escolher a camada conforme o ambiente. Não use variável para carregar segredo no log; ela apenas controla a feature operacional.

Medir overhead

Instrumentação consome CPU, memória, buffers e rede. O impacto depende do volume de tasks, eventos, retenção e carga. Faça um teste com tráfego representativo antes de manter a camada ativa por longos períodos.

Limitar duração

Para incidentes, defina uma janela: ativar, capturar, diagnosticar, desativar. Observabilidade permanente deve depender principalmente de métricas, logs e traces com política de retenção.

Armadilhas comuns

Esquecer tokio_unstable

A TUI pode conectar sem entregar a visão esperada ou a aplicação pode não emitir a instrumentação detalhada. Force uma recompilação limpa quando estiver em dúvida:

cargo clean
RUSTFLAGS="--cfg tokio_unstable" cargo run

Use cargo clean com parcimônia em repositórios grandes; primeiro confira o comando efetivo e as flags.

Inicializar o subscriber tarde demais

Tasks criadas antes da inicialização podem não aparecer com o contexto desejado. Configure observabilidade no começo do main, antes de subir servidores e workers.

Confundir espera legítima com travamento

Consumers, listeners e timers passam grande parte da vida ociosos. Compare o estado observado com o contrato daquela task.

Usar std::sync::Mutex durante trabalho assíncrono longo

Um mutex síncrono pode ser correto para seções curtíssimas sem .await, mas segurá-lo durante espera ou trabalho longo bloqueia a thread do executor. Escolha a primitiva pelo comportamento, não por regra decorada.

Criar uma task por item sem limite

tokio::spawn é barato, não gratuito. Uma entrada mais rápida que a saída pode gerar milhares de tasks pendentes. Use semáforos, buffers limitados, buffer_unordered ou workers conforme o desenho.

Concluir que “Tokio está lento”

O runtime frequentemente só expõe um problema de aplicação: query sem índice, API externa lenta, lock amplo, CPU no executor ou pool saturado. O console aponta a espera; a causa pode estar fora do Tokio.

Colocar dados pessoais nos spans

A TUI é operacional, mas isso não elimina LGPD e políticas internas. Registre identificadores mínimos e evite payloads, tokens e informações pessoais.

Checklist de adoção

  • instalar tokio-console com versão controlada;
  • adicionar console-subscriber ao projeto;
  • habilitar --cfg tokio_unstable no modo de diagnóstico;
  • inicializar observabilidade antes de criar tasks;
  • compor a camada com o subscriber de logs existente;
  • dar nomes e spans úteis às operações críticas;
  • reproduzir contenção e tasks pendentes em ambiente controlado;
  • restringir host, porta e acesso em produção;
  • medir overhead sob carga representativa;
  • remover dados sensíveis de campos de tracing;
  • validar a correção com métricas e testes;
  • revisar a integração ao atualizar Tokio.

tokio-console para carreira e portfólio Rust

Saber usar async/await é diferente de saber operar um serviço assíncrono. Em entrevistas para backend, plataforma e infraestrutura, uma boa explicação de incidente inclui:

  • diferença entre task pendente e thread bloqueada;
  • papel de Poll, waker e scheduler;
  • contenção em Mutex e Semaphore;
  • backpressure em canais e streams;
  • quando usar spawn_blocking;
  • correlação entre tasks, spans e métricas;
  • validação da correção por carga, não por intuição.

Um projeto de portfólio pode demonstrar isso com uma API Axum que possui limite de concorrência, tracing, uma rota de carga e um cenário reproduzível de lock amplo. Documente no README como iniciar a aplicação instrumentada e o que observar no console. Combine com Axum, Tokio, testes em Rust e CI/CD.

Para ampliar a comparação de modelos de concorrência, o conteúdo sobre concorrência com goroutines e channels em Go ajuda a separar runtime assíncrono, tasks cooperativas e threads do sistema sem transformar a discussão em “qual linguagem é mais rápida”.

Esse tipo de diagnóstico também é relevante ao buscar vagas Rust ou avaliar empresas que usam Rust: times maduros precisam de devs capazes de explicar latência e saturação, não apenas escrever handlers que compilam.

Perguntas frequentes

Para que serve o tokio-console?

Ele mostra o comportamento de tasks e recursos assíncronos instrumentados pelo Tokio. É útil para investigar espera excessiva, contenção, wakes frequentes, tasks antigas e trabalho que ocupa o executor por tempo demais.

Como conectar o console à aplicação?

Adicione console-subscriber, inicialize a camada no começo do processo, compile com RUSTFLAGS="--cfg tokio_unstable", execute a aplicação e rode tokio-console em outro terminal. Ajuste endereço e rede se a aplicação estiver em container ou host remoto.

Preciso usar Rust nightly?

Não necessariamente. tokio_unstable é um cfg de APIs do Tokio e não equivale, por si só, a exigir uma toolchain nightly. Ainda assim, a superfície não possui a mesma garantia de estabilidade das APIs normais e deve ser testada a cada atualização.

O console encontra deadlocks automaticamente?

Ele ajuda a visualizar tasks e recursos que não progridem, mas a interpretação continua sendo sua. Um conjunto de tasks aguardando locks pode ser deadlock, contenção esperada ou dependência lenta. Correlacione com o código e os spans.

Devo deixá-lo sempre ligado em produção?

Não como padrão irrefletido. Avalie overhead, exposição de dados e segurança de rede. Uma prática melhor é ativação controlada durante diagnóstico, mantendo métricas, logs e tracing como observabilidade contínua.

Conclusão

O tokio-console ocupa uma lacuna importante no ecossistema Rust: ele transforma o runtime assíncrono, normalmente invisível, em uma visão navegável de tasks, recursos, wakes e tempo de execução. Isso encurta a distância entre “a requisição pendurou” e “vinte tasks estão serializadas atrás deste lock mantido durante um timer”.

O fluxo recomendado é direto: instrumente com console-subscriber, compile o modo de diagnóstico com tokio_unstable, reproduza o sintoma, observe a relação entre tasks e recursos, corrija uma causa específica e valide com métricas. Não trate a TUI como prova final nem a exponha como painel público.

Continue a trilha com Tokio em profundidade, tracing vs log, OpenTelemetry em Rust, profiling em produção e background jobs com Tokio. Juntas, essas práticas ajudam a construir serviços assíncronos que não apenas funcionam, mas também podem ser explicados quando falham.