E quando as threads precisam compartilhar dados?
Nos exemplos anteriores, fomos cuidadosos em garantir que cada thread trabalhasse sobre dados exclusivamente seus. Mas e se duas threads precisarem acessar e modificar a mesma variável?
O Código 5-5 cria 10 threads que incrementam um contador compartilhado, cada uma 100.000 vezes:
#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 (esperado: 1000000)\n", counter);
}
Código 5-5: Contador compartilhado sem sincronização.
O resultado esperado é \( 10 \times 100000 = 1000000 \). Porém:
$ ./main
counter = 474032 (esperado: 1000000)
$ ./main
counter = 538691 (esperado: 1000000)
$ ./main
counter = 623107 (esperado: 1000000)
O valor muda a cada execução e é quase sempre menor que 1.000.000. Esse é o
bug de lost update que analisamos no
Capítulo 1:
quando múltiplas threads leem, modificam e escrevem a mesma variável sem
sincronização, incrementos são perdidos. Lá, vimos as intercalações possíveis
de x = x + 1 executado por duas threads. Aqui, o mesmo problema aparece em
escala. Com 10 threads e 100.000 incrementos cada, a quantidade de
intercalações que causam perdas é astronômica.
“Mas é só somar no final”
Uma objeção justa a esse exemplo: o contador é artificial. Se cada thread
mantivesse a própria soma numa variável local e a thread principal juntasse
tudo no join, o problema sumia, e a gente cairia de volta no padrão dividir,
processar, combinar do resto do capítulo. E é verdade. Todo agregado que só
precisa do valor final (uma soma, um máximo, uma contagem) se resolve assim,
sem thread nenhuma tocar o estado da outra.
O compartilhamento vira inevitável quando o estado precisa ser lido enquanto as threads ainda estão rodando, não depois. Aí não tem “somar no final”: o final é tarde demais.
Volte ao download concorrente. Suponha que,
além de baixar, a gente queira uma barra de progresso: quantos arquivos já
terminaram e quantos KB já foram baixados no total, atualizados de tempos em
tempos enquanto os downloads acontecem. Duas variáveis compartilhadas,
arquivos_prontos e kb_baixados, que cada thread de download atualiza ao
avançar, e a thread principal lê num laço pra imprimir o placar:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
typedef struct {
const char *filename;
int size_kb;
} Download;
int arquivos_prontos = 0;
int kb_baixados = 0;
int total_arquivos = 0;
void *download_file(void *arg) {
Download *d = arg;
// "baixa" em pedaços de 100 KB, atualizando o progresso a cada pedaço
int restante = d->size_kb;
while (restante > 0) {
int pedaco = restante < 100 ? restante : 100;
usleep(pedaco * 100);
kb_baixados += pedaco;
restante -= pedaco;
}
arquivos_prontos++;
return NULL;
}
int main(void) {
Download files[] = {
{"relatorio.pdf", 4200},
{"foto.jpg", 1800},
{"dados.csv", 3500},
{"backup.tar.gz", 7200},
{"config.json", 50},
};
total_arquivos = sizeof(files) / sizeof(files[0]);
int total_kb = 0;
for (int i = 0; i < total_arquivos; i++)
total_kb += files[i].size_kb;
pthread_t tids[total_arquivos];
for (int i = 0; i < total_arquivos; i++)
pthread_create(&tids[i], NULL, download_file, &files[i]);
// enquanto os downloads rolam, a main lê o progresso e imprime
while (arquivos_prontos < total_arquivos) {
printf("progresso: %d/%d arquivos, %d/%d KB\n",
arquivos_prontos, total_arquivos, kb_baixados, total_kb);
usleep(200000);
}
for (int i = 0; i < total_arquivos; i++)
pthread_join(tids[i], NULL);
printf("progresso: %d/%d arquivos, %d/%d KB\n",
arquivos_prontos, total_arquivos, kb_baixados, total_kb);
printf("pronto.\n");
}
Código 5-6: Placar de progresso lido pela main enquanto as threads baixam.
Aqui não dá pra “juntar no final”. A barra de progresso só faz sentido durante a
execução, e é justamente durante a execução que várias threads estão somando em
kb_baixados ao mesmo tempo. É o mesmo x = x + 1 do contador, só que agora
com um motivo palpável pra existir: a informação é útil no meio do caminho, não
no fim. E o kb_baixados termina quase sempre abaixo dos 16.750 KB esperados,
pela mesmíssima razão de antes.
O contador era o exemplo mínimo do problema; o placar é onde ele te pega num programa que você teria escrito de qualquer jeito. Nos dois casos falta a mesma coisa: um jeito de garantir que aquele incremento aconteça inteiro antes de outra thread mexer na variável.