Uma única instrução pode parecer uma ação indivisível quando não o é de todo. Vejamos o caso de somar cinco a um valor em memória:
add qword [rel counter], 5
Por baixo, o processador não tem forma de somar diretamente à memória. Divide esta única instrução em três passos mais pequenos, chamados micro-operações:
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
Este é um padrão comum chamado leitura-modificação-escrita (RMW).
Repara que a leitura (ou carregamento) e a escrita (ou armazenamento) são eventos separados, pelo que existe uma janela de tempo entre eles. Normalmente não damos por isso porque, num único núcleo, cada instrução tem garantia de produzir o seu efeito por completo antes da seguinte. Essa janela é, portanto, invisível e o add comporta-se como uma única unidade.
No entanto, os CPUs modernos raramente têm um único núcleo, e as aplicações correm muitas vezes em vários núcleos ao mesmo tempo, sem sequenciação entre eles. Com vários núcleos a correr no mesmo instante, outro núcleo pode ler ou escrever o counter dentro dessa janela, depois do carregamento deste núcleo e antes do seu armazenamento. Duas threads carregam cada uma o mesmo valor antigo, cada uma soma cinco e cada uma armazena o seu resultado. Ocorreram duas adições, mas o valor só avançou cinco. Uma atualização foi silenciosamente perdida.
Isto é uma corrida de dados, e é um problema comum em código com várias threads.
Nem todos os valores estão expostos desta forma. Cada thread tem os seus próprios registos e a sua própria pilha, pelo que um valor guardado num registo, ou uma variável local na pilha de uma thread, é exclusivo dessa thread e não pode sofrer uma corrida. Só a memória que as threads partilham, como o counter acima, precisa de proteção.
O x86-64 oferece um conjunto de instruções para resolver este problema, tornando uma instrução indivisível. Comporta-se como uma única unidade 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 coesa, que nenhum outro núcleo consegue dividir, chama-se atómica.
É comum referir-se às aplicações que correm em vários núcleos como sendo multi-threaded. No entanto, uma thread não é a mesma coisa que um núcleo. Duas threads podem correr em simultâneo no mesmo núcleo, intercalando-se uma com a outra, ou em paralelo em núcleos diferentes.
Threads intercaladas podem já entrar em corrida ao longo de uma leitura-modificação-escrita se esta estiver dividida em várias instruções, pois o sistema operativo pode trocar de thread entre duas instruções quaisquer. A janela dentro de uma única instrução, porém, só fica exposta por código verdadeiramente paralelo. Como o sistema operativo só troca de thread entre instruções, nunca dentro de uma, uma única instrução é inerentemente segura num único núcleo.
A verdadeira atomicidade em vários núcleos é o que as instruções abaixo proporcionam.
A instrução xchg troca dois operandos. O operando de destino passa a ser igual ao valor anterior no operando de origem, enquanto o operando de origem passa a ser igual ao valor anterior no operando de destino. Conceptualmente, pode pensar-se nela como duas instruções mov a acontecer ao mesmo tempo.
Como habitualmente, pode usar-se com dois operandos de registo ou com um operando de memória e um operando de registo:
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, o xchg é sempre atómico.
O xchg é automaticamente atómico quando um dos operandos é uma posição de memória. Isso também significa que a operação é muito mais lenta nessa situação.
Se não precisares de atomicidade, faz a troca através de um registo livre com instruções mov simples.
A forma mais comum de tornar uma instrução atómica em x86-64 é acrescentar-lhe o prefixo lock. Funde a leitura, a modificação e a escrita num único passo indivisível. Isto significa que o núcleo detém a memória em exclusivo durante todo o processo, pelo que nenhum outro núcleo pode ler ou escrever nessa posição pelo meio.
lock add qword [rel counter], 5 ; the read, the modify, and the write are one step
O lock só funciona quando o destino é memória, e apenas em instruções que fazem leitura-modificação-escrita dessa memória:
add, sub, inc, dec, neg;and, or, xor, not;bts, btr, btc;xadd e cmpxchg, descritas abaixo.Deter uma posição em exclusivo e manter todos os outros núcleos de fora não sai de graça. Uma operação com o prefixo lock é bastante mais lenta do que a sua forma simples, e ainda mais lenta quando vários núcleos disputam a mesma posição.
Este prefixo deve ser reservado para memória que se espera ser modificada por mais do que uma thread. Evita usá-lo se a memória não for partilhada ou se for apenas lida.
Um lock add simples atualiza a memória, mas deita 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 distinto.
A instrução xadd (x de exchange) devolve o valor anterior ao mesmo tempo que soma. Escreve a soma no destino e deixa o valor original do destino no registo 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, isto é um fetch-and-add atómico. Executada por muitas threads sobre o mesmo contador, cada chamada devolve um valor antigo diferente.
Tal como com o lock add, o contador termina no número exato de chamadas. No entanto, ao contrário do lock add, todos os valores intermédios também são devolvidos, um a cada chamador.
O xadd soma e o xchg substitui, mas nenhum deles consegue fazer com que o novo valor dependa do atual e o aplique apenas se nada tiver mudado por baixo. Essa atualização condicional é o que o cmpxchg, comparação e troca, proporciona, e é a mais geral destas primitivas.
cmpxchg dest, src usa rax como acumulador implícito e compara-o com dest:
dest == rax, dest = src e ZF = 1.dest != rax, rax = dest e ZF = 0.Repara que dest só é atualizado quando é igual ao valor esperado, previamente carregado em rax. Essa igualdade garante que dest ainda contém o valor a partir do qual o novo foi calculado, pelo que nunca se aplica uma atualização baseada numa leitura obsoleta. Isso faz do cmpxchg a peça base 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
Este ciclo de repetição é o coração das atualizações sem bloqueio. A janela entre a leitura e a comparação e troca é exatamente quando outra thread pode intervir, e o cmpxchg deteta isso ao recusar-se a armazenar um valor calculado a partir de uma leitura obsoleta.
Todas as operações até aqui tocaram numa única posição. Quando as threads coordenam através de mais do que uma posição, surge uma nova pergunta: por que ordem se tornam visíveis para outra thread as escritas de uma thread. As regras que respondem a esta pergunta são a ordenação de memória do processador.
O conceito de código sem ramificações introduziu a ideia de que um núcleo moderno não percorre as instruções a passo vagaroso, uma a uma. Mantém muitas em curso ao mesmo tempo e avança à frente sempre que pode. Isto significa que uma escrita pode tornar-se visível para os outros núcleos mais tarde do que o programa sugere, enquanto instruções posteriores já avançaram.
O x86-64 mantém uma ordenação de memória forte entre carregamentos e armazenamentos comuns, de modo que, em cada núcleo:
A única reordenação possível é um armazenamento parecer concluir-se depois de um carregamento 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 mover-se através dela em qualquer direção. É por isso que são suficientes para garantir a ordenação completa na maioria das situações.
pause
As instruções que definem um sinalizador e devolvem ao mesmo tempo o seu estado anterior são conhecidas como test-and-set. Podem servir de base a um spinlock, garantindo que um núcleo tem acesso exclusivo a uma parte do código.
Este é o algoritmo geral, usando a instrução xchg com um sinalizador binário:
0.1.1, isso significa que o bloqueio está detido por outro núcleo. O núcleo atual espera então e tenta adquirir o bloqueio novamente.0, isso significa que o bloqueio estava livre. O xchg já o colocou em 1, e os outros núcleos esperarão até este núcleo o libertar.0 liberta o bloqueio.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 ciclo de espera não altera o que o código calcula. Dá uma indicação ao processador de que se trata de uma espera ativa. A CPU pode então reduzir o consumo de energia na thread em espera e ceder a uma thread irmã que partilhe o mesmo núcleo. Um ciclo de espera sem pause continua a ser correto, apenas desperdiça recursos.
Quando a thread termina o seu trabalho, pode libertar o bloqueio com um simples armazenamento de 0 através de mov. Não é preciso mais do que um mov, porque no x86-64 os carregamentos e armazenamentos nunca são reordenados depois de um armazenamento posterior. Um armazenamento que nunca ultrapassa os acessos que o antecedem diz-se ter ordenação de libertação, e no x86 todos os armazenamentos comuns a têm.
Qualquer instrução que teste e defina atomicamente uma posição de memória pode ser usada para um spinlock. Por exemplo, lock bts pode ser usado em vez de xchg, para definir um bit específico enquanto verifica se já estava definido.
Repara que os sinalizadores, como o CF modificado por bts, fazem parte de rflags, um registo. Isso significa que são exclusivos de cada thread.
É a noite de estreia no Grand Marquee e toda a equipa está a trabalhar ao mesmo tempo. A multidão entra a rodos por todas as portas, várias bilheteiras vendem a partir do mesmo rolo de bilhetes, a estreia está quase esgotada e, lá em cima, na cabine de projeção, as bobinas têm de continuar a andar.
Tudo o que a equipa partilha vive num endereço de memória, e os testes chamam as tuas funções a partir de vários threads ao mesmo tempo. Um valor que é lido, alterado e escrito de volta em passos separados vai perder atualizações com esta carga, por isso cada função tem de tornar a sua atualização atómica.
Todos os valores partilhados neste exercício são inteiros com sinal de 64 bits.
Tens cinco tarefas.
As atualizações neste exercício devem ser feitas com instruções atómicas. Os testes executam as tuas funções em simultâneo e verificam que nenhuma atualização se perde, por isso é muito provável que uma leitura, alteração e escrita não atómicas os faça falhar.
As portas estão abertas e em cada uma delas há um arrumador a picar bilhetes. Cada grupo que passa é contado numa única contagem partilhada de entradas e, com todas as portas a contar ao mesmo tempo, a contagem da sala tem de continuar a bater certo.
Implementa a função admit_group, que adiciona atomicamente o tamanho de um grupo à contagem.
Esta função recebe como argumentos, por esta ordem:
admissions: endereço de memória da contagem partilhada de entradas, um inteiro com sinal de 64 bits.group_size: o número de pessoas no grupo, um inteiro com sinal de 64 bits, sempre maior do 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 tem de ser igual à soma de todos os grupos que passaram.
Esta função não tem valor devolvido. A contagem deve ser atualizada no próprio local.
O projetor leva exatamente uma bobina. Montar a bobina seguinte e retirar a atual tem de ser um único movimento: com dois projecionistas na máquina, dois passos separados podiam entregar a mesma bobina aos dois, ou deixar cair uma ao chão.
Implementa a função change_reel, que monta atomicamente a bobina seguinte e devolve a que estava montada.
Esta função recebe como argumentos, por esta ordem:
mounted: endereço de memória do identificador da bobina montada, um inteiro com sinal de 64 bits.next_reel: o identificador da bobina a montar, um inteiro com sinal de 64 bits.mounted = 7, next_reel = 9
// => 7, and mounted is now 9
Esta função devolve o identificador da bobina que estava montada anteriormente, como um inteiro com sinal de 64 bits. Os testes fazem com que vários threads troquem bobinas no mesmo suporte ao mesmo tempo e verificam que cada bobina entregue sai exatamente uma vez.
Cada bilheteira vende a partir de uma contagem partilhada de bilhetes vendidos. Adicionar a uma contagem, como fazem as portas, não chega aqui: cada venda tem também de ficar a saber que número lhe foi atribuído. Não pode haver duas vendas com o mesmo número e, depois da correria, a contagem tem de ser exatamente igual ao número de bilhetes vendidos.
Implementa a função sell_ticket, que adiciona atomicamente um à contagem e devolve o número do bilhete acabado de vender.
O número do bilhete é a contagem depois desta venda: quando a contagem era 41, esta venda é o bilhete 42.
Esta função recebe como argumento:
tickets_sold: endereço de memória da contagem partilhada de bilhetes vendidos, um inteiro com sinal de 64 bits.tickets_sold = 0
// => 1, and tickets_sold is now 1
tickets_sold = 41
// => 42, and tickets_sold is now 42
Esta função devolve o número do bilhete como um inteiro com sinal de 64 bits. Os testes vendem a partir de várias bilheteiras ao mesmo tempo: todos os números devolvidos têm de ser distintos e a contagem final tem de ser igual ao número de vendas.
A estreia tem um número fixo de lugares e todas as bilheteiras a estão a vender. Uma venda só pode avançar enquanto houver um lugar livre, e a sessão nunca pode ficar sobrelotada, nem mesmo quando todas as bilheteiras se atiram ao último lugar no mesmo instante. Verificar a contagem e depois adicionar-lhe algo, em dois passos separados, deixa uma janela de oportunidade entre os dois, por isso a verificação e a atualização têm de ter sucesso ou falhar em conjunto.
Implementa a função claim_seat, que ocupa atomicamente o lugar seguinte quando ainda há um livre.
Quando um lugar é ocupado, a função devolve o número do lugar, que é a contagem de lugares ocupados depois desta reserva.
Quando a sessão está esgotada, devolve 0 e deixa a contagem inalterada.
Esta função recebe como argumentos, por esta ordem:
seats_taken: endereço de memória da contagem de lugares já ocupados, um inteiro com sinal de 64 bits, nunca acima de capacity.capacity: o número de lugares da sessão, um inteiro com sinal de 64 bits, sempre maior do 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 devolve o número do lugar, ou 0 quando a sessão está esgotada, como um inteiro com sinal de 64 bits.
A cabine de projeção só leva uma pessoa.
Uma palavra na memória diz se está ocupada: 0 para livre, 1 para ocupada.
A equipa passa por lá toda a noite, cada um com uma tarefa para fazer lá dentro: assinar o livro de registo, enfiar a película no projetor, arquivar uma bobina.
Seja qual for a tarefa, a regra da cabine é a mesma, e garantir que é cumprida é a tua tarefa: uma pessoa lá dentro de cada vez, desde o início da tarefa até ao fim.
Implementa a função visit_booth, que:
Enquanto a cabine estiver ocupada por outra pessoa, deve esperar educadamente com pause.
Esta função recebe como argumentos, por esta ordem:
booth: endereço de memória da palavra de ocupação, um inteiro com sinal de 64 bits que é 0 ou 1.task: a tarefa do visitante, uma função que não recebe parâmetros e não tem valor devolvido.Nota que os testes executam tarefas que olham para a cabine e para um livro de registo partilhado. Isto significa que a cabine tem de estar ocupada durante toda a tarefa, cada visita tem de executar a sua tarefa exatamente uma vez e, com vários visitantes ao mesmo tempo, o livro de registo tem de ficar exato.
A tarefa do visitante é uma função normal que segue a convenção de chamada:
Uma violação de qualquer uma destas regras pode fazer o programa rebentar em vez de dar origem a uma verificação falhada.
Inscreve-te no Exercism para aprenderes e dominares x86-64 Assembly com 22 conceitos130 exercícios, e mentoria humana real, tudo grátis.