Concorrência sem Medo em Rust
"Concorrência sem medo" é a própria frase de marketing da Rust, e ao contrário da maioria das frases de marketing, ela descreve algo concreto e verificável. Na maioria das linguagens, escrever código multithread correto depende de disciplina: revisores esperando que cada variável compartilhada seja devidamente bloqueada, e ferramentas de tempo de execução como ThreadSanitizer capturando corridas apenas nos caminhos de código que um teste por acaso exercita. Rust move uma fatia significativa desse ônus de correção para o próprio sistema de tipos, de modo que uma categoria inteira de corridas de dados se torna um erro de compilação em vez de um alerta às 3 da manhã.
Esta página é a âncora conceitual para o restante da seção de concorrência. Ela abrange o que Send e Sync realmente prometem, por que o compilador pode verificá-los sem qualquer anotação na maioria dos casos, e a escolha fundamental de design entre compartilhar memória atrás de um lock e passar a propriedade através de um canal que molda todo programa Rust concorrente.
Resumo
SendeSyncsão traits marcadores que o compilador usa para provar, em tempo de compilação, que um determinado tipo pode cruzar ou ser compartilhado entre limites de thread sem uma corrida de dados.- Por que Importa: Corridas de dados são comportamento indefinido, não apenas bugs, e o modelo de propriedade da Rust dá ao compilador informações suficientes para descartar a maioria delas antes que o programa seja executado.
- Conceitos Chave: Send, Sync, corrida de dados (data race), auto trait, concorrência de estado compartilhado, concorrência de passagem de mensagens.
- Quando Usar: Sempre que um programa gera threads, compartilha um
Arc, usa tarefas assíncronas em um runtime multithread, ou encontra um erro de compilador mencionandoSendouSync. - Limitações / Trade-offs:
Send/Syncapenas descartam corridas de dados; eles não dizem nada sobre deadlocks, corridas de lógica ou bugs de ordenação, todos os quais permanecem responsabilidade do programador. - Tópicos Relacionados: threads e
JoinHandle, atômicos e ordenação de memória, tarefas assíncronas, o padrão actor.
Fundamentos
Uma corrida de dados (data race) tem um significado técnico preciso em Rust: duas ou mais threads acessando a mesma localização de memória concorrentemente, com pelo menos um acesso sendo uma escrita, e nenhuma sincronização ordenando os acessos. Isso é distinto de uma "condição de corrida" no sentido mais amplo (duas threads competindo para atualizar uma linha de banco de dados, por exemplo), que o sistema de tipos da Rust não impede e não pode impedir.
A resposta da Rust para corridas de dados são dois traits marcadores sem métodos: Send e Sync.
Um tipo é Send se for seguro mover a propriedade de um valor desse tipo para outra thread. Um tipo é Sync se for seguro compartilhar uma referência (&T) a um valor desse tipo entre threads; formalmente, T: Sync se e somente se &T: Send.
A maioria dos tipos obtém esses traits gratuitamente. Eles são auto traits: o compilador deriva Send e Sync automaticamente com base nos campos de um tipo, recursivamente, sem um bloco impl necessário. Um struct feito inteiramente de campos Send é Send; um struct contendo mesmo um campo !Send é !Send, a menos que um humano afirme explicitamente o contrário com unsafe impl.
Um pequeno número de tipos opta por não participar deliberadamente. Rc<T>, o ponteiro de contagem de referência single-threaded da Rust, é !Send porque sua contagem de referência é um inteiro simples e não atômico; duas threads incrementando-o concorrentemente corromperiam a contagem, uma corrida de dados de livro didático. Arc<T>, seu equivalente atômico, usa um inteiro atômico em vez disso e é Send (quando T é Send + Sync), que é precisamente por que o código Rust thread usa Arc em vez de Rc.
O efeito prático é que APIs de geração de threads como std::thread::spawn exigem que seu closure seja F: Send + 'static. Se um valor capturado for !Send, o código falha na compilação com um erro específico e nomeado em vez de compilar com sucesso e correr em tempo de execução. Este é o mecanismo por trás de "sem medo": o modo de falha muda de um bug de tempo de execução que pode nunca aparecer em testes para um erro de compilador que sempre aparece, em cada build.
Mecânicas e Interações
Send e Sync são verificados sempre que um tipo cruza um limite de concorrência, e as mensagens de erro do compilador nomeiam o tipo exato que falha no bound, o que torna a depuração deles tratável na prática.
Os dois traits são independentes, e entender por que eles esclarece muitas mensagens de erro confusas. Um tipo pode ser Send mas não Sync, significando que é seguro transferir completamente, mas não seguro acessar de duas threads ao mesmo tempo através de uma referência compartilhada; Cell<T> e RefCell<T> são exatamente isso, pois sua mutabilidade interna não tem bloqueio e correria se compartilhada concorrentemente. Um tipo também pode ser Copy e !Send ao mesmo tempo, como Rc<T>, porque Copy e segurança de thread são propriedades ortogonais.
Mutex<T> é um exemplo de trabalho útil de como os dois traits se compõem. Mutex<T> é Sync sempre que T: Send, porque o lock em si fornece a sincronização que torna o acesso compartilhado seguro; o mutex pega um tipo que é seguro para mover, mas não necessariamente seguro para compartilhar, e produz um tipo que é seguro para compartilhar. É exatamente por isso que o padrão canônico Rust para estado mutável compartilhado é Arc<Mutex<T>>: Arc fornece propriedade compartilhada segura entre threads, e Mutex fornece mutação compartilhada segura uma vez dentro.
use std::sync::{Arc, Mutex};
// Arc<Mutex<T>> compõe duas garantias:
// Arc<T> é Sync porque clona atomicamente (propriedade compartilhada).
// Mutex<T> é Sync porque o acesso é serializado (mutação compartilhada).
let counter: Arc<Mutex<i32>> = Arc::new(Mutex::new(0));
let handle = counter.clone(); // clona o Arc, não o i32O ponto de atrito mais comum na vida real é um MutexGuard mantido através de um ponto .await em código assíncrono. MutexGuard é deliberadamente !Send, porque um lock adquirido em uma thread do sistema operacional deve ser liberado nessa mesma thread na maioria das implementações de lock, e uma tarefa assíncrona pode, em princípio, retomar em uma thread worker diferente após suspender. O compilador rejeita isso em tempo de compilação como uma falha de bound Send no future circundante, o que parece críptico na primeira vez, mas é o mecanismo exato que protege contra um perigo genuíno de correção.
Este é o ponto onde a história do sistema de tipos encontra seu limite honesto. Send e Sync provam a ausência de corridas de dados; eles não dizem nada sobre deadlocks (duas threads esperando umas pelas outras para sempre), livelocks, ou corridas de lógica simples onde o programa compila bem, mas duas threads ainda se intercalam de uma forma que produz a resposta errada. "Sem medo" descreve uma classe específica e bem definida de bug, não a correção de concorrência em geral.
Considerações Avançadas e Aplicações
Todo design Rust concorrente eventualmente escolhe entre duas filosofias, e a escolha molda o restante do código muito mais do que qualquer detalhe individual da API.
Concorrência de estado compartilhado mantém uma cópia dos dados e serializa o acesso a ela, tipicamente através de Arc<Mutex<T>> ou Arc<RwLock<T>>. Toda thread que precisa dos dados busca o mesmo lock. Concorrência de passagem de mensagens mantém cada pedaço de dados pertencente a exatamente uma thread por vez e o move entre threads enviando-o através de um canal; nada é verdadeiramente compartilhado, apenas passado adiante.
O modelo de propriedade da Rust torna a passagem de mensagens incomumente barata de raciocinar em comparação com outras linguagens, porque send em um canal é uma transferência genuína de propriedade imposta pelo compilador. A thread remetente não pode acidentalmente continuar usando o valor após enviá-lo; o borrow checker rejeitaria isso em tempo de compilação.
| Abordagem | Força | Fraqueza | Melhor Ajuste |
|---|---|---|---|
Arc<Mutex<T>> | Modelo mental simples para estado compartilhado; sem lógica de agendamento extra | Contenção escala mal com muitos escritores; risco de deadlock com múltiplos locks | Caches compartilhados, contadores, estado pequeno com escritas infrequentes |
Arc<RwLock<T>> | Leituras concorrentes baratas | Escritores bloqueiam tudo; algumas implementações famintos escritores | Dados compartilhados com muitas leituras, snapshots de configuração |
Canais (mpsc, crossbeam) | Sem estado mutável compartilhado, transferência de propriedade é verificada pelo compilador | Indireção extra; canais ilimitados podem vazar memória sob backpressure | Pipelines, pools de workers, designs produtor/consumidor |
| Atômicos | Lock-free, menor overhead para valores simples | Só funciona para dados de tamanho primitivo; fácil de usar mal a ordenação de memória | Contadores, flags, valores únicos lock-free |
| Padrão Actor (uma tarefa proprietária por recurso) | Combina passagem de mensagens com estado encapsulado; sem locks | Mais boilerplate; indireção para casos simples | Serviços complexos com estado, gerenciadores de conexão |
Na prática, a maioria dos sistemas Rust de produção usam ambas as filosofias em diferentes camadas: passagem de mensagens para mover trabalho entre estágios de um pipeline, e um pequeno número de instâncias Arc<Mutex<T>> para caches ou contadores genuinamente compartilhados onde a passagem de mensagens seria apenas um lock disfarçado. O ecossistema Tokio tende mais para a passagem de mensagens do que o código std::thread puro, porque manter um std::sync::MutexGuard através de um .await é proibido pelo bound Send descrito acima, o que empurra bases de código assíncronas para canais ou tokio::sync::Mutex (um lock ciente de async projetado para ser mantido através de .await).
Atômicos ficam na extremidade de desempenho desse espectro. AtomicUsize, AtomicBool e seus parentes usam instruções atômicas de nível de CPU em vez de bloqueio de nível de sistema operacional, o que evita o custo de colocar uma thread para dormir em contenção, mas eles só funcionam para valores que cabem em uma única palavra de máquina e exigem a escolha de uma ordenação de memória (Relaxed, Acquire, Release, SeqCst) que é genuinamente difícil de raciocinar corretamente sem um modelo mental documentado.
Concepções Errôneas Comuns
- "
Arctorna qualquer tipo seguro para compartilhar entre threads" -Arc<T>só éSyncquandoTem si éSend + Sync;Arc<RefCell<T>>ainda é inseguro para compartilhar porque a mutabilidade interna deRefCellnão tem bloqueio. - "
SendeSyncsão verificações em tempo de execução" - eles são bounds de trait em tempo de compilação com custo zero em tempo de execução; uma violação deSendé um erro de compilador, nunca um pânico em tempo de execução. - "Concorrência sem medo significa que Rust previne todos os bugs de concorrência" - ela previne especificamente corridas de dados; deadlocks, livelocks e erros de lógica simples entre threads compilam e executam sem nenhum aviso.
- "Você geralmente precisa implementar
Send/Syncmanualmente" - a esmagadora maioria dos tipos obtém ambos automaticamente de seus campos;unsafe implmanual é raro e reservado para ponteiros brutos ou handles FFI. - "Canais são sempre mais lentos que locks" - para contadores compartilhados de alta contenção, um lock ou atômico é frequentemente mais rápido, mas para pipelines com itens de trabalho independentes, canais frequentemente superam locks evitando completamente a contenção.
- "Async e threads usam o mesmo modelo de concorrência" - tarefas async adicionam agendamento cooperativo em cima da mesma base
Send/Sync, que é por que futures não-Sendfalham na compilação sob um runtime multithread, embora as regras subjacentes sejam idênticas.
FAQs
O que exatamente é uma corrida de dados, no sentido técnico de Rust?
Duas ou mais threads acessando a mesma memória concorrentemente, com pelo menos um acesso sendo uma escrita, e nenhuma sincronização ordenando os acessos. É um comportamento indefinido no modelo de memória de Rust, não meramente um bug de lógica.
Como o compilador sabe que um tipo é Send ou Sync sem um impl explícito?
Ambos são auto traits: o compilador os deriva recursivamente dos campos de um tipo. Um struct é Send se cada campo for Send, e Sync se cada campo for Sync, sem necessidade de um bloco impl manual no caso comum.
Por que Rc não é Send mas Arc é?
Rc<T> incrementa uma contagem de referência de inteiro simples sem sincronização, então clones concorrentes de threads diferentes correriam e corromperiam a contagem. Arc<T> usa um inteiro atômico para a mesma operação, que é seguro sob acesso concorrente.
Um tipo pode ser Send mas não Sync?
Sim. Cell<T> e RefCell<T> são Send (quando T é Send) porque mover um para outra thread é bom, mas !Sync porque compartilhar &RefCell<T> entre threads não tem bloqueio e correria.
Por que manter um MutexGuard através de .await falha na compilação?
MutexGuard é !Send, e o future de uma tarefa async pode retomar em uma thread worker diferente após suspender em um .await. Manter um guard !Send através desse ponto de suspensão tornaria o future circundante !Send, o que runtimes multithread rejeitam.
Quando devo escolher canais em vez de Arc<Mutex<T>>?
Escolha canais quando os itens de trabalho são naturalmente pertencentes a uma thread por vez, como estágios de pipeline ou pools de workers. Escolha Arc<Mutex<T>> quando várias threads genuinamente precisam de acesso de leitura/escrita ao mesmo pedaço de estado de longa duração, como um cache compartilhado.
Send e Sync previnem deadlocks?
Não. Deadlocks acontecem quando duas threads esperam pelos locks umas das outras, o que é um programa válido e livre de corridas de dados do ponto de vista do sistema de tipos. Prevenir deadlocks requer disciplina como uma ordem fixa de aquisição de locks, não um bound de compilador.
Posso implementar Send ou Sync manualmente para meu próprio tipo?
Sim, com unsafe impl Send for MyType {}, mas apenas quando você pode provar a segurança de thread por si mesmo, tipicamente para um wrapper em torno de um ponteiro bruto ou handle FFI onde o compilador não pode derivar o bound automaticamente.
Atômicos são sempre melhores que um Mutex?
Apenas para valores simples, de tamanho de palavra, como contadores e flags. Atômicos evitam o overhead de lock, mas exigem raciocinar explicitamente sobre a ordenação de memória, e eles não podem proteger uma estrutura de dados com múltiplos campos da maneira que um único Mutex pode.
Como isso é diferente de linguagens com garbage collector e threads?
Linguagens como Java ou Go permitem compilar e executar código com uma corrida de dados; o bug aparece (ou não) em tempo de execução, muitas vezes intermitentemente. Rust rejeita a mesma classe de bug em tempo de compilação via Send/Sync, antes que o programa seja executado.
Rust async usa Send/Sync de forma diferente de Rust com threads?
Os traits significam a mesma coisa em ambos os contextos, mas async adiciona uma complicação: um future inteiro deve ser Send para que um runtime multithread possa movê-lo entre threads workers através de pontos de suspensão, o que expõe erros de Send em locais onde o código thread nunca o faria.
O que é o padrão actor e como ele se relaciona com Send/Sync?
Um actor possui seu estado exclusivamente e se comunica apenas através de mensagens, então o estado em si nunca precisa ser Sync de forma alguma, apenas as mensagens precisam ser Send. É a concorrência de passagem de mensagens levada à sua conclusão lógica para um único recurso.
Relacionados
- Fundamentos de Concorrência - primeiras threads, closures
moveejoin - Send & Sync - os traits marcadores em profundidade, com uma tabela de regras de relance
- Mutex & RwLock - padrões de bloqueio de estado compartilhado
- Canais - passagem de mensagens com
mpscecrossbeam - Arc para Estado Compartilhado - propriedade compartilhada entre threads
- Estado Compartilhado em Async - o mesmo trade-off dentro de tarefas Tokio
Versões da Stack: Esta página foi escrita para Rust 1.97.0 (edição 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, e Polars 0.46+.