Canais Lock-Free em Rust: SPSC vs MPSC e Concorrência Real

Aprenda como canais SPSC e MPSC lock-free funcionam em Rust, com comparações de performance, memory ordering e quando cada arquitetura faz sentido para sistemas de baixa latência.

Canais Lock-Free em Rust: SPSC vs MPSC e o Custo Real da Concorrência

Quando você implementa um sistema de processamento de áudio em tempo real ou um roteador de alta frequência, cada nanossegundo conta. O std::sync::mpsc do Rust resolve o básico de comunicação entre threads, mas oferece performance inconsistente e uma API limitada. É nesse espaço que canais lock-free especializados fazem diferença: ring buffers SPSC alcançam 20-40 nanossegundos por operação, enquanto implementações baseadas em mutex tradicionais ficam em 200-500ns.

A questão não é apenas velocidade bruta. Discord processa voice packets, Cloudflare roteia Workers requests e Firecracker coordena comunicação entre threads de VMMs - todos usando arquiteturas de canais lock-free. Esses sistemas não podem tolerar contenção de locks ou latência imprevisível. Lock-free não significa simples: cada escolha arquitetural carrega trade-offs específicos entre latência, throughput e complexidade.

Este artigo explora como canais SPSC e MPSC funcionam internamente, quando cada um faz sentido, e por que você provavelmente não precisa de MPMC para resolver seu problema de performance.

Anatomia de um Canal SPSC Lock-Free

SPSC (Single Producer Single Consumer) representa o caso mais simples de comunicação concorrente: exatamente uma thread escrevendo e uma lendo. Essa restrição permite otimizações impossíveis em cenários multi-producer ou multi-consumer.

A estrutura fundamental é um ring buffer - um array circular com dois ponteiros atômicos. O producer mantém um índice head que avança ao inserir elementos; o consumer mantém um tail que avança ao remover. Buffer cheio? Head alcançou tail (considerando wrap-around). Vazio? Tail alcançou head.

A mágica está nas operações atômicas. No SPSC, o producer possui exclusividade sobre head e o consumer sobre tail. Não existe contenção direta: cada lado modifica seu próprio ponteiro sem interferência. A sincronização acontece através de memory ordering específico. O producer usa Ordering::Release ao publicar novos elementos, garantindo que todas as escritas sejam visíveis antes de avançar head. O consumer usa Ordering::Acquire ao ler tail, garantindo que vê as escritas completas.

// Simplificação conceitual - não é código de produção
pub struct SpscQueue<T> {
    buffer: Box<[MaybeUninit<T>]>,
    head: AtomicUsize, // Producer escreve aqui
    tail: AtomicUsize, // Consumer escreve aqui
}

impl<T> SpscQueue<T> {
    pub fn push(&self, value: T) -> Result<(), T> {
        let head = self.head.load(Ordering::Relaxed);
        let next_head = (head + 1) % self.buffer.len();
        
        // Verifica se há espaço (tail é lido com Acquire)
        if next_head == self.tail.load(Ordering::Acquire) {
            return Err(value); // Buffer cheio
        }
        
        unsafe {
            self.buffer[head].as_mut_ptr().write(value);
        }
        
        // Publica com Release - garante visibilidade
        self.head.store(next_head, Ordering::Release);
        Ok(())
    }
}

Cache line padding é crítico aqui. Processadores modernos carregam memória em blocos de 64 bytes (cache lines). Se head e tail compartilhassem a mesma cache line, cada modificação invalidaria o cache da outra thread - false sharing. Implementações sérias colocam 64 bytes de padding entre os ponteiros, forçando-os em cache lines diferentes.

Bibliotecas como rtrb (real-time ring buffer) exploram essa arquitetura para alcançar latências de 20-40ns por operação em SPSC puro. Não existe CAS (compare-and-swap), não existe retry loop. Apenas loads e stores atômicos simples.

MPSC: Quando Múltiplos Producers Mudam o Jogo

MPSC (Multi Producer Single Consumer) adiciona complexidade fundamental. Agora múltiplas threads competem para escrever no canal. Você não pode simplesmente incrementar head com load-and-store - duas threads poderiam ler o mesmo valor e sobrescrever dados uma da outra.

A solução padrão é CAS (compare-and-swap). Cada producer tenta atomicamente: “se head ainda é X, mude para X+1”. Se outra thread mudou head primeiro, a operação falha e você tenta novamente. Esse retry loop adiciona overhead: MPSC lock-free tipicamente fica em 100-200ns por operação, comparado aos 20-40ns de SPSC dedicado.

O algoritmo original vem do paper ‘Scalable Lock-Free FIFO Queues’ por Maged M. Michael e Michael L. Scott, base da implementação do crossbeam-channel. A ideia central? Separar reserva de slot (via CAS) da escrita de dados. Um producer usa CAS para reservar um índice no buffer, depois escreve seu valor nessa posição reservada. O consumer só avança quando vê que o slot está preenchido.

Crossbeam oferece duas variantes: bounded channel (~100-150ns/op) e unbounded (~150-250ns/op). Bounded usa ring buffer direto; unbounded aloca blocos dinamicamente quando necessário. Para MPSC geral, crossbeam é escolha sólida - mais features (select, iteradores) e throughput consistente mesmo sob contenção.

Mas se seu caso é realmente single producer? SPSC especializado vence em latência mínima por 2-3x. Isso importa em sistemas de baixa latência onde cada fração de microssegundo se acumula.

Zero-Copy e o Problema de Ownership

Zero-copy é objetivo comum em sistemas de alta performance: passar ponteiros em vez de copiar dados. Em C++, você compartilha ponteiros raw e gerencia lifetime manualmente. Rust complica isso com ownership - quem possui o objeto quando ele entra no canal?

A solução prática é Arc (atomic reference counting). Você não transfere ownership do objeto completo, transfere ownership de um Arc. O custo é ~10-20ns de overhead para incrementar/decrementar o contador atômico, mas ganha shallow copy seguro. O objeto real fica na heap, compartilhado entre threads até que o último Arc seja dropado.

Zero-copy verdadeiro com shared memory buffers existe, mas requere unsafe e gerenciamento manual de lifetime. Para a maioria dos casos, Arc oferece o melhor balanço entre performance e segurança. Bibliotecas como crossbeam aceitam qualquer tipo Send, deixando você escolher a estratégia: copiar valores pequenos diretamente, usar Arc para estruturas grandes, ou compartilhar slices via Arc<[u8]>.

Escolhendo a Implementação: Trade-offs Reais

A hierarquia de escolha não é simplesmente “mais rápido é melhor”. Depende do seu cenário específico.

Processamento de áudio em tempo real ou trading de alta frequência? SPSC dedicado faz sentido. Você tem exatamente um producer (thread capturando dados) e um consumer (thread processando). rtrb oferece latência mínima e API simples. Bounded ring buffer força backpressure natural - se o consumer não acompanha, o producer sabe imediatamente.

Sistemas com múltiplos producers mas padrões conhecidos se beneficiam do crossbeam bounded channel. Você perde a latência ótima de SPSC mas ganha flexibilidade. A API é rica: select entre múltiplos canais, iteradores, timeouts. Bounded significa alocação upfront - sem surpresas de memória em runtime.

Workloads assíncronos em Tokio funcionam melhor com tokio::sync::mpsc, que integra com o executor. Operações retornam futures que cooperam com outras tasks. A overhead é maior (~200ns+) mas você ganha composabilidade com async/await.

std::sync::mpsc raramente é a melhor escolha. Falta unbounded eficiente, a API é limitada (sem select nativo), e a performance é inconsistente. Existe principalmente por razões históricas e para casos simples onde performance não é crítica.

False sharing merece atenção especial. Se você acessar estruturas compartilhadas além do canal, considere padding explícito. Mesmo com canal otimizado, seu código pode introduzir contenção acidental se producer e consumer modificam campos na mesma cache line de um struct compartilhado.

Memory Ordering: Quando Relaxed é Suficiente

Memory ordering controla como operações atômicas se sincronizam entre threads. É tentador sempre usar SeqCst (sequentially consistent) por segurança, mas isso adiciona overhead desnecessário.

SPSC permite otimização interessante. O producer pode usar Relaxed para carregar seu próprio head - não precisa sincronizar com ninguém. Só ao publicar (store em head) usa Release, garantindo que escritas no buffer sejam visíveis. O consumer faz o inverso: Relaxed para seu tail, Acquire ao ler head alheio.

Para operações no fast path (quando há espaço/dados disponíveis), Relaxed reduz barreiras de memória. Quando precisa sincronizar estado - verificar se buffer está cheio/vazio - Acquire/Release entra. SeqCst só é necessário em algoritmos que requerem ordem total observável por todas as threads, raro em canais simples.

Implementações sérias usam diferentes orderings em hot paths vs operações raras. Push/pop em loop tight usa Relaxed maximamente; apenas pontos de decisão (cheio/vazio) sincronizam. Isso contribui para a diferença de 10-20ns entre implementações bem ajustadas e versões ingênuas.

Quando Você Não Precisa de Lock-Free

Lock-free não é panaceia. Mutex modernos com adaptive spinning são surpreendentemente rápidos quando contenção é baixa. Se suas threads raramente competem por acesso, a overhead de 200-500ns pode ser aceitável comparada à complexidade de lock-free.

Bounded channels forçam decisões sobre backpressure. Buffer enche? Você bloqueia o producer, dropa mensagens ou aloca dinamicamente? Cada escolha tem implicações no design do sistema. Unbounded parece conveniente mas pode esconder problemas - se producer é mais rápido que consumer indefinidamente, memória eventualmente estoura.

SPSC é limitado por design: sem broadcast, sem múltiplos consumers. Precisa de fan-out (uma mensagem para várias threads)? Você precisa de estrutura diferente - talvez múltiplos canais ou bus pattern. MPMC genérico adiciona overhead significativo; frequentemente melhor usar SPSC múltiplos ou estruturas especializadas.

Para a maioria dos sistemas, crossbeam bounded channel oferece o melhor ponto de partida: performance boa o suficiente, API completa, comportamento previsível. Otimize para SPSC especializado apenas quando profiling mostrar que latência de canal é realmente seu bottleneck - não antes.

O design lock-free ensina lição importante sobre concorrência: restrições bem escolhidas (single producer, bounded buffer) permitem otimizações impossíveis no caso geral. SPSC não é subset inferior de MPMC - é solução otimizada para problema específico. Reconhecer quando você tem o problema específico, não o geral, é a habilidade real.


← Voltar para home