2.17

View in English

2.17 Concorrência e paralelismo

Visão geral e motivação

A concorrência é a arte de estruturar um programa como tarefas independentes que podem progredir sem esperar umas pelas outras. O paralelismo é executar de fato essas tarefas no mesmo instante em vários processadores. A distinção não é pedante. A concorrência é uma forma de organizar o código para que uma chamada de rede lenta não congele o programa inteiro. O paralelismo é uma forma de terminar uma grande computação mais rápido, espalhando-a por núcleos. Confundir os dois leva as equipes a acrescentar threads na esperança de velocidade e receber apenas bugs.

Para equipes grandes, este tema importa porque a concorrência é onde a correção morre em silêncio. Um único autor escrevendo código de uma só thread consegue raciocinar sobre ele linha a linha, mas no momento em que muitos autores compartilham memória entre threads, o número de intercalações possíveis explode, e um programa que passa em todos os testes ainda pode falhar uma vez em um milhão sob carga de produção, não com um travamento barulhento, mas com dados corrompidos, requisições presas e incidentes que ninguém consegue reproduzir. Este capítulo se apoia no foco em nível de código do capítulo 2.16 (engenharia de desempenho) e nos fundamentos de computação do capítulo 2.13 e alimenta os problemas de coordenação do capítulo 3.3 (sistemas distribuídos), que é a concorrência entre máquinas com a crueldade acrescida de uma rede não confiável.

Para as empresas, os bugs de concorrência são bugs de vazão. Os serviços de alto tráfego vivem ou morrem pela capacidade de atender milhares de requisições simultâneas sem disputar o estado compartilhado, e um único contador não sincronizado pode corromper um livro-razão sob carga. Para o governo, o que está em jogo é a correção e a auditabilidade em sistemas que funcionam por décadas e tocam segurança, benefícios ou registros públicos. Uma condição de corrida num sistema tributário ou de saúde não é um incômodo: é uma resposta errada que alguém terá de explicar depois a um órgão de supervisão. Nos dois contextos o objetivo é o mesmo: fazer do caminho seguro o padrão, para que as muitas pessoas que tocam o código não precisem ser, cada uma, especialistas em concorrência.

Princípios fundamentais

  • Concorrência é estrutura. Paralelismo é execução. Decida de qual dos dois você realmente precisa antes de recorrer a threads.
  • O estado mutável compartilhado é o inimigo. Quase todo bug de concorrência remonta a duas tarefas tocando os mesmos dados mutáveis.
  • Prefira imutabilidade e troca de mensagens. Dados que não podem mudar não podem ser disputados, e as mensagens vencem a memória compartilhada em segurança.
  • O não determinismo é a dificuldade central. O bug que aparece uma execução em mil é o problema inteiro, não um caso de borda.
  • Limite tudo. Filas, quantidades de threads e trabalho em andamento sem limite transformam um pico numa queda de serviço.
  • Modelos de mais alto nível vencem travas cruas. Atores, canais e concorrência estruturada dão a muitos autores um padrão seguro, e toda trava tem um custo.
  • Teste as intercalações, não apenas o caminho feliz. Testes determinísticos não conseguem pegar um bug que só uma ordenação rara revela.

Recomendações

Decida se você precisa de concorrência ou de paralelismo

Comece nomeando o problema. Se o seu serviço passa a maior parte do tempo esperando (bancos de dados, chamadas de rede ou disco), você tem uma carga limitada por E/S, e a concorrência é a resposta: estruture o código de modo que, enquanto uma requisição espera, as outras avancem. Uma única thread com async/await, ou um pequeno conjunto delas, pode atender milhares de requisições em espera. Se, em vez disso, o seu programa é limitado por CPU, triturando computação com pouca espera, então o paralelismo entre núcleos é o que compra velocidade, e aqui o teto é definido pela lei de Amdahl (veja o capítulo 2.16): a fração serial limita o seu ganho não importa quantos núcleos você acrescente. Meça em que regime você está antes de projetar.

Trate o estado mutável compartilhado como o inimigo

Quase todo defeito de concorrência se reduz à mesma forma: duas tarefas leem e escrevem os mesmos dados mutáveis sem concordar sobre uma ordem. Essa é uma condição de corrida, e produz atualizações perdidas, objetos escritos pela metade e valores que violam invariantes que o código presumia seguros. A defesa mais confiável é ter menos estado mutável compartilhado. Dê a cada tarefa os próprios dados, passe cópias em vez de referências e confine o estado mutável a um único dono que os demais alcançam por mensagens. Quando você de fato precisar compartilhar, torne o compartilhamento explícito e pequeno, para que um revisor veja todos os lugares em que o estado é tocado.

Prefira a imutabilidade e a troca de mensagens como padrões

O dado compartilhado mais seguro é o dado que não pode mudar. Um objeto imutável, depois de construído, pode ser lido por qualquer número de threads sem nenhuma sincronização, porque não há nada para disputar. Faça da imutabilidade o seu padrão e da mutabilidade a exceção deliberada. Quando as tarefas precisam se coordenar, prefira a troca de mensagens à memória compartilhada: em vez de compartilhar uma variável comum, faça uma tarefa enviar o valor à outra, que é a filosofia por trás do provérbio de Go “não se comunique compartilhando memória, compartilhe memória comunicando-se”. A troca de mensagens transforma bugs invisíveis e dependentes de ordem em fluxo de dados explícito e inspecionável, e essa clareza quase sempre vale o custo por mensagem em código mantido por muita gente.

Recorra a modelos de mais alto nível antes de travas cruas

O travamento escrito à mão é correto em princípio e desastroso na prática, porque os humanos são ruins em raciocinar sobre todas as intercalações. Prefira modelos que façam da concorrência segura o padrão. O modelo de atores dá a cada ator um estado privado e uma caixa de correio: os atores nunca compartilham memória e só enviam mensagens, então classes inteiras de corrida desaparecem. Os processos sequenciais comunicantes (CSP), o modelo por trás dos canais em linguagens como Go, fazem processos independentes passarem valores por canais tipados. A concorrência estruturada liga o tempo de vida das tarefas concorrentes a um escopo léxico, de modo que as tarefas não podem sobreviver ao bloco que as criou e os erros se propagam em vez de sumir. O async/await permite escrever código concorrente e limitado por E/S num estilo sequencial. Cada um desses eleva o piso para o autor médio, que é o de que uma equipe grande precisa.

Entenda o seu modelo de memória, a atomicidade e a visibilidade

Quando você de fato compartilha memória, duas propriedades mordem. A atomicidade significa que uma operação acontece toda de uma vez ou não acontece: um incremento simples (x = x + 1) não é atômico, porque lê, soma e escreve em três passos que outra thread pode interromper, que é como os contadores perdem atualizações. A visibilidade significa que a escrita de uma thread se torna observável para outra: sem a devida sincronização, um valor escrito num núcleo pode ficar num cache sem ser visto por outro, de modo que uma thread pode girar para sempre num sinalizador que já estava definido. O modelo de memória da sua linguagem define quando as escritas se tornam visíveis e quais ordenações o compilador e a CPU podem reorganizar, então você não pode presumir que o código roda na ordem em que o escreveu. Use os tipos atômicos e as primitivas de sincronização da linguagem em vez de inventar o seu próprio esquema sem travas.

Use as primitivas de sincronização deliberadamente e projete contra o impasse

Quando o compartilhamento é inevitável, recorra à primitiva certa e respeite o preço dela. Uma trava ou mutex (exclusão mútua) deixa uma thread por vez entrar numa seção crítica, mas serializa o acesso, de modo que uma trava quente vira um gargalo que apaga o benefício de muitos núcleos. Um semáforo limita quantas tarefas podem prosseguir de uma vez, que é como você limita um conjunto de threads. As operações atômicas oferecem atualizações sem trava para valores simples como contadores, mais baratas que uma trava mas fáceis de usar mal em qualquer coisa composta. As travas trazem três modos clássicos de falha. Um impasse (deadlock) é quando as tarefas esperam umas pelas outras num ciclo e nenhuma consegue prosseguir, o caso de manual sendo duas threads, cada uma segurando uma trava e querendo a outra. Um livelock é quando as tarefas continuam reagindo umas às outras sem fazer progresso. A inanição é quando uma tarefa nunca obtém um recurso porque outras continuam passando à frente. As disciplinas que previnem isso são concretas: imponha uma ordem global de travas, segure as travas brevemente, acrescente tempos limite para que uma tarefa presa falhe de forma audível, nunca chame código desconhecido segurando uma trava e use escalonamento justo onde a inanição for um risco. Escreva essas regras, porque uma pessoa autora nova não consegue redescobri-las só pelo código.

Limite suas filas, conjuntos e trabalho em andamento com contrapressão

Uma fila sem limite é uma bomba-relógio. Sob um pico de tráfego, o trabalho chega mais rápido do que é escoado, a fila cresce sem limite, a memória enche e o serviço morre de um jeito que parece uma misteriosa queda por falta de memória e não a sobrecarga que é. Limite toda fila, delimite todo conjunto de threads e aplique contrapressão: quando o sistema está cheio, sinalize a montante para desacelerar ou rejeite o trabalho rápido em vez de aceitar trabalho infinito que você não consegue terminar. Dimensione os conjuntos para a carga (aproximadamente a contagem de núcleos para trabalho limitado por CPU, mais alta para trabalho limitado por E/S em que as threads em sua maioria esperam) e trate o limite como uma decisão deliberada de capacidade. Isso se liga aos padrões de resiliência do capítulo 3.3.

Use o paralelismo de dados onde o trabalho é “embaraçosamente paralelo”

Alguns problemas se dividem de forma limpa: aplicar a mesma operação a cada elemento de um grande conjunto de dados, sem que nenhum elemento dependa de outro. Esse paralelismo de dados é o tipo mais amigável, porque há pouco estado compartilhado para disputar e o ganho pode se aproximar da contagem de núcleos, como mostram os pipelines de map-reduce, as operações paralelas em vetores e o código numérico vetorizado. Mesmo aqui, respeite a lei de Amdahl: a etapa de fusão ou redução costuma ser serial e limita o seu ganho, e o custo de dividir pode dominar para entradas pequenas. Recorra a ele quando o trabalho por elemento é substancial e os elementos são realmente independentes. Fora disso, a versão sequencial mais simples costuma ser ao mesmo tempo rápida o bastante e muito mais fácil de manter correta, um ponto que as práticas de construção do capítulo 2.9 reforçam.

Teste e depure código não determinístico de propósito

Os bugs de concorrência são não determinísticos, então os testes comuns, que rodam uma intercalação, em sua maioria os perdem. Ataque o problema deliberadamente com testes de estresse e de fuzzing que rodam muitas tarefas sob temporização aleatória para sacudir ordenações raras. Recorra a detectores de corrida e a sanitizadores de threads, ferramentas que instrumentam o acesso à memória para pegar corridas de dados mesmo quando a intercalação com bug não aconteceu nesta execução. Onde a sua plataforma oferecer, use simulação determinística ou escalonadores controlados que reproduzem uma intercalação específica, transformando um heisenbug num reproduzível, e projete para que um travamento em produção permita capturar o estado das threads e a posse das travas, o que se liga à disciplina de depuração do capítulo 2.15. Acima de tudo, prefira designs (imutabilidade, troca de mensagens, propriedade única) que tornem impossíveis categorias inteiras desses bugs, porque um bug que você não consegue criar é um que você nunca precisa depurar.

Compromissos: prós e contras

AbordagemPrósContras
Memória compartilhada com travasRápida por operação. FamiliarBugs de corrida, impasse e visibilidade. Difícil de manter correta por muitos autores
ImutabilidadeSem necessidade de sincronização. Leituras trivialmente seguras entre threadsCusto de cópia. Desajeitada para grandes estruturas mutáveis
Troca de mensagens (atores, canais)Fluxo de dados explícito. Classes inteiras de bugs desaparecemCusto por mensagem. Pode esconder a contrapressão se as filas não tiverem limite
Async/awaitConcorrência barata para trabalho limitado por E/S. Código de aparência sequencialSem paralelismo para trabalho de CPU. Bloquear uma tarefa trava as outras
Concorrência estruturadaTempos de vida de tarefas claros. Os erros se propagam. Sem tarefas vazadasMais nova, menos disponível em alguns ecossistemas
Paralelismo de dadosGanho quase linear em trabalho independenteTeto de Amdahl. O custo domina entradas pequenas
Atômicos / sem travasSem contenção de trava para valores simplesExtremamente fácil de errar de forma sutil. Difícil de revisar

A tensão central é segurança versus velocidade bruta, e a resolução é comprar a correção primeiro e gastar desempenho apenas onde a medição prova que você precisa. O travamento cru de memória compartilhada é o mais rápido por operação e o mais perigoso por linha de código. Os modelos de mais alto nível custam um pouco de vazão e devolvem muita segurança e clareza, e para código mantido por muitas mãos essa troca vale decisivamente a pena. Reserve a concorrência sem travas ajustada à mão para os pequenos pontos quentes em que um profiler (capítulo 2.16) prova que o custo de coordenação importa e mantenha até esses atrás de uma fronteira bem testada.

Perguntas para discutir com sua equipe

  1. Para o seu serviço mais movimentado, a carga é limitada por E/S ou por CPU, e o seu design de concorrência corresponde a isso? As equipes rotineiramente acrescentam conjuntos de threads a serviços que passam 95% do tempo esperando um banco de dados, ganhando contenção mas nenhuma vazão, ou tentam paralelizar uma computação cuja fração serial limita qualquer ganho. O design certo decorre do regime: async ou um pequeno conjunto para trabalho cheio de espera, paralelismo real entre núcleos para trabalho pesado em computação. Leve um profile que mostre para onde o tempo de fato vai, e não uma suposição, e, se a maior parte do tempo é gasta computando, meça a fração serial e deixe a lei de Amdahl dizer o teto. A resposta molda se vocês recorrem a async, a um conjunto limitado ou ao paralelismo de dados.

  2. Qual é o padrão da sua equipe para compartilhar estado entre tarefas, e ele é seguro por construção? Numa equipe grande, o padrão importa mais que as exceções, porque a maior parte do código é escrita por pessoas que não são especialistas em concorrência e que copiam o padrão já presente. Se o padrão são objetos mutáveis compartilhados guardados por travas ad hoc, vocês estão a uma trava esquecida de uma corrida que surge meses depois em produção. Se o padrão são imutabilidade e troca de mensagens, categorias inteiras de bug nunca ocorrem, e o raro lugar que de fato precisa de memória compartilhada se destaca para revisão cuidadosa. Discutam a que uma pessoa engenheira nova recorreria hoje, se as suas revisões pegariam uma escrita não sincronizada e como fazer do caminho seguro o caminho fácil.

  3. Como vocês encontrariam, reproduziriam e corrigiriam um bug de concorrência que aparece uma vez em um milhão de requisições em produção? A resposta honesta para muitas equipes é que não conseguiriam, porque o bug some quando olham e os testes só rodam uma intercalação benigna. Isso deveria preocupar vocês, porque esses bugs corrompem dados em silêncio e corroem a confiança. Falem sobre se vocês rodam detectores de corrida e sanitizadores de threads na integração contínua, se fazem testes de estresse com temporização aleatória e se a observabilidade de produção captura o estado de threads e travas no momento de um travamento. As melhores equipes respondem tornando impossíveis a maioria desses bugs pela escolha do modelo, de modo que os poucos residuais sejam raros e contidos.

  4. Onde, no seu sistema, ainda existe uma fila sem limite ou um conjunto de threads sem teto, e o que acontece com ela sob um súbito pico de dez vezes? Isso importa porque o trabalho em andamento sem limite é a falha que se disfarça de misteriosa queda por falta de memória: o trabalho chega mais rápido do que é escoado, a memória enche e o serviço morre parecendo uma falha de hardware e não a sobrecarga que é. As considerações concorrentes são reais, porque um limite definido baixo demais rejeita tráfego legítimo e um limite alto demais adia a queda em vez de preveni-la, então o número é uma decisão de capacidade, não um palpite. Leve um inventário de toda fila e conjunto, seu limite atual (ou a admissão de que não tem), o comportamento de contrapressão quando enche e evidências de teste de carga de como o sistema se degrada no limite. Numa frota corporativa, uma única fila sem limite pode se propagar até uma queda em toda a frota, e para uma plataforma governamental que precisa continuar disponível aos cidadãos, a rejeição graciosa com um erro claro é uma obrigação de serviço, então o limite e o seu caminho de rejeição pertencem ao plano de capacidade e ao runbook, não à memória de um engenheiro.

  5. Qual é a política da sua equipe para usar modelos de concorrência de mais alto nível versus travas escritas à mão, e onde vocês permitiram exceções? O modelo padrão decide quão segura é a mudança média, porque a maioria dos autores não é especialista em concorrência e copiará o padrão já existente: atores, canais e concorrência estruturada elevam o piso para todos, enquanto a trava crua é correta em teoria e fonte de impasses na prática. A tensão é que os modelos de mais alto nível custam um pouco de sobrecarga por mensagem ou por tarefa, e um profiler ocasionalmente provará que um caminho quente precisa de código sem travas ajustado à mão, então uma proibição geral é tão errada quanto uma liberdade total. Leve a lista dos lugares em que vocês desceram abaixo do padrão seguro, as evidências de profiling que justificaram cada um e como cada exceção é cercada por uma fronteira testada e por uma ordem de travas documentada. Numa grande empresa, essa política é o que impede milhares de contribuidores de reinventar, cada um, um esquema inseguro, e num sistema governamental de longa vida é o que permite a um revisor, anos depois, entender por que um padrão perigoso foi permitido e confirmar que ainda se justifica.

  6. Quando vocês decidem paralelizar uma computação, como medem a fração serial, e quem é responsável por confirmar que o ganho de velocidade é real? As equipes rotineiramente espalham uma computação por núcleos e celebram um número que um profiler jamais confirmaria, porque a lei de Amdahl limita o ganho ao recíproco da fração serial não importa quantos núcleos você acrescente, e o custo de dividir e fundir pode apagar totalmente o benefício para entradas pequenas. A atração concorrente é que o paralelismo acrescenta complexidade real e nova superfície de corrida, então a pergunta é se o ganho medido justifica o risco de correção que vocês assumem. Leve um profile que isole a parte serial, os tamanhos de entrada em que o paralelismo de fato vence e um benchmark de antes e depois em hardware representativo, e não uma estimativa esperançosa. Para uma empresa que paga por uma grande frota de computação, uma análise honesta da fração serial se traduz em gasto de hardware economizado ou desperdiçado, e para um órgão governamental que responde pelo custo de um sistema público, quem aprovou o design paralelo deveria conseguir mostrar, numa auditoria, a medição que o justificou.

Perspectiva por setor

Startup. Com uma equipe minúscula e nenhuma pista sobrando, compre correção com estrutura, e não com um especialista em concorrência que você não consegue contratar. Recorra ao único padrão seguro que a sua linguagem oferece, async/await para trabalho limitado por E/S, uma tarefa dona ou um ator para qualquer estado compartilhado e pule por completo o travamento ajustado à mão. Uma corrida de atualização perdida num caminho de pagamentos pode afundar você mais rápido que uma funcionalidade perdida, então gaste o pouco de código extra para tornar impossível essa classe de bug e siga em frente.

Pequena empresa. Você não tem ninguém cujo trabalho seja a concorrência, então favoreça plataformas e serviços gerenciados que cuidam disso por você: uma transação de banco de dados, uma fila hospedada ou o modelo de requisição de um framework vencem threads que você mantém à mão. Ao avaliar uma ferramenta, trate “isso torna a concorrência segura por padrão” como uma pergunta de comprar versus construir e prefira a opção em que uma intercalação errada não consiga corromper em silêncio o registro de um cliente. Mantenha o estado mutável compartilhado fora do seu próprio código sempre que um serviço gerenciado e limitado puder guardá-lo.

Grande empresa. Entre muitas equipes, o objetivo é um padrão da casa que mantenha seguros milhares de contribuidores: imutabilidade e troca de mensagens como norma, modelos de mais alto nível em vez de travas cruas, filas e conjuntos limitados com contrapressão e uma ordem global de travas documentada. Codifique isso em padrões de engenharia, imponha com detectores de corrida e testes de estresse na CI e governe as exceções em que um profiler justificou código sem travas para que cada uma fique atrás de uma fronteira testada e revisada. Gerencie a capacidade de concorrência como uma preocupação de toda a frota, com limites de fila e tamanhos de conjunto ligados à carga medida.

Governo. A correção e a auditabilidade em sistemas que funcionam por décadas valem mais que a vazão bruta. Exija que toda transição de estado seja registrada e reproduzível, para que uma suspeita de corrida possa ser reproduzida e a correção provada a um órgão de supervisão, e mantenha caminhos determinísticos livres de IA para decisões que tocam benefícios, segurança ou registros públicos. A contratação deve exigir que os fornecedores divulguem seu modelo de concorrência e evidências de cobertura de detector de corrida e de teste de estresse, porque uma resposta errada sob carga num sistema público não é um incômodo: é algo que uma autoridade responsável precisará explicar depois.

Exemplos

Startup. Uma pequena equipe entrega uma funcionalidade de pagamentos e nota que os saldos das contas ocasionalmente se desviam em alguns centavos sob carga. A causa é uma simples leitura-modificação-escrita num campo de saldo por tratadores de requisições concorrentes, uma corrida de atualização perdida. Em vez de espalhar travas, eles movem o saldo de cada conta para trás de uma única tarefa dona que processa débitos e créditos como mensagens, um por vez. O desvio desaparece, o código fica fácil de raciocinar e eles acrescentam um teste de estresse que dispara milhares de transferências concorrentes para proteger a correção. Uma mudança estrutural, uma classe inteira de bug aposentada.

Grande empresa. Um serviço de pedidos de alta vazão que atende dezenas de milhares de requisições por segundo sofre picos periódicos de latência e quedas ocasionais por falta de memória durante surtos de tráfego. A investigação encontra uma fila de trabalho sem limite por trás de um conjunto de threads que cresce sem teto quando a demanda excede a capacidade. A equipe limita a fila, delimita o conjunto num tamanho ligado à contagem de núcleos e acrescenta uma contrapressão que rejeita o excesso de carga rapidamente com um erro claro. A vazão se torna previsível, as quedas cessam e uma trava quente num cache compartilhado é substituída por uma estrutura sem travas só depois de um profiler provar que a contenção é real. Padrões seguros para os muitos autores, concorrência ajustada apenas onde medido.

Governo. Uma plataforma nacional de benefícios funciona por décadas e precisa produzir resultados auditáveis e corretos mesmo sob atualizações concorrentes de casos. A equipe escolhe a imutabilidade e a troca de mensagens como padrão da casa, confina todo estado mutável a um único dono e impõe uma ordem global de travas onde ainda restam travas, tudo escrito nos padrões de engenharia. Eles rodam sanitizadores de threads e testes de estresse aleatórios no pipeline e projetam para que toda transição de estado seja registrada e reproduzível para a supervisão, o que lhes permite reproduzir e provar a correção quando uma intercalação rara é suspeitada. Correção e auditabilidade são tratadas como requisitos de primeira classe, não como reflexões tardias de desempenho.

Justificativa de negócio: motivações, ROI e TCO

O retorno da concorrência disciplinada aparece como incidentes que nunca acontecem. Uma única corrida de produção pode corromper dados em milhares de registros, e o custo inclui tanto as horas de engenharia para encontrar um bug que se esconde quando observado quanto o custo muito maior de reconciliar dados ruins, notificar os usuários afetados e reconstruir a confiança. Estão entre os defeitos mais caros de diagnosticar justamente por serem não determinísticos, então perseguir um heisenbug pode fazer sombra ao esforço de escolher de antemão um modelo seguro.

O lado positivo também aparece como vazão e custo. Dimensionar a concorrência na medida certa permite a um serviço atender muito mais carga no mesmo hardware, uma economia recorrente para uma frota grande, enquanto a contrapressão e as filas limitadas previnem as quedas em cascata que transformam um pico de tráfego num incidente público. O custo total de propriedade é modesto e em grande parte cultural: você investe num estilo da casa (imutabilidade, troca de mensagens, concorrência estruturada), em ferramentas (detectores de corrida, sanitizadores de threads, arcabouços de estresse na CI) e em padrões que codificam a ordem das travas e os limites. A alternativa é uma base de código em que a correção depende de todo autor ser um especialista para sempre, o que nenhuma equipe em crescimento consegue sustentar. Defenda o caso junto à liderança nas unidades dela: traduza uma corrida evitada em incidentes de corrupção de dados evitados, a contrapressão em quedas prevenidas e um padrão seguro em tempo de integração economizado.

Antipadrões e armadilhas

  • Acrescentar threads por velocidade em trabalho limitado por E/S. Mais threads num serviço cheio de espera compram contenção, não vazão.
  • Estado mutável compartilhado por toda parte. Qualquer thread mutando qualquer objeto faz da correção uma questão de sorte que nenhum revisor consegue verificar.
  • Filas e conjuntos sem limite. Um pico faz a fila crescer até a memória morrer. A queda parece misteriosa mas é sobrecarga pura.
  • Travamento ad hoc sem ordem global. Travas tomadas em ordens diferentes pela base de código entram em impasse sob carga.
  • Presumir que o código roda na ordem escrita. Ignorar o modelo de memória, de modo que um bug de visibilidade deixa uma thread girando num valor obsoleto.
  • Esperteza sem travas escrita à mão. Esquemas sem travas sob medida quase sempre estão sutilmente errados e são quase impossíveis de revisar.
  • Testar só a intercalação feliz. Testes determinísticos passam enquanto a ordenação de um em um milhão corrompe a produção.
  • Chamar código desconhecido segurando uma trava. Um callback que bloqueia ou reentra transforma uma seção crítica num impasse.

Modelo de maturidade

  • Nível 1, Iniciar: A concorrência é ad hoc e reativa. Threads e travas são acrescentadas por instinto, o estado mutável compartilhado está por toda parte e as filas não têm limite. As condições de corrida surgem como incidentes de produção irreproduzíveis que ninguém consegue diagnosticar, e não há ferramentas para pegá-las.
  • Nível 2, Desenvolver: Algumas equipes aprenderam práticas básicas: usam travas com mais cuidado e limitam suas filas mais óbvias. Há consciência informal de corridas e impasses, e alguns caminhos críticos recebem escrutínio extra. A prática é inconsistente entre as equipes, o teste ainda é em sua maioria de uma só intercalação e os padrões seguros vivem em indivíduos e não por escrito.
  • Nível 3, Padronizar: A organização tem um estilo da casa documentado e imposto em toda a organização: imutabilidade e troca de mensagens como padrões, modelos de mais alto nível em vez de travas cruas, filas e conjuntos limitados com contrapressão e uma ordem global de travas documentada. Detectores de corrida e testes de estresse rodam na CI, e as escolhas de concorrência decorrem de o trabalho ser limitado por E/S ou por CPU.
  • Nível 4, Gerenciar: A organização mede e controla sua postura de concorrência em relação a linhas de base. Ela acompanha a cobertura de detectores de corrida e sanitizadores de threads entre os serviços, registra a profundidade das filas, o tempo de espera de travas, a saturação dos conjuntos e as taxas de rejeição como métricas monitoradas e faz testes de carga da curva de degradação, de modo que cada limite é uma decisão de capacidade baseada em dados. Os incidentes de concorrência são contados e tendenciados, as frações seriais das cargas paralelizadas são medidas em relação ao ganho de fato obtido e as decisões de seguir ou não com novos designs se apoiam nessa evidência e não no instinto.
  • Nível 5, Orquestrar: A concorrência segura é o caminho de menor resistência para todo autor, e a prática é continuamente melhorada e integrada em toda a organização. Classes inteiras de bug são impossíveis por construção, os pontos quentes são ajustados apenas onde o profiling prova que vale, e a reprodução determinística torna reproduzível o raro bug residual. A correção e a auditabilidade são propriedades continuamente defendidas, os limites de capacidade se adaptam à carga observada e os padrões evoluem conforme a plataforma e a carga mudam.

Ideias para discussão

  1. Se você auditasse hoje o seu serviço mais movimentado, quanto do estado dele é compartilhado e mutável, e quanto desse compartilhamento é realmente necessário?
  2. Qual é a resposta padrão da sua equipe quando alguém precisa que duas tarefas se coordenem, e você preferiria que fosse imutabilidade ou troca de mensagens?
  3. Onde ainda se escondem filas sem limite ou conjuntos sem teto no seu sistema, e o que aconteceria com eles sob um súbito pico de tráfego de dez vezes?
  4. Suas execuções de integração contínua incluem um detector de corrida ou sanitizador de threads, e quando um deles pegou algo pela última vez antes da produção?
  5. Para a sua carga mais paralelizada, qual é a fração serial, e a lei de Amdahl limita o ganho que vocês de fato perseguem?
  6. A sua equipe conseguiria reproduzir sob demanda um bug de intercalação de um em um milhão, e o que seria preciso para chegar lá?

Principais conclusões

  • A concorrência estrutura um programa como tarefas independentes. O paralelismo as executa de uma vez. Decida de qual dos dois você precisa antes de acrescentar threads.
  • O estado mutável compartilhado é a raiz de quase todo bug de concorrência. Prefira imutabilidade e troca de mensagens como padrões seguros para muitos autores.
  • Recorra a modelos de mais alto nível (atores, canais, concorrência estruturada, async/await) antes de travas escritas à mão, que são corretas em teoria e perigosas na prática.
  • Entenda a atomicidade, a visibilidade e o seu modelo de memória. Use a primitiva certa, segure as travas brevemente e imponha uma ordem global de travas para evitar impasse, livelock e inanição.
  • Limite toda fila e todo conjunto e aplique contrapressão, para que um pico se degrade com elegância em vez de derrubar o serviço (capítulo 3.3).
  • Teste as intercalações de propósito com detectores de corrida, testes de estresse e reprodução (capítulo 2.15) e respeite a lei de Amdahl ao paralelizar (capítulo 2.16).
  • Para as empresas isso é vazão e incidentes evitados. Para o governo é correção e auditabilidade em sistemas de longa vida.

Referências e leitura complementar

  • Brian Goetz et al., Java Concurrency in Practice (atomicity, visibility, the memory model, and safe publication).
  • Herb Sutter, “The Free Lunch Is Over” (why software must embrace concurrency as clock speeds plateau).
  • Leslie Lamport, “Time, Clocks, and the Ordering of Events in a Distributed System” (ordering and the foundations of concurrent reasoning).
  • C. A. R. Hoare, “Communicating Sequential Processes” (Communications of the ACM, 1978): the CSP model behind channels.
  • Carl Hewitt, Peter Bishop, and Richard Steiger, “A Universal Modular Actor Formalism for Artificial Intelligence” (the origin of the actor model).
  • Edsger W. Dijkstra, “Cooperating Sequential Processes” (semaphores, mutual exclusion, and the deadlock problem).
  • Maurice Herlihy and Nir Shavit, The Art of Multiprocessor Programming (locks, atomics, and lock-free data structures).
  • Nathaniel J. Smith, “Notes on Structured Concurrency, or: Go Statement Considered Harmful” (the case for structured concurrency).
  • Martin Kleppmann, Designing Data-Intensive Applications (concurrency and consistency where memory meets distributed systems).
  • Gene M. Amdahl, “Validity of the Single Processor Approach to Achieving Large-Scale Computing Capabilities” (1967): the origin of Amdahl’s law.