Data Race
Um data race acontece quando duas threads acessam a mesma posição de memória sem nenhuma ordem imposta entre os acessos, e pelo menos um deles é uma escrita. As três condições precisam valer juntas. Duas leituras não são um data race, porque ler não muda nada. E se algum mecanismo de sincronização garante que um acesso acontece antes do outro, também não tem race, porque existe uma ordem.
Repare que a definição não fala em “ao mesmo tempo”. Isso é proposital, e é um
ponto onde muita gente tropeça. Um data race não precisa de dois cores rodando de
verdade em paralelo. Num computador single-core, só uma thread executa por vez,
mas o escalonador interrompe uma thread no meio do counter++ (lembre-se que ele
vira três instruções) e passa a vez pra outra. A thread interrompida leu o valor
antigo, a outra escreve por cima, e quando a primeira volta ela sobrescreve com o
resultado do valor velho. As duas threads não tocaram a memória no mesmo
instante físico, e mesmo assim o acesso de uma se intercalou com o da outra sem
ordem nenhuma garantindo quem vem antes. Isso basta. Paralelismo real deixa o
data race mais provável e mais frequente, mas não é ele que cria o problema. O
problema é a ausência de ordem, e ela existe em qualquer sistema concorrente,
com um core ou com noventa e seis.
A versão sem mutex do contador tinha as três condições: várias threads escrevendo
em counter, várias lendo, e nenhum lock no meio. Vamos pegar aquela versão
quebrada e apontar uma ferramenta pra ela. O compilador tem um detector de data
races embutido, o ThreadSanitizer, ligado com a flag -fsanitize=thread:
#include <stdio.h>
#include <pthread.h>
int counter = 0;
void *increment(void *arg) {
for (int i = 0; i < 100000; i++)
counter++;
return NULL;
}
int main(void) {
pthread_t tids[10];
for (int i = 0; i < 10; i++)
pthread_create(&tids[i], NULL, increment, NULL);
for (int i = 0; i < 10; i++)
pthread_join(tids[i], NULL);
printf("counter = %d\n", counter);
}
Código 7-1: O mesmo contador sem sincronização, agora sob o ThreadSanitizer.
$ cc main.c -o main -pthread -fsanitize=thread -g
$ ./main
==================
WARNING: ThreadSanitizer: data race (pid=154701)
Read of size 4 at 0x58acd8fc7064 by thread T2:
#0 increment main.c:8 (main+0x11f9)
Previous write of size 4 at 0x58acd8fc7064 by thread T1:
#0 increment main.c:8 (main+0x1211)
Location is global 'counter' of size 4 at 0x58acd8fc7064 (main+0x4064)
Thread T2 (tid=154704, running) created by main thread at:
#0 pthread_create <null> (libtsan.so.2+0x5fb47)
#1 main main.c:16 (main+0x128e)
Thread T1 (tid=154703, running) created by main thread at:
#0 pthread_create <null> (libtsan.so.2+0x5fb47)
#1 main main.c:16 (main+0x128e)
SUMMARY: ThreadSanitizer: data race main.c:8 in increment
==================
counter = 809976
ThreadSanitizer: reported 2 warnings
A ferramenta aponta o dedo direto pra linha 8, counter++, e diz exatamente o
que aconteceu: a thread T2 leu counter, e a thread T1 já tinha escrito naquela
mesma posição, sem nada garantindo a ordem entre as duas. Ela ainda mostra onde
cada thread foi criada. Isso é bem diferente de olhar pra saída 809976 e ficar
adivinhando o que deu errado. O data race é um defeito
estrutural do programa, e o ThreadSanitizer o encontra mesmo numa execução que,
por sorte, tivesse dado o resultado certo.
Data race é comportamento indefinido
Em C e C++, um data race não é só “um bug que às vezes dá resultado errado”. O
modelo de memória dessas linguagens diz que um programa com data race tem
comportamento indefinido (undefined behavior). Isso é mais forte do que
parece. Não é que counter vai receber um valor errado. É que o compilador tem
permissão pra assumir que data races não acontecem, e otimizar o código com base
nessa suposição.
Considere um loop que lê uma flag compartilhada:
int done = 0;
void *worker(void *arg) {
while (!done) {
// faz trabalho
}
return NULL;
}
Como nada dentro do while modifica done, o compilador pode concluir que
done nunca muda durante o loop, carregar o valor uma vez num registrador, e
transformar o teste num if (!done) while (true) {}. Se outra thread setar
done = 1 mais tarde, a thread do worker nunca vai perceber. Ela fica presa
num loop infinito que, olhando só pra esse código-fonte, não deveria existir. O
compilador não introduziu um bug: ele executou uma otimização perfeitamente
válida, porque a suposição de que não existem data races é uma premissa da linguagem.
É por isso que “funcionou na minha máquina” não significa nada num programa com
data race. O mesmo código-fonte, compilado com -O2 em vez de -O0, ou por
outro compilador, ou pra outra arquitetura, pode se comportar de forma
completamente diferente, porque comportamento indefinido não tem obrigação de
ser consistente1.
Como remover o data race
O ThreadSanitizer não reclamou do resultado errado. Reclamou da falta de
coordenação entre os acessos. Qualquer coisa que estabeleça uma ordem entre a
escrita de uma thread e a leitura de outra tira as três condições do data race. O
mutex que usamos pra proteger o counter++ faz
exatamente isso: enquanto uma thread segura o lock, nenhuma outra lê nem escreve
counter, e a ordem entre os acessos deixa de ser arbitrária. Uma operação
atômica também resolve, trocando a leitura, o incremento e a escrita por uma única
instrução indivisível.
O que importa entender aqui é a natureza do defeito. O data race é uma propriedade dos acessos à memória: existe, ou não existe, independente de qual intercalação o escalonador escolheu naquela execução. Ele pode estar num programa que passou em todos os testes. E, como vimos, num programa que ainda por cima imprimiu o número certo.
-
Na JVM (Java, Kotlin) o modelo de memória é mais brando: um data race não é comportamento indefinido, e o programa continua sendo uma máquina de estados bem-definida. Mas continua quebrado de outro jeito. Sem sincronização, uma thread pode simplesmente nunca enxergar a escrita feita por outra, porque o valor ficou num cache de core ou num registrador. É o problema de visibilidade, e a solução (campos
volatile, blocossynchronized,AtomicInteger) também passa por estabelecer uma ordem entre os acessos. ↩