Introdução a pthreads em C

POSIX threads para programação concorrente: create / join, condições de corrida, mutex, deadlock.

Threads vs processos

Um processo é um programa em execução com memória própria. Uma thread é um fluxo de execução dentro de um processo.
POSIX threads: a API padrão de threading no Linux e macOS. Inclua <pthread.h> e compile com -pthread.

pthread_create / join

Um exemplo mínimo: duas threads imprimem cada uma uma mensagem diferente.
#include <stdio.h>
#include <pthread.h>

// Ponto de entrada da thread: tanto arg quanto retorno são void*
void *worker(void *arg) {
    int id = *(int *)arg;
    printf("olá da thread %d\n", id);
    return NULL;
}

int main(void) {
    pthread_t t1, t2;
    int a = 1, b = 2;

    pthread_create(&t1, NULL, worker, &a);
    pthread_create(&t2, NULL, worker, &b);

    pthread_join(t1, NULL);  // espera t1
    pthread_join(t2, NULL);

    printf("main terminou\n");
    return 0;
}
$ gcc -pthread hello_pt.c -o hello_pt $ ./hello_pt olá da thread 1 olá da thread 2 main terminou # a ordem pode variar
Sempre faça join: sem ele, main pode sair antes das threads terminarem e você vaza recursos. (A menos que você explicitamente destaque a thread.)

Recebendo um valor de retorno

void *compute(void *arg) {
    int x = *(int *)arg;
    int *result = malloc(sizeof(int));
    *result = x * x;
    return result;
}

// em main:
void *ret;
pthread_join(t, &ret);
printf("resultado = %d\n", *(int *)ret);
free(ret);

Condições de corrida (demo)

Quando várias threads modificam a mesma variável sem sincronização, o resultado fica imprevisível. Isso é uma condição de corrida.
#include <stdio.h>
#include <pthread.h>

#define N 4
#define LOOPS 1000000

long counter = 0;         // compartilhada

void *worker(void *arg) {
    for (int i = 0; i < LOOPS; i++) {
        counter++;              // NÃO é atômico!
    }
    return NULL;
}

int main(void) {
    pthread_t th[N];
    for (int i = 0; i < N; i++)
        pthread_create(&th[i], NULL, worker, NULL);
    for (int i = 0; i < N; i++)
        pthread_join(th[i], NULL);

    printf("esperado: %d\n", N * LOOPS);
    printf("real:     %ld\n", counter);
    return 0;
}
$ ./race esperado: 4000000 real: 2841739 # diferente a cada execução
Por que quebra: counter++ parece atômico, mas no nível da CPU são três passos — carregar, somar, armazenar. Duas threads fazendo isso simultaneamente perdem atualizações.

Corrigir com mutex

Um mutex é um lock que só uma thread pode segurar por vez. Envolva seções críticas com lock e unlock.
#include <stdio.h>
#include <pthread.h>

#define N 4
#define LOOPS 1000000

long counter = 0;
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;

void *worker(void *arg) {
    for (int i = 0; i < LOOPS; i++) {
        pthread_mutex_lock(&mtx);
        counter++;
        pthread_mutex_unlock(&mtx);
    }
    return NULL;
}

int main(void) {
    pthread_t th[N];
    for (int i = 0; i < N; i++)
        pthread_create(&th[i], NULL, worker, NULL);
    for (int i = 0; i < N; i++)
        pthread_join(th[i], NULL);

    printf("counter = %ld\n", counter);  // sempre 4000000
    pthread_mutex_destroy(&mtx);
    return 0;
}
Granularidade: seções críticas menores dão mais paralelismo, mas se forem pequenas demais, o overhead do lock domina. Meça para decidir.

Para contadores simples, atômicos são mais rápidos

#include <stdatomic.h>

atomic_long counter = 0;

// no worker:
atomic_fetch_add(&counter, 1);   // seguro, lock-free
<stdatomic.h> é C11. Use atômicos para contadores simples e mutexes para proteger invariantes mais complexas.

Evitando deadlock

Duas threads travando dois mutexes em ordens opostas podem acabar esperando uma pela outra para sempre.
// RUIM: ordens de lock diferentes por thread
// Thread A: lock(m1) → lock(m2)
// Thread B: lock(m2) → lock(m1)
// → as duas seguram um e esperam o outro

Estratégias

  1. Fixe a ordem — dê um número a cada mutex e sempre trave primeiro o de menor número. Nenhum ciclo se forma.
  2. trylockpthread_mutex_trylock falha em vez de esperar. Se não conseguir pegar o segundo lock, libere o primeiro e tente de novo.
  3. Use menos locks — um lock bem delimitado geralmente é mais simples que vários granulares.
Ferramentas: valgrind --tool=helgrind ./app detecta condições de corrida e deadlocks potenciais.

Desafios

Desafio 1: Soma paralela
Some um array de 1.000.000 elementos usando 4 threads de 250.000 elementos cada. Cada thread acumula primeiro numa variável local, depois soma uma vez sob um mutex para minimizar disputa.
Desafio 2: Produtor / consumidor
Implemente um buffer circular de 10 slots em que uma thread empurra e outra remove. Use pthread_cond_t para esperar quando o buffer está vazio ou cheio.
Desafio 3: atomic vs mutex
Implemente o contador com mutex e com atômico. Meça o tempo de cada um com clock_gettime para 1, 2, 4 e 8 threads, depois plote os resultados.
Desafio 4: Causar e detectar um deadlock
Escreva um programa que trava dois mutexes em ordens opostas. Rode sob o helgrind para ver o aviso, depois corrija travando em ordem consistente.

Quiz de Revisão

Teste seu entendimento desta aula!

Q1. Qual função cria uma nova thread POSIX?

pthread_create
thread_start
fork_thread

pthread_create(&tid, NULL, func, arg) dispara uma nova thread que começa a executar em func.

Q2. O que você usa para exclusão mútua quando várias threads acessam dados compartilhados?

pthread_mutex_t (um mutex)
pthread_sema_t
pthread_lock

Código envolvido em pthread_mutex_lock / unlock só pode ser executado por uma thread de cada vez.

Q3. Qual função espera uma thread criada terminar?

pthread_join
pthread_wait
pthread_end

pthread_join(tid, &retval) bloqueia até a thread terminar. Pule e os recursos dela não serão recuperados.