Concorrência e Paralelismo
Poucos temas em programação acumulam tanta confusão de vocabulário quanto este. “Concorrência”, “paralelismo”, “thread”, “async”, “corrotina” são termos que circulam como sinônimos vagos e raramente com bordas nítidas. A raiz da confusão é que quase todos descrevem uma resposta ao mesmo problema, e é fácil trocar a resposta pela pergunta. O problema, formulado da forma mais crua possível, é este: um programa precisa lidar com mais de uma coisa ao mesmo tempo, e essas coisas precisam, de vez em quando, coordenar-se. Tudo o mais são estratégias distintas para domar essa frase.
Este conjunto de notas mapeia essas estratégias. Não como um catálogo de APIs, mas como um mapa de decisões de design: cada modelo de concorrência é uma resposta a “como estruturamos coisas que acontecem ao mesmo tempo, e como elas conversam entre si”, e cada linguagem que você conhece fez uma aposta sobre qual resposta é a certa. Entender o mapa é o que permite olhar para uma linguagem nova e, em vez de decorar sua API de concorrência, reconhecer qual aposta ela fez e por quê.
Concorrência não é paralelismo
Section titled “Concorrência não é paralelismo”A primeira fronteira a fixar, porque quase todo o resto depende dela, é a que separa concorrência de paralelismo. A formulação mais limpa é a de Rob Pike: concorrência é lidar com muitas coisas ao mesmo tempo; paralelismo é fazer muitas coisas ao mesmo tempo. A diferença não é retórica.
Concorrência é uma propriedade da estrutura do programa. Um programa é concorrente quando é composto por tarefas que progridem de forma independente e podem estar em andamento simultaneamente, mesmo que, num dado instante, apenas uma esteja de fato executando. Um garçom que atende cinco mesas está lidando com cinco pedidos concorrentemente: ele alterna entre eles, anota um, leva outro à cozinha, volta para o primeiro. Há um só garçom, nada acontece literalmente ao mesmo tempo, mas a estrutura do trabalho é concorrente porque as cinco tarefas estão vivas e entrelaçadas.
Paralelismo é uma propriedade da execução. Ele acontece quando duas coisas rodam de fato no mesmo instante físico, o que exige mais de uma unidade de execução: dois núcleos, duas CPUs, duas máquinas. Cinco garçons, um por mesa, é paralelismo.
A relação entre os dois é assimétrica, e é aqui que mora a maioria dos mal-entendidos. Você pode ter concorrência sem paralelismo: é o caso do event loop de um Node.js rodando numa única thread, entrelaçando milhares de conexões sem nunca executar duas linhas ao mesmo tempo. E você pode ter paralelismo sem concorrência no sentido estrutural interessante: um cálculo que multiplica duas matrizes gigantes dividindo as linhas entre oito núcleos é puro paralelismo de dados, sem a coordenação intrincada que caracteriza um sistema concorrente. Concorrência é como você escreve o programa; paralelismo é como ele roda. Um bom desenho concorrente é o que permite ao runtime, se houver núcleos sobrando, colher paralelismo de graça, mas os dois são eixos separados.
Essa distinção não termina em filosofia. Ela decide qual modelo faz sentido para o seu problema. Trabalho limitado por I/O, um servidor que passa a maior parte do tempo esperando a rede ou o disco, ganha com concorrência: enquanto uma tarefa espera, outra progride, e um único núcleo dá conta de milhares de conexões. Trabalho limitado por CPU, um cálculo pesado que satura o processador, só acelera com paralelismo de verdade, mais núcleos moendo os números. Escolher o modelo errado para o perfil errado é a origem de metade das frustrações com desempenho: nenhum event loop, por mais elegante, faz um cálculo puramente CPU-bound ir mais rápido num só núcleo.
Por que existem tantos modelos
Section titled “Por que existem tantos modelos”Se o problema é único, por que há tantas respostas? Porque a frase “coisas que acontecem ao mesmo tempo e precisam coordenar-se” esconde duas perguntas independentes, e cada modelo é uma combinação particular de respostas a elas.
A primeira pergunta é quem escalona o trabalho. Quando você tem mais tarefas do que unidades de execução, alguém precisa decidir qual roda agora e quando ceder a vez. Esse alguém pode ser o kernel do sistema operacional, que fatia o tempo do processador entre threads e as interrompe à força; ou pode ser um runtime dentro do próprio processo, que escalona tarefas mais leves em espaço de usuário sem incomodar o kernel a cada troca. A escolha entre um e outro determina o custo de cada tarefa, quantas você pode ter, e se elas cedem a vez voluntariamente ou são interrompidas.
A segunda pergunta é como as tarefas se comunicam. Duas tarefas que precisam coordenar-se têm, no fundo, dois caminhos. Ou compartilham memória, escrevem e leem as mesmas variáveis, e então precisam de alguma disciplina para não pisar umas nas outras; ou não compartilham nada e trocam mensagens, copiando o que precisa passar de uma para a outra. Essa escolha é a mais consequente de todas, porque memória compartilhada é rápida mas traz consigo toda a família de bugs que o próximo documento cataloga (race conditions, deadlocks), enquanto troca de mensagens é mais cara em cópia mas elimina categorias inteiras de erro pela raiz.
Cruze os dois eixos e o mapa dos modelos aparece. Threads do SO com locks é escalonamento pelo kernel mais memória compartilhada. Goroutines são escalonamento por runtime mais, por padrão, troca de mensagens via channels. O event loop é escalonamento cooperativo por runtime numa thread só. O actor model é escalonamento por runtime mais isolamento total e troca de mensagens levada ao extremo. Cada linguagem escolheu um ponto nesse espaço, e as notas que seguem percorrem esse espaço de baixo para cima.
O mapa destas notas
Section titled “O mapa destas notas”O arco vai do hardware para as abstrações, porque cada modelo de alto nível só faz sentido quando você entende o que ele está tentando esconder ou consertar.
Nível de sistema é o chão: o que são processos e threads do sistema operacional, o que o scheduler do kernel faz, quanto custa uma troca de contexto, e onde exatamente o paralelismo real acontece. Sem esse chão, os modelos acima parecem mágica; com ele, viram engenharia.
Memória compartilhada e sincronização trata do modelo mais antigo e mais direto, threads compartilhando memória, e de por que ele é tão traiçoeiro. É onde moram mutex, race condition, deadlock, atomics e o modelo de memória. Entender a dor desse modelo é entender a motivação de todos os outros.
Green threads e o modelo M:N é a primeira grande resposta ao custo das threads do SO: mover o escalonamento para o espaço de usuário e tornar cada tarefa barata o bastante para ter milhões delas. É onde entram as goroutines de Go e as virtual threads do Java.
Event loop e I/O assíncrono é a resposta que aposta em uma só thread e non-blocking I/O, com async/await por cima. É o modelo de Node.js, do asyncio de Python, do tokio de Rust, e é onde aparece o problema das “funções coloridas”.
Actor model leva o isolamento ao limite: nada é compartilhado, tudo é mensagem, e a unidade de concorrência tem identidade própria. É o modelo do BEAM, a máquina virtual de Erlang e Elixir, e a razão de esses sistemas sobreviverem a falhas que derrubariam qualquer outro.
O objetivo, ao fim dos seis, é que quando alguém disser “esse sistema usa um pool de threads com trabalho roubado” ou “aqui a gente foi de atores porque precisava de resiliência”, você não só entenda a frase como saiba qual trade-off ela está comprando e qual está pagando.
Palavras-chave
Section titled “Palavras-chave”Conceitos: concorrência, paralelismo, escalonamento, memória compartilhada, troca de mensagens, I/O-bound, CPU-bound, coordenação Modelos: threads e locks, green threads, event loop, actor model, escalonamento M:N Linguagens: Go, Elixir, Erlang, Node.js, Zig, Rust, Java, Python Referências: Rob Pike, “Concurrency is not Parallelism”