Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.


  1. 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, blocos synchronized, AtomicInteger) também passa por estabelecer uma ordem entre os acessos. ↩