Threads com pthreads

[431] Threads com pthreads

contador++ não é uma operação só: são ler, somar e escrever — e duas threads intercalando esses três passos perdem incrementos que ninguém vê sumir. É por aí que a aula apresenta o pthread_create, o join que espera, e o motivo de a intuição sequencial parar de valer justamente aqui.
Linguagem C

12 min de leitura

Na aula anterior, criamos processos — universos isolados, cada um com sua própria memória. Hoje conhecemos uma forma mais leve de fazer várias coisas ao mesmo tempo: as threads. Uma thread é como um "fio de execução" independente dentro de um único processo. Enquanto processos são isolados, as threads de um mesmo processo compartilham a memória — e é aí que mora tanto o seu enorme poder quanto o seu perigo mais sutil. Hoje aprendemos a criar threads com a biblioteca pthreads e damos o primeiro passo num território onde a intuição sequencial que construímos o curso inteiro precisa ser reexaminada com cuidado.

Thread versus processo: a diferença crucial

Vale fixar a distinção desde o início, pois ela governa tudo. Um processo tem sua própria memória isolada; criá-lo é relativamente custoso; e a comunicação entre processos exige mecanismos especiais. Uma thread vive dentro de um processo e compartilha a memória com as outras threads do mesmo processo; criá-la é mais leve e rápido; e a comunicação entre threads é direta, pois elas veem as mesmas variáveis. Essa memória compartilhada é a característica definidora: o que uma thread escreve numa variável global, as outras enxergam imediatamente. Isso torna threads poderosas para tarefas que precisam colaborar — mas, como veremos, exige cuidado redobrado.

Criando uma thread com pthread_create

No mundo Unix/Linux, as threads são criadas com a biblioteca pthreads (POSIX threads), incluída via <pthread.h>. Uma thread executa uma função que você fornece. A função pthread_create inicia uma nova thread que roda essa função em paralelo com o resto do programa:

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

// a função que a thread vai executar
void *tarefa(void *arg) {
    int id = *(int *)arg; // recupera o argumento
    for (int i = 0; i < 3; i++) {
        printf("Thread %d, passo %d\n", id, i);
        sleep(1);
    }
    return NULL;
}

int main(void) {
    pthread_t thread1, thread2;
    int id1 = 1, id2 = 2;

    // cria duas threads, cada uma rodando 'tarefa'
    pthread_create(&thread1, NULL, tarefa, &id1);
    pthread_create(&thread2, NULL, tarefa, &id2);

    // espera ambas terminarem antes de prosseguir
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);

    printf("Ambas as threads terminaram.\n");
    return 0;
}

Alguns pontos sobre a mecânica. A função da thread tem uma assinatura fixa: recebe um void * (o argumento genérico, que reaparece aqui, como no qsort da aula qsort e bsearch: Ordenando e Buscando com a Biblioteca) e retorna um void *. O pthread_create recebe: um ponteiro para a variável pthread_t que identificará a thread, atributos (geralmente NULL), a função a executar, e o argumento a passar. E o pthread_join — análogo ao wait dos processos — faz o programa principal esperar a thread terminar. Compile com a flag -pthread:

gcc -pthread programa.c -o programa

Ao rodar, você verá as mensagens das duas threads intercaladas, porque elas executam concorrentemente. Essa execução simultânea é o ponto: enquanto uma thread espera (no sleep), outra pode trabalhar.

O poder: memória compartilhada

A grande diferença em relação aos processos aparece quando as threads acessam a mesma variável. Diferente do fork (onde cada processo tinha sua cópia), as threads compartilham a memória do processo — uma variável global é vista e modificada por todas:

#include <stdio.h>
#include <pthread.h>

int contador = 0; // variável GLOBAL, compartilhada por todas as threads

void *incrementar(void *arg) {
    for (int i = 0; i < 100000; i++) {
        contador++; // todas as threads mexem no MESMO contador
    }
    return NULL;
}

int main(void) {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, incrementar, NULL);
    pthread_create(&t2, NULL, incrementar, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);

    printf("Contador final: %d\n", contador);
    // Esperado: 200000. Mas... o resultado real pode ser MENOR e imprevisível!
    return 0;
}

Aqui está o poder e a armadilha juntos. As duas threads incrementam a mesma variável contador — não há cópias, é literalmente a mesma posição de memória. Você esperaria que o resultado final fosse 200000 (cada thread soma 100000). Mas, ao rodar este programa, é muito provável que o resultado seja menor que 200000, e diferente a cada execução. Como isso é possível? Bem-vindo ao problema mais fundamental da programação concorrente.

O perigo: condições de corrida

O que acabamos de ver é uma condição de corrida (race condition), e entendê-la é essencial. O problema está em que contador++ não é uma operação única — por baixo, ela são três passos: (1) ler o valor atual de contador da memória, (2) somar 1 a esse valor, (3) escrever o resultado de volta na memória. Quando duas threads fazem isso ao mesmo tempo, seus passos podem se intercalar de forma desastrosa:

Imagine contador valendo 5. A thread A lê 5. Antes de A escrever, a thread B também lê 5. A soma 1 (obtém 6) e escreve 6. B soma 1 (obtém 6, pois leu 5) e escreve 6. Resultado: duas incrementações, mas contador só subiu para 6, não 7. Uma incrementação foi perdida. Isso acontece incontáveis vezes ao longo dos 200000 incrementos, e o total final fica misteriosamente abaixo do esperado. Pior: o resultado é não determinístico — depende do momento exato em que as threads se intercalam, variando a cada execução. Uma condição de corrida é um bug que às vezes não aparece, o que a torna traiçoeira. É o preço da memória compartilhada: o poder de colaborar diretamente vem com o risco de interferir mutuamente.

Por que a intuição sequencial falha aqui

Preciso destacar por que este é um dos momentos mais importantes do curso. Durante 47 aulas, raciocinamos sobre programas de forma sequencial — uma instrução após a outra, numa ordem previsível. Essa intuição, sólida até agora, quebra na programação concorrente. Quando várias threads compartilham memória, você não pode mais assumir que uma operação completa antes de outra começar, nem que a ordem é a que você escreveu. Múltiplas threads acessando dados compartilhados sem coordenação levam a bugs imprevisíveis, dependentes de tempo, difíceis de reproduzir. Essa mudança de mentalidade — de "uma coisa de cada vez" para "muitas coisas ao mesmo tempo, em ordem incerta" — é o grande desafio conceitual da concorrência. Reconhecer que a intuição sequencial não vale mais é o primeiro passo para programar threads corretamente.

A solução vem a seguir

Se as condições de corrida assustam, é intencional — elas devem ser levadas a sério. Mas há solução, e é o tema da próxima aula. O problema fundamental é que operações sobre dados compartilhados precisam ser protegidas, de modo que apenas uma thread por vez execute a parte crítica. Os mecanismos que garantem isso — chamados de primitivas de sincronização, como os mutexes — são o que torna a programação com threads segura. Por ora, o essencial é você ter visto o problema com clareza: memória compartilhada dá poder, mas acessá-la sem coordenação corrompe dados de forma imprevisível. Com esse entendimento, a próxima aula fará todo o sentido.

O que vem a seguir

Hoje conhecemos as threads: fios de execução leves que vivem dentro de um processo e compartilham sua memória, criadas com pthread_create e coordenadas com pthread_join. Vimos o poder dessa memória compartilhada e, com ele, o perigo das condições de corrida — quando threads acessam dados compartilhados sem coordenação e corrompem o resultado de forma imprevisível, quebrando a intuição sequencial que nos guiou o curso inteiro. Na próxima aula, resolvemos esse problema: aprenderemos os mutexes e as variáveis de condição — as ferramentas de sincronização que permitem às threads colaborar com segurança, garantindo que as operações críticas sobre dados compartilhados aconteçam de forma ordenada e correta.

Fontes e leituras recomendadas

  • The Linux Programming Interface, Michael Kerrisk — capítulos sobre pthreads
  • Programming with POSIX Threads, David Butenhof — o livro de referência sobre pthreads
  • man pages — man 3 pthread_create, man 3 pthread_join
  • Operating Systems: Three Easy Pieces — capítulos sobre concorrência e threads — https://pages.cs.wisc.edu/~remzi/OSTEP/
  • C Programming: A Modern Approach, K. N. King — introdução a threads em C

Exercícios

Exercício 1

Escreva um programa que crie duas threads, cada uma imprimindo uma mensagem diferente cinco vezes (com um pequeno sleep entre as impressões). Use pthread_join para esperar ambas. Observe como as saídas se intercalam. Compile com -pthread.

Ver resposta

✓ Resposta:

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

void *mensagem(void *arg) {
    const char *texto = (const char *)arg;
    for (int i = 0; i < 5; i++) {
        printf("%s\n", texto);
        sleep(1);
    }
    return NULL;
}

int main(void) {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, mensagem, "Thread A");
    pthread_create(&t2, NULL, mensagem, "Thread B");
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    return 0;
}

As mensagens "Thread A" e "Thread B" aparecem intercaladas, pois as threads executam concorrentemente. A ordem exata pode variar entre execuções.

Exercício 2

Escreva uma função de thread que receba um número inteiro como argumento (via void *) e imprima seu quadrado. Crie três threads com argumentos diferentes e espere todas terminarem.

Ver resposta

✓ Resposta:

#include <stdio.h>
#include <pthread.h>

void *quadrado(void *arg) {
    int n = *(int *)arg;
    printf("%d ao quadrado = %d\n", n, n * n);
    return NULL;
}

int main(void) {
    pthread_t t[3];
    int valores[3] = {4, 7, 10};
    for (int i = 0; i < 3; i++) {
        pthread_create(&t[i], NULL, quadrado, &valores[i]);
    }
    for (int i = 0; i < 3; i++) {
        pthread_join(t[i], NULL);
    }
    return 0;
}

Cada thread recebe o endereço de um elemento do vetor e imprime seu quadrado. Passamos &valores[i] (endereços distintos) para evitar que todas leiam a mesma variável.

Exercício 3

Reproduza o programa do contador compartilhado do artigo (duas threads incrementando contador 100000 vezes cada). Rode-o várias vezes e anote o resultado final de cada execução. O resultado é sempre 200000? Ele varia? O que isso demonstra?

Ver resposta

✓ Resposta: Reproduzindo o programa do contador e rodando várias vezes, você observará que o resultado final raramente é 200000 e varia entre execuções — pode dar 187342, 195118, 200000 ocasionalmente, números diferentes a cada vez. Isso demonstra concretamente uma condição de corrida: as duas threads, ao incrementar a mesma variável simultaneamente, ocasionalmente "perdem" incrementos quando seus passos de ler-somar-escrever se intercalam. A variabilidade do resultado prova que o comportamento é não determinístico — depende do escalonamento imprevisível das threads pelo sistema operacional. É a evidência experimental de que acessar memória compartilhada sem sincronização produz resultados corrompidos e imprevisíveis.

Exercício 4

Explique, com suas palavras, a diferença fundamental entre threads e processos no que diz respeito à memória. Cite uma vantagem e uma desvantagem de as threads compartilharem memória.

Ver resposta

✓ Resposta: A diferença fundamental é o isolamento versus compartilhamento da memória. Processos têm cada um sua própria memória isolada — após um fork, pai e filho têm cópias separadas, e um não pode acessar as variáveis do outro diretamente. Threads de um mesmo processo compartilham a memória do processo — todas veem e podem modificar as mesmas variáveis globais e o mesmo heap. Vantagem de compartilhar memória: a comunicação e colaboração entre threads é direta e eficiente — uma thread simplesmente escreve numa variável e as outras enxergam o valor imediatamente, sem precisar de mecanismos especiais de comunicação (que processos isolados exigiriam); , threads são mais leves e rápidas de criar que processos. Desvantagem de compartilhar memória: o risco de condições de corrida — quando várias threads acessam e modificam os mesmos dados sem coordenação, elas podem interferir umas nas outras, corrompendo o estado de forma imprevisível; a memória compartilhada exige sincronização cuidadosa (mutexes) para ser usada com segurança, o que adiciona complexidade e novas categorias de bugs (que processos isolados, por não compartilharem memória, não têm). compartilhar memória troca a segurança do isolamento pela eficiência e poder da colaboração direta.

Exercício 5

Explique o que é uma condição de corrida, usando o exemplo do contador++. Por que essa operação, que parece única, é na verdade vulnerável quando executada por várias threads simultaneamente?

Ver resposta

✓ Resposta: Uma condição de corrida ocorre quando o resultado de um programa depende da ordem imprevisível em que múltiplas threads acessam dados compartilhados — e essa ordem, ditada pelo escalonamento do sistema operacional, pode intercalar as operações de forma que corrompa o resultado. No exemplo do contador++, o problema é que essa operação, embora pareça única (uma só linha, um só símbolo ++), na verdade se decompõe em três passos separados no nível da máquina: (1) ler o valor atual de contador da memória para um registrador; (2) somar 1 a esse valor; (3) escrever o novo valor de volta na memória. Entre esses três passos, o sistema operacional pode pausar uma thread e executar outra. A vulnerabilidade surge assim: suponha contador valendo 5. A thread A executa o passo (1) e lê 5. Antes de A completar os passos (2) e (3), o sistema passa a vez para a thread B, que também executa (1) e lê 5 (pois A ainda não escreveu). Agora ambas têm o valor 5 em mãos. A soma (obtém 6) e escreve 6; B soma (obtém 6, pois também leu 5) e escreve 6. O resultado: duas incrementações ocorreram, mas contador subiu apenas de 5 para 6, não para 7 — uma incrementação se perdeu. Como esse cenário se repete aleatoriamente ao longo dos milhares de incrementos, o total final fica imprevisivelmente abaixo do esperado. A operação é vulnerável precisamente porque não é atômica: ela pode ser interrompida no meio, e quando duas threads a executam com passos intercalados, uma sobrescreve o trabalho da outra. É por isso que operações sobre dados compartilhados precisam ser protegidas — para garantir que uma thread complete os três passos antes que outra comece, tornando a operação efetivamente indivisível.

Comentários

Mais em Linguagem C

Tratamento de Erros Idiomático: errno e Códigos de Retorno
Tratamento de Erros Idiomático: errno e Códigos de Retorno

Sem try nem catch, toda falha em C vira um retorno que alguém precisa conferir…

Código Limpo em C: Nomes, Funções e Legibilidade
Código Limpo em C: Nomes, Funções e Legibilidade

f(a, b) e somar_intervalo(inicio, fim) fazem exatamente a mesma coisa; só a…

Quando o Programa Decide: if, else e switch
Quando o Programa Decide: if, else e switch

Trocar == por = dentro de um if compila, roda e faz a coisa errada em…