Durante nossas aulas de Sistemas Operacionais, exploramos um dos conceitos mais fundamentais e complexos para o desempenho dos sistemas modernos: a concorrência. A concorrência permite que múltiplas tarefas progridam simultaneamente, aproveitando os múltiplos núcleos de processamento ou alternando rapidamente a execução em um único núcleo (time-sharing). Embora poderosa, a concorrência introduz desafios significativos de sincronização e coordenação entre processos e threads.

Chegamos ao dia 250 com uma base sólida em gerenciamento de processos e memória, e agora é hora de entender como essas entidades podem coexistir e cooperar dentro de um sistema operacional moderno. A concorrência é a espinha dorsal de aplicações performáticas, permitindo que um navegador baixe arquivos enquanto renderiza uma página, ou que um servidor web atenda milhares de clientes simultaneamente.

Processos vs Threads

Tradicionalmente, um processo executa um único fluxo de execução sequencial. Com a introdução de threads (linhas de execução), um único processo pode conter múltiplos fluxos que compartilham o mesmo espaço de endereçamento. Isso significa que threads de um mesmo processo podem acessar as mesmas variáveis globais e estruturas de dados, tornando a comunicação entre elas extremamente eficiente — muito mais rápida do que a comunicação entre processos (IPC) via pipes, filas de mensagens ou memória compartilhada.

No Linux, threads são frequentemente implementadas como unidades leves de escalonamento, e a API POSIX (pthreads) é o padrão para trabalhar com elas em C e C++. Cada thread possui seu próprio contador de programa, pilha (stack) e conjunto de registradores, mas dividem o código, os dados globais e os recursos do sistema (como descritores de arquivo).

O Problema da Condição de Corrida (Race Condition)

Quando duas ou mais threads manipulam dados compartilhados concorrentemente, o resultado final pode depender da ordem não determinística de execução. Isso é uma race condition. O exemplo clássico é o incremento de uma variável compartilhada:

int contador = 0;

// Thread 1
contador++;

// Thread 2
contador++;

A leitura do valor por T1 pode ser sobrescrita por T2 antes que T1 termine sua operação de escrita, resultando em um incremento perdido. Este problema é insidioso porque pode não se manifestar frequentemente, tornando a depuração um verdadeiro pesadelo. Em um laço de 100.000 iterações sem sincronização, o resultado final é imprevisível e quase nunca o esperado (200.000).

Exclusão Mútua e a Seção Crítica

A solução para o problema das race conditions é garantir que apenas uma thread por vez possa executar um trecho específico de código: a seção crítica. As propriedades necessárias para uma boa solução de exclusão mútua são:

  • Exclusão Mútua: Apenas uma thread pode estar na seção crítica.
  • Progresso: Se nenhuma thread está na seção crítica, uma thread que deseja entrar deve poder fazê-lo sem esperar indefinidamente.
  • Espera Limitada (Bounded Waiting): Deve existir um limite para o número de vezes que outras threads podem entrar na seção crítica enquanto uma thread espera.

Semáforos de Dijkstra

A ferramenta mais versátil para sincronização são os semáforos, introduzidos por Edsger Dijkstra. Um semáforo S é uma variável inteira não negativa que pode ser acessada apenas por duas operações atômicas: sem_wait(S) (ou P, do holandês proberen, testar) e sem_post(S) (ou V, do holandês verhogen, incrementar). Para exclusão mútua, usamos um semáforo binário (mutex). Para sincronização de eventos, usamos semáforos contadores.

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

#define NUM_THREADS 5
sem_t mutex;
int recurso_compartilhado = 0;

void* tarefa(void* arg) {
    long id = (long)arg;
    sem_wait(&mutex); // Down / P / Entrada na SC
    recurso_compartilhado++;
    printf("[Thread %ld] Recurso: %d\n", id, recurso_compartilhado);
    sem_post(&mutex); // Up / V / Saída da SC
    return NULL;
}

int main() {
    pthread_t threads[NUM_THREADS];
    sem_init(&mutex, 0, 1); // Inicializa como mutex

    for (long i = 0; i < NUM_THREADS; i++)
        pthread_create(&threads[i], NULL, tarefa, (void*)i);

    for (int i = 0; i < NUM_THREADS; i++)
        pthread_join(threads[i], NULL);

    sem_destroy(&mutex);
    return 0;
}

Ao executar esse código, cada thread aguarda sua vez para modificar o recurso. O resultado final é sempre NUM_THREADS, independentemente da ordem de escalonamento imposta pelo sistema.

Problemas Clássicos de Sincronização

Estudamos dois problemas clássicos que ilustram os desafios da sincronização no contexto de sistemas operacionais:

  • Produtor-Consumidor (Bounded Buffer): Um produtor insere dados em um buffer de tamanho limitado, e um consumidor retira. Sem sincronização, o buffer pode transbordar (produtor muito rápido) ou o consumidor pode tentar retirar de um buffer vazio. A solução usa dois semáforos: empty (conta vagas) e full (conta itens), além de um mutex para garantir exclusão mútua no acesso ao buffer.
  • Jantar dos Filósofos: Cinco filósofos sentados em uma mesa redonda, cada um com um garfo entre si e dois pratos de espaguete. Precisam de dois garfos para comer. A solução ingênua (pegar garfo esquerdo, depois direito) pode levar a um deadlock se todos pegarem o garfo esquerdo simultaneamente. Discutimos soluções como permitir que apenas 4 filósofos se sentem à mesa, usar um garfo assimétrico (ímpares pegam esquerdo primeiro, pares pegam direito) ou adicionar um semáforo para controle de simultaneidade.

Deadlocks e Condições de Coffman

Um deadlock (impasse) ocorre quando cada thread em um conjunto está esperando por um evento que apenas outra thread do conjunto pode causar. As quatro condições necessárias para que ocorra um deadlock (Coffman et al., 1971) são:

  • Exclusão Mútua: Os recursos envolvidos não podem ser compartilhados.
  • Posse e Espera: Uma thread mantém um recurso enquanto espera por outro.
  • Não-Preenção (No Preemption): Recursos não podem ser retirados à força de uma thread.
  • Espera Circular: Existe um ciclo de threads onde cada uma espera por um recurso que a próxima no ciclo possui.

Para lidar com deadlocks, as estratégias incluem prevenção (quebrar uma das quatro condições), detecção e recuperação (permitir o deadlock e depois resolvê-lo, matando threads ou recursos), ou a estratégia do avestruz (ignorar, assumindo que são raros o suficiente para não justificar o overhead). Em sistemas operacionais modernos, a prevenção é a abordagem mais comum para locks internos do kernel, enquanto em aplicações de usuário, o design cuidadoso com a ordem de aquisição de locks é fundamental.

Considerações Finais

A sincronização correta é vital para a construção de sistemas concorrentes confiáveis. Escolher entre mutex, semáforo, variáveis de condição ou barreiras depende do problema específico. Implementações inadequadas podem levar a deadlocks, starvation (uma thread nunca consegue acessar o recurso) ou baixo desempenho devido a busy waiting.

Compreender a fundo os mecanismos de sincronização é essencial não apenas para programação de sistemas, mas também para o desenvolvimento de aplicações concorrentes em qualquer linguagem que ofereça suporte a threads.

Perguntas Frequentes

  • O que é um Deadlock? É uma situação onde cada thread espera por um recurso que outra thread possui, formando um ciclo de espera infinita. Nenhuma das threads progride.
  • Qual a diferença entre Mutex e Semáforo? Um mutex é essencialmente um semáforo binário com o conceito de propriedade (a thread que travou deve ser a única a destravar). Semáforos contadores permitem gerenciar múltiplas instâncias de um recurso e não possuem o conceito de propriedade.
  • Como evitar Busy Waiting? Utilizando bloqueios que colocam a thread para dormir (sleep lock) como semáforos com fila de espera, ao invés de ficar testando uma variável em loop (spinlock). Spinlocks são úteis apenas para tempos de espera muito curtos, comuns em kernels de sistemas operacionais.