Threads, condições de corrida e travas

Aprenda como as threads permitem que um programa faça várias coisas ao mesmo tempo e por que compartilhar dados entre elas é arriscado. Crie threads e aguarde seu término com threads POSIX em C, acompanhe como condições de corrida causam atualizações perdidas e proteja seções críticas com mutexes. Veja como travas são construídas com instruções atômicas de hardware, como teste e atribuição e comparação e troca, por que os sistemas operacionais suspendem threads em espera e como projetar contadores, listas, filas e tabelas de dispersão que permaneçam corretos e rápidos com muitas threads.

IntermediárioAula 5 de 9 do curso Curso de Sistemas operacionais

O que você vai aprender

  1. Descreva como as threads de um processo compartilham um espaço de endereçamento enquanto cada uma mantém seu próprio estado de execução e escreva programas em C que criam threads, passam dados a elas e aguardam seu término.
    • Compare threads e processos
      Explique o que pertence a uma thread, o que ela compartilha com as outras threads de seu processo, por que os programas usam threads e como a troca entre threads difere da troca entre processos.
    • Crie threads, passe dados a elas e aguarde seu término com pthreads
      Use a API de threads POSIX em C para iniciar threads, fornecer argumentos a cada uma, coletar seus resultados e garantir que o programa aguarde seu término antes de encerrar.
  2. Acompanhe como instruções de máquina intercaladas sobre dados compartilhados produzem resultados incorretos, identifique condições de corrida e seções críticas no código e indique o que uma solução correta para seções críticas deve garantir.
    • Acompanhe como atualizações sem proteção perdem incrementos
      Divida uma atualização compartilhada em suas etapas de máquina, acompanhe intercalações que perdem atualizações e explique por que esses erros são raros em testes, mas ainda assim reais.
    • Identifique condições de corrida, seções críticas e operações atômicas
      Defina o vocabulário essencial de concorrência, reconheça no código condições de corrida em padrões de leitura, modificação e escrita e de verificação seguida de ação e indique os requisitos que uma solução correta para o problema da seção crítica deve atender.
  3. Use mutexes POSIX para tornar seções críticas atômicas, decida o que cada trava protege e quanto código ela abrange e evite erros que comprometem a proteção sem aviso.
    • Adquira e libere um mutex POSIX ao entrar e sair de seções críticas
      Explique o que uma trava garante, inicialize e use corretamente um mutex pthread e corrija com ele as condições de corrida do contador compartilhado e da conta bancária.
    • Escolha a granularidade das travas e avalie-as
      Decida quantos dados cada trava protege e quanto código ela abrange e avalie uma trava por sua correção, justiça e desempenho.
    • Evite erros comuns no uso de travas
      Reconheça os erros típicos que deixam dados desprotegidos ou threads esperando para sempre e corrija cada um deles.
  4. Mostre por que carregamentos e armazenamentos comuns não são suficientes para uma trava correta em hardware moderno, construa travas de espera ocupada com instruções atômicas, torne-as justas e explique como o suporte do sistema operacional evita o desperdício de espera ocupada.
    • Explique por que implementações simples de travas falham
      Analise três abordagens iniciais para exclusão mútua, desativação de interrupções, um indicador simples e o algoritmo de Peterson, e explique onde cada uma falha.
    • Construa uma trava de espera ocupada com instruções atômicas
      Descreva teste e atribuição, comparação e troca, leitura vinculada e escrita condicional e busca e soma, construa uma trava de espera ocupada com essas operações e avalie-a.
    • Torne justo o acesso às travas de espera ocupada com uma trava por bilhetes
      Construa uma trava por bilhetes com busca e soma, acompanhe seu funcionamento e explique como ela garante que cada thread em espera tenha sua vez.
    • Evite o desperdício de espera ocupada com cessão da CPU, suspensão e travas de duas fases
      Explique como a cessão da CPU, a suspensão em uma fila, os futexes e as travas de duas fases reduzem o desperdício de tempo de CPU, decida quando a espera ocupada compensa e reconheça a inversão de prioridade.
  5. Torne contadores, listas encadeadas, filas e tabelas de dispersão seguros para muitas threads com travas e avalie cada projeto por sua correção e por sua capacidade de escalar à medida que threads são adicionadas.
    • Construa contadores escaláveis
      Proteja um contador com uma trava, explique por que ele escala mal e construa um contador aproximado com contadores por núcleo e um limiar.
    • Torne listas encadeadas seguras para uso por múltiplas threads
      Proteja uma lista encadeada com uma trava, mantendo as seções críticas pequenas e garantindo a liberação em todos os caminhos, e avalie quando uma trava por nó ajuda.
    • Projete filas e tabelas de dispersão concorrentes
      Construa uma fila com duas travas e um nó sentinela e uma tabela de dispersão com uma trava por balde, e adote uma abordagem baseada em medições para o travamento de granularidade mais fina.

Guia de estudo de Threads e condições de corrida

5 capítulos

1

Threads e sua memória compartilhada

Um processo fornece a um programa um espaço de endereçamento; as threads fornecem vários caminhos de execução dentro desse espaço. Comece separando o estado que cada thread carrega dos objetos que todas podem acessar. Depois, use a interface de threads POSIX para iniciar threads trabalhadoras e coletar seus resultados. As principais questões de projeto são quem é responsável por cada argumento, por quanto tempo sua memória permanece válida e quando a thread criadora pode ler a resposta com segurança.

1.1 Um espaço de endereçamento, vários caminhos de execução

Uma thread é uma sequência de execução dentro de um processo. Várias threads podem trabalhar nos mesmos dados do programa, mas cada uma precisa de sua própria posição no código e de suas próprias chamadas de função ainda não concluídas.

O que é compartilhado e o que pertence a uma thread

Threads de um processo compartilham código, variáveis globais e estáticas, objetos da área de alocação dinâmica, descritores de arquivos abertos e o identificador de processo. Cada thread tem seu próprio contador de programa, registradores, pilha e identificador de thread. Uma variável global int total representa um objeto compartilhado. Uma variável local comum int i na função trabalhadora representa um objeto diferente em cada chamada.

Um processo com três threads tem três pilhas em endereços diferentes no mesmo espaço de endereçamento. Uma pilha não é um compartimento protegido: outra thread pode acessar uma variável local se receber seu endereço. Esse compartilhamento só é válido enquanto a variável existir. Um ponteiro para um quadro de pilha de uma função já encerrada não pode manter a variável ativa. Arrays locais grandes e recursão profunda também podem esgotar a pilha de uma thread.

Troca entre threads

O sistema operacional salva o estado de execução de cada thread em um bloco de controle de thread, ou TCB. Quando troca de T1 para T2, ele salva os registradores de T1 e restaura os de T2. O cálculo inacabado de T1 permanece em seus registradores salvos até que ela execute novamente.

Para threads do mesmo processo, o espaço de endereçamento permanece igual durante a troca. As tabelas de páginas não precisam mudar, o que geralmente torna essa troca menos custosa que uma troca entre processos. Memória compartilhada também significa falhas compartilhadas: uma escrita por ponteiro inválido pode danificar dados de outra thread, e uma falha que encerra o processo encerra todas as threads.

Por que usar várias threads?

Paralelismo significa executar no mesmo instante em diferentes núcleos de CPU. Quatro partes independentes de um cálculo podem alcançar uma aceleração próxima de quatro vezes em quatro núcleos, se o trabalho compartilhado e a coordenação não se tornarem gargalos. As threads também permitem que um programa sobreponha espera e trabalho: enquanto uma espera por uma leitura de disco ou resposta da rede, outra pode usar a CPU.

Concorrência significa que execuções progridem durante períodos sobrepostos. Em um núcleo, o escalonador alterna entre threads, portanto elas são concorrentes sem executar em paralelo. Um único núcleo ainda permite erros de concorrência: uma troca pode interromper uma atualização compartilhada entre suas instruções de máquina.

Quem escalona as threads?

Linux e Windows usam um modelo um para um: cada thread comum de aplicação corresponde a uma thread escalonada pelo núcleo. Threads diferentes podem executar em núcleos diferentes, e uma chamada bloqueante bloqueia a thread que a chamou. Uma biblioteca muitos para um escalona várias threads de usuário em uma thread do núcleo. Suas trocas podem ser baratas, mas ela não pode executar essas threads em vários núcleos, e uma chamada de sistema bloqueante pode interromper todo o grupo.

Criar A antes de B não garante que A execute primeiro. Se A imprime “A” e B imprime “B”, pode aparecer “AB” ou “BA”. A ordem de criação determina quando uma thread fica disponível para executar; o escalonador escolhe seu momento de execução.

Principais conclusões

  • Threads compartilham o espaço de endereçamento do processo, mas cada uma tem seus próprios registradores, contador de programa e pilha.
  • Um ponteiro pode tornar compartilhada uma variável da pilha; seu tempo de vida ainda termina com a função à qual pertence.
  • A concorrência é possível em um núcleo; a execução paralela exige vários núcleos.
  • O escalonador escolhe qual thread pronta para executar será executada em seguida.
  • O modelo de threads um para um permite execução paralela e bloqueio individual de threads.

1.2 Inicie threads trabalhadoras e colete seus resultados

A interface de threads POSIX, ou pthreads, inicia uma função C em outra thread. Inclua <pthread.h> e compile com um comando como gcc -pthread -o sum sum.c. A opção -pthread fornece as configurações de compilador e vinculador para código com threads.

Crie uma thread com argumentos estáveis

A chamada pthread_create(&t, NULL, worker, arg) armazena um identificador de thread em t e inicia worker(arg). O ponteiro de atributos NULL solicita os padrões. Uma função trabalhadora tem a assinatura void *worker(void *arg): o argumento e o valor de retorno são ponteiros. Uma struct permite que esse único ponteiro transporte vários campos.

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

struct range { int lo, hi; long result; };

void *sum_range(void *arg) {
    struct range *r = arg;
    long sum = 0;
    for (int n = r->lo; n <= r->hi; n++)
        sum += n;
    r->result = sum;
    return NULL;
}

A thread criadora prepara duas structs, {1, 50, 0} e {51, 100, 0}, e passa um endereço diferente a cada trabalhadora. Mantenha ambas as structs válidas até que as trabalhadoras terminem. Cada trabalhadora acumula em sua própria variável local sum e escreve apenas em seu próprio campo result.

pthread_create retorna 0 em caso de sucesso e um número de erro em caso de falha. Verifique diretamente o número retornado; a função não define errno para informar a falha. Somente uma criação bem-sucedida fornece uma thread cujo término pode ser aguardado.

Aguarde o término antes de ler os resultados

Depois de criar ambas as trabalhadoras com sucesso, a thread principal chama pthread_join(t[0], NULL) e pthread_join(t[1], NULL). Uma chamada de join bloqueia até que a thread indicada termine. A conclusão bem-sucedida dessa espera também torna as escritas da trabalhadora visíveis para a thread que aguardou. A thread principal pode então ler 1.275 e 3.775 e somá-los para obter 5.050 sem um mutex: as trabalhadoras escreveram em objetos separados, e a thread principal esperou antes de lê-los.

Iniciar ambas as threads antes de aguardar seu término permite que seus trabalhos se sobreponham. Criar uma trabalhadora e aguardar imediatamente seu término antes de criar a próxima faz com que elas executem uma após a outra.

Veja o código principal. Ele encerra o programa em caso de erro de pthread, portanto nunca usa um identificador de uma criação malsucedida:

static void require_success(int rc) {
    if (rc != 0) {
        fprintf(stderr, "pthread error: %d\n", rc);
        exit(EXIT_FAILURE);
    }
}

int main(void) {
    struct range jobs[2] = {{1, 50, 0}, {51, 100, 0}};
    pthread_t threads[2];
    for (int i = 0; i < 2; i++)
        require_success(pthread_create(&threads[i], NULL,
                                      sum_range, &jobs[i]));
    for (int i = 0; i < 2; i++)
        require_success(pthread_join(threads[i], NULL));
    printf("%ld\n", jobs[0].result + jobs[1].result);
    return 0;
}

Para coletar um ponteiro retornado, passe o endereço que o receberá: void *answer; pthread_join(t, &answer);. O ponteiro deve referir-se a uma área de armazenamento que continue válida após o término da trabalhadora, como memória alocada na área de alocação dinâmica ou uma struct da thread criadora. Retornar &local_result da trabalhadora é inválido porque sua variável local deixa de existir quando a função retorna.

Evite compartilhar uma variável de laço que muda

Passar &i a cada trabalhadora em um laço de criação passa repetidamente um único endereço. As trabalhadoras podem lê-lo depois que o laço o tiver alterado, portanto várias podem receber o mesmo identificador aparente. Use um array: atribua ids[i] = i, passe &ids[i] e mantenha o array válido e inalterado até que todas as esperas terminem.

Encerre threads e processos de forma intencional

Retornar de main ou chamar exit() em qualquer thread encerra todo o processo e pode interromper as trabalhadoras. A thread principal normalmente aguarda o término das trabalhadoras antes de retornar. Chamar pthread_exit() encerra apenas a thread que o chamou; outras threads podem continuar.

Uma thread destacada libera seus recursos automaticamente quando termina e não permite que se aguarde seu término com join. Garanta que o término de cada thread que permite join seja aguardado uma vez ou que ela seja destacada. A espera por término aguarda a conclusão; ela não protege duas trabalhadoras que atualizam o mesmo objeto enquanto ambas executam.

Principais conclusões

  • Verifique diretamente os códigos de retorno de pthread; aguarde apenas threads criadas com sucesso.
  • Passe objetos de argumento distintos e estáveis e mantenha-os válidos até o término das trabalhadoras.
  • Aguarde o término de uma trabalhadora antes de ler um resultado que ela ainda possa estar escrevendo.
  • Retorne ponteiros apenas para objetos que permaneçam válidos após o término da trabalhadora.
  • Destaque uma thread quando seus recursos devem ser recuperados sem uma chamada de join.
  • Retornar de main encerra o processo, incluindo as trabalhadoras que ainda não terminaram.
2

Encontre as etapas compartilhadas que podem interferir entre si

Uma instrução de código-fonte pode ocultar várias operações de máquina. Acompanhar essas operações em ordem torna visível uma falha de concorrência: uma thread lê, outra altera o objeto e a primeira armazena uma resposta baseada em sua leitura desatualizada. Use acompanhamentos para encontrar a sequência compartilhada que deve permanecer unida. Depois, diferencie uma corrida de dados de um erro mais amplo de temporização e avalie uma solução por exclusão, progresso e espera.

2.1 Acompanhe uma atualização perdida instrução por instrução

A instrução counter++ parece pequena, mas um incremento comum de inteiro compartilhado não é uma operação indivisível. Ele normalmente carrega um valor, soma em um registrador e armazena o resultado. Outra thread pode executar entre essas etapas.

Um acompanhamento que perde um incremento

Comece com counter igual a 50. Trate carregamento, soma e armazenamento como etapas de máquina separadas:

EtapaThread em execuçãoAçãoContador compartilhado
1T1Carrega 50 e soma 1 em seu registrador50
2T2Carrega 50 e soma 1 em seu registrador50
3T2Armazena o valor de seu registrador, 5151
4T1Armazena o valor de seu registrador, 5151

Dois incrementos deveriam ter produzido 52. Em vez disso, T1 sobrescreve o resultado de T2 com um valor calculado a partir do antigo 50. Essa é uma atualização perdida.

Uma troca de contexto preserva os registradores de T1 em seu TCB. Quando T1 retoma, seu registrador ainda contém 51; ela não recarrega o contador compartilhado só porque T2 executou. O erro está na sequência sem proteção, não na restauração de um registrador incorreto pelo escalonador.

Compare com uma execução serial

Se T1 concluir as três etapas antes que T2 carregue, T1 armazena 51 e T2 depois armazena 52. Inverter a ordem das threads inteiras também resulta em 52. O resultado intercalado 51 não pode surgir de nenhuma das duas ordens seriais. Para encontrar esse tipo de falha, marque uma leitura, coloque depois dela uma escrita de outra thread e retome o cálculo da primeira thread com o valor desatualizado.

Esse acompanhamento é um modelo do erro no nível de máquina. Em C real, acessos conflitantes sem sincronização a um inteiro compartilhado comum formam uma corrida de dados, que causa comportamento indefinido. C não limita esse programa aos resultados de um modelo simples de intercalação de instruções.

Por que passar nos testes é uma evidência fraca

Duas threads que fazem 10.000.000 de incrementos cada deveriam produzir 20.000.000. Execuções experimentais sem proteção podem imprimir totais menores e diferentes, como 19.345.221 e 19.221.041 em exemplos publicados. Uma execução com resultado aparentemente correto significa apenas que o resultado observado por acaso foi aceitável.

A janela vulnerável pode ter apenas algumas instruções. Quantidades diferentes de núcleos, escolhas do compilador ou carga do sistema podem expor um escalonamento que milhares de execuções anteriores não mostraram. Para duas threads que fazem cada uma um incremento modelado a partir de zero, o modelo de acompanhamento permite 1 ou 2. Com pelo menos dois incrementos por thread, escalonamentos raros podem deixar um total tão baixo quanto 2, mesmo quando o número de incrementos pretendidos aumenta.

Corrija a operação, não sua aparência

Declarar o inteiro como volatile não torna seu carregamento, soma e armazenamento indivisíveis e não sincroniza threads. Escrever todo o trabalho em uma linha não muda nada. Mesmo uma instrução x86 que soma diretamente na memória precisa da forma atômica adequada para coordenar os núcleos.

Proteja toda a sequência de leitura, modificação e escrita com um mutex compartilhado ou use uma operação atômica de leitura, modificação e escrita de C11, como atomic_fetch_add. A mesma necessidade existe em um núcleo: uma troca provocada pelo temporizador pode separar o carregamento do armazenamento mesmo sem execução paralela.

Principais conclusões

  • Um incremento comum pode ser composto de carregamento, soma e armazenamento.
  • Um armazenamento baseado em uma leitura desatualizada pode sobrescrever uma atualização concluída por outra thread.
  • Trocas de contexto preservam os registradores privados de cada thread.
  • Volatile não fornece atomicidade nem sincronização de threads.
  • Uma corrida de dados em C tem comportamento indefinido; um acompanhamento ilustra a falha sem limitar seus resultados.

2.2 Mantenha uma decisão e sua atualização juntas

Um erro de concorrência pode envolver um acesso à memória ou uma regra de negócio inteira. Identifique tanto os objetos compartilhados quanto a operação que deve parecer indivisível para outras threads.

Condição de corrida ou corrida de dados?

Uma condição de corrida ocorre quando a correção depende da temporização relativa. Uma corrida de dados ocorre quando threads diferentes acessam o mesmo local de memória sem sincronização, pelo menos uma escreve nele e pelo menos um acesso não é atômico. C11 e C++11 atribuem comportamento indefinido a uma corrida de dados.

Acessos atômicos podem evitar uma corrida de dados enquanto uma operação maior ainda tem uma condição de corrida. Por exemplo, leituras e escritas protegidas individualmente não mantêm válida uma decisão entre elas. Duas threads podem ler um saldo suficiente, liberar a trava e depois sacar usando essa decisão desatualizada.

Acompanhe um saque

Um saldo começa em 100. Duas threads solicitam 80 cada uma. A regra é aceitar um saque somente quando o saldo o cobre.

  1. T1 verifica 100 e decide que seu saque pode ser realizado.
  2. T2 também verifica 100 e toma a mesma decisão.
  3. T1 subtrai 80, deixando 20.
  4. T2 age com base em sua decisão anterior e subtrai 80 de 20, deixando −60.

Proteger apenas a subtração não corrige essa sequência. Proteja a verificação e a subtração com a mesma trava, mantida continuamente. Assim, uma thread deixa 20, e a seguinte verifica 20 e recusa o saque. Outro escalonamento sem proteção pode perder uma subtração e deixar 20 depois que ambos os saques forem pagos, o que também está errado.

Encontre a seção crítica

Uma seção crítica é a região de dados compartilhados que deve excluir acessos concorrentes. Uma sequência de leitura, modificação e escrita primeiro lê dados, calcula um novo valor e o escreve de volta. Uma sequência de verificação seguida de ação decide se uma ação é permitida e depois a executa. Operações em listas, inicialização tardia e criação de arquivos também podem conter esses padrões.

Uma trava fornece exclusão mútua: no máximo uma thread participante está dentro da região protegida por vez. Uma operação atômica aparece às outras threads participantes como uma operação completa; elas não podem observar um estado parcialmente concluído. Um mutex fornece essa visão apenas para código que segue o mesmo acordo de uso da trava.

Nem todo objeto precisa de uma trava. Dados acessados por uma thread permanecem privados, a menos que seu endereço seja compartilhado. Dados totalmente inicializados antes do início das trabalhadoras e apenas lidos depois podem ser compartilhados sem trava. Avalie quem pode acessar um objeto e se escritas se sobrepõem ao seu uso, não apenas onde ele foi declarado.

O que uma solução correta promete
  • Exclusão mútua: threads concorrentes não podem entrar juntas.
  • Progresso: quando a seção está livre e threads querem entrar, a escolha de quem entra não pode ser adiada indefinidamente por threads que não estão tentando entrar.
  • Espera limitada: após uma solicitação, há um limite para quantas entradas concorrentes podem precedê-la.

atomic_fetch_add(&count, 1) de C11 faz uma atualização atômica em um atomic_int. A atomicidade é garantida mesmo em uma plataforma que implementa a operação com uma trava interna da biblioteca. Tornar dois campos de contas individualmente atômicos ainda não transforma uma transferência entre eles em uma transação atômica; a relação entre os campos também precisa de proteção.

Principais conclusões

  • Corridas de dados dizem respeito a acessos conflitantes à memória sem sincronização; condições de corrida dizem respeito à correção dependente da temporização.
  • Uma verificação e a ação que depende dela pertencem à mesma seção crítica.
  • Exclusão mútua, progresso e espera limitada são requisitos separados.
  • Acessos atômicos separados não tornam atômica uma operação de várias etapas.
3

Proteja operações compartilhadas com mutexes

Uma trava funciona por meio de um acordo compartilhado: toda operação sobre os dados protegidos segue a mesma regra. Aprenda o ciclo de vida de um mutex POSIX e depois decida onde uma seção crítica começa e termina. Seções curtas reduzem a espera, mas uma verificação deve permanecer junto da ação que autoriza. Examinar o código em busca de liberações ausentes, travas incompatíveis e tempos de vida inseguros torna esse acordo algo que você pode revisar e manter.

3.1 Use um mutex da inicialização à liberação

Um mutex é uma trava que permite que uma thread por vez seja proprietária de uma região protegida. Uma thread o adquire antes de acessar os dados protegidos e o libera depois. Se outra thread o detiver, uma chamada bloqueante de aquisição espera.

Inicialize o mutex compartilhado

Use pthread_mutex_t de <pthread.h>. Um mutex estático pode começar com pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;. Um objeto criado dinamicamente pode inicializar seu campo com pthread_mutex_init(&obj->lock, NULL). A inicialização deve terminar antes que qualquer thread use o mutex.

Veja uma operação de contador que usa um mutex compartilhado inicializado estaticamente. A função auxiliar verifica os valores retornados por pthread, que são diretamente os números de erro:

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

static pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
static long counter = 0;  // guarded by m

static void check(int rc) {
    if (rc != 0) {
        fprintf(stderr, "pthread error: %d\n", rc);
        exit(EXIT_FAILURE);
    }
}

void increment(void) {
    check(pthread_mutex_lock(&m));
    counter = counter + 1;
    check(pthread_mutex_unlock(&m));
}

Toda leitura ou atualização concorrente de counter deve seguir essa mesma regra. Uma função separada que use outro mutex não se coordenaria com increment.

O que acontece enquanto o mutex é mantido?

T1 adquire m, carrega o contador, calcula e armazena. Se T2 chamar pthread_mutex_lock(&m) durante esse intervalo, ela não poderá passar da chamada até adquirir a posse após a liberação por T1. T1 ainda pode sofrer preempção: um mutex impede a entrada concorrente, não as trocas de contexto.

Para a regra do saldo, adquira o mutex antes de avaliar balance >= amount. Subtraia apenas se a verificação for satisfeita, registre o sucesso ou a falha e depois libere o mutex. Um saldo de 100 com duas solicitações de 80 permite exatamente um sucesso e deixa 20.

Escolha como esperar

pthread_mutex_trylock(&m) retorna imediatamente: 0 significa que quem chamou adquiriu o mutex; EBUSY significa que não o adquiriu porque o mutex estava ocupado. No caso de mutex ocupado, a thread pode fazer outro trabalho e não deve acessar os dados protegidos. pthread_mutex_timedlock espera até um prazo absoluto. Essas interfaces ainda exigem a verificação dos códigos de retorno e a liberação de uma trava somente após uma aquisição bem-sucedida.

Respeite a posse e o tempo de vida

A thread proprietária libera um mutex. Um mutex normal, não recursivo, bloqueia se sua proprietária tentar adquiri-lo novamente. Um mutex com verificação de erros informa erros como uma nova aquisição com EDEADLK ou uma liberação por quem não é proprietária com EPERM. Um mutex recursivo conta as aquisições repetidas por sua proprietária e exige o mesmo número de liberações. Não dependa de um mutex padrão para diagnosticar uso incorreto: seu comportamento depende do tipo ao qual a implementação o associa e da operação.

Quando todas as threads tiverem deixado de usar o mutex e ele estiver livre, chame pthread_mutex_destroy. Destruir ou liberar um objeto enquanto outra thread ainda pode adquirir sua trava viola o tempo de vida do mutex. Passar o mutex por ponteiro preserva sua identidade; copiá-lo não cria outro identificador válido para a mesma trava.

Principais conclusões

  • Inicialize um mutex antes de disponibilizá-lo às threads trabalhadoras.
  • Adquira o mesmo mutex compartilhado antes de cada acesso concorrente aos dados que ele protege.
  • Um mutex adquirido impede a entrada concorrente, embora sua proprietária ainda possa sofrer preempção.
  • Trylock retornando EBUSY significa que a posse não foi adquirida.
  • Mantenha o mutex válido até que nenhuma thread possa usá-lo novamente.
  • Mutexes com verificação de erros informam erros de posse; mutexes recursivos contam aquisições aninhadas.

3.2 Escolha o que uma trava protege

A granularidade das travas é a quantidade de estado compartilhado protegida por uma trava. Ela determina quais operações devem esperar umas pelas outras, mesmo quando executam em núcleos diferentes.

Uma trava grande ou várias travas menores?

Um projeto de granularidade grossa coloca um mutex em torno de uma estrutura ou subsistema inteiro. Ele tem uma regra de proteção simples, mas operações não relacionadas são serializadas. Um projeto de granularidade fina atribui mutexes separados a contas, baldes ou regiões distintas. Assim, threads podem trabalhar em regiões diferentes ao mesmo tempo, ao custo de mais operações de trava e regras mais complexas.

Coloque a trava ao lado do estado que ela protege:

struct account {
    pthread_mutex_t lock;
    long balance;
    unsigned id;
};

Uma operação sobre uma conta adquire seu mutex. Uma transferência entre duas contas distintas precisa de ambos, pois altera a relação entre elas. Escolha uma ordem fixa, como a ordem crescente dos identificadores das contas, e use-a em todos os caminhos. Caso contrário, uma thread pode deter A e esperar por B enquanto outra detém B e espera por A.

Uma trava estabelece um limite para a vazão

Suponha que cada operação detenha uma única trava global por 2 µs. O trabalho protegido pode concluir no máximo uma operação a cada 2 µs:

vaza˜o maˊxima=12×10−6=500.000 operac¸o˜es por segundo.

Adicionar núcleos não permite que duas operações detenham o mesmo mutex simultaneamente. A sobrecarga da trava e outros trabalhos podem reduzir ainda mais a taxa alcançada. Portanto, calcular a duração do trecho protegido fornece um limite útil antes de esperar uma aceleração.

Encurte a seção sem comprometer a operação

Prepare o trabalho privado antes de adquirir o mutex. Para inserir em uma lista, primeiro aloque o novo nó e preencha sua chave. Adquira o mutex da lista apenas para ler a cabeça atual e ligar o nó. Assim, uma falha de alocação ocorre sem que a trava esteja adquirida.

Entrada e saída lenta, impressão ou uma função de callback desconhecida podem deixar todas as threads em espera paradas. Mova esse trabalho para fora quando os dados puderem permanecer válidos fora da seção. Mantenha uma decisão e sua atualização dependente juntas: encurtar um saque liberando a trava entre a verificação do saldo e a subtração compromete a correção.

Avalie três propriedades diferentes

A exclusão mútua diz respeito a se o projeto realmente impede a entrada conflitante. A justiça diz respeito a se uma thread em espera acaba tendo sua vez ou pode sofrer inanição. O desempenho diz respeito ao custo da trava com uma thread, várias threads em uma CPU e threads em várias CPUs. Uma trava pode ser correta e ainda assim ser injusta ou lenta.

A contenção ocorre quando threads disputam uma trava já adquirida. Sob contenção intensa, elas também transferem linhas de cache compartilhadas da trava e dos dados entre núcleos. Um experimento publicado com contador levou cerca de 0,03 s para um milhão de atualizações por uma thread, mas mais de 5 s para duas threads fazendo um milhão cada; essas são medições daquela configuração, não tempos universais.

Em um experimento de escalabilidade, mantenha fixo o trabalho de cada thread. A escalabilidade ideal permite que N threads em N núcleos façam N vezes o trabalho no tempo original. Comece com uma estrutura simples e correta, meça a espera e o tempo total e divida uma trava apenas onde a concorrência adicional compensa sua sobrecarga.

Principais conclusões

  • Travas de granularidade grossa simplificam a proteção; travas mais finas permitem que regiões independentes avancem juntas.
  • Uma única trava serializa o trabalho que protege e limita a vazão.
  • Adquira várias travas em uma ordem fixa.
  • Avalie exclusão, justiça e desempenho separadamente.
  • Meça antes de substituir uma trava simples por um projeto mais complexo.

3.3 Revise todos os caminhos de uso da trava

Um mutex pode ser implementado perfeitamente e, ainda assim, um programa pode usá-lo de forma incorreta. Revise todo o caminho de uma função, incluindo leituras, erros, funções de callback e destruição de objetos.

Libere a posse em todas as saídas

Esta função deixa a trava adquirida em seu caminho de falha:

pthread_mutex_lock(&m);
if (failed) return -1;
update_shared_state();
pthread_mutex_unlock(&m);

A thread que retorna pode terminar, mas o mutex permanece adquirido. Uma chamada posterior espera por uma liberação que nunca ocorrerá. Use um único caminho de liberação de recursos ou coloque uma liberação antes de cada retorno. Com chamadas pthread verificadas, a estrutura pode ser:

int rc = -1;
check(pthread_mutex_lock(&m));
if (!failed) {
    update_shared_state();
    rc = 0;
}
check(pthread_mutex_unlock(&m));
return rc;

Revisar um ponto de liberação é mais fácil do que revisar vários pontos espalhados. A propriedade importante é que toda trava adquirida com sucesso seja liberada em cada saída normal.

Siga a regra de proteção também nas leituras

Uma função de estado que “apenas lê” pode entrar em condição de corrida com uma atualização. Ela também pode observar campos relacionados em etapas diferentes de uma atualização. Se uma operação de lista publicar um nó antes de preencher seus campos, um leitor sem proteção poderá seguir um ponteiro para dados incompletos. Adquira a trava documentada para cada acesso cuja correção dependa dela.

Usar mutexes diferentes para o mesmo objeto não ajuda. Se T1 adquire lockA e T2 adquire lockB, ambas as aquisições podem ter sucesso. Um mutex declarado como uma nova variável local dentro de cada função trabalhadora torna esse erro fácil de passar despercebido: cada thread adquire seu próprio objeto, portanto não há exclusão compartilhada.

Preserve a identidade e os limites da operação

Um pthread_mutex_t não pode ser copiado por atribuição ou pela cópia da struct que o contém e depois usado como um segundo identificador de mutex. Usar a cópia é comportamento indefinido. Passe um ponteiro para o mutex compartilhado original.

Verifique também onde ocorrem as liberações. Uma função que adquire a trava para verificar um saldo, libera-a e depois a adquire novamente para subtrair protege os acessos individuais, mas deixa a decisão desatualizada. Mantenha toda a sequência de verificação seguida de ação sob posse contínua da trava.

Evite esperar por si mesmo

Chamar código desconhecido enquanto detém um mutex pode reentrar inesperadamente no mesmo objeto protegido. Com um mutex normal, uma segunda chamada de aquisição pela proprietária bloqueia essa proprietária. Um mutex com verificação de erros informa esse erro, enquanto um mutex recursivo permite posse aninhada e precisa de liberações correspondentes. Escolha o tipo conscientemente; não use recursão para ocultar um projeto de proteção pouco claro.

Quando possível, mantenha operações lentas de entrada e saída e suspensões fora de uma seção crítica compartilhada. Antes de destruir o mutex ou liberar o objeto que o contém, garanta que nenhuma thread o detenha ou possa acessá-lo novamente. Inicialização, uso e destruição devem ter uma ordem clara.

Use ferramentas de execução para expor falhas observadas

O ThreadSanitizer, ativado com -fsanitize=thread, e o Helgrind do Valgrind podem relatar corridas de dados e uso incorreto de travas nas execuções que observam. Combine seus relatórios com uma revisão dos acessos compartilhados e das possíveis intercalações. Uma execução sem alertas estabelece que a ferramenta não encontrou problemas naquelas execuções; um caminho não testado ainda precisa ser analisado.

Principais conclusões

  • Libere cada mutex adquirido com sucesso em todos os caminhos de saída.
  • Leitores devem seguir a regra de proteção quando escritores podem executar concorrentemente.
  • Um mutex é compartilhado por endereço; mutexes separados ou copiados não coordenam o acesso.
  • Não separe uma verificação de sua ação com uma liberação de trava.
  • Funções de callback desconhecidas podem reentrar no código e tentar adquirir o mutex já detido.
  • Uma execução sem alertas do detector de corridas abrange as execuções observadas, portanto revise também os caminhos não testados.
4

Como as travas são adquiridas, aguardadas e transferidas

Uma trava deve tornar indivisível sua decisão de posse e ordenar os acessos à memória protegida. Indicadores comuns não fazem nenhuma das duas coisas. Parta de tentativas de trava que falham até chegar à troca atômica e depois acrescente justiça com números de bilhete. Por fim, considere a thread em espera: espera ocupada, cessão da CPU e suspensão têm custos diferentes, e a suspensão precisa de um protocolo que não perca um despertar. A prioridade de escalonamento pode alterar qual estratégia de espera permite o progresso.

4.1 Por que verificar um indicador simples não basta

Uma trava precisa de uma maneira confiável de escolher uma proprietária. Uma leitura comum seguida de uma escrita comum deixa uma lacuna nessa decisão, mesmo que o trabalho protegido seja muito curto.

O controle de interrupções tem um papel restrito

Em uma CPU, um núcleo pode desativar brevemente as interrupções em torno de uma seção crítica não bloqueante para impedir ali a preempção causada por interrupções. Isso depende de permanecer na CPU sem bloquear ou trocar voluntariamente. O núcleo usa essa técnica para operações internas curtas.

Isso não é um mutex de uso geral para aplicações. Instruções de controle de interrupções são privilegiadas; permitir seu uso irrestrito por uma aplicação deixaria que ela monopolizasse a CPU. Desativar interrupções em um núcleo não impede que outros núcleos acessem os mesmos dados. Períodos longos com interrupções desativadas também atrasam seu tratamento e podem causar perda de eventos em alguns dispositivos.

Acompanhe a trava com indicador que falha

Considere este pseudocódigo semelhante a C:

while (flag == 1) { }
flag = 1;
// critical section
flag = 0;

Comece com flag em 0. T1 o testa e sai do laço, depois pausa antes da atribuição. T2 testa o mesmo 0, define-o como 1 e entra. T1 retoma, define-o como 1 e também entra. Ambas passaram pelo teste antes que qualquer uma reivindicasse a posse.

Tornar a leitura e a escrita individualmente atômicas eliminaria uma corrida de dados no indicador, mas ainda deixaria duas operações separadas nesse protocolo. A reivindicação de posse precisa de uma operação atômica de leitura, modificação e escrita ou de um algoritmo de sincronização mais completo.

O laço vazio é uma espera ocupada: verificação repetida que consome tempo de CPU. Em uma CPU, uma thread que aguarda ocupadamente após causar a preempção da proprietária não pode fazer com que ela libere a trava mais cedo. Ela precisa, em algum momento, deixar a proprietária executar.

A solução de Peterson para duas threads

O algoritmo de Peterson coordena duas threads com um indicador de interesse por thread e um turno compartilhado. A thread i executa:

flag[i] = 1;
turn = 1 - i;
while (flag[1 - i] == 1 && turn == 1 - i) { }
// critical section
flag[i] = 0;

Ela anuncia interesse, dá prioridade à outra thread em caso de empate e espera apenas enquanto a outra está interessada e tem prioridade. Sob consistência sequencial, as operações de cada thread mantêm a ordem do programa e todas as threads observam uma ordem comum. Nesse modelo, o algoritmo de Peterson fornece exclusão mútua, progresso e espera limitada para duas threads.

A ordem da memória também importa

Processadores e compiladores modernos podem permitir que operações de memória se tornem visíveis em uma ordem diferente da do código-fonte. Um armazenamento pode permanecer em buffer enquanto um carregamento posterior lê a memória. As duas threads podem então ver o indicador de interesse da outra como 0 e ambas entrar. Inteiros compartilhados comuns em C também introduzem corridas de dados em C.

Uma implementação válida precisa de operações atômicas e ordenação adequadas, como operações atômicas de C11 sequencialmente consistentes para os indicadores e o turno. volatile não fornece essa sincronização. Travas práticas combinam uma operação indivisível de posse com garantias de ordenação de memória, para que os acessos protegidos permaneçam do lado correto da aquisição e da liberação.

Principais conclusões

  • Separar o teste da atribuição permite que duas threads reivindiquem a mesma trava livre.
  • Desativar interrupções em uma CPU não exclui outro núcleo.
  • O algoritmo de Peterson para duas threads exige comportamento sequencialmente consistente.
  • Uma operação atômica de posse e a ordenação correta da memória são necessárias para uma trava prática.

4.2 Use uma instrução atômica para escolher uma proprietária

Uma operação atômica de leitura, modificação e escrita combina a leitura de um local e sua atualização em uma única ação indivisível. Ela elimina a lacuna que permite que duas threads passem juntas por um teste comum de indicador.

Troque o indicador e examine seu valor antigo

Teste e atribuição, ou troca atômica, escreve um novo valor e retorna o antigo. Com um indicador inicialmente em 0, T1 troca seu valor por 1 e recebe 0, portanto entra. T2 troca seu valor por 1 e recebe 1, portanto espera. Até que a liberação escreva 0, nenhuma outra troca pode retornar 0.

Veja uma trava de espera ocupada em C11 que usa as operações atômicas padrão sequencialmente consistentes:

#include <stdatomic.h>

static atomic_flag held = ATOMIC_FLAG_INIT;

void lock(void) {
    while (atomic_flag_test_and_set(&held)) { }
}

void unlock(void) {
    atomic_flag_clear(&held);
}

A ordenação importa tanto quanto o indicador. A aquisição deve manter os acessos posteriores da seção crítica depois da aquisição bem-sucedida; a liberação deve manter os acessos anteriores da seção crítica antes da liberação. Limpar o indicador também deve ser atômico; misturar armazenamentos comuns com acessos atômicos não implementa essa transferência de posse.

Compare antes de substituir

Comparação e troca, ou CAS, altera um valor somente quando ele é igual a um valor esperado. Suponha que duas threads leiam um contador em 7 e cada uma queira somar 1. T2 altera 7 para 8 com sucesso. A tentativa de T1 de alterar 7 para 8 agora falha, portanto T1 tenta novamente a partir de 8 e o altera para 9. A nova tentativa preserva ambos os incrementos.

atomic_compare_exchange_strong de C11 retorna sucesso ou falha e atualiza o argumento esperado em caso de falha. Alguns pseudocódigos no estilo de hardware retornam o valor antigo armazenado. Verifique qual convenção um acompanhamento usa. No x86, xchg com memória implementa a troca, enquanto lock cmpxchg fornece comparação e troca atômica.

Outros componentes de hardware

Leitura vinculada e escrita condicional, ou LL/SC, lê enquanto estabelece uma reserva e depois tenta uma escrita. A escrita condicional falha se outra escrita invalidou a reserva; ela também pode falhar por outros motivos, portanto deve estar em um laço de novas tentativas. LDXR/STXR do ARM e lr/sc do RISC-V oferecem suporte a esse padrão.

Busca e soma soma atomicamente e retorna o valor antigo. C11 a disponibiliza como atomic_fetch_add; o x86 fornece lock xadd. Retornar o valor antigo a torna útil para distribuir números de bilhete únicos.

C garante o comportamento atômico das operações de sua biblioteca atômica. Ela não exige que todo tipo atômico seja livre de travas; algumas operações usam uma trava interna da biblioteca. atomic_flag tem a garantia de ser livre de travas, e atomic_is_lock_free verifica outros objetos.

Ser correta não significa ser justa ou rápida

Uma trava de teste e atribuição fornece exclusão mútua, mas não ordem de chegada: uma thread pode perder repetidamente a disputa após a liberação e sofrer inanição. Em uma CPU, quatro threads com fatias de tempo de 10 ms em escalonamento circular podem desperdiçar 30 ms em espera ocupada por três threads antes que a proprietária que sofreu preempção execute novamente. Em vários núcleos, com a proprietária em execução e seções curtas, uma breve espera ocupada pode evitar a sobrecarga de suspensão e despertar.

Principais conclusões

  • Teste e atribuição retorna o indicador antigo enquanto escreve atomicamente o novo valor.
  • CAS tenta novamente após encontrar um valor esperado desatualizado, preservando atualizações concorrentes.
  • Código LL/SC deve tratar a falha da escrita condicional com uma nova tentativa.
  • Busca e soma retorna o valor antigo ao fazer o incremento.
  • A aquisição e a liberação de uma trava também devem ordenar os acessos à memória protegida.
  • Uma trava de espera ocupada com teste e atribuição fornece exclusão, mas não garante ausência de inanição.

4.3 Distribua turnos com uma trava por bilhetes

Uma trava de teste e atribuição registra se está adquirida, mas não quem está esperando. Uma trava por bilhetes acrescenta uma ordem de chegada usando um distribuidor atômico de bilhetes e um contador de atendimento.

Retire um número e espere sua vez

Ambos os contadores começam em 0. Cada thread que chama a função incrementa ticket atomicamente e mantém seu valor antigo como mine. Ela só entra quando turn é igual a mine. A proprietária libera a trava incrementando turn uma vez.

#include <stdatomic.h>

static atomic_uint ticket = 0;
static atomic_uint turn = 0;

void lock(void) {
    unsigned mine = atomic_fetch_add(&ticket, 1);
    while (atomic_load(&turn) != mine) { }
}

void unlock(void) {
    atomic_fetch_add(&turn, 1);
}

Essas operações atômicas padrão de C11 fornecem tanto atomicidade quanto ordenação sequencialmente consistente. Cada thread mantém mine em sua própria variável local. O contador compartilhado de bilhetes distribui números únicos entre os bilhetes ainda pendentes.

Acompanhe três chegadas

A retira o bilhete 0 e entra porque turn é 0. B retira o bilhete 1 e espera. C retira o bilhete 2 e espera. O próximo bilhete disponível agora é 3, enquanto o turno atual ainda é 0.

Quando A libera a trava, turn passa a 1, portanto B entra. C ainda espera pelo 2. A liberação por B avança turn para 2 e C entra. Uma thread recém-chegada recebe o bilhete 3 e não pode passar à frente de C.

Essa distinção importa ao acompanhar a execução: ticket registra o próximo número a distribuir; turn registra o número em atendimento. Retirar um bilhete altera apenas o distribuidor. A liberação avança o atendimento.

O que a justiça garante

Uma thread espera atrás das threads com bilhetes anteriores. Se cada proprietária acabar por liberar a trava e as threads à frente puderem executar, chegadas posteriores não poderão ultrapassá-la. O número de seções críticas concorrentes à frente é limitado pelos bilhetes anteriores ainda pendentes. Isso fornece espera limitada e impede a inanição nessas condições de progresso.

A garantia diz respeito à ordem de entrada, não ao tempo decorrido. Uma proprietária que fica parada por muito tempo pode fazer todas as chamadas posteriores esperarem muito também. Um número de bilhete não pode obrigar o escalonador a executar sua detentora.

Custos que a ordem dos bilhetes mantém

Todas as threads em espera ainda aguardam ocupadamente. Se a thread cuja vez será a próxima estiver fora de execução, threads posteriores prontas esperam mesmo que nenhuma thread ocupe atualmente a seção crítica. Esse é um custo de escalonamento da ordem estrita.

As threads em espera também leem um único local compartilhado turn. Cada liberação altera a linha de cache que todas observam, produzindo tráfego entre núcleos. Travas de espera ocupada baseadas em filas reduzem esse tráfego permitindo que cada thread observe um local separado; o Linux passou das travas de espera ocupada por bilhetes para projetos baseados em filas.

Contadores de bilhetes reais usam aritmética sem sinal. Verificações de igualdade podem funcionar após o retorno cíclico quando o número de bilhetes pendentes permanece menor que a quantidade de valores possíveis do contador. Reutilizar um número enquanto um bilhete anterior igual ainda está pendente destruiria a unicidade e a exclusão.

Principais conclusões

  • A distribuição de bilhetes é atômica; uma thread só entra quando seu número é igual ao turno atual.
  • A liberação avança o turno, enquanto a aquisição obtém um número do contador de bilhetes.
  • Detentoras de bilhetes anteriores não podem ser ultrapassadas por chegadas posteriores.
  • A ordem justa ainda mantém a espera ocupada, as pausas de escalonamento e o tráfego de cache compartilhado.

4.4 Permita que threads em espera sejam suspensas sem perder uma liberação

Uma trava correta pode desperdiçar a maior parte de uma CPU com espera. A estratégia de espera adequada depende de a proprietária poder executar e de quanto tempo ela deterá a trava.

Ceder a CPU abre mão de uma vez

Uma thread em espera pode chamar yield() após uma tentativa malsucedida de adquirir a posse. Ceder a CPU a move do estado de execução para o estado de pronto, para que o escalonador possa selecionar outra thread. Ela ainda está pronta para executar, portanto pode logo ser selecionada e falhar novamente. Com 100 threads em uma CPU, até 99 threads em espera podem executar e ceder a CPU antes que uma proprietária que sofreu preempção tenha outra vez. Ceder a CPU reduz a espera ocupada, mas não fornece uma ordem justa de entrada.

O bloqueio retira a thread em espera do trabalho pronto para executar

Uma trava que suspende coloca quem não conseguiu adquiri-la em uma fila de espera. A thread não consome CPU enquanto está bloqueada. A liberação desperta uma thread em espera para que ela possa executar novamente. Uma pequena trava interna de espera ocupada pode proteger o indicador e a fila; essa trava é mantida apenas para operações breves de controle.

Separar “decidir suspender” de “suspender de fato” cria o risco de um despertar perdido:

  1. Uma thread em espera coloca-se na fila e libera a trava interna.
  2. Ela é pausada antes de chamar park().
  3. A proprietária libera a trava e chama unpark() para ela.
  4. Se esse despertar for descartado, a thread será suspensa depois, sem ninguém para despertá-la.

setpark() do Solaris anuncia a intenção de se suspender. Um unpark intermediário faz então o park posterior retornar imediatamente. O protocolo conecta a decisão ao bloqueio real.

O Linux usa uma comparação e bloqueio atômicos

Um futex combina uma palavra na memória de usuário com suporte do núcleo para espera. futex_wait(addr, expected) bloqueia somente se a palavra ainda for igual ao valor esperado; a verificação e a decisão de bloqueio são atômicas em relação às operações sobre essa palavra de futex. Se uma liberação já a tiver alterado, quem chamou não será suspenso com base na expectativa desatualizada.

futex_wake(addr, n) desperta até n threads em espera. Uma trava sem contenção pode ser adquirida e liberada com instruções atômicas no espaço de usuário, evitando uma chamada de sistema. Os mutexes pthread da glibc do Linux usam futexes para esperas com contenção. Um despertar torna uma thread pronta para executar; seu protocolo de travamento ainda deve estabelecer a posse antes de acessar os dados protegidos.

Aguarde ocupadamente por pouco tempo e depois suspenda

Uma trava de duas fases primeiro aguarda ocupadamente por pouco tempo e depois bloqueia se a espera continuar. A espera ocupada pode compensar quando a proprietária está executando em outro núcleo e a espera prevista é menor que o tempo de suspender e despertar. O bloqueio é preferível para esperas longas ou quando há mais threads prontas para executar do que núcleos. Em uma CPU, uma thread em espera ocupada não pode ajudar uma proprietária fora de execução a terminar.

A prioridade de escalonamento pode impedir o progresso

Na inversão de prioridade, uma thread de alta prioridade espera por um mutex detido por uma thread de baixa prioridade enquanto trabalho de prioridade média mantém a proprietária fora da CPU. Suspender a thread de alta prioridade evita a espera ocupada, mas não elimina esse atraso de escalonamento.

A herança de prioridade eleva temporariamente a prioridade da proprietária à da thread em espera de maior prioridade, permitindo que ela termine e libere a trava. A Mars Pathfinder da NASA sofreu reinicializações devido à inversão de prioridade em 1997; ativar a herança de prioridade do mutex corrigiu o problema. Uma thread de alta prioridade em espera ocupada em uma única CPU com escalonamento estrito por prioridade pode ser pior: ela pode impedir completamente a execução da proprietária de baixa prioridade.

Principais conclusões

  • Ceder a CPU mantém uma thread em espera pronta para executar; suspendê-la a bloqueia e não consome CPU.
  • Um protocolo de suspensão correto elimina a lacuna entre decidir suspender e bloquear de fato.
  • A espera em futex verifica seu valor esperado atomicamente com a decisão de bloqueio.
  • Uma trava de duas fases aguarda ocupadamente por pouco tempo antes de suspender a thread.
  • A herança de prioridade ajuda uma proprietária de baixa prioridade a liberar uma trava necessária para trabalho de prioridade mais alta.
5

Construa estruturas compartilhadas que permaneçam corretas

Uma estrutura é segura para uso por múltiplas threads quando chamadas concorrentes obedecem à sua especificação em todas as intercalações possíveis. Comece com um mutex e uma regra explícita para cada campo compartilhado. Depois, examine onde as operações realmente precisam excluir umas às outras: atualizações de contadores, percursos de listas, extremidades opostas de filas e baldes separados de tabelas de dispersão têm necessidades diferentes. Cada refinamento deve preservar a publicação segura e o tempo de vida da memória, justificando sua complexidade adicional por meio de medições.

5.1 Troque precisão do contador por menos atualizações compartilhadas

Um contador compartilhado é fácil de proteger com um mutex. Suas operações increment, decrement e get adquirem a mesma trava, executam sua ação e a liberam. Ele é exato, mas todas as atualizações passam por um único ponto compartilhado.

Por que o contador exato se torna um gargalo

Em vários núcleos, um mutex serializa as operações do contador. Os núcleos também transferem a posse das linhas de cache da trava e do contador. Adicionar threads trabalhadoras pode aumentar a espera mais rapidamente do que o trabalho útil. Substituir o mutex por atomic_fetch_add remove a sobrecarga explícita da trava em processadores comuns, mas cada atualização ainda escreve no mesmo local compartilhado.

Um contador aproximado, também chamado de sloppy counter, distribui atualizações por contadores locais e as transfere em lotes para um contador global. Seu valor global informado omite deliberadamente incrementos locais recentes.

Construa a atualização local para global

Mantenha um contador e um mutex por núcleo, um contador global com seu próprio mutex e um limiar S. Para cada incremento:

  1. Escolha a posição local para essa atualização e adquira seu mutex.
  2. Some 1 à contagem local.
  3. Se ela atingir S, adquira o mutex global enquanto ainda detém o local.
  4. Some essa contagem local à contagem global, reinicie a contagem local em 0 e libere o mutex global.
  5. Libere o mutex local.

Mantenha a posição escolhida estável durante toda a atualização. Threads que usam posições diferentes geralmente acessam travas diferentes e adquirem o mutex global apenas em uma transferência. A leitura da contagem global também segue sua regra de travamento.

Acompanhe uma transferência em lote

Com quatro núcleos e S igual a 5, suponha que seus totais de atualizações sejam 7, 3, 4 e 0. O núcleo 1 transfere 5 em seu quinto incremento e deixa 2 incrementos locais depois. As contagens locais finais são 2, 3, 4 e 0. A contagem global é 5, e o total real é 14: nove incrementos permanecem locais.

Após atualizações concluídas com incrementos unitários, cada contagem local fica entre 0 e S − 1. Com P núcleos, o total global pode ficar defasado em no máximo:

P(S−1).

Para quatro núcleos e S igual a 5, o limite é 16. Esse limite diz respeito a atualizações concluídas e suas contagens locais restantes; não o aplique no meio de uma transferência.

Escolha o limiar para a tarefa

Um limiar menor melhora a atualidade do valor, mas exige mais aquisições da trava global. Com S igual a 1, cada incremento é transferido globalmente, acrescentando a sobrecarga da trava local sem evitar o gargalo global. Um limiar maior melhora a escalabilidade das atualizações, mas aumenta a defasagem possível. Em um experimento publicado, S igual a 1024 permitiu que quatro threads fizessem quatro milhões de atualizações em quase o mesmo tempo que uma thread precisou para um milhão.

Um retrato exato do estado deve manter todos os mutexes locais em uma ordem fixa e depois o mutex global antes de somar as contagens. Essa ordem é compatível com as atualizações que adquirem uma trava local antes da trava global. O retrato interrompe transferências concorrentes durante a leitura; ler cada campo separadamente poderia contar uma transferência duas vezes ou não contá-la.

Contadores aproximados servem para estatísticas que toleram visibilidade atrasada. Uma contagem de referências que determina quando a memória pode ser liberada precisa de um método exato: uma contagem subestimada poderia liberar um objeto ainda em uso.

Principais conclusões

  • Um único contador atômico ainda pode ser um gargalo de cache compartilhado.
  • Contadores aproximados agrupam incrementos locais em um contador global protegido.
  • Após atualizações unitárias concluídas, a defasagem máxima é P(S − 1).
  • Limiares de transferência maiores reduzem a contenção global enquanto aumentam a defasagem possível.
  • Um retrato exato do estado mantém todas as travas relevantes em uma ordem compatível com as atualizações.

5.2 Publique e percorra nós de listas encadeadas com segurança

Uma lista encadeada compartilhada precisa preservar tanto suas ligações quanto o tempo de vida de seus nós. Um mutex em torno de cada operação é um primeiro projeto útil porque todo percurso e toda modificação compartilhados seguem uma única regra.

Como duas inserções perdem um nó

Suponha que a lista comece em A. T1 prepara o nó X e T2 prepara o nó Y. Sem uma trava compartilhada, ambas podem ler head == A, atribuir A ao next de seu novo nó e depois armazenar seu próprio nó em head. A atribuição posterior à cabeça oculta o primeiro nó inserido. Esse nó fica inacessível, perdendo-se uma inserção e vazando sua memória.

A sequência protegida deve incluir a leitura da cabeça atual, a definição da nova ligação e a publicação da nova cabeça. Uma trava apenas em torno da atribuição final permitiria que ambas as threads construíssem ligações a partir da mesma cabeça desatualizada.

Prepare dados privados antes de adquirir a trava

Um novo nó permanece privado até ser ligado à lista compartilhada. A alocação e a inicialização da chave podem ocorrer antes da aquisição. Usando a função auxiliar de verificação de pthread apresentada antes, a inserção pode ser escrita assim:

int insert(list_t *list, int key) {
    node_t *node = malloc(sizeof(*node));
    if (node == NULL) return -1;
    node->key = key;

    check(pthread_mutex_lock(&list->lock));
    node->next = list->head;
    list->head = node;
    check(pthread_mutex_unlock(&list->lock));
    return 0;
}

Uma falha de alocação retorna antes que qualquer mutex seja adquirido. A seção compartilhada permanece pequena enquanto a verificação de head e a atualização da ligação permanecem juntas. Uma alocação segura para uso por múltiplas threads não torna a lista em si segura para esse uso; suas ligações ainda precisam de proteção própria.

Leitores participam da mesma regra

Uma busca adquire o mutex da lista antes de ler head e o mantém durante o percurso. Isso a coordena com a inserção e a remoção. Defina uma variável de resultado antes do laço, atualize-a ao encontrar uma correspondência e saia do laço para um único ponto de liberação antes de retornar. Caso contrário, um retorno antecipado após uma busca bem-sucedida dentro do laço protegido precisaria de sua própria liberação.

Nesse projeto, a remoção mantém o mesmo mutex enquanto desliga um nó. Outros percursos não podem estar nesse nó porque também precisam do mutex. Libere-o somente quando nenhuma outra operação ainda puder acessá-lo. Se uma API permitir que quem a chamou mantenha um ponteiro para o nó após liberar a trava, esse uso adicional precisa de seu próprio acordo de tempo de vida; a trava da lista, sozinha, não o cobre mais.

Decida se travas por nó compensam

O travamento por acoplamento adquire a trava do próximo nó antes de liberar a do nó atual. Essa sobreposição preserva um caminho protegido à medida que o percurso avança. Assim, várias operações podem ocupar regiões diferentes da lista.

A concorrência adicional tem um preço: cada nó visitado acrescenta operações de aquisição e liberação de trava, mesmo quando nenhuma outra thread está próxima. Uma carga de trabalho com muitos percursos pode tornar-se mais lenta que a versão com um mutex. Travar grupos de nós é uma solução intermediária. Compare o projeto mais complexo com o simples sob a carga de trabalho real, incluindo buscas malsucedidas e caminhos de remoção.

Principais conclusões

  • Proteja conjuntamente a leitura da cabeça antiga e a publicação da nova cabeça.
  • Aloque e inicialize os dados privados do nó antes de adquirir a trava da lista.
  • A busca e a remoção seguem a mesma regra de proteção que a inserção.
  • No travamento por acoplamento, adquira a trava do próximo nó antes de liberar a do nó atual.

5.3 Separe as extremidades da fila e os baldes da tabela de dispersão

Um mutex por estrutura é um ponto de partida sensato, mas pode serializar operações que acessam regiões diferentes. Dois refinamentos úteis são uma trava por extremidade da fila e uma trava por balde de tabela de dispersão.

Mantenha um sentinela na cabeça da fila

Uma fila com duas travas começa com um nó sentinela D e head == tail == D. D não contém um item atual da fila. O primeiro item real, se existir, está em head->next. Um mutex da cabeça serializa remoções; um mutex da cauda serializa inserções. Uma inserção e uma remoção podem, portanto, avançar juntas.

A inserção prepara de forma privada o valor de um novo nó e sua ligação nula para o próximo nó. Sob o mutex da cauda, ela publica esse nó pelo campo next da cauda antiga e move tail para o novo nó. A remoção usa o mutex da cabeça para ler a ligação para o próximo nó do sentinela antigo. Se for nula, libera a trava e informa que a fila está vazia. Caso contrário, copia o primeiro valor real, move head para esse nó, libera a trava e libera a memória do sentinela antigo.

O nó que continha o valor retornado torna-se o próximo sentinela. Liberar esse nó imediatamente deixaria head pendente.

Acompanhe a mudança do sentinela

Comece com D. Inserir 5 cria N5 e liga D -> N5, com tail == N5. Inserir 8 adiciona N8, produzindo D -> N5 -> N8. Uma remoção retorna 5, move head para N5 e libera D. N5 agora é o sentinela; apenas 8 permanece como valor na fila. Após outra remoção, N8 torna-se o sentinela e a fila fica vazia.

As duas extremidades ainda compartilham uma ligação

Quando a fila está vazia, uma inserção pode escrever no next do sentinela atual enquanto uma remoção o lê sob outro mutex. Os dois mutexes, sozinhos, não sincronizam esses acessos. Em C11, declare next como um ponteiro atômico e publique-o e carregue-o com ordenação adequada; armazenamentos e carregamentos atômicos padrão sequencialmente consistentes são suficientes. A publicação deve tornar visível o valor preparado do nó antes que a remoção o leia.

Esperar por um item exige um mecanismo de sinalização além do travamento.

Distribua pela chave e depois adquira a trava do balde

Uma tabela de dispersão de tamanho fixo pode usar uma lista segura para uso por múltiplas threads em cada balde, cada uma com seu próprio mutex. Com 101 baldes e bucket = key % 101, as chaves 7 e 50 usam travas diferentes. As chaves 7 e 108 são ambas mapeadas para o balde 7 e devem ser serializadas ali. Colisões afetam a contenção e também o tamanho da busca.

Todos os acessos à lista de um balde seguem a regra de travamento desse balde, inclusive as leituras. O redimensionamento altera o mapeamento dos baldes e move entradas, portanto esse projeto simples precisa de acesso exclusivo a toda a tabela durante o redimensionamento. Um mutex de balde sozinho não pode proteger uma alteração que afeta todos os baldes.

Refine somente após encontrar o gargalo

Comece com um mapa do estado compartilhado: quais campos são compartilhados, qual mutex ou operação atômica protege cada um e quando os nós se tornam acessíveis ou podem ser liberados. Verifique todos os caminhos de retorno e de falha de alocação. Analise as intercalações, teste sob carga com um detector de corridas e meça a contenção antes de acrescentar travas mais finas.

Principais conclusões

  • Um sentinela permite que operações na cabeça e na cauda da fila usem travas separadas.
  • Uma remoção bem-sucedida libera o sentinela antigo e transforma o nó de valor no novo sentinela.
  • A ligação compartilhada para o próximo nó precisa de publicação e leitura atômicas em uma fila com duas travas em C11.
  • Travas separadas por balde permitem que operações em baldes diferentes se sobreponham.
  • O redimensionamento de uma tabela de dispersão exige coordenação em toda a tabela.
  • Refine um projeto correto com base na contenção medida e na verificação dos tempos de vida da memória.

Exercícios resolvidos de Threads e condições de corrida

5 Lições Interativas

1

Acompanhe uma thread trabalhadora da criação à conclusão

14 passos
Separe a memória compartilhada do estado das threads, passe argumentos estáveis e colete resultados após aguardar o término.
  1. 1
    IntroduçãoComece com dois tipos de estado

    Um programa pode ter várias threads percorrendo seu código. Cada execução precisa de sua própria instrução atual e de seus cálculos inacabados, enquanto todas as threads podem acessar os mesmos objetos do programa.

    Acompanhe primeiro esses dois tipos de estado. Depois, construa uma soma com duas trabalhadoras e decida quando a thread principal pode ler os resultados. A segurança desse projeto depende dos tempos de vida dos objetos e da conclusão das threads, não de adivinhar qual trabalhadora executa primeiro.

  2. 2
    ExplicaçãoDê a cada execução seu próprio lugar

    Uma thread tem seu próprio contador de programa, registradores, pilha e identificador de thread. Sua pilha armazena os quadros de chamadas de função e as variáveis locais comuns. O sistema operacional salva o estado de execução em um TCB, ou bloco de controle de thread.

    Quando o escalonador troca de thread, ele restaura o estado da nova thread. Um cálculo em um registrador da primeira thread permanece inacabado até que ela retome a execução.

  3. 3
    ExemploEncontre os objetos compartilhados

    Suponha que total seja uma variável global e que cada trabalhadora declare um i local. Todas as trabalhadoras acessam um único total compartilhado, mas cada chamada de função tem seu próprio i.

    As threads também compartilham a área de alocação dinâmica e os descritores de arquivos abertos. Suas pilhas ficam em endereços diferentes dentro do mesmo espaço de endereçamento do processo. Passar o endereço de uma variável local de uma trabalhadora permite que outra thread a acesse; pilhas separadas não garantem isolamento.

  4. 4
    ConexãoSobreponha trabalho, mesmo em um núcleo

    Em um núcleo de CPU, o escalonador intercala threads: elas progridem de forma concorrente, mas não executam simultaneamente. Em vários núcleos, elas podem executar em paralelo.

    Threads podem acelerar cálculos independentes ou sobrepor a espera por entrada e saída a trabalho útil. Uma troca dentro do mesmo processo mantém o espaço de endereçamento e as tabelas de páginas, portanto geralmente custa menos que uma troca de processos. A memória compartilhada também permite que uma escrita inválida danifique dados de outras threads.

  5. 5
    ExplicaçãoVeja o que o núcleo pode escalonar

    Em um modelo um para um, cada thread de aplicação é uma thread escalonada pelo núcleo. O escalonador pode colocá-las em núcleos diferentes, e uma chamada de sistema bloqueante interrompe a thread que a chamou. Linux e Windows usam esse modelo para threads comuns.

    Uma biblioteca muitos para um executa threads de usuário em uma thread do núcleo. Essas threads de usuário não podem executar em vários núcleos, e uma chamada de sistema bloqueante pode interromper seu grupo.

  6. 6
    DesafioUm núcleo adicional cria o erro?

    Uma trabalhadora pausa após carregar dados compartilhados, e outra trabalhadora os altera antes que a primeira retome. Isso poderia acontecer em um núcleo?

    ---reveal---

    Sim. O escalonador pode trocar de thread entre o carregamento e o uso posterior do valor. A execução paralela pode expor mais sobreposições, mas a concorrência por si só permite esse escalonamento. A afirmação de que uma execução em um núcleo é segura deve considerar trocas entre instruções de máquina.

  7. 7
    ExplicaçãoInicie uma função com um ponteiro

    Inclua <pthread.h> e compile com -pthread. Uma função trabalhadora tem a assinatura void *worker(void *arg). pthread_create(&t, NULL, worker, arg) a inicia, solicita atributos padrão e armazena seu identificador de thread em t.

    A chamada retorna 0 em caso de sucesso ou diretamente um número de erro. Verifique esse resultado antes de considerar a thread criada. A nova thread pode executar imediatamente; a inicialização já deve estar concluída.

  8. 8
    ExemploDivida a soma em tarefas separadas

    Prepare duas structs contendo lo, hi e result: um intervalo é 1–50 e o outro é 51–100. Passe um endereço de struct diferente para cada trabalhadora.

    Cada trabalhadora soma seu intervalo em um acumulador local privado e depois escreve em seu próprio campo de resultado. A primeira obtém 1.275 e a segunda, 3.775. Nenhuma trabalhadora escreve no resultado da outra, portanto o projeto evita atualizações concorrentes em um único acumulador.

  9. 9
    ExemploAguarde antes de ler as respostas

    Crie ambas as trabalhadoras antes de aguardar o término de qualquer uma, para que seus cálculos possam se sobrepor. Depois, a thread principal chama pthread_join para cada criação bem-sucedida. Uma chamada de join aguarda a conclusão da thread indicada e torna as escritas dessa trabalhadora visíveis para quem aguardou.

    A thread principal pode agora ler os dois campos de resultado separados e somá-los, obtendo 5.050. Aguardar apenas uma trabalhadora não estabeleceria que a outra terminou seu próprio resultado.

  10. 10
    DicaMantenha argumentos e resultados válidos

    Um ponteiro de argumento não prolonga o tempo de vida de um objeto. Mantenha as structs da thread criadora válidas até que as trabalhadoras terminem. Um ponteiro retornado pode referir-se à memória da área de alocação dinâmica ou a armazenamento adequado da thread criadora; ele não pode apontar com segurança para uma variável local da trabalhadora que já deixou de existir.

    Em um laço de criação, passe elementos distintos e estáveis de um array em vez de passar &i repetidamente. Esta última opção faz todas as trabalhadoras apontarem para a mesma variável de laço que muda.

  11. 11
    DesafioQual resultado está pronto?

    Duas trabalhadoras escrevem em campos de resultado separados. A thread principal aguarda com sucesso o término da trabalhadora A e depois lê ambos os campos enquanto a trabalhadora B ainda pode estar executando. Toda essa leitura é segura?

    ---reveal---

    O resultado de A está pronto e visível após a espera. O campo de B ainda pode estar mudando, portanto a thread principal também deve aguardar o término de B antes de lê-lo nesse projeto. O armazenamento separado impede interferência entre trabalhadoras; a espera pelo término estabelece a passagem de cada trabalhadora para a thread principal.

  12. 12
    DicaDiferencie encerrar uma thread de encerrar seu processo

    Retornar da função principal ou chamar exit() encerra todo o processo, incluindo trabalhadoras que não terminaram. A thread principal normalmente aguarda as trabalhadoras necessárias antes de retornar; pthread_exit() encerra apenas a thread que o chamou.

    Uma trabalhadora destacada recupera automaticamente seus recursos de thread quando termina e não permite que se aguarde seu término com join. Destacá-la não mantém o processo ativo nem dá à trabalhadora uma cópia privada dos objetos de argumento da thread criadora.

  13. 13
    DesafioEncerre o programa no momento certo

    A thread principal inicia duas trabalhadoras que leem seu array local, destaca uma e aguarda o término da outra. A trabalhadora destacada ainda está executando. A pilha separada dessa trabalhadora permite que ela termine de usar o array após o retorno da função principal?

    ---reveal---

    Não. Retornar da função principal encerra todo o processo, incluindo essa trabalhadora. Sua pilha separada também não copia o array: o ponteiro acessa o objeto da função principal no espaço de endereçamento compartilhado. Aguardar uma trabalhadora estabelece apenas a conclusão dessa trabalhadora.

  14. 14
    RecapitulaçãoVerifique a passagem de dados da trabalhadora

    Para um projeto com trabalhadoras, localize seu estado de execução, os objetos que elas compartilham e o tempo de vida de cada argumento e resultado. Depois, identifique o evento que torna um resultado pronto para seu leitor.

    Objetos de resultado distintos e chamadas de join podem evitar completamente o uso de travas. Atualizações compartilhadas enquanto trabalhadoras executam em períodos sobrepostos precisam de coordenação adicional. Ordem de criação, pilhas separadas e destaque respondem cada uma a uma questão diferente; nenhuma estabelece que outra thread concluiu suas escritas.

2

Acompanhe a operação que precisa de proteção

11 passos
Acompanhe uma atualização perdida, diferencie dois tipos de corrida e proteja uma decisão junto de sua ação.

Salve esta aula para seguir cada Lição Interativa passo a passo.

  1. 1
    IntroduçãoDetalhe a operação compartilhada
  2. 2
    ExplicaçãoSepare a memória dos registradores privados
  3. 3
    ExemploPause antes do armazenamento
  4. 4
    ExemploRetome com o resultado desatualizado
  5. 5
    DesafioMude o ponto de escalonamento
  6. 6
    DicaEscolha uma correção de sincronização
  7. 7
    ExplicaçãoDiferencie os dois tipos de corrida
  8. 8
    ExemploAprove um saque duas vezes
  9. 9
    Ideia-chaveDefina a seção completa
  10. 10
    DesafioAcompanhe a aprovação e o decremento juntos
  11. 11
    RecapitulaçãoEncontre o limite antes de aplicar a trava
3

Revise uma regra de proteção com mutex

14 passos
Use uma trava compartilhada, escolha seu escopo e acompanhe todos os caminhos de sucesso e erro até a liberação.

Salve esta aula para seguir cada Lição Interativa passo a passo.

  1. 1
    IntroduçãoTorne o acordo visível
  2. 2
    ExplicaçãoInicialize antes de publicar o objeto
  3. 3
    ExemploColoque toda a atualização dentro
  4. 4
    DesafioInterprete um resultado de trylock
  5. 5
    DicaEscolha o tipo de mutex conscientemente
  6. 6
    ExplicaçãoPermita a sobreposição de regiões independentes
  7. 7
    ExemploCalcule o limite da serialização
  8. 8
    DesafioEncurte o trabalho adequado
  9. 9
    Ideia-chaveMeça três resultados diferentes
  10. 10
    ExemploAcompanhe o retorno antecipado
  11. 11
    ExplicaçãoMantenha uma trava e um tempo de vida
  12. 12
    DesafioIdentifique um ciclo de callback
  13. 13
    DesafioEscolha uma correção completa para a revisão
  14. 14
    RecapitulaçãoLeia o código considerando a posse
4

Construa posse, turnos justos e espera eficiente

21 passos
Passe de testes de indicadores que falham para travas atômicas e depois compare a ordem por bilhetes, protocolos de suspensão e herança de prioridade.

Salve esta aula para seguir cada Lição Interativa passo a passo.

  1. 1
    IntroduçãoConstrua a trava em camadas
  2. 2
    ExemploDuas threads passam pelo mesmo teste
  3. 3
    ExplicaçãoA ordenação faz parte do algoritmo
  4. 4
    DesafioO controle de interrupções protegeria dois núcleos?
  5. 5
    ExplicaçãoTroque o indicador atomicamente
  6. 6
    ExemploCrie um indicador portável para espera ocupada
  7. 7
    ExemploUma comparação que falha fornece informação útil
  8. 8
    ExplicaçãoConheça os resultados de outras operações atômicas
  9. 9
    DesafioTente novamente a atualização, não a resposta antiga
  10. 10
    Ideia-chaveExclusão não garante uma vez
  11. 11
    AnalogiaUse uma fila de atendimento com senhas
  12. 12
    ExemploAvance o atendimento uma proprietária por vez
  13. 13
    DesafioSepare o distribuidor do atendimento
  14. 14
    ExplicaçãoCeder a CPU e suspender mudam estados diferentes
  15. 15
    ExemploEncontre a janela de despertar perdido
  16. 16
    ExplicaçãoCompare e bloqueie em uma única decisão
  17. 17
    DesafioUm despertar chega antes da espera
  18. 18
    ConexãoAguarde ocupadamente por pouco tempo quando a proprietária puder terminar
  19. 19
    ExemploObserve a inversão de prioridade pela proprietária
  20. 20
    DesafioCombine escalonamento com a trava
  21. 21
    RecapitulaçãoPergunte pelo que a thread está esperando
5

Acompanhe contadores, nós e baldes compartilhados

16 passos
Examine contadores em lotes, publicação em listas, o sentinela móvel de uma fila e travamento por balde.

Salve esta aula para seguir cada Lição Interativa passo a passo.

  1. 1
    IntroduçãoEncontre o limite do compartilhamento
  2. 2
    ExplicaçãoDistribua escritas frequentes do contador
  3. 3
    ExemploTransfira um lote completo
  4. 4
    Ideia-chaveEstabeleça um limite para os incrementos ausentes
  5. 5
    DesafioEscolha a atualidade do valor para uma nova carga de trabalho
  6. 6
    DicaInterrompa transferências para obter um retrato exato do estado
  7. 7
    ExemploPublique o novo nó da lista por último
  8. 8
    ExplicaçãoLeitores e responsáveis pela remoção compartilham a trava
  9. 9
    DesafioPercorra usando travas por nó
  10. 10
    ExemploInicialize a fila sem um item real
  11. 11
    ExemploMova o sentinela durante a remoção
  12. 12
    Ideia-chaveO caso de fila vazia conecta as duas extremidades
  13. 13
    DesafioAcompanhe o próximo sentinela
  14. 14
    ConexãoSepare os baldes e preserve o mapeamento
  15. 15
    DesafioCombine uma trava de balde com inserção segura
  16. 16
    RecapitulaçãoMeça o projeto completo

Questões de Threads e condições de corrida

40 Questões de Prática em 4 conjuntos

Conjunto 1: Limites do mutex e posse atômica10 Questões de Prática

Questão 1 de 10

Um mutex protege todos os baldes de uma tabela. Que tipo de granularidade de trava é essa?
  1. Travamento de granularidade fina
  2. Travamento por acoplamento
  3. Travamento de granularidade grossa
  4. Travamento por núcleo
Mostrar respostaOcultar resposta
Resposta: C
Uma única trava para toda a tabela é de granularidade grossa: mesmo operações em baldes diferentes compartilham um ponto de exclusão. O travamento de granularidade fina atribuiria travas separadas a regiões menores, como baldes individuais. O número de baldes, por si só, não determina a granularidade da trava.

Questão 2 de 10

Uma busca em lista adquire seu mutex, retorna imediatamente ao encontrar uma correspondência e libera a trava somente após o laço quando não encontra nenhuma. Por que operações posteriores podem ficar paradas?
  1. Uma busca bem-sucedida retorna mantendo o mutex adquirido.
  2. Uma busca malsucedida libera o mutex antes de ler a cabeça.
  3. Uma busca bem-sucedida destrói automaticamente o mutex do nó.
  4. Um resultado retornado transfere a posse do mutex a quem chamou.
Mostrar respostaOcultar resposta
Resposta: A
O caminho da correspondência passa ao largo da única liberação, deixando o mutex compartilhado adquirido após o retorno da busca. Retornar um resultado não transfere nem libera implicitamente a posse. Use uma variável de resultado e um único caminho de liberação ou libere explicitamente antes de cada retorno após a aquisição.

Questão 3 de 10

Em um protocolo com indicador, A observa que o indicador está desativado e pausa antes de ativá-lo. B o observa desativado, ativa-o e entra. O que pode acontecer quando A retoma?
  1. A deve testar novamente o indicador antes de sua atribuição pendente.
  2. A ativa o indicador e também entra, violando a exclusão.
  3. A atribuição de B altera o valor que A já observou.
  4. A troca de contexto de A cancela seu teste de disponibilidade concluído.
Mostrar respostaOcultar resposta
Resposta: B
A já passou por seu teste de disponibilidade, portanto o protocolo permite que ela continue com a atribuição e entre junto de B. Uma troca pelo escalonador não executa novamente um teste anterior nem altera o valor já lido. O teste e a reivindicação de posse devem ser uma única ação atômica ou parte de um protocolo maior correto.

Questão 4 de 10

Uma troca atômica armazena 1 em um indicador que atualmente contém 0. Qual valor o teste e atribuição retorna?
  1. 1
  2. O endereço do indicador
  3. O bilhete da thread
  4. 0
Mostrar respostaOcultar resposta
Resposta: D
Teste e atribuição retorna o valor presente antes da troca, portanto retorna 0 enquanto deixa o indicador definido como 1. Retornar o 1 recém-armazenado não distinguiria um indicador livre de um já ocupado nesse protocolo de trava. O valor antigo retornado informa a quem chamou se adquiriu a posse.

Questão 5 de 10

Um serviço com 16 núcleos usa um mutex por conta. Uma transferência adquire primeiro o mutex de origem e depois o de destino. Transferências concorrentes podem ocorrer em sentidos opostos. Qual regra permite que contas independentes permaneçam concorrentes e corrige o problema da ordem das travas?
  1. Adquirir primeiro o mutex de origem e liberá-lo antes de adquirir o de destino.
  2. Adquirir primeiro o mutex de destino em todas as transferências nos dois sentidos.
  3. Adquirir um mutex global em torno de cada operação em todas as contas.
  4. Adquirir ambos os mutexes em ordem crescente dos identificadores das contas.
Mostrar respostaOcultar resposta
Resposta: D
A ordem crescente dos identificadores das contas dá às transferências em sentidos opostos a mesma ordem de aquisição, enquanto pares de contas não relacionados ainda podem avançar. Passar a adquirir sempre o destino primeiro inverte ambas as transferências e mantém a possibilidade de ordens opostas de travas. Liberar a origem antes de adquirir o destino também elimina o limite atômico da transferência.

Questão 6 de 10

Um laço de CAS lê um contador em 12 e prepara 13. Outra thread altera o contador para 13 antes da CAS. Como a primeira thread preserva seu incremento?
  1. Repetir a mesma tentativa com valor esperado 12 até que tenha sucesso.
  2. Tentar novamente a partir do novo valor do contador e tentar armazenar 14.
  3. Armazenar o 13 preparado sem verificar o contador novamente.
  4. Tratar a tentativa malsucedida como um incremento já concluído.
Mostrar respostaOcultar resposta
Resposta: B
O valor esperado 12 está desatualizado, portanto a CAS deve falhar sem sobrescrever 13. Tentar novamente com dados atualizados permite que a primeira thread acrescente seu próprio incremento e chegue a 14. Um armazenamento simples do 13 preparado apagaria a distinção entre a atualização da outra thread e a atualização ainda não realizada desta thread.

Questão 7 de 10

Uma trava por bilhetes tem turn == 6 e o próximo ticket == 9 disponível. Uma nova thread retira um bilhete. Por qual número ela espera?
  1. 9
  2. 6
  3. 8
  4. 10
Mostrar respostaOcultar resposta
Resposta: A
Busca e soma retorna o valor antigo do distribuidor, portanto a thread recebe 9 e o próximo bilhete disponível passa a ser 10. Ela espera pelo turno 9, atrás dos bilhetes anteriores pendentes. O turno atual 6 descreve o atendimento, não o número recebido por uma thread recém-chegada.

Questão 8 de 10

Trabalhadoras copiam uma struct de registro que contém um mutex e um ponteiro para registros compartilhados. Elas adquirem suas cópias, retornam antecipadamente em caso de falha de validação e leem o estado usando mutexes locais separados. Qual correção estabelece um único acordo de proteção e libera a posse em caso de falha?
  1. Usar o registro original por ponteiro, proteger escritas com seu mutex e permitir que leitores mantenham travas locais privadas.
  2. Inicializar cada mutex copiado, proteger leituras e escritas com essa cópia e encaminhar falhas para sua liberação.
  3. Usar o registro original por ponteiro, proteger leituras e escritas com seu mutex e encaminhar falhas para a liberação.
  4. Usar o registro original por ponteiro, proteger leituras e escritas com seu mutex e retornar diretamente em caso de falha.
Mostrar respostaOcultar resposta
Resposta: C
Os registros compartilhados precisam de uma única trava original usada por leitores e escritores, e todo caminho após a aquisição deve liberá-la. Um mutex privado inicializado ainda não coordena com outras trabalhadoras; um mutex pthread copiado é inválido. Corrigir apenas a identidade ainda deixa, nos outros projetos, a lacuna dos leitores ou a falta de liberação no retorno antecipado.

Questão 9 de 10

Duas threads executam o protocolo de Peterson. As escritas em seus próprios indicadores permanecem em buffer, portanto ambas as leituras posteriores dos indicadores veem a outra como desinteressada. Apenas turn é atômico; os indicadores são inteiros comuns de C. Qual conclusão e correção decorrem disso?
  1. Apenas uma pode entrar; um turno atômico sozinho preserva todo o protocolo.
  2. Ambas podem entrar; use acessos atômicos ordenados adequadamente para indicadores e turno.
  3. Ambas podem entrar; marcar apenas os indicadores comuns como volatile corrige a ordem.
  4. Apenas uma pode entrar; linhas de cache separadas eliminam corridas de dados nos indicadores comuns.
Mostrar respostaOcultar resposta
Resposta: B
Cada observação de indicador desativado permite que sua thread saia da espera, portanto a exclusão pode falhar, e indicadores compartilhados comuns também introduzem corridas de dados em C. Operações atômicas com ordenação adequada para todos os campos do protocolo, como operações sequencialmente consistentes, tratam ambos os problemas. Um turno atômico sozinho não pode corrigir acessos aos indicadores sem sincronização e reordenados.

Questão 10 de 10

Toda atualização de um par de campos compartilhados usa o mutex m, mas uma função de diagnóstico lê ambos os campos sem m. O que o diagnóstico deve fazer para obter um par protegido e consistente?
  1. Declarar suas cópias locais como volatile.
  2. Manter um mutex privado para as leituras.
  3. Manter m adquirido enquanto lê ambos os campos.
  4. Ler o primeiro campo duas vezes sem trava.
Mostrar respostaOcultar resposta
Resposta: C
O diagnóstico deve participar do mesmo acordo de proteção que a função de atualização e manter m adquirido durante ambas as leituras. Um mutex privado não exclui a função de atualização, portanto não pode tornar o par consistente. Ler sem a trava compartilhada também pode criar uma corrida de dados em campos comuns, mesmo que o próprio diagnóstico não escreva em nenhum dos campos.
Conjunto 2: Tempos de vida de threads e atualizações compartilhadas10 Questões de Prática

Salve esta aula para ver todas as respostas e praticar cada conjunto.

Questão 1 de 10

Duas threads pertencem ao mesmo processo. Qual estado de execução cada uma mantém separadamente?
  1. Seu código executável e sua área de alocação dinâmica
  2. Seu contador de programa e seus registradores
  3. Seus arquivos abertos e seu identificador de processo
  4. Suas variáveis globais e suas tabelas de páginas
Mostrar respostaOcultar resposta

Salve esta aula para ver esta resposta.

Questão 2 de 10

Um servidor com um único núcleo alterna entre duas threads de requisição enquanto uma espera por uma resposta da rede. Como a execução delas deve ser descrita?
  1. Paralela, sem progresso concorrente
  2. Concorrente, com execução paralela
  3. Sequencial, sem progresso sobreposto
  4. Concorrente, sem execução paralela
Mostrar respostaOcultar resposta

Salve esta aula para ver esta resposta.

Questão 3 de 10

Uma trabalhadora retorna um ponteiro para um resultado alocado. Qual chamada aguarda a thread t e coloca esse ponteiro em void *result?
  1. pthread_join(t, &result)
  2. pthread_join(t, result)
  3. pthread_create(&t, NULL, result, NULL)
  4. pthread_detach(t)
Mostrar respostaOcultar resposta

Salve esta aula para ver esta resposta.

Questão 4 de 10

Em um modelo de carregamento, soma e armazenamento, um contador começa em 36. A e B carregam o valor, cada uma soma 1 de forma privada e depois A e B armazenam nessa ordem. Qual é o valor final do contador?
  1. 36
  2. 38
  3. 37
  4. 39
Mostrar respostaOcultar resposta

Salve esta aula para ver esta resposta.

Questão 5 de 10

Um saldo atômico começa em 90. Duas threads o leem atomicamente, cada uma aprova uma solicitação de 60 e depois cada uma faz uma subtração atômica de 60. Qual falha ainda é possível?
  1. Uma aprovação é perdida, deixando um saldo de 30.
  2. Os acessos atômicos formam uma corrida de dados em C.
  3. Ambas aprovam, deixando um saldo de −30.
  4. As duas subtrações se fundem em uma única atualização.
Mostrar respostaOcultar resposta

Salve esta aula para ver esta resposta.

Questão 6 de 10

Uma chamada a pthread_mutex_trylock(&m) retorna EBUSY. Qual posse a thread que chamou obtém dessa chamada?
  1. Ela não adquiriu a posse de m.
  2. Ela detém m até seu próximo bloqueio.
  3. Ela compartilha m com a atual proprietária.
  4. Ela detém m assim que a proprietária liberar a trava.
Mostrar respostaOcultar resposta

Salve esta aula para ver esta resposta.

Questão 7 de 10

Uma função iniciadora cria duas trabalhadoras passando repetidamente &i, o endereço de seu índice local de laço, e retorna enquanto elas executam. A thread principal quer resultados separados de ambas. Qual projeto substituto torna seguras as passagens de argumentos e resultados?
  1. A thread principal mantém válida uma struct de tarefa que muda, inicia ambas as trabalhadoras e aguarda cada uma antes de ler seu resultado.
  2. A iniciadora mantém duas structs de tarefa locais, inicia ambas as trabalhadoras, retorna e a thread principal depois as aguarda antes de ler.
  3. A thread principal mantém válidas duas structs de tarefa distintas, inicia ambas as trabalhadoras e lê os resultados antes de aguardar qualquer uma.
  4. A thread principal mantém válidas duas structs de tarefa distintas, inicia ambas as trabalhadoras e aguarda cada uma antes de ler seu resultado.
Mostrar respostaOcultar resposta

Salve esta aula para ver esta resposta.

Questão 8 de 10

Um programa perde incrementos compartilhados em um núcleo. Por que acrescentar volatile ao contador pode não corrigir o problema?
  1. O qualificador volatile obriga todas as threads a compartilhar registradores.
  2. O carregamento e o armazenamento ainda podem ser separados por uma troca de thread.
  3. O contador torna-se privado à thread atualmente em execução.
  4. O qualificador adia trocas de thread até depois de um carregamento.
Mostrar respostaOcultar resposta

Salve esta aula para ver esta resposta.

Questão 9 de 10

Um valor de capacidade começa em 7. Solicitações de 3, 5 e 2 verificam esse valor sob um mutex antes que qualquer decremento ocorra, portanto todas são aprovadas. Depois, elas decrementam sob esse mutex na mesma ordem. Qual é o valor final e qual limite de uso do mutex corrige a regra?
  1. Final 2; mantenha intervalos de posse separados para verificar e decrementar.
  2. Final −3; mantenha a posse durante cada verificação e seu decremento condicional.
  3. Final −3; mantenha os intervalos separados, mas torne cada acesso também atômico.
  4. Final −1; mantenha a posse apenas durante cada decremento já aprovado.
Mostrar respostaOcultar resposta

Salve esta aula para ver esta resposta.

Questão 10 de 10

T1 detém um mutex enquanto altera dois campos relacionados e então sofre preempção. T2 chama pthread_mutex_lock no mesmo mutex. O que acontece com o acesso protegido?
  1. T2 espera pela aquisição; T1 pode retomar e concluir sua atualização.
  2. T2 entra porque T1 parou de executar no processador.
  3. T2 adquire a posse e conclui a atualização inacabada de T1.
  4. T1 perde a posse e deve reiniciar as duas alterações de campos.
Mostrar respostaOcultar resposta

Salve esta aula para ver esta resposta.

Conjunto 3: Protocolos de espera e estruturas compartilhadas10 Questões de Prática

Salve esta aula para ver todas as respostas e praticar cada conjunto.

Questão 1 de 10

Uma thread que espera por uma trava está suspensa em uma fila de espera do núcleo. Quanto tempo de CPU ela consome enquanto permanece bloqueada?
  1. Uma fatia de tempo completa em cada ciclo do escalonador.
  2. Um núcleo continuamente até que a proprietária libere a trava.
  3. Uma curta espera ocupada em cada acesso à memória protegida.
  4. Nenhum para executar essa thread bloqueada.
Mostrar respostaOcultar resposta

Salve esta aula para ver esta resposta.

Questão 2 de 10

A detém o bilhete 20; B e C detêm 21 e 22. A libera a trava, B fica fora de execução e C, em execução, continua esperando. D retira agora o próximo bilhete, sem novas chegadas. Qual é o próximo bilhete disponível e qual ordem de entrada deve ocorrer quando B puder executar?
  1. Próximo disponível 23; B, depois C, depois D.
  2. Próximo disponível 24; C, depois D, depois B.
  3. Próximo disponível 24; B, depois C, depois D.
  4. Próximo disponível 22; B, depois D, depois C.
Mostrar respostaOcultar resposta

Salve esta aula para ver esta resposta.

Questão 3 de 10

Uma thread em espera decide se suspender com park, pausa e recebe um unpark antes de chamar park. O unpark é descartado. Qual é o risco resultante?
  1. Ela pode ser suspensa depois que o único despertar já passou.
  2. Ela pode adquirir dois bilhetes para a mesma entrada na fila.
  3. Ela deve aguardar ocupadamente até a próxima fatia de tempo.
  4. Ela desperta imediatamente porque park sempre lembra de unpark.
Mostrar respostaOcultar resposta

Salve esta aula para ver esta resposta.

Questão 4 de 10

Um contador aproximado usa três posições locais e limiar 4. Começando em zero, seus totais de atualizações são 6, 1 e 3. Depois que todas as atualizações terminam, quais são a contagem global e o total real?
  1. Global 8; total real 10.
  2. Global 4; total real 10.
  3. Global 6; total real 10.
  4. Global 4; total real 6.
Mostrar respostaOcultar resposta

Salve esta aula para ver esta resposta.

Questão 5 de 10

Antes de ligar um nó de lista recém-alocado a qualquer estrutura compartilhada, qual trabalho pode ser feito sem o mutex da lista?
  1. Ler a cabeça compartilhada para uma atualização posterior da ligação.
  2. Alterar a cabeça compartilhada para publicar o novo nó.
  3. Inicializar o campo de chave do nó privado.
  4. Percorrer nós compartilhados que uma remoção pode liberar.
Mostrar respostaOcultar resposta

Salve esta aula para ver esta resposta.

Questão 6 de 10

Uma fila contém o sentinela R seguido de nós que contêm 14 e 29. Após uma remoção bem-sucedida, qual nó é liberado e qual se torna o sentinela?
  1. Libera-se o nó que contém 14; R permanece como sentinela.
  2. Libera-se R; o nó que contém 14 torna-se o sentinela.
  3. Libera-se R; o nó que contém 29 torna-se o sentinela.
  4. Libera-se o nó que contém 29; R permanece como sentinela.
Mostrar respostaOcultar resposta

Salve esta aula para ver esta resposta.

Questão 7 de 10

Uma tabela de dispersão de tamanho fixo usa 17 baldes, key % 17 e um mutex por balde. Trabalhadoras atualizam as chaves 8, 25 e 26. Qual par deve ser serializado e qual chave usa uma trava separada?
  1. 8 e 25 são serializadas; 26 usa uma trava separada.
  2. 8 e 26 são serializadas; 25 usa uma trava separada.
  3. 25 e 26 são serializadas; 8 usa uma trava separada.
  4. As três são serializadas; cada uma usa a mesma trava de balde.
Mostrar respostaOcultar resposta

Salve esta aula para ver esta resposta.

Questão 8 de 10

Um contador aproximado tem quatro posições locais e incrementos unitários. Sua defasagem após atualizações concluídas nunca deve exceder 20. Escolha o maior limiar inteiro que atenda a esse limite. As posições então fazem 17, 8, 5 e 0 atualizações a partir de zero. Quais são o limiar e a leitura global resultantes?
  1. Limiar 5; leitura global 25.
  2. Limiar 7; leitura global 21.
  3. Limiar 6; leitura global 30.
  4. Limiar 6; leitura global 18.
Mostrar respostaOcultar resposta

Salve esta aula para ver esta resposta.

Questão 9 de 10

Um percurso com travamento por acoplamento detém a trava do nó atual. Em que ordem deve avançar para o próximo nó?
  1. Liberar a trava atual e depois adquirir a próxima trava.
  2. Liberar a trava atual e depois copiar o próximo ponteiro.
  3. Adquirir todas as travas dos nós posteriores antes de liberar qualquer trava.
  4. Adquirir a próxima trava e depois liberar a trava atual.
Mostrar respostaOcultar resposta

Salve esta aula para ver esta resposta.

Questão 10 de 10

Em uma CPU com escalonamento estrito por prioridade, L, de baixa prioridade, detém uma trava; H, de alta prioridade, aguarda ocupadamente por ela; e M, de prioridade média, está pronta. Primeiro, a trava é alterada para uma trava de espera ocupada por bilhetes. Depois, ela é substituída por um mutex com herança de prioridade no qual H bloqueia. Qual progresso a segunda alteração permite que a ordem por bilhetes sozinha não permite?
  1. L executa antes de M, libera a trava e permite que H prossiga; a ordem por bilhetes sozinha deixa H impedir a execução de L.
  2. H entra antes de L, libera a trava e permite que M prossiga; a ordem por bilhetes sozinha exige que M libere primeiro.
  3. M executa antes de L, libera o mutex de H e permite que H prossiga; a ordem por bilhetes sozinha bloqueia igualmente todas as prioridades.
  4. L permanece abaixo de M até que M termine; a ordem por bilhetes sozinha elevaria L à prioridade de H.
Mostrar respostaOcultar resposta

Salve esta aula para ver esta resposta.

Conjunto 4: Revise projetos concorrentes considerando seus limites10 Questões de Prática

Salve esta aula para ver todas as respostas e praticar cada conjunto.

Questão 1 de 10

Uma trabalhadora cujo término pode ser aguardado nunca terá seu valor de retorno coletado e deve recuperar seus recursos de thread quando terminar. Qual operação pthread providencia isso?
  1. Chamar pthread_join sem esperar por ela.
  2. Destacar a trabalhadora com pthread_detach.
  3. Retornar da função principal antes que a trabalhadora termine.
  4. Copiar o pthread_t da trabalhadora localmente.
Mostrar respostaOcultar resposta

Salve esta aula para ver esta resposta.

Questão 2 de 10

W observa uma palavra de trava de futex em 1. A proprietária a limpa para 0 e envia um despertar antes que W chame a espera. C então a adquire, definindo-a como 1. W chama a espera em futex esperando 1, e C depois a limpa e envia um despertar. W pode bloquear nessa chamada, e o despertar posterior concede a posse?
  1. W pode bloquear no estado ocupado por C; após despertar, deve tentar adquirir novamente.
  2. W não pode bloquear porque uma proprietária anterior já enviou uma notificação de despertar.
  3. W pode bloquear no estado ocupado por C; o despertar posterior concede a posse automaticamente.
  4. W deve bloquear para sempre porque sua leitura anterior se referia a outra proprietária.
Mostrar respostaOcultar resposta

Salve esta aula para ver esta resposta.

Questão 3 de 10

Uma fila com duas travas está vazia no sentinela D. E prepara o nó R com valor 11, publica D->next = R e pausa antes de mover a cauda. Q então remove um item com sucesso; depois, E termina de mover a cauda para R. Qual estado permanece e como os acessos ao campo de próximo nó entre as extremidades devem ser implementados em C11?
  1. Um item 11 em R; publicar e ler o próximo nó atomicamente com ordenação adequada.
  2. Vazia no sentinela R; acessos comuns ao próximo nó são ordenados pelos mutexes separados das extremidades.
  3. Vazia no sentinela D; acessos volatile ao próximo nó fornecem a ordem de publicação necessária.
  4. Vazia no sentinela R; publicar e ler o próximo nó atomicamente com ordenação adequada.
Mostrar respostaOcultar resposta

Salve esta aula para ver esta resposta.

Questão 4 de 10

Cada requisição detém um mutex compartilhado por 5 µs. Ignorando todos os outros custos, qual limite máximo de vazão esse trabalho protegido impõe?
  1. 5.000 requisições por segundo.
  2. 1.000.000 de requisições por segundo.
  3. 200.000 requisições por segundo.
  4. 5.000.000 de requisições por segundo.
Mostrar respostaOcultar resposta

Salve esta aula para ver esta resposta.

Questão 5 de 10

Um contador tem uma posição local em 5 e a contagem global em 40. Um leitor copia a contagem global sob sua trava e a libera. Uma trabalhadora transfere os 5 locais para a contagem global e reinicia a local. O leitor então copia a contagem local sob sua trava. Qual total o leitor calcula e como pode obter um retrato exato do estado?
  1. Calcula 40; manter todas as travas locais e depois a global durante o retrato.
  2. Calcula 45; ler a contagem global duas vezes sem adquirir travas locais.
  3. Calcula 50; adquirir apenas a trava global durante todo o retrato.
  4. Calcula 40; adquirir apenas a trava de cada campo brevemente ao copiá-lo.
Mostrar respostaOcultar resposta

Salve esta aula para ver esta resposta.

Questão 6 de 10

Uma atualização de cache detém um mutex normal e chama uma função de callback que consulta o mesmo cache. Mover a função de callback para depois da liberação evitaria a reentrada, mas seu ponteiro direto para um nó poderia então ser invalidado pela liberação do nó por outra trabalhadora. Qual novo projeto permite chamar a função fora da trava mantendo sua entrada válida?
  1. Manter o ponteiro direto para o nó, liberar a trava antes da função de callback e deixar que ela adquira o mesmo mutex ao começar.
  2. Copiar os campos necessários para armazenamento da função de callback sob o mutex, liberar a trava e depois chamá-la com essa cópia.
  3. Copiar os campos para armazenamento da função de callback, manter o mutex adquirido e deixar que ela consulte usando o mesmo mutex.
  4. Manter o ponteiro direto para o nó, liberar a trava e marcar o ponteiro como volatile para impedir que uma remoção posterior invalide o nó.
Mostrar respostaOcultar resposta

Salve esta aula para ver esta resposta.

Questão 7 de 10

Uma escrita condicional falha após uma leitura vinculada, embora o código não tenha evidência de outra thread escrevendo. Como a rotina de atualização deve reagir?
  1. Tratar a escrita condicional malsucedida como bem-sucedida.
  2. Escrever o valor com um armazenamento incondicional comum.
  3. Tentar novamente a sequência LL/SC com uma nova leitura vinculada.
  4. Retornar o valor antigo e informar que a atualização foi efetivada.
Mostrar respostaOcultar resposta

Salve esta aula para ver esta resposta.

Questão 8 de 10

Duas buscas encontram um nó. A copia seu valor inteiro para armazenamento de quem chamou sob o mutex da lista. B salva um ponteiro e libera a trava. Uma operação de remoção então desliga e libera o nó; seu endereço pode ser reutilizado antes que B leia. Qual resultado permanece válido e o que a interface de B deve fornecer?
  1. Ambos os resultados permanecem válidos; B só precisa adquirir novamente a trava da lista antes de ler o endereço salvo.
  2. Apenas o ponteiro de B permanece válido; A deve adquirir a trava da lista sempre que ler o valor copiado.
  3. O valor copiado por A permanece válido; marcar o ponteiro salvo de B como volatile preserva o nó removido.
  4. O valor copiado por A permanece válido; B precisa de uma cópia do valor ou de proteção para manter o nó válido.
Mostrar respostaOcultar resposta

Salve esta aula para ver esta resposta.

Questão 9 de 10

O próximo bilhete disponível de uma trava por bilhetes é 15 e seu turno atual é 12. Três threads retiram bilhetes antes de qualquer liberação. Quais são seus bilhetes e o novo valor do distribuidor?
  1. Bilhetes 15, 16, 17; próximo disponível 18.
  2. Bilhetes 12, 13, 14; próximo disponível 15.
  3. Bilhetes 16, 17, 18; próximo disponível 18.
  4. Bilhetes 15, 15, 15; próximo disponível 18.
Mostrar respostaOcultar resposta

Salve esta aula para ver esta resposta.

Questão 10 de 10

Uma tabela cresce de 11 para 17 baldes usando a chave módulo o número de baldes. Uma trabalhadora selecionou o balde antigo da chave 24. A operação de redimensionamento adquire apenas a trava do novo balde de destino, move a entrada e libera o array antigo de baldes. Qual destino e regra de transição preservam esse projeto simples?
  1. Balde 2; manter o mapeamento antigo enquanto publica o novo número de baldes para operações comuns.
  2. Balde 7; proteger apenas o novo destino porque a trabalhadora calculou seu índice antigo antes.
  3. Balde 7; excluir operações comuns da tabela e aguardar os usuários antigos antes de liberar o armazenamento antigo.
  4. Balde 2; proteger apenas a origem antiga enquanto libera todos os baldes antigos após mover a entrada.
Mostrar respostaOcultar resposta

Salve esta aula para ver esta resposta.

Travou em uma questão sua? Ajuda com Tarefas

Termos-Chave

75 Termos-Chave, cada um com sua definição

A

Algoritmo de Peterson
O algoritmo de Peterson é um protocolo de exclusão para duas threads que usa indicadores de interesse e uma variável de turno, correto sob consistência sequencial.
Quando ambas as threads solicitam entrada, a variável de turno determina qual das concorrentes interessadas espera.
Argumento de thread
Um argumento de thread consiste em dados fornecidos a uma nova trabalhadora por meio do parâmetro de ponteiro de sua função inicial, geralmente apontando para uma struct.
Um array fornece a cada trabalhadora um objeto de argumento distinto que permanece válido até que todas as esperas pelo término sejam concluídas.
Atomicidade
Atomicidade significa que uma operação parece indivisível aos observadores relevantes, de modo que eles não podem observar seu estado intermediário parcial.
Usar um mutex compartilhado pode tornar uma atualização de dois campos atômica para outros usuários desse mesmo mutex.
Atualização perdida
Uma atualização perdida ocorre quando uma thread armazena um resultado baseado em uma leitura antiga e sobrescreve o efeito de uma atualização concorrente.
Duas trabalhadoras podem, cada uma, carregar 50 de um contador compartilhado e armazenar 51, deixando apenas um incremento visível após duas tentativas.

B

Balde de tabela de dispersão
Um balde de tabela de dispersão é uma região da tabela selecionada pelo mapeamento de dispersão de uma chave, que contém entradas associadas àquele mesmo local.
Módulo 101 coloca as chaves 7 e 108 no mesmo balde, embora seus valores numéricos sejam diferentes.
Bloco de controle de thread
Um bloco de controle de thread é um registro do sistema operacional que contém o estado de execução salvo de uma thread para seu escalonamento e retomada.
Em uma troca entre duas threads, o estado dos registradores da primeira thread é salvo em seu bloco de controle de thread antes que o estado da segunda seja restaurado.
Busca e soma
Busca e soma adiciona atomicamente uma quantidade a um valor armazenado e retorna o valor que existia antes da adição.
Um distribuidor de bilhetes retorna 12 a uma thread enquanto avança seu próximo número disponível para 13.

C

Caminho de liberação de recursos
Um caminho de liberação de recursos é uma sequência comum de saída que libera recursos adquiridos antes que uma função retorne, com sucesso ou falha.
Uma busca armazena seu valor de retorno, sai do laço e chega a um único ponto compartilhado de liberação do mutex.
Cessão da CPU
A cessão da CPU abre mão da vez atual da thread que a chama, mantendo-a pronta para executar novamente quando o escalonador a selecionar.
Muitas threads que cedem a CPU enquanto esperam por uma trava podem causar trocas de contexto repetidas antes que a proprietária tenha outra vez.
Chamada reentrante
Uma chamada reentrante entra novamente em uma operação antes que sua invocação anterior termine, podendo tentar readquirir uma trava já detida.
Uma função de callback de registro pode consultar o mesmo cache enquanto sua atualização externa ainda detém o mutex do cache.
Colisão de dispersão
Uma colisão de dispersão ocorre quando chaves distintas são mapeadas para o mesmo balde da tabela, fazendo-as compartilhar uma região de busca e sua trava.
Com 13 baldes, as chaves 2 e 15 colidem porque ambas têm resto 2 quando divididas por 13.
Comparação e troca
Comparação e troca substitui atomicamente um valor somente quando ele corresponde a um valor esperado, informando o suficiente para detectar falha.
Uma thread que atualiza um contador tenta novamente quando um incremento concorrente torna seu valor esperado desatualizado.
Concorrência
Concorrência é o progresso de várias execuções durante períodos sobrepostos, incluindo a execução intercalada em um único núcleo de CPU.
Um temporizador pode alternar entre trabalhadoras concorrentes mesmo quando a máquina só pode executar uma em cada instante.
Condição de corrida
Uma condição de corrida ocorre quando a correção de uma operação depende da temporização relativa de ações concorrentes.
Duas aprovações protegidas separadamente podem usar o saldo antigo antes que qualquer um dos saques seja aplicado.
Consistência sequencial
A consistência sequencial dá às operações uma ordem comum que preserva a ordem do programa de cada thread e é observada por todas as threads.
A demonstração de Peterson pressupõe que os indicadores e o turno sigam essa ordenação, em vez de uma visibilidade reordenada.
Contador aproximado
Um contador aproximado transfere atualizações locais protegidas em lotes para uma contagem global protegida cuja leitura pode ficar defasada em relação ao total exato.
Um coletor de estatísticas pode tolerar que incrementos recentes permaneçam locais até que seu limiar de transferência seja atingido.
Contador de turno
Um contador de turno registra o bilhete atualmente autorizado a entrar em uma trava por bilhetes e avança uma vez quando sua detentora libera a trava.
Retirar um novo bilhete avança o distribuidor enquanto deixa o turno atual inalterado.
Contador por núcleo de processamento
Um contador por núcleo de processamento registra atualizações em uma posição associada a um núcleo de CPU, reduzindo escritas em um único local compartilhado globalmente.
Threads que usam posições locais diferentes podem atualizar sob mutexes diferentes até que uma transferência em lote seja necessária.
Contenção
Contenção ocorre quando threads disputam um recurso compartilhado, como ao tentar adquirir um mutex que outra thread detém.
Adicionar trabalhadoras a um contador protegido por uma trava pode aumentar a espera e o tráfego de cache compartilhado em vez da vazão.
Corrida de dados
Uma corrida de dados é um acesso conflitante sem sincronização ao mesmo local de memória por threads diferentes, incluindo uma escrita e um acesso não atômico.
Uma leitura de estado pode entrar em corrida com uma escrita comum em um contador, embora a função de estado não altere nada.
Criação de threads
A criação de threads inicia um novo caminho de execução em uma função fornecida, com um ponteiro de argumento e atributos de thread configuráveis.
A thread principal prepara uma struct de intervalo antes de passar seu endereço a uma trabalhadora de soma criada com sucesso.

D

Despertar perdido
Um despertar perdido ocorre quando uma notificação necessária não impede nem encerra uma espera, permitindo que uma thread permaneça bloqueada indevidamente.
Uma thread em espera com protocolo simples pode ser suspensa após um unpark anterior ter sido descartado, sem outra liberação para despertá-la.

E

Espaço de endereçamento
Um espaço de endereçamento é o conjunto de endereços de memória disponíveis para um processo; suas threads compartilham o código e os objetos nele contidos.
Duas pilhas de trabalhadoras ocupam regiões diferentes de um mesmo espaço de endereçamento, portanto um ponteiro pode passar de uma para a outra.
Espera limitada
Espera limitada restringe quantas entradas concorrentes na seção crítica podem preceder uma thread após ela solicitar entrada.
Bilhetes anteriores pendentes limitam o número de entradas antes do turno de uma thread que espera por uma trava por bilhetes.
Espera ocupada
Espera ocupada é testar repetidamente uma condição enquanto a thread permanece pronta para executar e consome tempo de CPU até que a condição permita progresso.
Uma thread em espera pode passar toda a sua fatia de tempo verificando um indicador enquanto a proprietária que sofreu preempção não pode executar.
Espera pelo término de uma thread
Uma espera pelo término de uma thread aguarda o encerramento de uma thread que permite join e torna suas escritas concluídas visíveis para a thread que aguarda seu término com sucesso.
A thread principal aguarda o término de duas trabalhadoras de soma antes de ler e combinar seus campos de resultado separados.
Exclusão mútua
Exclusão mútua é a propriedade de que no máximo uma thread concorrente ocupa uma seção crítica protegida por vez.
Uma segunda thread não pode concluir a aquisição do mutex compartilhado enquanto uma primeira thread o detém, mesmo que esse proprietário sofra preempção temporariamente.

F

Fila com duas travas
Uma fila com duas travas serializa inserções com uma trava da cauda e remoções com uma trava da cabeça, permitindo que operações em extremidades opostas se sobreponham.
Em uma fila de duas travas em C11, a ligação do nó sentinela para o próximo nó, quando a fila está vazia, ainda precisa de publicação e leitura atômicas.
Futex
Um futex é uma palavra na memória de usuário com suporte do núcleo do Linux para verificar atomicamente um valor esperado e bloquear, além de despertar threads em espera.
Um mutex sem contenção pode usar operações atômicas no espaço de usuário e chamar o núcleo apenas para espera com contenção.

G

Granularidade das travas
A granularidade das travas é a quantidade de estado compartilhado que uma trava protege, determinando quais operações devem ser serializadas entre si.
Passar de um mutex para toda a tabela para mutexes por balde permite que operações em baldes diferentes se sobreponham.

H

Herança de prioridade
A herança de prioridade eleva temporariamente uma proprietária de trava à prioridade mais alta de uma thread em espera para que a proprietária possa executar e liberar a trava mais cedo.
Elevar a prioridade da proprietária impede que trabalho de prioridade intermediária atrase indefinidamente a liberação necessária.

I

Identidade do mutex
A identidade do mutex está vinculada ao objeto de mutex original em seu endereço inicializado; uma cópia de mutex pthread não é uma referência alternativa válida.
Passe um registro compartilhado por ponteiro em vez de copiar sua struct e usar o mutex copiado.
Inanição
Inanição é o atraso indefinido de uma thread enquanto threads concorrentes continuam progredindo e obtêm repetidamente o recurso de que ela precisa.
Uma thread que espera por teste e atribuição pode perder todas as disputas após a liberação porque a trava não registra a ordem de chegada.
Intercalação
Uma intercalação é uma ordem em que etapas de threads diferentes ocorrem, preservando a ordem relevante de execução dentro de cada thread.
Colocar a atualização completa de uma thread entre o carregamento e o armazenamento de outra revela como um resultado desatualizado pode sobrescrevê-la.
Inversão de prioridade
A inversão de prioridade ocorre quando trabalho de prioridade mais alta espera por uma proprietária de prioridade mais baixa cujo progresso é atrasado por outra atividade de escalonamento.
Trabalho de prioridade média pode impedir que uma proprietária de mutex de baixa prioridade libere a trava para uma thread em espera de alta prioridade.

L

Leitura vinculada e escrita condicional
Leitura vinculada e escrita condicional combina uma leitura que estabelece uma reserva com uma tentativa de escrita que pode falhar e deve ser repetida em caso de insucesso.
Uma reserva invalidada faz a escrita condicional falhar em vez de sobrescrever uma alteração concorrente.
Leitura, modificação e escrita
Uma operação de leitura, modificação e escrita lê um valor armazenado, calcula um valor alterado e escreve o resultado de volta no mesmo local compartilhado.
Um incremento comum de contador expõe uma lacuna entre seu carregamento inicial e seu armazenamento final.
Limiar de transferência
Um limiar de transferência é a contagem local na qual um contador aproximado move seus incrementos acumulados para a contagem global.
Elevar o limiar reduz as aquisições da trava global, mas permite que mais incrementos permaneçam sem ser informados.

M

Modelo de threads muitos para um
O modelo de threads muitos para um associa várias threads de usuário a uma thread do núcleo, impedindo sua execução simultânea em núcleos diferentes.
Uma chamada de sistema bloqueante pode interromper todas as threads de usuário associadas a esse único caminho de execução do núcleo.
Modelo de threads um para um
O modelo de threads um para um associa cada thread de aplicação a uma thread escalonada pelo núcleo, permitindo execução paralela e bloqueio individual de threads.
Duas threads comuns do Linux podem ser colocadas em núcleos separados pelo escalonador do sistema operacional.
Mutex
Um mutex é uma trava de exclusão mútua detida por uma thread por vez e usada para proteger uma operação compartilhada ou uma região de dados.
Todas as operações de saldo adquirem o mesmo mutex da conta antes de usar o campo de saldo protegido.
Mutex com verificação de erros
Um mutex com verificação de erros informa erros específicos de posse, incluindo uma nova aquisição pela proprietária e a liberação por uma thread que não o detém.
Durante o desenvolvimento, uma tentativa de nova aquisição pode retornar EDEADLK em vez de bloquear silenciosamente a mesma thread.
Mutex recursivo
Um mutex recursivo permite aquisições repetidas por sua proprietária e fica disponível somente após o número correspondente de liberações.
Duas aquisições bem-sucedidas por uma thread exigem duas liberações antes que outra thread possa adquirir o mutex.

N

Nó sentinela
Um nó sentinela ancora uma fila encadeada sem representar um item atual; seu próximo nó, quando existe, contém o primeiro valor da fila.
Uma remoção retorna o valor do próximo nó, libera o sentinela antigo e torna o nó do valor o novo sentinela.

O

Operação atômica livre de travas
Uma operação atômica livre de travas fornece seu efeito atômico sem depender de outra thread liberar uma trava interna.
Outros objetos atômicos podem usar uma trava de biblioteca, portanto a correção atômica, por si só, não estabelece que sejam livres de travas.
Ordem de atendimento por chegada
A ordem de atendimento por chegada atende solicitações na ordem de chegada registrada, impedindo que chegadas registradas posteriormente ultrapassem as anteriores.
Em uma trava por bilhetes, a chegada é registrada quando a thread obtém atomicamente seu número de bilhete.
Ordenação de operações de memória
A ordenação de operações de memória especifica restrições sobre como as operações de memória se tornam visíveis, incluindo as operações em torno da transferência de posse de travas.
A liberação de uma trava deve manter as escritas protegidas antes da publicação do novo estado disponível.

P

Paralelismo
Paralelismo é a execução simultânea em diferentes recursos de processamento, como várias threads executando em núcleos distintos de CPU.
Partes independentes de uma imagem podem ser processadas em paralelo quando há vários núcleos disponíveis.
Posse da trava
A posse da trava é o direito exclusivo obtido por uma aquisição bem-sucedida e mantido até que a thread proprietária libere o mutex.
Uma troca de contexto pausa a proprietária sem conceder a uma thread em espera permissão para entrar.
Progresso
Progresso exige que threads concorrentes a uma seção crítica livre possam escolher uma para entrar sem obstrução indefinida por threads que não concorrem à entrada.
Uma thread que não está tentando entrar não deve manter uma seção disponível fechada para sempre.
Publicação de nó
A publicação de nó torna um nó privado preparado acessível por meio de ligações compartilhadas, para que outras operações possam encontrar seus dados inicializados com segurança.
Uma inserção no início preenche primeiro a chave e publica a cabeça somente enquanto detém o mutex compartilhado da lista.

R

Recuperação de memória
A recuperação de memória libera o armazenamento alocado somente depois que nenhuma operação ainda puder acessar o objeto que o ocupa.
Desligar um nó é insuficiente quando uma thread que chamou manteve seu ponteiro e pode desreferenciá-lo depois.
Redimensionamento da tabela
O redimensionamento da tabela altera a organização dos baldes de uma tabela de dispersão e remapeia entradas, exigindo coordenação além da trava comum de um único balde.
Uma tabela hash simples com travas por balde exclui operações concorrentes na tabela enquanto move entradas para novos baldes.
Regra de proteção
Uma regra de proteção associa o estado compartilhado a uma trava específica e determina quais acessos devem adquirir essa mesma trava.
Um comentário ao lado de um contador pode nomear o mutex que toda leitura e atualização concorrente deve adquirir.
Retorno cíclico do contador
O retorno cíclico do contador ocorre quando a aritmética sem sinal passa do valor máximo representável e continua a partir de zero.
A comparação de igualdade dos bilhetes continua utilizável após o retorno cíclico somente enquanto os números de bilhetes pendentes permanecerem únicos.
Retrato exato do estado
Um retrato exato do estado lê valores compartilhados relacionados de forma consistente em um estado protegido, impedindo que transferências dividam a observação.
Um retrato do estado de um contador aproximado mantém as travas locais e a trava global enquanto soma todas as contagens.

S

Seção crítica
Uma seção crítica é uma região de código que acessa estado compartilhado e deve excluir operações concorrentes para preservar a correção da operação.
A seção crítica de um saque começa antes da verificação do saldo e inclui a subtração condicional.
Segurança para uso por múltiplas threads
A segurança para uso por múltiplas threads significa que uma estrutura se comporta de acordo com sua especificação quando operações de várias threads são intercaladas.
Um contador precisa de uma regra de proteção para leituras e também para incrementos quando essas operações podem se sobrepor.

T

Tempo de vida de um objeto
O tempo de vida de um objeto é o período durante o qual um objeto armazenado existe e pode ser acessado por meio de uma referência válida.
Retornar um ponteiro para uma variável local de uma trabalhadora falha porque o tempo de vida desse objeto termina com a função.
Tentativa não bloqueante de aquisição de trava
Uma tentativa não bloqueante de aquisição de trava é uma tentativa imediata que informa se quem chamou obteve o mutex, em vez de esperar enquanto ele está ocupado.
Um resultado EBUSY permite que uma trabalhadora escolha trabalho independente, mas não concede acesso aos campos protegidos.
Teste e atribuição
Teste e atribuição armazena atomicamente um novo valor e retorna o antigo, permitindo que uma thread reivindique um indicador livre sem uma lacuna.
Uma troca que recebe 0 adquire a posse do indicador; concorrentes que recebem 1 continuam esperando pela liberação.
Thread
Uma thread é uma sequência de execução dentro de um processo, com seu próprio contador de programa, registradores, pilha e identificador de thread.
Um servidor pode usar uma thread para esperar uma resposta enquanto outra processa uma requisição concluída.
Thread bloqueada
Uma thread bloqueada não pode ser selecionada para executar até que um evento a torne pronta novamente, portanto não usa CPU enquanto está bloqueada.
Uma thread suspensa à espera de um mutex sai do conjunto de threads prontas até que um despertar lhe permita disputar novamente a trava.
Thread destacada
Uma thread destacada recupera seus recursos de thread automaticamente quando termina e não permite que se aguarde seu término com join para coletar seu valor de retorno.
Uma trabalhadora em segundo plano pode ser destacada, mas retornar da função principal ainda encerra o processo que a contém.
Threads POSIX
Threads POSIX são a interface padronizada de threads usada por programas C em sistemas do tipo Unix para criar e coordenar threads.
Um programa C com threads trabalhadoras inclui <pthread.h> e é compilado e vinculado com a opção -pthread.
ThreadSanitizer
O ThreadSanitizer é uma ferramenta de análise em tempo de execução que detecta corridas de dados em execuções instrumentadas e informa os acessos conflitantes.
Uma compilação de teste que usa -fsanitize=thread pode expor um caminho de leitura que não adquiriu o mutex da função de atualização.
Trava de duas fases
Uma trava de duas fases faz a thread esperar ocupadamente por pouco tempo e depois bloquear se a proprietária não tiver liberado a trava nesse intervalo curto.
A espera ocupada curta pode evitar a sobrecarga de despertar quando a trava é mantida por pouco tempo, sem consumir um núcleo durante esperas longas.
Trava de espera ocupada
Uma trava de espera ocupada é uma trava de exclusão mútua cujas threads que não conseguem adquiri-la verificam ou tentam adquiri-la repetidamente enquanto consomem tempo de CPU.
Uma posse curta em outro núcleo em execução pode tornar a espera ocupada mais barata que bloquear e despertar.
Trava por balde
Uma trava por balde protege as entradas de um balde de tabela de dispersão, permitindo que operações em baldes diferentes avancem sob travas diferentes.
Duas chaves com o mesmo índice de balde disputam a mesma trava, apesar de terem valores diferentes.
Trava por bilhetes
Uma trava por bilhetes dá a cada solicitante um bilhete atômico único e a admite quando o contador de atendimento alcança esse bilhete.
Uma thread com o bilhete 6 espera enquanto o turno é 5, mesmo que uma thread com bilhete posterior já esteja executando.
Travamento de granularidade fina
O travamento de granularidade fina atribui travas separadas a regiões menores do estado compartilhado, permitindo que operações independentes avancem concorrentemente.
Mutexes separados por conta permitem que dois pares de contas não relacionados sejam atualizados ao mesmo tempo.
Travamento de granularidade grossa
O travamento de granularidade grossa protege uma estrutura ou subsistema grande com uma trava, simplificando a correção, mas limitando o acesso independente.
Um mutex para toda a lista faz cada busca esperar por uma inserção, mesmo quando seu alvo está distante.
Travamento por acoplamento
O travamento por acoplamento adquire a trava do próximo nó antes de liberar a do atual, preservando a proteção durante o percurso.
Percursos diferentes podem ocupar regiões separadas da lista, embora cada nó visitado acrescente sobrecarga de travamento.

V

Vazamento de memória
Um vazamento de memória é o armazenamento alocado que não é mais necessário, mas não pode ser recuperado porque o programa perdeu sua referência utilizável.
Duas inserções concorrentes no início podem sobrescrever a cabeça e deixar o primeiro novo nó inacessível.
volatile
O qualificador C volatile marca acessos sujeitos a regras especiais de acesso observável; ele não fornece atomicidade nem sincronização de threads.
Marcar um contador compartilhado como volatile não combina seu carregamento, soma e armazenamento em uma única atualização.

Sobre esta aula

Por StudeliRelatar um erro

Crie seus próprios Materiais de Estudo

Transforme suas anotações, slides e documentos em Guias de Estudo, Questões de Prática, Flashcards e mais 6 ferramentas.

Começar