Concorrência e paralelismo: o que muda quando seu código faz várias coisas ao mesmo tempo
Race conditions, deadlocks, locks e o modelo de memória: entenda por que código concorrente é difícil e quais padrões o tornam seguro e previsível.
Neste artigo
Duas palavras que quase todo mundo confunde#
Concorrência e paralelismo são tratados como sinônimos na conversa do dia a dia, mas são conceitos distintos, e confundi-los leva a decisões de design equivocadas. Vale fixar a distinção logo de início, porque ela organiza todo o resto do assunto.
Concorrência é sobre estrutura: lidar com muitas tarefas que estão em andamento ao mesmo tempo, alternando entre elas. Um único cozinheiro preparando três pratos simultaneamente — picando um enquanto outro assa — é concorrente, mesmo tendo só um par de mãos. Paralelismo é sobre execução: realizar várias tarefas literalmente ao mesmo instante, o que exige múltiplas unidades de processamento. Três cozinheiros trabalhando lado a lado é paralelismo. A frase que cristaliza a diferença: concorrência é sobre lidar com muitas coisas ao mesmo tempo; paralelismo é sobre fazer muitas coisas ao mesmo tempo.
A distinção tem consequências práticas. Um programa pode ser concorrente sem ser paralelo (um núcleo alternando entre tarefas) e o paralelismo real depende de hardware com múltiplos núcleos. Mas o que torna ambos difíceis é o mesmo fenômeno: quando várias linhas de execução acessam e modificam os mesmos dados, o comportamento do programa deixa de ser previsível a partir da leitura sequencial do código. É aí que mora toda a dificuldade — e todo o interesse.
A raiz de todo o problema: estado mutável compartilhado#
Se houvesse uma única frase para explicar por que código concorrente é difícil, seria esta: o problema é o estado mutável compartilhado. Enquanto cada tarefa trabalha com seus próprios dados isolados, a concorrência é trivial — as tarefas não interferem umas nas outras. A dificuldade nasce no instante em que duas ou mais tarefas leem e escrevem a mesma posição de memória, porque a ordem em que essas operações se intercalam deixa de ser controlada por você e passa a ser decidida pelo escalonador, de forma não determinística.
Considere o exemplo mais simples possível: duas tarefas incrementando o mesmo contador.
``` contador = 0
Tarefa A Tarefa B lê contador (0) lê contador (0) soma 1 (1) soma 1 (1) escreve contador (1) escreve contador (1) ```
Você esperaria que dois incrementos resultassem em 2. Mas o inocente contador += 1 não é uma operação atômica — ele são três passos: ler, somar, escrever. Se as duas tarefas leem o valor 0 antes de qualquer uma escrever, ambas somam 1 e ambas escrevem 1. O resultado é 1, não 2. Um incremento foi perdido. E o mais traiçoeiro: na maioria das vezes o escalonamento não intercala assim, e o código dá o resultado certo — o bug só aparece sob a temporização exata que o torna visível, talvez uma vez em milhões, talvez só sob carga em produção.
Isso é uma race condition (condição de corrida), e ela é o pesadelo característico da concorrência precisamente por essas propriedades: é não determinística, difícil de reproduzir, muitas vezes invisível em testes, e capaz de corromper dados silenciosamente. Um programa com uma race condition pode passar por toda a bateria de testes e ainda assim falhar de forma intermitente em produção, deixando um rastro de dados inconsistentes que ninguém consegue explicar.
Locks: a solução óbvia e seus próprios problemas#
A ferramenta clássica para domar o acesso concorrente ao estado compartilhado é a exclusão mútua, materializada num lock (ou mutex). A ideia é delimitar a seção crítica — o trecho de código que acessa o dado compartilhado — e garantir que apenas uma tarefa por vez execute essa seção. Quem chega primeiro adquire o lock; os outros esperam a vez.
`` adquire(lock) contador = contador + 1 # seção crítica: só uma tarefa por vez libera(lock) ``
Com o lock, o incremento vira efetivamente atômico do ponto de vista das outras tarefas, e a race condition desaparece. Problema resolvido — exceto que os locks trazem sua própria família de dificuldades, e trocar uma classe de bug por outra é um risco real.
O primeiro perigo é o deadlock (impasse): duas tarefas que esperam uma pela outra, para sempre. A tarefa A adquire o lock 1 e espera pelo lock 2; a tarefa B adquire o lock 2 e espera pelo lock 1. Nenhuma solta o que tem, nenhuma consegue o que quer, e o programa congela. Deadlocks surgem sempre que múltiplos locks são adquiridos em ordens inconsistentes, e a prevenção clássica é estabelecer uma ordem global de aquisição: se todos sempre pegam o lock 1 antes do lock 2, o impasse circular não se forma.
O segundo perigo é mais silencioso: contenção. Se muitas tarefas disputam o mesmo lock, elas passam mais tempo esperando na fila do que trabalhando, e o paralelismo que você buscava evapora. Um lock grosseiro demais — que protege muito mais do que a seção crítica realmente precisa — serializa o programa inteiro, transformando seus oito núcleos no desempenho de um só. O equilíbrio é delicado: locks finos demais aumentam o risco de deadlock e a complexidade; grossos demais matam o paralelismo. Segurar um lock por tempo desnecessariamente longo — especialmente enquanto se espera por uma operação lenta como I/O — é um dos erros mais custosos, porque estende a fila de espera de todos os outros.
O modelo de memória: por que o hardware conspira contra você#
Há uma camada de dificuldade ainda mais profunda que pega de surpresa quem acha que entende locks: o modelo de memória. A intuição de que o processador executa suas instruções exatamente na ordem em que você as escreveu, e que uma escrita numa variável fica imediatamente visível para todas as outras threads, está errada. Por razões de desempenho, tanto os compiladores quanto os processadores modernos reordenam operações e mantêm caches locais por núcleo.
A consequência é perturbadora: uma escrita feita por uma thread pode não se tornar visível para outra thread imediatamente, ou pode se tornar visível numa ordem diferente da que o código sugere. Uma thread pode setar uma flag pronto = true depois de preencher um dado, e outra thread pode ver a flag true antes de ver o dado preenchido, porque as duas escritas foram reordenadas ou propagaram-se pelos caches em ordens diferentes. Código que parece obviamente correto na leitura sequencial pode falhar por causa dessa reordenação invisível.
É por isso que os mecanismos de sincronização — locks, variáveis atômicas, barreiras de memória — fazem duas coisas, não uma. Além de garantir exclusão mútua, eles estabelecem relações de "acontece-antes" que forçam a visibilidade e a ordenação corretas da memória entre threads. Um lock não só impede acesso simultâneo; ele garante que tudo que uma thread fez antes de liberar o lock esteja visível para a próxima thread que o adquirir. Essa é a razão pela qual você não pode simplesmente marcar uma variável compartilhada como "cuidado" e acessá-la sem sincronização: sem as garantias do modelo de memória, não há promessa nenhuma sobre o que a outra thread vai enxergar.
Padrões que tornam a concorrência tratável#
Diante de toda essa dificuldade, a indústria convergiu para padrões que evitam o problema em vez de gerenciá-lo à mão. O princípio que os unifica é reduzir ou eliminar o estado mutável compartilhado — atacar a raiz, não os sintomas.
- Imutabilidade. Dados que nunca mudam podem ser compartilhados livremente entre qualquer número de threads sem lock nenhum, porque não há escrita para causar corrida. Estruturas imutáveis e programação funcional tornam classes inteiras de bugs de concorrência impossíveis por construção.
- Passagem de mensagens. Em vez de várias threads compartilharem memória, cada uma é dona dos seus dados e elas se comunicam enviando mensagens por canais. O lema, popularizado por linguagens desenhadas para concorrência, é: não comunique compartilhando memória; compartilhe memória comunicando. O estado não é compartilhado; ele é transferido, e a corrida desaparece.
- Confinamento. Manter cada dado acessível a uma única thread elimina a necessidade de sincronização. Um dado que só uma thread toca nunca sofre corrida.
- Operações atômicas de alto nível. Para casos simples como contadores, tipos atômicos fornecidos pela linguagem oferecem incremento e comparação-e-troca sem os riscos de gerenciar locks manualmente.
Um padrão que merece destaque próprio é o de limitar o fan-out e aplicar backpressure. Disparar tarefas concorrentes sem limite — uma para cada item de uma lista de um milhão — esgota memória e recursos tão certamente quanto qualquer bug de lock. Filas com capacidade limitada e semáforos que restringem quantas tarefas rodam ao mesmo tempo mantêm o sistema dentro dos seus limites, fazendo o produtor desacelerar quando o consumidor não dá conta. Concorrência sem limites é uma forma de descontrole tão perigosa quanto a race condition.
Cancelamento, timeouts e o cuidado com o que fica pela metade#
Um aspecto frequentemente esquecido do código concorrente é o que acontece quando uma tarefa precisa ser interrompida — porque estourou um timeout, porque o cliente desistiu, porque outra tarefa relacionada falhou. Uma tarefa cancelada no meio de uma operação pode deixar dados num estado inconsistente: metade de uma transferência feita, um lock adquirido e nunca liberado, um recurso alocado e nunca devolvido.
Código concorrente robusto trata o cancelamento como parte do design, não como acidente. Toda operação que pode demorar deve ter um timeout, para que uma dependência lenta não trave a cadeia inteira. E toda tarefa que pode ser cancelada precisa garantir que seus invariantes permaneçam íntegros mesmo se ela for interrompida a qualquer momento — tipicamente através de transações atômicas (tudo ou nada) e de mecanismos de limpeza que rodam na saída, garantidos aconteça o que acontecer. Ignorar o cancelamento é construir um sistema que funciona no caminho feliz e corrompe dados no primeiro imprevisto.
Respeite a dificuldade, projete para evitá-la#
A conclusão honesta sobre concorrência é que ela é genuinamente difícil, e nenhuma quantidade de esperteza transforma um design ruim em um seguro. A postura produtiva não é dominar truques para gerenciar estado compartilhado complexo, mas projetar sistemas que minimizam esse estado desde o começo — preferindo imutabilidade, isolamento e passagem de mensagens ao malabarismo de locks. O código concorrente mais confiável é aquele que tem pouquíssimos pontos onde a concorrência realmente importa, e que trata esses poucos pontos com o cuidado extremo que eles merecem.
Vale lembrar que a concorrência raramente vive sozinha: ela quase sempre opera sobre as estruturas de dados que guardam o estado do programa, e proteger corretamente uma estrutura compartilhada é uma extensão direta de entender como essa estrutura funciona, tema que exploramos em estruturas de dados essenciais. Escolher a estrutura certa resolve o desempenho de um fluxo único; sincronizar o acesso a ela é o que preserva a correção quando muitos fluxos disputam os mesmos dados ao mesmo tempo. Concorrência bem-feita não é sobre fazer o computador correr mais rápido a qualquer custo — é sobre fazê-lo correr rápido e continuar certo, e essa segunda metade é a que separa os sistemas confiáveis dos que falham de madrugada por motivos que ninguém consegue reproduzir.