Programação concorrente

Threads, corridas, secções críticas, atomics, mutexes, semáforos e condições.

TLDR

Perguntar sobre esta página

ChatGPTClaudePerplexityGrokGeminiCopiar e abrirDeepSeekCopiar e abrir

Duas threads contam linhas de ficheiros diferentes e somam os resultados num contador comum. Cada thread tem a sua execução, mas ambas acedem à memória do processo. Se lerem o mesmo total e escreverem separadamente os novos valores, uma atualização pode perder-se. A coordenação tem de proteger toda a operação que deve ser indivisível.

O que uma thread partilha

Uma thread tem registos, contador de programa e pilha próprios. As threads do processo partilham o espaço de endereçamento, incluindo globais e heap, e os descritores. Uma variável automática pode ser acedida por outra thread se lhe passarmos um apontador válido; estar numa pilha não garante privacidade.

pthread_create inicia uma função com assinatura void *f(void *), e o argumento precisa de continuar vivo enquanto a thread o usa. Para passar um índice e um nome a cada thread, define uma struct com esses campos, guarda um vetor de estruturas vivo até aos joins e passa &tarefa[i] a cada thread.

Passar a todas o endereço da mesma variável de um ciclo permite que leiam o índice errado; se o ciclo a altera enquanto uma thread a lê, há ainda uma data race, com comportamento indefinido em C.

pthread_join espera pela terminação de uma thread e permite recolher o seu retorno, por exemplo um apontador para um bloco obtido com malloc ou para um objeto estático por thread. O retorno não pode apontar para um objeto automático cuja vida acabou com a thread.

Retornar da função inicial da thread equivale a terminar essa thread com pthread_exit. Já exit termina o processo inteiro, incluindo as restantes threads; retornar de main tem esse efeito. Os joins do exemplo conservam o processo vivo até todos os resultados estarem prontos.

O programa seguinte passa uma estrutura distinta a cada thread e recolhe o retorno de cada uma com pthread_join. Guarda-o em fios.c e compila num terminal UNIX com cc -std=c17 -Wall -Wextra -Wpedantic fios.c -pthread -o fios.

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

struct tarefa {
    const char *nome;
    int valor;
};

static void conferir(int erro, const char *op) {
    if (erro == 0)
        return;
    fprintf(stderr, "%s: %s\n", op, strerror(erro));
    exit(1);
}

static void *dobrar(void *arg) {
    struct tarefa *t = arg;
    long *r = malloc(sizeof *r);
    if (r == NULL)
        return NULL;
    *r = 2L * t->valor;
    return r;
}

int main(void) {
    struct tarefa tarefas[] = {
        {"a", 3}, {"b", 7}, {"c", -4},
    };
    const int n = 3;
    pthread_t fios[3];
    for (int i = 0; i < n; i++)
        conferir(pthread_create(&fios[i], NULL, dobrar, &tarefas[i]),
                 "create");
    long total = 0;
    for (int i = 0; i < n; i++) {
        void *ret;
        conferir(pthread_join(fios[i], &ret), "join");
        if (ret == NULL) {
            fputs("sem retorno\n", stderr);
            return 1;
        }
        printf("%s: %d -> %ld\n", tarefas[i].nome, tarefas[i].valor,
               *(long *)ret);
        total += *(long *)ret;
        free(ret);
    }
    printf("total=%ld\n", total);
    return 0;
}

Saída observada:

a: 3 -> 6
b: 7 -> 14
c: -4 -> -8
total=12

Cada thread recebe &tarefas[i], que continua vivo até depois dos joins. O retorno é um bloco reservado pela thread e libertado pelo pai depois de o ler. Com o endereço da mesma variável do ciclo, o valor não é uma cópia estável por thread. Nem o último índice é um resultado garantido quando há leitura e escrita sem sincronização.

Uma atualização perdida

Num modelo de ler, calcular e escrever, o contador começa em 5:

PassoThread A, incrementarThread B, decrementarMemória
1Lê 55
2Lê 55
3Calcula 65
4Calcula 45
5Escreve 66
6Escreve 44

A execução serial daria 5. Este entrelaçamento dá 4; inverter as duas escritas daria 6. É um exemplo para explicar a corrida. Em C, acessos concorrentes incompatíveis a um objeto não atómico, sem sincronização, constituem data race e têm comportamento indefinido, por isso não podes afirmar que o programa C real só terá os resultados 4, 5 ou 6. Declarar o contador como volatile não resolve esta corrida, porque impede certas otimizações dos acessos mas não cria uma operação atómica nem uma relação de sincronização entre threads.

Secção crítica e propriedades

A secção crítica é o trecho que usa os dados cujo acesso precisa de coordenação. Uma solução clássica procura:

  • Exclusão mútua: no máximo uma tarefa está na secção crítica protegida.
  • Progresso: se há tarefas interessadas e ninguém está lá dentro, a escolha de quem entra não pode ser adiada indefinidamente por quem não participa.
  • Espera limitada: há um limite ao número de entradas de outras tarefas antes de uma tarefa que pediu acesso poder entrar.

Exclusão mútua não implica as restantes, ou seja um protocolo que nunca deixa ninguém entrar exclui todos e falha o progresso. Um lock sem justiça pode impedir acesso simultâneo sem garantir espera limitada.

Peterson e operações atómicas

Para duas tarefas, a solução de Peterson usa interessado[2] e vez. A tarefa ii assinala interesse, cede preferência a jj e espera enquanto jj está interessado e tem a vez. Ao sair, retira o seu interesse.

interessado[i] = verdadeiro
vez = j
enquanto interessado[j] e vez == j: esperar
secção crítica
interessado[i] = falso

Segue uma entrada com ambos interessados: A escreve vez = B; B escreve depois vez = A. A testa se B está interessado e se vez == B. O segundo teste é falso, por isso A entra. B testa se A está interessado e se vez == A; ambos são verdadeiros, por isso B espera. Quando A sai e retira o interesse, B deixa de esperar. Esta prova assume operações atómicas e uma ordem de memória adequada. Traduzir o pseudocódigo para globais C comuns não cumpre essas hipóteses. Para programar, usa primitivas da biblioteca ou atomics com um modelo de memória escolhido conscientemente.

Test-and-set lê um valor antigo e escreve o novo numa operação atómica. Compare-and-swap só escreve o novo valor se o antigo coincidir com o esperado. Um spinlock pode repetir test-and-set até obter o lock, consumindo CPU enquanto espera. Faz sentido em certos trechos curtos, mas uma espera longa desperdiça CPU. Desativar interrupções numa CPU não impede outra CPU de aceder aos mesmos dados.

Mutex com Pthreads

O mutex protege o contador no programa completo seguinte. Guarda-o como contador.c e compila no terminal UNIX com cc -std=c17 -Wall -Wextra -Wpedantic contador.c -pthread -o contador.

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

static int contador = 0;
static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

static void conferir(int erro) {
    if (erro == 0) return;
    fprintf(stderr, "pthread: %s\n", strerror(erro));
    exit(1);
}

static void *incrementar(void *arg) {
    (void)arg;
    for (int i = 0; i < 10000; i++) {
        conferir(pthread_mutex_lock(&mutex));
        contador++;
        conferir(pthread_mutex_unlock(&mutex));
    }
    return NULL;
}

int main(void) {
    pthread_t a, b;
    conferir(pthread_create(&a, NULL, incrementar, NULL));
    conferir(pthread_create(&b, NULL, incrementar, NULL));
    conferir(pthread_join(a, NULL));
    conferir(pthread_join(b, NULL));
    printf("%d\n", contador);
    conferir(pthread_mutex_destroy(&mutex));
    return 0;
}

A saída é 20000, porque todos os incrementos usam o mesmo mutex; a leitura final acontece depois dos dois joins, quando já não há escritores.

Pthreads devolve o código de erro diretamente em muitas funções, por isso não o interpretes como errno; usa strerror(erro), como no programa.

Obter o mutex uma vez para todo o ciclo também daria o total correto, mas cada thread faria o seu trabalho inteiro sem a outra entrar. Obter por incremento permite entrelaçamento fino e custa mais operações de sincronização. A granularidade deve seguir o que precisa de ser indivisível, não a regra de pôr sempre o menor número de linhas possível.

Semáforos

Um semáforo conta permissões. wait, também chamado P, consome uma se houver disponibilidade; caso contrário espera. post, ou V, acrescenta uma e pode permitir que alguém avance. As operações são atómicas.

Um semáforo iniciado em 3 limita a três utilizadores de um recurso. Iniciado em 0, pode impor uma ordem: B faz wait, A termina a preparação e faz post. Um semáforo binário pode conseguir exclusão mútua, mas não tem necessariamente a noção de dono de um mutex. Não confundas sinalizar disponibilidade com libertar um lock que pertence à thread.

Numa fila limitada a NN itens, um protocolo com semáforos começa com vazios=N, cheios=0 e exclusão mútua sobre os índices. O produtor obtém um vazio, obtém o mutex, insere, liberta o mutex e anuncia um cheio. O consumidor obtém um cheio, obtém o mutex, retira, liberta o mutex e anuncia um vazio.

Esperar por uma condição

Uma variável de condição não guarda a verdade da condição nem acumula todos os sinais. A verdade fica nos dados, protegidos pelo mutex. pthread_cond_wait(&cond, &mutex) liberta o mutex e entra em espera atomicamente; quando retorna, voltou a obter o mutex.

Este é o padrão para um consumidor, assumindo quantidade, buffer e índices protegidos por mutex:

pthread_mutex_lock(&mutex);
while (quantidade == 0)
    pthread_cond_wait(&nao_vazio, &mutex);
int valor = buffer[saida];
saida = (saida + 1) % CAPACIDADE;
quantidade--;
pthread_cond_signal(&nao_cheio);
pthread_mutex_unlock(&mutex);

O produtor faz o simétrico: espera enquanto quantidade == CAPACIDADE, insere em buffer[entrada], avança entrada módulo capacidade, aumenta a quantidade e sinaliza nao_vazio. A invariável é 0≤quantidade≤CAPACIDADE0 \leq quantidade \leq CAPACIDADE: cada inserção precisa de espaço e cada retirada precisa de um item.

Evento numa fila inicialmente vaziaQuantidadePosse do mutex
Consumidor testa a fila0Consumidor
Consumidor entra em cond_wait0Libertado atomicamente ao esperar
Produtor obtém o mutex e insere1Produtor
Produtor sinaliza nao_vazio1Ainda do produtor
Produtor liberta; consumidor reobtém1, se ninguém retirou entretantoConsumidor
Consumidor volta ao teste do whileConfere de novoConsumidor

Para vários consumidores, define também como termina a produção. Uma flag terminado, sob o mesmo mutex, permite esperar enquanto a fila está vazia e não terminou, e sair quando vazia e terminada. Nesse fim, um broadcast acorda os consumidores que precisam de observar a mudança.

Se uma fila passa de vazia a um item e depois acumula vários itens, sinalizar apenas na transição pode ser suficiente para um único consumidor que continua a retirar. Com vários consumidores, pode deixar outros adormecidos apesar de haver trabalho. Decide entre sinalizar por inserção e acordar vários segundo o número de consumidores e o protocolo, não só segundo o desejo de fazer menos chamadas.

Monitores com retoma imediata e diferida

Um monitor reúne dados partilhados e as operações que os usam sob exclusão mútua, com variáveis de condição para esperar. Em C/Pthreads, o programador constrói essa disciplina com mutex e condição; outras linguagens oferecem parte dela diretamente.

Nos monitores clássicos com retoma imediata, ou semântica de Hoare, um signal entrega o monitor à tarefa que esperava; a tarefa que sinalizou fica suspensa. Se o sinalizador tornou o predicado verdadeiro imediatamente antes do sinal, a tarefa acordada observa esse estado antes de outra operação do monitor o alterar.

Na retoma diferida, associada a Mesa, o sinalizador continua. A tarefa acordada só executa depois de voltar a obter exclusão mútua, e o predicado pode entretanto ter mudado. Pthreads segue esta disciplina de reavaliação e admite ainda despertares espúrios: conserva o while em torno de pthread_cond_wait.

Um exercício que usa if (v == 0) wait(cond) pode assumir retoma imediata num monitor abstrato. Não traduzas essa conclusão para Pthreads sem rever as hipóteses. A disciplina de sinalização e o predicado protegido fazem parte da solução, não apenas a palavra monitor.

Leitores e escritores

Vários leitores podem consultar um objeto sem o alterar, mas um escritor precisa de excluir tanto outros escritores como leitores. A condição de segurança permite vários leitores ou um escritor, nunca ambos ao mesmo tempo.

Num protocolo com preferência pelos leitores, um novo leitor pode juntar-se enquanto já existem leitores ativos. Se chegam leitores continuamente, o conjunto pode nunca ficar vazio e um escritor ficar em inanição, embora os leitores avancem. Bloquear novos leitores quando há um escritor à espera impede esse entrelaçamento; dar preferência estrita a uma fila contínua de escritores pode criar o problema simétrico.

EstadoNovo leitorNovo escritor
Sem participantesPode entrarPode entrar sozinho
Leitores ativos, preferência por leitoresPode juntar-seEspera todos saírem
Escritor ativoEsperaEspera

Um mutex exclusivo para todas as consultas garante segurança, mas perde a simultaneidade entre leitores. Um read-write lock permite essa simultaneidade, porém a política de admissão e a justiça dependem do contrato. Não concluas espera limitada só por usar um lock de leitores/escritores. Este problema e a distinção entre políticas de retoma acima são úteis para interpretar fichas e provas históricas; a prática Pthreads atual continua a exigir mutex e predicado reavaliado.

Mutexes evitam corridas, mas um conjunto de locks ainda pode criar impasses. Para avaliar correção, procura tanto acessos concorrentes como ordens de obtenção incompatíveis.

Exercícios

Granularidade e leitura protegida

O contador começa em zero. A faz 10000 incrementos e B faz 10000 decrementos. Compara obter o mesmo mutex uma vez por ciclo com obtê-lo por atualização. Qual é o valor depois dos dois joins? Que entrelaçamentos permite cada versão?

B deve ainda imprimir um ponto quando o contador fica negativo após um decremento. Onde colocas o teste? Uma thread C pode ler o contador sem mutex enquanto A e B trabalham?

Pista
A proteção deve abranger os acessos à mesma variável, incluindo leituras.
Solução

Ambas as versões dão zero depois dos joins: houve o mesmo número de incrementos e decrementos, sem atualizações perdidas. Com um lock por ciclo, uma thread executa todas as suas atualizações antes de a outra entrar. Com um lock por atualização, os ciclos podem entrelaçar-se, mas cada leitura, cálculo e escrita de uma atualização fica protegida.

Para testar o valor produzido pelo seu decremento, B testa contador < 0 antes de libertar o mutex. Se a ordem exata dos pontos não importa, pode guardar o resultado desse teste numa variável local e imprimir depois de libertar, reduzindo trabalho dentro da secção crítica. C também precisa do mutex para ler enquanto existem escritores; proteger só as escritas não elimina a data race. Depois dos joins, a leitura final já não concorre com A ou B.

A versão fina faz mais operações de sincronização. Não é garantido que seja mais rápida; mede separadamente correção, tempo e possibilidades de entrelaçamento.

Confere a tua resposta:

  • Obtive zero após os joins com ambos os ciclos protegidos.
  • Distingui atomicidade do incremento de execução indivisível do ciclo.
  • Protegi também o teste do sinal do contador.
Voltar à explicação
Retomar não garante dados

Um consumidor espera por dados enquanto a fila está vazia. O produtor insere um item e sinaliza. Um segundo consumidor pode chegar antes de o primeiro voltar a obter o mutex. O primeiro pode encontrar a fila vazia? Explica por que o predicado deve estar num while.

Pista

Entre acordar e obter o mutex, outra tarefa pode avançar.

Mais uma pista

Outro consumidor pode obter o mutex primeiro e retirar o item.

Solução

Pode, porque um consumidor retira o único item antes de o acordado conseguir o mutex; também pode haver despertar espúrio. O wait retorna com o mutex adquirido, e o while confirma de novo a existência de dados antes da retirada.

Confere a tua resposta:

  • Expliquei a reaquisição do mutex.
  • Dei um entrelaçamento com dois consumidores.
  • Usei while para reavaliar o predicado.

Erros frequentes

Tratar signal como reserva de um item ou como transferência imediata do mutex.

Voltar à explicação
Esperar sem impedir quem cria espaço

Uma fila de capacidade 2 está cheia. O produtor obtém o mutex e só depois faz wait(vazios). O consumidor faz wait(cheios) e tenta obter o mutex antes de retirar. Constrói a ordem de eventos que impede ambos de avançar. Reordena as operações do produtor e indica os valores iniciais de vazios e cheios quando a fila está vazia.

Pista
Que tarefa pode criar a permissão pela qual o produtor espera?
Mais uma pista
Numa fila cheia, vazios=0. O consumidor tem de entrar na secção crítica para retirar.
Solução

O produtor obtém o mutex e bloqueia em wait(vazios). O consumidor obtém uma permissão de cheios, mas bloqueia no mutex. O produtor precisa da retirada que o consumidor não consegue fazer.

O produtor correto faz wait(vazios), obtém o mutex, insere, liberta o mutex e faz post(cheios). O consumidor faz wait(cheios), obtém o mutex, retira, liberta o mutex e faz post(vazios). Para a fila inicialmente vazia, os valores são vazios=2 e cheios=0.

Confere a tua resposta:

  • Mostrei o produtor bloqueado em vazios mantendo o mutex.
  • Mostrei o consumidor impedido de retirar pela posse do mutex.
  • Esperei por vazios antes de obter o mutex e anunciei cheios após inserir.

Erros frequentes

Libertar o mutex de outra tarefa à força ou retirar a proteção dos índices por já haver semáforos.
Voltar à explicação
Argumentos vivos até ao join

Quatro threads contam linhas de ficheiros distintos e devolvem a contagem. Cada estrutura contém um índice e um nome; cada thread recebe &tarefa[i] de um vetor que o pai conserva sem alterar até aos joins. Explica por que este desenho funciona e o que falharia se todas recebessem o endereço da mesma variável do ciclo.

Pista
Cada thread precisa da sua própria cópia dos dados de entrada.
Mais uma pista
A variável do ciclo muda antes de a thread a ler.
Solução

Cada thread lê a sua posição do vetor, que continua vivo até depois dos joins. O retorno usa um bloco reservado ou um valor estático por thread, recolhido com pthread_join. Com o endereço da mesma variável do ciclo, o índice não seria uma cópia estável para cada thread. Se o pai o escreve enquanto uma thread o lê, há uma data race e comportamento indefinido em C; nem ler sempre o último índice é garantido. O programa fios.c da lição executa este desenho com três tarefas e obtém total=12.

Confere a tua resposta:

  • Expliquei a estrutura distinta e imutável por thread.
  • Guardei o vetor de structs vivo até aos joins.
  • Recolhi o retorno de cada thread sem apontar para a sua pilha.

Erros frequentes

Passar o endereço da variável do ciclo, ou devolver um apontador para a pilha da thread.
Voltar à explicação
Leitores avançam, escritor espera

Um lock permite entrar um novo leitor sempre que não há escritor ativo, mesmo que haja escritores à espera. R1 está a ler quando W pede escrita. Antes de R1 sair, R2 entra; antes de R2 sair, R3 entra, e o padrão pode repetir-se. W só entra quando não existem leitores ativos.

Mostra por que W pode esperar indefinidamente sem haver impasse. Que regra de admissão impede esta sequência? Um mutex exclusivo para todas as leituras resolveria a segurança com o mesmo grau de simultaneidade?

Solução

O número de leitores ativos pode alternar entre um e dois sem chegar a zero. Cada leitor termina, mas há outro que mantém W à espera. Há progresso dos leitores e falta de serviço do escritor, portanto é inanição, não um impasse em que todos aguardam recursos uns dos outros.

Registar que há um escritor à espera e impedir a entrada de novos leitores permite esvaziar o conjunto dos leitores atuais. Isso remove este entrelaçamento, mas é preciso definir quem é admitido a seguir e que garantias de justiça se pretendem. Preferência estrita por uma sequência contínua de escritores pode prejudicar os leitores.

Um mutex exclusivo para todas as operações garante que o escritor não concorre com leitores, mas também só deixa um leitor de cada vez. Segurança não exige perder essa simultaneidade: o problema permite vários leitores, desde que nenhum escritor esteja ativo. Um mutex sem garantia de justiça também não prova, por si, que W obtenha acesso.

Confere a tua resposta:

  • Construí uma sequência com pelo menos um leitor sempre ativo.
  • Distingui inanição do escritor de impasse do conjunto.
  • Impedi a entrada de novos leitores quando um escritor espera, sem prometer justiça automática.
Voltar à explicação

À tua maneira

Aparência

Modo de cor
Tema
Cor de destaque

Leitura

Fonte de leitura

Álgebra, lógica e uma ideia de cada vez.

Código

Fonte do código

Navegação

Cursor de leitura, sem edição. h, j, k, l movem; / pesquisa nesta página. v seleciona caracteres; V seleciona linhas; y copia. Tab permite destacar ou comentar. Espaço ou Enter abre blocos recolhíveis. Nos cartões, h, j, k, l mudam o foco.

CSS personalizado

Ativa uma sugestão ou guarda o teu CSS. Só muda este navegador.

Usa --page, --text e --accent para acompanhar o tema.

Seletores e recuperação

.site-header, .prose, .course-card, [data-reading-history], [data-playground] e .page-actions.

Abre a aparência com a tecla vírgula ou no rodapé. Se o CSS esconder os controlos, abre esta página sem CSS personalizado.

Pesquisar

Escreve para pesquisar em todo o site.

para escolher · Enter para abrir · Esc para fechar

Copiar fórmula

A cópia automática não está disponível. Copia o LaTeX abaixo.

Pesquisar nesta página

Enter procura · n / N repetem · Esc cancela. Ctrl / Cmd K pesquisa todo o site.

Atalhos de teclado

Clica numa tecla para a mudar. Esc cancela. Backspace desativa.

PesquisarCtrl / Cmd K

Os atalhos não interferem enquanto escreves. Tab e Enter funcionam sempre.