Sincronização: Mutexes e Variáveis de Condição

[435] Sincronização: Mutexes e Variáveis de Condição

Esperar numa variável de condição pede while, nunca if: a thread pode acordar e descobrir que outra já levou o item. Junto disso vêm o mutex que fecha a seção crítica, a regra de manter essa seção curta, e a ordem de aquisição das travas que impede o abraço mortal entre duas threads.
Linguagem C

15 min de leitura

Na aula anterior, deixamos um problema em aberto que assustava por bons motivos: as condições de corrida. Threads compartilham memória, e quando várias mexem no mesmo dado ao mesmo tempo, o resultado se corrompe de forma imprevisível. Hoje resolvemos isso. Vamos aprender as ferramentas de sincronização — os mutexes, que garantem que apenas uma thread por vez acesse um dado crítico, e as variáveis de condição, que permitem às threads esperarem umas pelas outras de forma eficiente. Com elas, a programação concorrente deixa de ser um campo minado e se torna uma técnica poderosa e controlável.

A ideia central: exclusão mútua

O problema da aula passada, no fundo, era que duas threads executavam ao mesmo tempo uma sequência de passos que deveria ser indivisível (ler-somar-escrever o contador). A solução é garantir que, enquanto uma thread executa essa sequência crítica, nenhuma outra possa executá-la simultaneamente. Esse conceito se chama exclusão mútua (mutual exclusion): apenas uma thread por vez tem acesso à região crítica do código. O trecho que precisa dessa proteção — onde os dados compartilhados são acessados — chama-se seção crítica. A ferramenta que implementa a exclusão mútua é o mutex (contração de mutual exclusion).

O mutex: uma trava para a seção crítica

Pense num mutex como a chave de um banheiro público: só quem tem a chave pode entrar; quem chega e encontra a porta trancada espera até a chave ser devolvida. Um mutex tem dois estados — travado (locked) e destravado (unlocked) — e duas operações fundamentais: travar (lock), que adquire a trava (esperando se ela já estiver com outra thread), e destravar (unlock), que a libera. Uma thread trava o mutex antes de entrar na seção crítica e o destrava ao sair. Como só uma thread pode ter o mutex travado por vez, apenas uma executa a seção crítica de cada vez.

Vamos corrigir o contador da aula anterior, que dava resultados errados:

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

int contador = 0;
pthread_mutex_t trava = PTHREAD_MUTEX_INITIALIZER; // cria e inicializa o mutex

void *incrementar(void *arg) {
    for (int i = 0; i < 100000; i++) {
        pthread_mutex_lock(&trava);   // ENTRA na seção crítica (espera se preciso)
        contador++;                    // seção crítica: acesso protegido ao dado
        pthread_mutex_unlock(&trava); // SAI da seção crítica (libera a trava)
    }
    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); // AGORA sempre 200000!
    return 0;
}

A diferença em relação à aula passada são as duas linhas de lock e unlock em volta do contador++. Agora, quando uma thread trava o mutex e começa a incrementar, a outra thread — se tentar travar o mesmo mutex — espera até a primeira destravar. Isso garante que o ciclo ler-somar-escrever de uma thread complete inteiramente antes de a outra começar o seu. Rodando este programa, o resultado é sempre 200000, execução após execução. A condição de corrida foi eliminada: a operação, antes vulnerável a intercalações, agora é protegida e efetivamente indivisível.

O custo e o cuidado com mutexes

Os mutexes resolvem o problema, mas trazem responsabilidades. Primeiro, há um custo de desempenho: travar e destravar tem um preço, e threads que esperam pela trava ficam ociosas. Por isso, mantenha as seções críticas o menor possível — proteja apenas o acesso ao dado compartilhado, não código extra que não precisa de proteção. Segundo, e mais grave, todo lock precisa de seu unlock correspondente — a mesma disciplina de par que vem nos guiando desde malloc/free. Se uma thread trava o mutex e esquece de destravá-lo (por exemplo, ao retornar de uma função por um caminho de erro sem liberar), todas as outras threads que esperam por ele ficam travadas para sempre. Esse tipo de bug tem um nome que veremos a seguir. A regra: sempre garanta que cada lock seja seguido de um unlock, em todos os caminhos de saída da seção crítica.

Deadlock: o abraço mortal

Há um perigo específico da sincronização que todo programador concorrente teme: o deadlock (impasse ou "abraço mortal"). Ele ocorre quando duas ou mais threads ficam esperando umas pelas outras indefinidamente, e nenhuma consegue prosseguir. O cenário clássico envolve dois mutexes:

Imagine a thread A que trava o mutex 1 e depois tenta travar o mutex 2; e a thread B que trava o mutex 2 e depois tenta travar o mutex 1. Se A travar o 1 e B travar o 2 ao mesmo tempo, então A espera pelo 2 (que B tem) e B espera pelo 1 (que A tem). Nenhuma libera o que a outra precisa, e ambas esperam para sempre. O programa congela. Esse é o deadlock. A prevenção mais comum é uma regra simples: sempre adquira múltiplos mutexes na mesma ordem em todas as threads. Se tanto A quanto B sempre travassem o mutex 1 antes do 2, o impasse não poderia ocorrer. Deadlocks são bugs sutis e às vezes intermitentes; conhecer o padrão que os causa é a melhor defesa.

Variáveis de condição: esperar por um evento

Mutexes resolvem o "apenas uma por vez", mas há outra necessidade comum: uma thread precisa esperar até que uma certa condição se torne verdadeira — por exemplo, esperar até que haja dados para processar. Poderíamos fazer a thread verificar a condição num laço, repetidamente (busy waiting), mas isso desperdiça o processador girando à toa. As variáveis de condição oferecem uma solução eficiente: uma thread pode dormir esperando por um sinal, sem consumir CPU, e outra thread a acorda quando a condição muda.

O padrão clássico é o produtor-consumidor: uma thread produz itens, outra os consome, e o consumidor precisa esperar quando não há itens. As operações centrais são pthread_cond_wait (a thread dorme esperando o sinal, liberando o mutex enquanto dorme) e pthread_cond_signal (acorda uma thread que espera):

#include <pthread.h>

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t  tem_item = PTHREAD_COND_INITIALIZER;
int itens_disponiveis = 0;

void *consumidor(void *arg) {
    pthread_mutex_lock(&mutex);
    while (itens_disponiveis == 0) {       // enquanto não há itens...
        pthread_cond_wait(&tem_item, &mutex); // dorme, liberando o mutex
    }
    itens_disponiveis--;                    // consome um item
    pthread_mutex_unlock(&mutex);
    return NULL;
}

void *produtor(void *arg) {
    pthread_mutex_lock(&mutex);
    itens_disponiveis++;              // produz um item
    pthread_cond_signal(&tem_item);   // acorda uma thread que espera
    pthread_mutex_unlock(&mutex);
    return NULL;
}

Observe dois detalhes idiomáticos importantes. A espera usa um while (não um if) para reverificar a condição ao acordar — porque uma thread pode ser acordada e, ao checar de novo, descobrir que a condição já não vale (outra thread pegou o item primeiro). E pthread_cond_wait libera o mutex enquanto dorme e o readquire ao acordar, permitindo que o produtor entre na seção crítica enquanto o consumidor espera. As variáveis de condição, sempre usadas junto com um mutex, permitem essa dança eficiente de espera e notificação — a base de sistemas onde threads coordenam a passagem de trabalho entre si.

A mentalidade da programação concorrente

Vale fechar com a mudança de pensamento que estas duas aulas exigem. Programar com threads não é escrever código sequencial mais rápido — é raciocinar sobre múltiplos fluxos simultâneos e sobre o que acontece quando eles se encontram nos dados compartilhados. A disciplina central: identifique os dados compartilhados e proteja todo acesso a eles com sincronização. Perguntas que o programador concorrente sempre se faz: quais dados são compartilhados entre threads? Onde estão as seções críticas? Cada lock tem seu unlock? Há risco de deadlock na ordem de aquisição das travas? Essa vigilância sobre o acesso concorrente é o que transforma threads de fonte de bugs imprevisíveis em ferramenta confiável. A concorrência recompensa o pensamento cuidadoso e pune a distração.

O que vem a seguir

Hoje resolvemos o problema deixado em aberto: os mutexes garantem exclusão mútua, protegendo seções críticas para que apenas uma thread por vez acesse dados compartilhados; as variáveis de condição permitem que threads esperem eficientemente por eventos, coordenando o trabalho entre si. Vimos também os perigos a evitar — seções críticas grandes demais, lock sem unlock, e o temido deadlock. Com processos, threads e sincronização dominados, na próxima aula abrimos o programa para o mundo: as redes. Aprenderemos os sockets, o mecanismo que permite a programas se comunicarem através da rede — a base de toda a internet, de servidores web a mensageiros —, escrevendo nosso primeiro programa que conversa com outro pela rede.

Fontes e leituras recomendadas

  • The Linux Programming Interface, Michael Kerrisk — capítulos sobre sincronização de threads
  • Programming with POSIX Threads, David Butenhof — tratamento profundo de mutexes e variáveis de condição
  • man pages — man 3 pthread_mutex_lock, man 3 pthread_cond_wait
  • Operating Systems: Three Easy Pieces — capítulos sobre locks e variáveis de condição — https://pages.cs.wisc.edu/~remzi/OSTEP/
  • The Art of Multiprocessor Programming, Herlihy & Shavit — teoria da concorrência

Exercícios

Exercício 1

Pegue o programa do contador compartilhado da aula anterior (que dava resultados errados) e adicione um mutex protegendo o contador++. Rode várias vezes e confirme que agora o resultado é sempre 200000.

Ver resposta

✓ Resposta:

#include <stdio.h>
#include <pthread.h>
int contador = 0;
pthread_mutex_t trava = PTHREAD_MUTEX_INITIALIZER;
void *incrementar(void *arg) {
    for (int i = 0; i < 100000; i++) {
        pthread_mutex_lock(&trava);
        contador++;
        pthread_mutex_unlock(&trava);
    }
    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("%d\n", contador); // sempre 200000
    return 0;
}

Com o mutex protegendo o contador++, o resultado é sempre 200000 em todas as execuções — a condição de corrida foi eliminada, pois só uma thread incrementa por vez.

Exercício 2

Explique o que é uma "seção crítica" e por que se recomenda mantê-la o menor possível. O que aconteceria com o desempenho se você travasse o mutex no início da função de thread e só o destravasse no fim (protegendo o laço inteiro)?

Ver resposta

✓ Resposta: A seção crítica é o trecho de código que acessa dados compartilhados e que, portanto, precisa ser protegido para que apenas uma thread o execute por vez. Recomenda-se mantê-la o menor possível porque, enquanto uma thread está dentro da seção crítica (com o mutex travado), todas as outras threads que precisam dela ficam esperando, ociosas. Quanto maior a seção crítica, mais tempo as outras threads passam bloqueadas, e menos paralelismo real o programa alcança — no limite, uma seção crítica gigante serializa completamente as threads, anulando a vantagem de usá-las. Se você travasse o mutex no início da função de thread e só o destravasse no fim (protegendo o laço inteiro de 100000 iterações), o desempenho seria péssimo: enquanto a thread 1 executa suas 100000 iterações com o mutex travado, a thread 2 ficaria inteiramente bloqueada, esperando; só quando a thread 1 terminasse todo o laço é que a thread 2 começaria. O resultado seria correto (200000), mas as threads rodariam sequencialmente, uma após a outra, sem nenhum paralelismo — na prática, mais lento que travar/destravar a cada iteração poderia até ser, dependendo do custo relativo, mas o ponto é que você perderia toda a concorrência. A arte está em proteger apenas o acesso ao dado compartilhado (o contador++), deixando o resto do laço livre para rodar em paralelo.

Exercício 3

Descreva o cenário clássico de deadlock com dois mutexes. Explique a regra de prevenção (adquirir mutexes sempre na mesma ordem) e por que ela funciona.

Ver resposta

✓ Resposta: O cenário clássico de deadlock com dois mutexes: a thread A trava o mutex 1 e, em seguida, tenta travar o mutex 2; a thread B trava o mutex 2 e, em seguida, tenta travar o mutex 1. Se, por infelicidade de escalonamento, A travar o mutex 1 e B travar o mutex 2 antes de qualquer uma conseguir o segundo mutex, forma-se o impasse: A fica esperando o mutex 2 (que B possui) e B fica esperando o mutex 1 (que A possui). Como cada uma segura o que a outra precisa e nenhuma libera antes de conseguir o segundo, ambas esperam eternamente, e o programa congela. A regra de prevenção é: sempre adquirir os mutexes na mesma ordem em todas as threads — por exemplo, toda thread que precisa dos dois deve travar o mutex 1 antes do mutex 2, nunca o contrário. Ela funciona porque elimina a possibilidade do "abraço circular": se ambas as threads tentam pegar o mutex 1 primeiro, apenas uma consegue; a outra espera pelo mutex 1 sem ter travado o mutex 2 ainda. Assim, a thread que conseguiu o mutex 1 conseguirá também o mutex 2 (ninguém mais o segura, pois ninguém trava o 2 sem antes ter o 1), completará seu trabalho e liberará ambos, permitindo que a segunda prossiga. A ordem consistente quebra a dependência circular que é a raiz do deadlock — não há como A esperar por B enquanto B espera por A, porque ambas competem pelo mesmo primeiro recurso.

Exercício 4

No exemplo do consumidor, a espera pela condição usa um while (itens_disponiveis == 0) em vez de um if. Explique por que o while é necessário e o que poderia dar errado se fosse um if.

Ver resposta

✓ Resposta: O while (itens_disponiveis == 0) é necessário porque, quando pthread_cond_wait retorna (a thread é acordada), não há garantia de que a condição que ela esperava ainda seja verdadeira. Dois motivos: primeiro, entre o momento em que o consumidor é sinalizado e o momento em que ele efetivamente readquire o mutex e volta a rodar, outra thread consumidora pode ter pegado o item disponível, deixando itens_disponiveis de volta em 0. Segundo, existem os chamados "despertares espúrios" (spurious wakeups), em que uma thread pode ser acordada sem que ninguém tenha sinalizado. Por isso, ao acordar, a thread deve reverificar a condição; se ela não vale mais, deve voltar a dormir. O while faz exatamente isso: ao retornar de pthread_cond_wait, o laço testa a condição de novo, e se ela ainda não for satisfeita, chama wait novamente. Se fosse um if, a thread verificaria a condição apenas uma vez antes de esperar, e ao ser acordada prosseguiria sem reverificar — assumindo que há um item quando talvez não haja mais. O resultado seria a thread tentar consumir um item inexistente (itens_disponiveis-- levando o contador a -1) ou processar dados inválidos, um bug sério. O while garante que a thread só prossegue quando a condição está realmente satisfeita no momento em que ela detém o mutex.

Exercício 5

Explique a diferença de propósito entre um mutex e uma variável de condição. Um protege dados; o outro faz o quê? Por que uma variável de condição é sempre usada em conjunto com um mutex?

Ver resposta

✓ Resposta: Um mutex e uma variável de condição têm propósitos distintos e complementares. O mutex serve para proteger dados — garantir exclusão mútua, de modo que apenas uma thread por vez acesse uma seção crítica, evitando condições de corrida. Ele responde à necessidade "não deixe duas threads mexerem neste dado ao mesmo tempo". A variável de condição serve para esperar por um evento ou mudança de estado — permitir que uma thread durma eficientemente até que uma certa condição se torne verdadeira, sendo acordada por outra thread quando isso acontece. Ela responde à necessidade "faça esta thread esperar até que haja trabalho para ela, sem desperdiçar CPU". Ou seja: o mutex controla acesso (quem entra na seção crítica); a variável de condição controla espera e notificação (quando uma thread pode prosseguir). Uma variável de condição é sempre usada em conjunto com um mutex por uma razão fundamental: a condição que a thread espera envolve dados compartilhados (como itens_disponiveis), e verificar ou alterar esses dados exige a proteção do mutex para não haver condição de corrida. há um problema de temporização sutil: entre uma thread verificar a condição (e vê-la falsa) e efetivamente começar a esperar, outra thread poderia alterar a condição e sinalizar — e o sinal se perderia, deixando a primeira thread dormindo para sempre. Para evitar isso, pthread_cond_wait recebe o mutex e o libera atomicamente no exato instante em que coloca a thread para dormir (e o readquire ao acordar), garantindo que não haja brecha entre "checar a condição" e "começar a esperar" na qual um sinal possa se perder. Assim, o mutex protege o acesso à condição e fecha essa janela de temporização, e é por isso que os dois mecanismos são inseparáveis: a variável de condição depende do mutex para funcionar corretamente.

Comentários

Mais em Linguagem C

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…

Listas Encadeadas: A Estrutura que Só Existe com Ponteiros
Listas Encadeadas: A Estrutura que Só Existe com Ponteiros

Liberar um nó e só então ler o seu proximo é use-after-free: guarda-se o…

Listas Duplamente Encadeadas e Circulares
Listas Duplamente Encadeadas e Circulares

Numa lista dupla, toda ligação tem dois lados: fez A->proximo = B, tem de…