Pergunta

Esta pergunta e minha resposta abaixo são principalmente uma resposta a uma área de confusão em outra pergunta.

No final da resposta, há alguns problemas "voláteis" do WRT e sincronização de threads sobre os quais não estou totalmente confiante - agradeço comentários e respostas alternativas.O ponto da questão refere-se principalmente aos registros da CPU e como eles são usados.

Foi útil?

Solução

Os registros da CPU são pequenas áreas de armazenamento de dados no silício da CPU. Para a maioria das arquiteturas, elas são o principal local que todas as operações acontecem (os dados são carregados da memória, operados e empurrados de volta).

Qualquer tópico em execução usa os registros e possui o ponteiro de instrução (que diz qual instrução vem a seguir). Quando o sistema operacional troca em outro segmento, todo o estado da CPU, incluindo os registros e o ponteiro de instrução, é salvo em algum lugar, efetivamente congelando o estado do tópico para quando ele voltar à vida.

Muito mais documentação sobre tudo isso, é claro, em todo o lugar. Wikipedia nos registros. Wikipedia na troca de contexto. para iniciantes. Editar: ou leia a resposta de Steve314. :)

Outras dicas

Os registros são o “armazenamento de trabalho” em uma CPU.Eles são muito rápidos, mas têm recursos muito limitados.Normalmente, uma CPU possui um pequeno conjunto fixo de registradores nomeados, sendo os nomes parte da convenção da linguagem assembly para o código de máquina dessa CPU.Por exemplo, as CPUs Intel x86 de 32 bits têm quatro registros de dados principais denominados eax, ebx, ecx e edx, juntamente com vários registros de indexação e outros registros mais especializados.

Estritamente falando, isso não é verdade hoje em dia - renomear registros, por exemplo, é comum.Alguns processadores têm registros suficientes para numerá-los em vez de nomeá-los, etc.Continua, no entanto, a ser um bom modelo básico para trabalhar.Por exemplo, a renomeação de registradores é usada para preservar a ilusão deste modelo básico, apesar da execução fora de ordem.

O uso de registradores em assembler escrito manualmente tende a ter um padrão simples de uso de registradores.Algumas variáveis ​​serão mantidas puramente em registros durante uma sub-rotina, ou alguma parte substancial dela.Outros registradores são usados ​​em um padrão de leitura-modificação-gravação.Por exemplo...

mov eax, [var1]
add eax, [var2]
mov [var1], eax

IIRC, esse é um código assembler x86 válido (embora provavelmente ineficiente).Em um Motorola 68000, eu poderia escrever...

move.l [var1], d0
add.l  [var2], d0
move.l d0, [var1]

Desta vez, a origem geralmente é o parâmetro da esquerda, com o destino à direita.O 68000 tinha 8 registradores de dados (d0..d7) e 8 registradores de endereço (a0..a7), com a7 IIRC também servindo como ponteiro de pilha.

Em um 6510 (de volta ao bom e velho Commodore 64) eu poderia escrever...

lda    var1
adc    var2
sta    var1

Os registros aqui estão em sua maioria implícitos nas instruções - acima de tudo, usam o registro A (acumulador).

Por favor, perdoe quaisquer erros bobos nestes exemplos - eu não escrevi nenhuma quantidade significativa de assembler "real" (em vez de virtual) por pelo menos 15 anos.O princípio é o ponto, no entanto.

O uso de registradores é específico para um determinado fragmento de código.O que um registrador contém é basicamente qualquer que seja a última instrução restante nele.É responsabilidade do programador acompanhar o que está em cada registro em cada ponto do código.

Ao chamar uma sub-rotina, o chamador ou o receptor devem assumir a responsabilidade de garantir que não haja conflito, o que geralmente significa que os registros são salvos na pilha no início da chamada e lidos novamente no final.Problemas semelhantes ocorrem com interrupções.Coisas como quem é responsável por salvar os registros (chamador ou receptor) normalmente fazem parte da documentação de cada sub-rotina.

Um compilador normalmente decidirá como usar os registradores de uma forma muito mais sofisticada do que um programador humano, mas opera segundo os mesmos princípios.O mapeamento de registros para variáveis ​​específicas é dinâmico e varia drasticamente de acordo com o fragmento de código que você está visualizando.Salvar e restaurar registros é feito principalmente de acordo com convenções padrão, embora o compilador possa improvisar "convenções de chamada personalizadas" em algumas circunstâncias.

Normalmente, imagina-se que as variáveis ​​locais em uma função residem na pilha.Esta é a regra geral com variáveis ​​"automáticas" em C.Como "auto" é o padrão, essas são variáveis ​​locais normais.Por exemplo...

void myfunc ()
{
  int i;  //  normal (auto) local variable
  //...
  nested_call ();
  //...
}

No código acima, “i” pode muito bem ser mantido principalmente em um registro.Ele pode até ser movido de um registro para outro e vice-versa conforme a função avança.No entanto, quando "nested_call" é chamado, o valor desse registro quase certamente estará na pilha - seja porque a variável é uma variável de pilha (não um registro) ou porque o conteúdo do registro é salvo para permitir que nested_call tenha seu próprio armazenamento de trabalho .

Em um aplicativo multithreading, variáveis ​​locais normais são locais para um thread específico.Cada thread recebe sua própria pilha e, enquanto está em execução, uso exclusivo dos registros da CPU.Em uma troca de contexto, esses registros são salvos.Seja nos registradores ou na pilha, as variáveis ​​locais não são compartilhadas entre threads.

Esta situação básica é preservada em uma aplicação multicore, mesmo que duas ou mais threads possam estar ativas ao mesmo tempo.Cada núcleo possui sua própria pilha e seus próprios registros.

Os dados armazenados na memória compartilhada requerem mais cuidado.Isso inclui variáveis ​​globais, variáveis ​​estáticas dentro de classes e funções e objetos alocados em heap.Por exemplo...

void myfunc ()
{
  static int i;  //  static variable
  //...
  nested_call ();
  //...
}

Neste caso, o valor de “i” é preservado entre chamadas de função.Uma região estática da memória principal é reservada para armazenar este valor (daí o nome "estático").Em princípio, não há necessidade de nenhuma ação especial para preservar "i" durante a chamada para "nested_call" e, à primeira vista, a variável pode ser acessada a partir de qualquer thread rodando em qualquer núcleo (ou mesmo em uma CPU separada).

No entanto, o compilador ainda está trabalhando duro para otimizar a velocidade e o tamanho do seu código.Leituras e gravações repetidas na memória principal são muito mais lentas que os acessos a registradores.O compilador quase certamente escolherá não seguirá o padrão simples de leitura-modificação-gravação descrito acima, mas em vez disso manterá o valor no registrador por um período relativamente prolongado, evitando leituras e gravações repetidas na mesma memória.

Isso significa que as modificações feitas em um thread podem não ser vistas por outro thread por algum tempo.Dois tópicos podem acabar tendo ideias muito diferentes sobre o valor de “i” acima.

Não existe uma solução mágica de hardware para isso.Por exemplo, não existe mecanismo para sincronizar o registro entre threads.Para a CPU, a variável e o registrador são entidades completamente separadas - ela não sabe que precisam ser sincronizadas.Certamente não há sincronização entre registros em threads diferentes ou em execução em núcleos diferentes - não há razão para acreditar que outro thread esteja usando o mesmo registro para a mesma finalidade em um determinado momento.

Uma solução parcial é sinalizar uma variável como "volátil"...

void myfunc ()
{
  volatile static int i;
  //...
  nested_call ();
  //...
}

Isso diz ao compilador para não otimizar leituras e gravações na variável.O processador não tem conceito de volatilidade.Esta palavra-chave diz ao compilador para gerar código diferente, fazendo leituras e gravações imediatas na memória conforme especificado pelas atribuições, em vez de evitar esses acessos usando um registrador.

Isso é não uma solução de sincronização multithreading, entretanto - pelo menos não em si.Uma solução multithreading apropriada é usar algum tipo de bloqueio para gerenciar o acesso a esse “recurso compartilhado”.Por exemplo...

void myfunc ()
{
  static int i;
  //...
  acquire_lock_on_i ();
  //  do stuff with i
  release_lock_on_i ();
  //...
}

Há mais coisas acontecendo aqui do que é imediatamente óbvio.Em princípio, em vez de escrever o valor de "i" de volta em sua variável pronta para a chamada "release_lock_on_i", ele poderia ser salvo na pilha.No que diz respeito ao compilador, isso não é irracional.De qualquer maneira, ele está acessando a pilha (por exemplosalvando o endereço de retorno), portanto, salvar o registro na pilha pode ser mais eficiente do que gravá-lo de volta em "i" - mais amigável ao cache do que acessar um bloco de memória completamente separado.

Infelizmente, porém, a função release lock não sabe que a variável ainda não foi gravada na memória, portanto não pode fazer nada para corrigi-la.Afinal, essa função é apenas uma chamada de biblioteca (a verdadeira liberação de bloqueio pode estar oculta em uma chamada mais profundamente aninhada) e essa biblioteca pode ter sido compilada anos antes do seu aplicativo - ela não sabe como seus chamadores usam registradores ou pilha.Essa é uma grande parte da razão pela qual usamos uma pilha e por que as convenções de chamada precisam ser padronizadas (por exemplo,quem salva os registros).A função de liberação de bloqueio não pode forçar os chamadores a "sincronizar" os registros.

Da mesma forma, você pode vincular novamente um aplicativo antigo a uma nova biblioteca - o chamador não sabe o que "release_lock_on_i" faz ou como, é apenas uma chamada de função.Ele não sabe que precisa primeiro salvar os registros na memória.

Para resolver isso, podemos trazer de volta o “volátil”.

void myfunc ()
{
  volatile static int i;
  //...
  acquire_lock_on_i ();
  //  do stuff with i
  release_lock_on_i ();
  //...
}

Podemos usar uma variável local normal temporariamente enquanto o bloqueio estiver ativo, para dar ao compilador a chance de usar um registrador para aquele breve período.Em princípio, porém, um bloqueio deve ser liberado o mais rápido possível, portanto não deve haver tanto código nele.Se o fizermos, porém, escreveremos nossa variável temporária de volta em “i” antes de liberar o bloqueio, e a volatilidade de “i” garantirá que ela seja gravada de volta na memória principal.

Em princípio, isso não é suficiente.Escrever na memória principal não significa que você gravou na memória principal - há camadas de cache para percorrer e seus dados podem ficar em qualquer uma dessas camadas por um tempo.Há um problema de "barreira de memória" aqui e não sei muito sobre isso - mas felizmente esse problema é de responsabilidade das chamadas de sincronização de threads, como as chamadas de aquisição e liberação de bloqueio acima.

No entanto, esse problema de barreira de memória não elimina a necessidade da palavra-chave "volátil".

Licenciado em: CC-BY-SA com atribuição
Não afiliado a StackOverflow
scroll top