O Despertar dos Múltiplos Núcleos - A Teoria da Concorrência
Este artigo explica por que a programação concorrente se tornou essencial após o fim da evolução da velocidade de núcleo único, introduzindo o conceito de "condição de corrida" como o principal desafio.
Se você programa há algum tempo, talvez se lembre de uma era mágica, principalmente até meados dos anos 2000. A cada ano, os computadores ficavam visivelmente mais rápidos. Um software que era lento num ano, tornava-se rápido no ano seguinte, sem que precisássemos de reescrever uma única linha de código. Este fenómeno era impulsionado pela Lei de Moore: a capacidade dos processadores dobrava aproximadamente a cada 18 meses. Os engenheiros de hardware davam-nos um "almoço grátis" de performance todos os anos.
Mas esse almoço acabou.
Neste artigo, vamos explorar a mudança fundamental que ocorreu no mundo do hardware e por que ela transferiu a responsabilidade da performance para nós, os desenvolvedores de software. Bem-vindo ao mundo da concorrência.
1. O Fim do "Almoço Grátis" e a Ascensão do Multi-Core
Por volta de 2005, os fabricantes de processadores como a Intel e a AMD esbarraram numa parede de tijolos da física. Aumentar ainda mais a velocidade (o clock) de um único núcleo de processamento estava a gerar calor e a consumir energia a níveis impraticáveis. A solução para continuar a evoluir não foi construir um motor de carro cada vez mais rápido, mas sim colocar múltiplos motores dentro do mesmo carro.
Nasceram os processadores multi-core. O seu telemóvel hoje tem provavelmente 8 núcleos. O seu computador de secretária pode ter 4, 8, 16 ou mais. Cada "núcleo" é, efetivamente, uma unidade de processamento independente, um cérebro capaz de executar tarefas.
Isto criou uma nova realidade para nós, programadores: um programa escrito da forma tradicional, de uma só linha de pensamento (uma única thread), só consegue usar um desses cérebros de cada vez. Os outros 3, 7 ou 15 cérebros do seu processador ficam parados, a olhar, sem fazer nada.
A responsabilidade de fazer o software mais rápido deixou de ser apenas do engenheiro de hardware e passou a ser nossa. Para que um programa aproveite o poder do hardware moderno, ele precisa de ser capaz de dividir o seu trabalho em múltiplas tarefas que possam ser executadas ao mesmo tempo. Ele precisa de ser concorrente.
Analogia: Pense numa cozinha. Durante anos, a estratégia para fazer mais jantares por hora era ter um único chef que se tornava cada vez mais rápido. De repente, esse chef atingiu o limite da velocidade humana. A nova estratégia? Manter o chef na sua velocidade normal, mas colocar quatro chefs a trabalhar na mesma cozinha. Para que a cozinha produza mais jantares, agora é preciso um plano para coordenar o trabalho desses quatro chefs.
2. Concorrência vs. Paralelismo e a Raiz de Todo o Mal
Antes de prosseguirmos, vamos esclarecer dois termos que são frequentemente confundidos, mas que têm significados distintos:
- Concorrência: É um conceito de design de software. Um programa é concorrente se ele for estruturado para gerir múltiplas tarefas ao mesmo tempo. Pense no nosso chef a fazer malabarismos com várias receitas: ele corta os legumes para a salada, depois vira-se para mexer o molho, depois verifica o assado no forno. Ele está a lidar com múltiplas tarefas, alternando entre elas. Um programa concorrente pode correr num único núcleo.
- Paralelismo: É um conceito de execução de hardware. O paralelismo acontece quando múltiplas tarefas estão, de facto, a ser executadas exatamente ao mesmo tempo. Na nossa cozinha, isso seria ter quatro chefs, cada um com o seu próprio fogão, a cozinhar pratos diferentes em simultâneo. Para alcançar o paralelismo, é necessário ter hardware com múltiplos núcleos.
A chave é: você cria um design concorrente para poder tirar proveito do paralelismo oferecido pelo hardware.
A Raiz de Todo o Mal: Estado Partilhado Mutável
A concorrência parece ótima, então qual é o problema? O problema surge quando os nossos múltiplos "chefs" (threads) precisam de usar os mesmos recursos. Na cozinha, talvez só haja um saleiro. No nosso software, o "saleiro" é uma variável, um objeto, um pedaço de memória que mais do que uma thread precisa de ler e modificar. A isto chamamos Estado Partilhado Mutável, e é a origem de 99% dos bugs em programação concorrente.
3. A Condição de Corrida (Race Condition)
Este é o bug mais clássico, traiçoeiro e fundamental da concorrência. Uma condição de corrida ocorre quando o resultado de uma operação depende da sequência ou do tempo imprevisível de eventos que não conseguimos controlar – neste caso, a forma como o sistema operativo decide dar tempo de antena a cada thread.
A Analogia da Conta Bancária (em Detalhe)
Imagine uma conta bancária partilhada com um saldo de R$ 1000. Você e outra pessoa decidem depositar dinheiro ao mesmo tempo usando duas caixas multibanco (duas threads).
- Você (Thread A) quer depositar R$ 100.
- A outra pessoa (Thread B) quer depositar R$ 50.
O resultado correto e esperado no final é R$ 1150.
A operação de depósito, que no código parece uma única linha (saldo += valor), é, na verdade, composta por três passos fundamentais para o processador:
- Ler o valor atual do saldo da memória.
- Modificar esse valor (somar o depósito).
- Escrever o novo valor de volta para a memória.
Agora, imagine esta sequência de eventos "azarada", mas perfeitamente possível:
- Thread A: Lê o saldo da memória. Vê R$ 1000.
- Thread A: Prepara-se para somar R$ 100.
- INTERRUPÇÃO! O sistema operativo pausa a Thread A para dar tempo à Thread B.
- Thread B: Lê o saldo da memória. A Thread A ainda não escreveu o seu resultado, então a Thread B também vê R$ 1000.
- Thread B: Soma R$ 50 ao seu valor lido. Calcula R$ 1050.
- Thread B: Escreve R$ 1050 de volta para a memória.
- INTERRUPÇÃO! O sistema operativo volta a dar tempo à Thread A.
- Thread A: Lembra-se do valor que leu originalmente (R$ 1000)? Ela continua a partir daí. Soma os seus R$ 100. Calcula R$ 1100.
- Thread A: Escreve R$ 1100 de volta para a memória, esmagando o trabalho que a Thread B tinha acabado de fazer.
O saldo final é R$ 1100. Acabámos de perder R$ 50. Este bug pode nunca acontecer em 1000 testes, mas pode acontecer na primeira vez que o seu cliente usa o sistema. É por isso que a concorrência é um campo minado.
4. O Hardware e o Compilador Conspiram Contra Si
Para piorar, o problema é ainda mais profundo do que o simples entrelaçamento de operações. Tanto o compilador quanto o processador realizam otimizações agressivas para fazer o seu código de thread única correr o mais rápido possível.
- Reordenação de Instruções: O compilador e o CPU sentem-se no direito de reordenar as suas instruções de código se conseguirem provar que isso não altera o resultado final num cenário de thread única. Numa aplicação multithreaded, no entanto, uma instrução que é movida para antes ou depois de outra pode quebrar completamente a lógica que uma segunda thread esperava encontrar.
- Caches da CPU: Cada núcleo do processador tem a sua própria memória cache, que é uma cópia local e super-rápida da memória principal. Quando a Thread A, a correr no Núcleo 1, modifica uma variável, ela pode modificar apenas a sua cópia no cache local. Pode levar algum tempo (nanossegundos que são uma eternidade para um CPU) até que essa modificação seja propagada para a memória principal e, subsequentemente, para o cache do Núcleo 2, onde a Thread B está a correr. Durante esse tempo, as duas threads têm "visões" diferentes e inconsistentes do mundo.
A Conclusão Teórica
Devido às condições de corrida, à reordenação de instruções e aos efeitos de cache, a conclusão é clara e assustadora: não podemos confiar em operações de leitura e escrita normais para comunicar ou partilhar dados entre threads.
Precisamos de ferramentas especiais. Precisamos de uma forma de dizer ao compilador e ao hardware: "Pare! Esta parte do código é uma 'região crítica'. Apenas uma thread pode entrar aqui de cada vez." Precisamos de criar pontos de sincronização que forcem a ordem e garantam que a visão da memória é consistente para todos.
Essas ferramentas são o tema do nosso próximo artigo, onde finalmente começaremos a escrever código C++ para domar este caos.