Noite de estreia

Noite de estreia

Exercício de aprendizagem

Introdução

Operações atômicas

Uma única instrução pode parecer uma ação indivisível e não ser nada disso. Veja o caso de somar cinco a um valor na memória:

add qword [rel counter], 5

Por baixo dos panos, o processador não consegue somar diretamente na memória. Ele divide essa única instrução em três etapas menores, chamadas de micro-operações:

  1. ler o valor atual da memória;
  2. modificar esse valor em um registrador, somando cinco a ele;
  3. escrever o resultado de volta.
mov rax, qword [rel counter] ; 1. load the current value
add rax, 5                   ; 2. add five
mov qword [rel counter], rax ; 3. store the result back

Esse é um padrão comum chamado leitura-modificação-escrita (RMW).

Repare que a leitura (ou load) e a escrita (ou store) são eventos separados, então existe uma janela de tempo entre eles. Normalmente não percebemos isso porque, em um único núcleo, cada instrução tem a garantia de produzir todo o seu efeito antes da próxima. Essa janela é, portanto, invisível, e o add se comporta como uma unidade única.

No entanto, as CPUs modernas raramente têm um único núcleo, e os aplicativos costumam rodar em vários núcleos ao mesmo tempo, sem nenhuma ordenação entre eles. Com vários núcleos rodando no exato mesmo instante, outro núcleo pode ler ou escrever o counter dentro dessa janela, depois do load deste núcleo e antes do seu store. Duas threads carregam o mesmo valor antigo, cada uma soma cinco e cada uma armazena seu resultado. Duas somas aconteceram, mas o valor avançou apenas cinco. Uma atualização foi perdida em silêncio.

Isso é uma corrida de dados, e é um problema comum em código multi-thread.

Nem todo valor fica exposto desse jeito. Cada thread tem seus próprios registradores e sua própria pilha, então um valor guardado em um registrador, ou uma variável local na pilha de uma thread, é exclusivo daquela thread e não pode sofrer corrida. Só a memória compartilhada entre as threads, como o counter acima, precisa de proteção.

O x86-64 oferece um conjunto de instruções para resolver esse problema, tornando uma instrução indivisível. Ela se comporta como uma unidade única não só para o núcleo que a processa, mas também para todos os outros núcleos. Uma operação que se mantém inteira, que nenhum outro núcleo consegue dividir, é chamada de atômica.

Note

É comum chamar de multi-thread as aplicações que rodam em vários núcleos. No entanto, uma thread não é a mesma coisa que um núcleo. Duas threads podem rodar ao mesmo tempo no mesmo núcleo, intercalando-se, ou em paralelo, em núcleos diferentes.

Threads que se intercalam já podem sofrer corrida em uma leitura-modificação-escrita se ela for dividida em várias instruções, já que o sistema operacional pode trocar de thread entre quaisquer duas instruções. A janela dentro de uma única instrução, porém, só é exposta por código realmente paralelo. Como o sistema operacional só troca de threads entre instruções, nunca dentro de uma, uma única instrução é inerentemente segura em um único núcleo.

A atomicidade de verdade entre vários núcleos é o que as instruções abaixo oferecem.

Troca atômica

A instrução xchg troca dois operandos. O operando de destino passa a valer o valor anterior do operando de origem, e o operando de origem passa a valer o valor anterior do operando de destino. Conceitualmente, dá para pensar nela como duas instruções mov acontecendo ao mesmo tempo.

Como de costume, ela pode ser usada com dois operandos de registrador ou com um operando de memória e um operando de registrador:

mov  eax, 1
xchg dword [rdi], eax ; [rdi] = 1, eax = the old value of [rdi]
mov ecx, 2
mov edx, 3
xchg edx, ecx         ; edx = 2, ecx = 3

Quando usada com um operando de memória, a xchg é sempre atômica.

Caution

A xchg é automaticamente atômica quando um dos operandos é um endereço de memória. Isso também significa que a operação é bem mais lenta nessa situação.

Se você não precisa de atomicidade, faça a troca por meio de um registrador livre, com instruções mov simples.

O prefixo lock

A forma mais comum de tornar uma instrução atômica no x86-64 é adicionar o prefixo lock. Ele funde a leitura, a modificação e a escrita em uma única etapa indivisível. Isso significa que o núcleo mantém a memória com exclusividade durante todo o processo, então nenhum outro núcleo consegue ler ou escrever nesse endereço no meio do caminho.

lock add qword [rel counter], 5 ; the read, the modify, and the write are one step

O lock só funciona quando o destino é a memória, e apenas em instruções que fazem leitura-modificação-escrita nessa memória:

  1. operações aritméticas, como add, sub, inc, dec, neg;
  2. operações bit a bit, como and, or, xor, not;
  3. as operações de bits bts, btr, btc;
  4. algumas outras instruções dedicadas, como xadd e cmpxchg, descritas abaixo.
Caution

Manter um endereço com exclusividade e barrar todos os outros núcleos tem um custo. Uma operação com o prefixo lock é bem mais lenta do que sua forma simples, e mais lenta ainda quando vários núcleos disputam o mesmo endereço.

Esse prefixo deve ser reservado para memória que se espera que seja alterada por mais de uma thread. Evite usá-lo se a memória não for compartilhada ou se ela só for lida.

Troca e soma

Um lock add simples atualiza a memória, mas joga fora o valor antigo. Muitas vezes o valor antigo é exatamente o que se quer, por exemplo para dar a cada thread um número de senha diferente.

A instrução xadd (x de exchange, troca) retorna o valor anterior enquanto soma. Ela escreve a soma no destino e deixa o valor original do destino no registrador de origem.

mov  rax, 1
lock xadd qword [rdi], rax ; [rdi] = [rdi] + rax = [rdi] + 1
                           ; rax = the old value of [rdi]

Com o prefixo lock, isso é um fetch-and-add atômico. Executado por muitas threads no mesmo contador, cada chamada retorna um valor antigo diferente.

Como no lock add, o contador termina exatamente no número de chamadas. No entanto, ao contrário do lock add, todo valor intermediário também é retornado, um para cada chamador.

Comparação e troca

O xadd soma e o xchg sobrescreve, mas nenhum dos dois consegue fazer o novo valor depender do atual e aplicá-lo apenas se nada tiver mudado por baixo. É essa atualização condicional que o cmpxchg, compare-and-exchange (comparação e troca), oferece, e ela é a mais geral dessas primitivas.

cmpxchg dest, src usa o rax como acumulador implícito e o compara com dest:

  • Se dest == rax, então dest = src e ZF = 1.
  • Se dest != rax, então rax = dest e ZF = 0.

Repare que o dest só é atualizado quando ele é igual ao valor esperado, previamente carregado no rax. Essa igualdade garante que o dest ainda contém o valor a partir do qual o novo foi calculado, então uma atualização baseada em uma leitura desatualizada nunca é aplicada. Isso faz do cmpxchg a peça fundamental de uma atualização atômica, também conhecida como compare-and-swap (CAS):

    mov rax, qword [rdi]          ; rax = the value we expect to find
.retry:
    lea rcx, [rax + 10]           ; rcx = the new value we want to install
    lock cmpxchg qword [rdi], rcx ; if [rdi] still equals rax, store rcx and set ZF
                                  ; otherwise reload rax with the current value, clear ZF
    jnz  .retry                   ; ZF is cleared, so another thread won the race. Recompute and retry

Esse laço de retentativa é o coração das atualizações lock-free. A janela entre a leitura e a comparação e troca é exatamente quando outra thread pode intervir, e o cmpxchg detecta isso ao se recusar a armazenar um valor calculado a partir de uma leitura desatualizada.

Ordenação de memória

Toda operação até aqui mexeu em um único endereço. Quando as threads se coordenam por meio de mais de um endereço, surge uma nova pergunta: em que ordem as escritas de uma thread se tornam visíveis para outra. As regras que respondem a essa pergunta são a ordenação de memória do processador.

O conceito de código sem ramificações apresentou a ideia de que um núcleo moderno não avança pelas instruções uma de cada vez. Ele mantém muitas em execução ao mesmo tempo e segue adiante sempre que pode. Isso significa que uma escrita pode se tornar visível para os outros núcleos mais tarde do que o programa sugere, enquanto instruções posteriores a ela já seguiram adiante.

O x86-64 mantém uma ordenação de memória forte entre leituras e escritas comuns, de modo que, em cada núcleo:

  1. uma leitura nunca é reordenada para depois de uma leitura posterior;
  2. uma escrita nunca é reordenada para depois de uma escrita posterior;
  3. uma leitura nunca é reordenada para depois de uma escrita posterior.

A única reordenação possível é uma escrita parecer se completar depois de uma leitura posterior de um endereço diferente.

Uma instrução com o prefixo lock, ou um xchg com um operando de memória, é uma barreira completa: nada parece atravessá-la em nenhuma direção. É por isso que elas bastam para garantir a ordenação completa na maioria das situações.

Espera ativa e pause

As instruções que definem uma flag e também retornam seu estado anterior são conhecidas como test-and-set. Elas podem servir de base para um spinlock, garantindo que um núcleo tenha acesso exclusivo a alguma parte do código.

Este é o algoritmo geral, usando a instrução xchg com uma flag binária:

  1. A flag começa em 0.
  2. Para adquirir o lock, um núcleo troca o valor da flag por 1.
  3. Se o valor retornado for 1, isso significa que outro núcleo está com o lock. O núcleo atual então espera e tenta adquirir o lock de novo.
  4. Se o valor retornado for 0, isso significa que o lock estava livre. A xchg agora o definiu como 1, e os outros núcleos vão esperar até que este núcleo o libere.
  5. Quando o núcleo atual termina seu trabalho, atualizar a flag com 0 libera o lock.
acquire:
    mov  eax, 1
    xchg dword [rdi], eax ; try to take the lock; eax = its old value
    test eax, eax
    jnz  .held            ; old value was 1: someone else holds it
    ret                   ; old value was 0: the lock is ours
.held:
    pause                 ; wait before trying again
    jmp  acquire

A instrução pause no laço de espera não muda o que o código calcula. Ela avisa o processador de que se trata de uma espera ativa. A CPU pode então reduzir o consumo de energia na thread que espera e ceder espaço para uma thread irmã que compartilha o mesmo núcleo. Um laço de espera ativa sem pause continua correto, apenas desperdiça recursos.

Quando a thread termina seu trabalho, ela pode liberar o lock com uma escrita simples de 0 usando mov. Nada além de um mov é necessário, porque no x86-64 leituras e escritas nunca são reordenadas para depois de uma escrita posterior. Diz-se que uma escrita que nunca ultrapassa os acessos anteriores tem ordenação de liberação, e no x86 toda escrita simples carrega essa propriedade.

Note

Qualquer instrução que teste e defina um endereço de memória de forma atômica pode ser usada em um spinlock. Por exemplo, lock bts pode ser usada no lugar de xchg, para definir um bit específico enquanto verifica se ele já estava definido.

Repare que as flags, como a CF modificada pelo bts, fazem parte do rflags, um registrador. Isso significa que elas são exclusivas de cada thread.

Instruções

É a noite de estreia no Grand Marquee, e toda a equipe trabalha ao mesmo tempo. A multidão entra em peso por todas as portas, várias bilheterias vendem a partir do mesmo rolo de ingressos, a estreia está quase esgotada e, lá em cima, na cabine de projeção, os rolos precisam continuar rodando.

Tudo o que a equipe compartilha fica em um endereço de memória, e os testes chamam suas funções a partir de várias threads ao mesmo tempo. Um valor lido, alterado e gravado de volta em etapas separadas vai perder atualizações sob essa carga, então cada função precisa tornar sua atualização atômica.

Todos os valores compartilhados neste exercício são inteiros de 64 bits com sinal.

Você tem cinco tarefas.

Note

As atualizações neste exercício devem ser feitas com instruções atômicas. Os testes executam suas funções de forma concorrente e verificam que nenhuma atualização se perde, então uma leitura-modificação-gravação não atômica tem grande chance de não passar.

1. Conte as entradas

As portas estão abertas, e em cada uma delas um lanterninha está rasgando os ingressos. Cada grupo que passa é contado em uma única contagem de entradas compartilhada e, mesmo com todas as portas contando ao mesmo tempo, o total da casa precisa sair correto.

Implemente a função admit_group, que adiciona atomicamente o tamanho de um grupo à contagem.

Esta função recebe como argumentos, nesta ordem:

  • admissions: endereço de memória da contagem de entradas compartilhada, um inteiro de 64 bits com sinal.
  • group_size: o número de pessoas no grupo, um inteiro de 64 bits com sinal, sempre maior que 0.
admissions = 120, group_size = 4
// admissions is now 124

Os testes admitem grupos por várias portas ao mesmo tempo. A contagem final precisa ser igual à soma de todos os grupos que passaram.

Esta função não tem valor de retorno. A contagem deve ser atualizada no mesmo local.

2. Troque o rolo

O projetor carrega exatamente um rolo. Montar o próximo rolo e retirar o atual precisa ser um único movimento: com dois projecionistas na máquina, duas etapas separadas poderiam entregar o mesmo rolo para os dois, ou derrubar um no chão.

Implemente a função change_reel, que monta atomicamente o próximo rolo e retorna o que estava montado.

Esta função recebe como argumentos, nesta ordem:

  • mounted: endereço de memória do identificador do rolo montado, um inteiro de 64 bits com sinal.
  • next_reel: o identificador do rolo a montar, um inteiro de 64 bits com sinal.
mounted = 7, next_reel = 9
// => 7, and mounted is now 9

Esta função retorna o identificador do rolo montado anteriormente como um inteiro de 64 bits com sinal. Os testes fazem várias threads trocarem rolos pelo mesmo encaixe ao mesmo tempo, e verificam que todo rolo entregue volta a sair exatamente uma vez.

3. Venda um ingresso

Cada bilheteria vende a partir de uma contagem compartilhada de ingressos vendidos. Somar a uma contagem, como as portas fazem, não basta aqui: cada venda também precisa saber qual número foi atribuído a ela. Duas vendas não podem compartilhar um número, e depois da correria a contagem precisa ser exatamente igual ao número de ingressos vendidos.

Implemente a função sell_ticket, que adiciona um à contagem atomicamente e retorna o número do ingresso recém-vendido. O número do ingresso é a contagem depois desta venda: quando a contagem era 41, esta venda é o ingresso 42.

Esta função recebe como argumento:

  • tickets_sold: endereço de memória da contagem compartilhada de ingressos vendidos, um inteiro de 64 bits com sinal.
tickets_sold = 0
// => 1, and tickets_sold is now 1

tickets_sold = 41
// => 42, and tickets_sold is now 42

Esta função retorna o número do ingresso como um inteiro de 64 bits com sinal. Os testes vendem a partir de várias bilheterias ao mesmo tempo: cada número retornado precisa ser distinto, e a contagem final precisa ser igual ao número de vendas.

4. Encha a estreia

A estreia tem um número fixo de lugares, e todas as bilheterias estão vendendo ingressos para ela. Uma venda só pode ser concluída enquanto houver um lugar livre, e a sessão nunca pode ser vendida além da capacidade, nem mesmo quando todas as bilheterias disputam o último lugar no mesmo instante. Verificar a contagem e depois somar a ela em duas etapas separadas abre uma brecha no meio, então a verificação e a atualização precisam ter sucesso ou falhar juntas.

Implemente a função claim_seat, que ocupa atomicamente o próximo lugar quando ainda há um livre. Quando um lugar é ocupado, a função retorna o número do lugar, que é a contagem de lugares ocupados após esta ocupação. Quando a sessão estiver esgotada, ela retorna 0 e deixa a contagem inalterada.

Esta função recebe como argumentos, nesta ordem:

  • seats_taken: endereço de memória da contagem de lugares já ocupados, um inteiro de 64 bits com sinal, nunca acima de capacity.
  • capacity: o número de lugares da sessão, um inteiro de 64 bits com sinal, sempre maior que 0.
seats_taken = 0, capacity = 150
// => 1, and seats_taken is now 1

seats_taken = 82, capacity = 150
// => 83, and seats_taken is now 83

seats_taken = 149, capacity = 150
// => 150, and seats_taken is now 150

seats_taken = 150, capacity = 150
// => 0, and seats_taken stays 150

Esta função retorna o número do lugar, ou 0 quando a sessão estiver esgotada, como um inteiro de 64 bits com sinal.

5. Fique de olho na cabine

A cabine de projeção comporta uma pessoa. Uma palavra na memória diz se ela está ocupada: 0 para livre, 1 para ocupada. Funcionários passam a noite inteira por ali, cada um com uma tarefa a fazer lá dentro: assinar o livro de registro, passar o filme no projetor, arquivar um rolo. Seja qual for a tarefa, a regra da cabine é a mesma, e fazer valer essa regra é seu trabalho: uma pessoa lá dentro por vez, do início ao fim da tarefa dela.

Implemente a função visit_booth, que:

  1. espera até que a cabine esteja livre;
  2. ocupa a cabine;
  3. executa a tarefa do visitante;
  4. reabre a cabine depois da tarefa e antes de retornar.

Enquanto a cabine estiver ocupada por outra pessoa, deve esperar educadamente usando pause.

Esta função recebe como argumentos, nesta ordem:

  • booth: endereço de memória da palavra de ocupação, um inteiro de 64 bits com sinal que é 0 ou 1.
  • task: a tarefa do visitante, uma função que não recebe parâmetros e não tem valor de retorno.

Repare que os testes executam tarefas que olham para a cabine e para um livro de registro compartilhado. Isso significa que a cabine precisa ficar ocupada durante toda a tarefa, que cada visita precisa executar sua tarefa exatamente uma vez e que, com vários visitantes ao mesmo tempo, o livro de registro precisa sair exato.

Caution

A tarefa do visitante é uma função comum que segue a convenção de chamada:

  • ela pode sobrescrever qualquer registrador salvo pelo chamador, então você deve guardar tudo o que ainda precisar depois da tarefa em algum lugar onde a chamada não possa tocar;
  • ela também espera que a pilha esteja alinhada em 16 bytes no ponto da chamada.

Uma violação de qualquer uma das regras pode se manifestar como um travamento em vez de uma verificação que falha.

Editar via GitHub O link abre em uma nova janela ou aba
x86-64 Assembly Exercism

Tudo pronto para começar Noite de estreia?

Crie sua conta no Exercism para aprender e dominar x86-64 Assembly com 22 conceitos130 exercícios e mentoria humana de verdade, tudo de graça.