Sincronização e problemas de multiprocessador

Os aplicativos podem encontrar problemas quando executados em sistemas multiprocessadores devido a suposições que eles fazem, que são válidas apenas em sistemas de processador único.

Prioridades do thread

Considere um programa com dois threads, um com prioridade maior que o outro. Em um sistema de processador único, o thread de prioridade mais alta não abrirá mão do controle para o thread de prioridade mais baixa porque o agendador dá preferência a threads de prioridade mais alta. Em um sistema multiprocessador, ambos os threads podem ser executados simultaneamente, cada um em seu próprio processador.

Os aplicativos devem sincronizar o acesso a estruturas de dados para evitar condições de corrida. O código que pressupõe que os threads de prioridade mais alta sejam executados sem interferência de threads de prioridade mais baixa falhará em sistemas multiprocessadores.

Ordenação de memória

Quando um processador grava em um local de memória, o valor é armazenado em cache para melhorar o desempenho. Da mesma forma, o processador tenta atender às solicitações de leitura do cache para melhorar o desempenho. Além disso, os processadores começam a buscar valores da memória antes de serem solicitados pelo aplicativo. Isso pode acontecer como parte da execução especulativa ou devido a problemas de linha de cache.

Os caches de CPU podem ser particionados em bancos que podem ser acessados em paralelo. Isso significa que as operações de memória podem ser concluídas fora de ordem. Para garantir que as operações de memória sejam concluídas em ordem, a maioria dos processadores fornece instruções de barreira de memória. Uma barreira de memória completa garante que as operações de leitura e gravação de memória que aparecem antes da instrução de barreira de memória sejam confirmadas na memória antes de qualquer operação de leitura e gravação de memória que apareça após a instrução de barreira de memória. Uma barreira de memória de leitura ordena apenas as operações de leitura de memória, e uma barreira de memória de gravação ordena apenas as operações de gravação de memória. Essas instruções também garantem que o compilador desabilite quaisquer otimizações que possam reordenar operações de memória entre as barreiras.

Os processadores podem dar suporte a instruções para barreiras de memória com semântica de aquisição, versão e isolamento. Essas semânticas descrevem a ordem na qual os resultados de uma operação ficam disponíveis. Com a semântica de aquisição, os resultados da operação estão disponíveis antes dos resultados de qualquer operação que aparece depois dela no código. Com a semântica de versão, os resultados da operação estão disponíveis após os resultados de qualquer operação exibida antes dela no código. A semântica de isolamento combina semântica de aquisição e liberação. Os resultados de uma operação com semântica de isolamento estão disponíveis antes dos de qualquer operação que aparece depois dela no código e após os de qualquer operação exibida antes dela.

Em processadores x86 e x64 que dão suporte ao SSE2, as instruções são mfence (isolamento de memória), lfence (isolamento de carga) e sfence (isolamento de repositório). Em processadores ARM, as instruções são dmb e dsb. Para obter mais informações, confira a documentação do processador.

As seguintes funções de sincronização usam as barreiras apropriadas para garantir a ordenação de memória:

  • Funções que inserem ou deixam seções críticas
  • Funções que adquirem ou liberam bloqueios SRW
  • Início e conclusão da inicialização única
  • Função EnterSynchronizationBarrier
  • Funções que sinalizam objetos de sincronização
  • Funções de espera
  • Funções intertravadas (exceto funções com sufixo NoFence ou intrínsecos com sufixo _nf)

Corrigir uma condição de corrida

O código a seguir tem uma condição de corrida em um sistema multiprocessador porque o processador executando CacheComputedValue primeira vez pode gravar fValueHasBeenComputed na memória principal antes de gravar iValue na memória principal. Consequentemente, um segundo processador executando FetchComputedValue ao mesmo tempo lê fValueHasBeenComputed como TRUE, mas o novo valor de iValue ainda está no cache do primeiro processador e não foi gravado na memória.

int iValue;
BOOL fValueHasBeenComputed = FALSE;
extern int ComputeValue();

void CacheComputedValue()
{
  if (!fValueHasBeenComputed) 
  {
    iValue = ComputeValue();
    fValueHasBeenComputed = TRUE;
  }
}
 
BOOL FetchComputedValue(int *piResult)
{
  if (fValueHasBeenComputed) 
  {
    *piResult = iValue;
    return TRUE;
  } 

  else return FALSE;
}

Essa condição de corrida acima pode ser reparada usando a palavra-chave volátil ou a função InterlockedExchange para garantir que o valor de iValue seja atualizado para todos os processadores antes que o valor de fValueHasBeenComputed seja definido como TRUE.

A partir do Visual Studio 2005, se compilado no modo /volatile:ms, o compilador usa semântica de aquisição para operações de leitura em variáveis voláteis e semântica de versão para operações de gravação em variáveis voláteis (quando compatível com a CPU). Portanto, você pode corrigir o exemplo da seguinte maneira:

volatile int iValue;
volatile BOOL fValueHasBeenComputed = FALSE;
extern int ComputeValue();

void CacheComputedValue()
{
  if (!fValueHasBeenComputed) 
  {
    iValue = ComputeValue();
    fValueHasBeenComputed = TRUE;
  }
}
 
BOOL FetchComputedValue(int *piResult)
{
  if (fValueHasBeenComputed) 
  {
    *piResult = iValue;
    return TRUE;
  } 

  else return FALSE;
}

Com o Visual Studio 2003, referências voláteis a voláteis são ordenadas; o compilador não ordenará novamente o acesso à variável volátil. No entanto, essas operações podem ser re-ordenadas pelo processador. Portanto, você pode corrigir o exemplo da seguinte maneira:

int iValue;
BOOL fValueHasBeenComputed = FALSE;
extern int ComputeValue();

void CacheComputedValue()
{
  if (InterlockedCompareExchange((LONG*)&fValueHasBeenComputed, 
          FALSE, FALSE)==FALSE) 
  {
    InterlockedExchange ((LONG*)&iValue, (LONG)ComputeValue());
    InterlockedExchange ((LONG*)&fValueHasBeenComputed, TRUE);
  }
}
 
BOOL FetchComputedValue(int *piResult)
{
  if (InterlockedCompareExchange((LONG*)&fValueHasBeenComputed, 
          TRUE, TRUE)==TRUE) 
  {
    InterlockedExchange((LONG*)piResult, (LONG)iValue);
    return TRUE;
  } 

  else return FALSE;
}

Objetos de seção críticos

Acesso à variável intertravada

Funções de espera