Misturar o runtime assíncrono do Tokio com o paralelismo de dados do Rayon parece inofensivo e já gerou bugs difíceis de achar. Este post junta dois materiais: a documentação do Tokio sobre trabalho bloqueante e o relato de um bug real do Meilisearch.
O caso do Meilisearch
No post de Dureuill, de maio de 2024, a versão 1.6.0 do Meilisearch tinha pânicos imprevisíveis. A causa: a mesma thread pertencia aos dois runtimes, e as cessões de controle do Rayon conflitavam com o estado de thread local do Tokio.
O autor chama o cenário de “sanduíche async-rayon”. Uma thread do Rayon executava um block_on do Tokio. Dentro dele, uma dependência usava iteradores paralelos do Rayon. Enquanto esperava, a thread roubava outros jobs da fila, e se o job roubado também precisasse de block_on, tentava iniciar um segundo runtime do Tokio na mesma thread, o que falhava. Era preciso “uma sequência bem específica de eventos” para disparar, e por isso os testes não pegaram.
A correção rápida foi reestruturar o código para não chamar Rayon dentro de block_on. A solução de longo prazo foi abandonar o async e trocar o Tokio pelo cliente HTTP síncrono ureq. O texto não propõe spawn_blocking nem pool de threads separado.
O que a documentação do Tokio diz
A documentação de spawn_blocking explica que a função roda um closure numa thread onde bloquear é aceitável, num pool dedicado, para não travar os executores assíncronos. Ela devolve um JoinHandle que você pode aguardar. O Tokio cria threads de bloqueio conforme necessário, até um limite configurável pelo Builder que é muito alto por padrão, porque a função costuma servir a operações de I/O.
Alguns avisos importantes dessa página:
- tarefas de
spawn_blockingnão podem ser abortadas depois de começar, e o desligamento do runtime espera por elas, salvo se você usarshutdown_timeout; - para trabalho intensivo de CPU, a doc sugere usar bibliotecas como o Rayon ou semáforos para limitar execuções paralelas, e alerta contra saturar o pool com muitas operações de CPU;
- a função é para operações bloqueantes de curta duração. Para cargas de longa duração, a recomendação é uma thread dedicada com
thread::spawn.
Como isso se encaixa
Dá para resumir assim: o executor assíncrono foi feito para muitas tarefas que esperam, e cálculo pesado sem ponto de .await segura a thread do começo ao fim. Mandar esse trabalho para fora do runtime é o caminho que a documentação aponta, mas o caso do Meilisearch mostra que combinar os dois mundos exige cuidado com quem chama quem. A versão anterior deste post usava uma discussão filosófica sobre “entrelaçar” concorrência que não vem dessas fontes, e eu a retirei.