Como um padrão salvou o C++ do caos do multithreading
Imagine: você escreve um programa multithread em C++ e ele funciona perfeitamente no seu notebook. Você envia o código para o servidor — e ele falha. Ou, pior ainda, produz resultados incorretos, mas apenas uma vez por mês. Familiar? Até 2011, isso era normal. O C++ não tinha um modelo de memória unificado — um conjunto de regras que descrevem como diferentes threads veem as alterações de dados. Cada compilador e processador fazia o que queria. O resultado — caos, condições de corrida e maldições contra o multithreading.
O C++11 mudou tudo. Ele introduziu um modelo de memória padronizado. Parece chato? Pois é, não é. Este modelo é a verdadeira base para um multithreading seguro e eficiente. Neste artigo, vamos olhar por baixo dos panos: como funciona o modelo de memória, o que são operações atômicas e barreiras de memória, e como isso afeta seu código. Se você programa em C++ e quer que seus programas sejam previsíveis, continue lendo.
O problema: um mundo sem modelo de memória
Antes do C++11, o multithreading em C++ era como o Velho Oeste. Você tinha threads (via pthreads, API do Windows ou Boost), mas o padrão da linguagem não dizia nada sobre como elas interagiam através da memória. O compilador podia reordenar instruções para otimização, o processador podia alterar a ordem das gravações no cache. Em código single-thread, isso é seguro: o resultado não muda. Mas em código multithread — é um desastre.
Considere o exemplo mais simples:
// Sinalizador de prontidão e dadosint data = 0;bool ready = false;
// Thread 1 (produtor)void producer() { data = 42; // (1) ready = true; // (2)}
// Thread 2 (consumidor)void consumer() { while (!ready); // espera o sinalizador std::cout << data; // espera 42}Intuitivamente parece: se ready == true, então data já é 42. Mas sem um modelo de memória, o compilador pode trocar (1) e (2) de lugar! Ou o processador pode tornar a gravação de ready visível para outro núcleo antes da gravação de data. Como resultado, o consumidor vê ready == true, mas lê data == 0. Um bug impossível de detectar com um depurador.
Os programadores lidavam com isso manualmente: inseriam barreiras de memória (asm volatile), usavam variáveis voláteis (que não resolvem o problema), rezavam. O C++11 finalmente deu regras claras.
O que é o modelo de memória C++11?
Modelo de memória são as regras formais que definem em quais condições uma thread tem garantia de ver as alterações feitas por outra thread. Ele responde a perguntas como:
- Quando uma gravação em uma variável se torna visível para outra thread?
- Em que ordem as operações de diferentes threads podem ser observadas?
- O que é uma condição de corrida (data race) e como evitá-la?
A principal inovação do C++11 são as operações atômicas (std::atomic<T>) e os seis modelos de ordenação de memória (memory_order). Eles dão ao programador controle sobre como o compilador e o processador reordenam as operações.
Agora vamos analisar como isso funciona por baixo dos panos.
Atomicidade e ordem: os dois pilares do modelo
Operações atômicas
Uma operação atômica é uma operação que é executada completamente ou não é executada. Para outras threads, ela é indivisível. Por exemplo, ler ou gravar um int no x86 geralmente é atômico (se alinhado), mas ++count não é (é read-modify-write). O C++11 fornece std::atomic<T>, que garante atomicidade para tipos básicos.
#include <atomic>
std::atomic<int> counter(0);
// Incremento atômicovoid increment() { counter.fetch_add(1); // equivalente a counter++, mas atômico}Mas atomicidade é apenas metade do trabalho. A outra metade é a or