Um dos usuários do compilador Visual C ++ deu o seguinte exemplo de código e perguntou por que o loop com a condição é executado sem parar, embora em algum momento a condição deva parar e o ciclo termine:
#include <windows.h> int x = 0, y = 1; int* ptr; DWORD CALLBACK ThreadProc(void*) { Sleep(1000); ptr = &y; return 0; } int main(int, char**) { ptr = &x; // starts out pointing to x DWORD id; HANDLE hThread = CreateThread(nullptr, 0, ThreadProc, 0, &id); // , ptr // while (*ptr == 0) { } return 0; }
Para aqueles que não estão familiarizados com os recursos específicos da plataforma Windows, eis o equivalente em C ++ puro:
#include <chrono> #include <thread> int x = 0, y = 1; int* ptr = &x; void ThreadProc() { std::this_thread::sleep_for(std::chrono::seconds(1)); ptr = &y; } int main(int, char**) { ptr = &x; // starts out pointing to x std::thread thread(ThreadProc); // , ptr // while (*ptr == 0) { } return 0; }
Em seguida, o usuário trouxe sua compreensão do programa:
O loop condicional foi transformado em infinito pelo compilador. Eu vejo isso no código do assembler gerado, que carrega o valor do ponteiro ptr no registrador (no início do loop) e, em seguida, compara o valor desse registrador com zero a cada iteração. Como o recarregamento do valor de ptr nunca acontece novamente, o ciclo nunca termina.
Entendo que declarar ptr como "int volátil *" deve fazer com que o compilador elimine otimizações e leia o valor de ptr a cada iteração do loop, o que resolverá o problema. Mas ainda assim eu gostaria de saber por que o compilador não pode ser inteligente o suficiente para fazer essas coisas automaticamente? Obviamente, a variável global usada em dois threads diferentes pode ser alterada, o que significa que não pode ser simplesmente armazenada em cache no registro. Por que o compilador não pode gerar imediatamente o código correto?
Antes de responder a essa pergunta, vamos começar com um pouco de nit-picking: “volatile int * ptr” não declara a variável ptr como um “ponteiro para o qual as otimizações são proibidas”. Este é um "ponteiro normal para uma variável para a qual as otimizações são proibidas". O que o autor da pergunta acima tinha em mente deveria ser declarado como "int * volátil ptr".
Agora, de volta à questão principal. O que está acontecendo aqui?
Mesmo uma rápida olhada no código nos dirá que não existem variáveis como std :: atomic, nem o uso de std :: memory_order (explícito ou implícito). Isso significa que qualquer tentativa de acessar ptr ou * ptr de dois fluxos diferentes leva a um comportamento indefinido. Intuitivamente, você pode pensar dessa maneira: “O compilador otimiza cada thread como se estivesse sendo executado sozinho no programa. Os únicos pontos em que o compilador DEVE pensar em acessar dados de diferentes fluxos estão usando std :: atomic ou std :: memory_order. ”
Isso explica por que o programa não se comportou como o programador esperava. A partir do momento em que você permite um comportamento vago - absolutamente nada pode ser garantido.
Mas tudo bem, vamos pensar na segunda parte de sua pergunta: por que o compilador não é inteligente o suficiente para reconhecer essa situação e desativar automaticamente a otimização carregando o valor do ponteiro no registro? Bem, o compilador aplica automaticamente todos os possíveis e não contrários ao padrão de otimização. Seria estranho exigir que ele fosse capaz de ler os pensamentos de um programador e desativar algumas otimizações que não contradizem o padrão, que, talvez, de acordo com o programador, deva mudar a lógica do programa para melhor. “Ah, e se esse ciclo esperar uma mudança no valor da variável global em outro segmento, embora não tenha sido explicitamente anunciado? Levarei cem vezes mais devagar para estar pronto para esta situação! " Deveria ser assim? Dificilmente.
Mas suponha que adicionemos uma regra ao compilador como "Se a otimização levou ao aparecimento de um loop infinito, você deve cancelá-lo e coletar o código sem otimização". Ou ainda assim: "Cancele sucessivamente otimizações individuais até que o resultado seja um loop não infinito". Além das surpreendentes surpresas que isso trará, isso trará algum benefício?
Sim, neste caso teórico, não teremos um loop infinito. Será interrompido se algum outro fluxo gravar um valor diferente de zero em * ptr. Também será interrompido se outro segmento gravar um valor diferente de zero na variável x. Torna-se claro o quão profundamente a análise das dependências deve funcionar para "capturar" todos os casos que podem afetar a situação. Como o compilador não inicia o programa criado e não analisa seu comportamento no tempo de execução, a única saída é supor que nenhuma chamada para variáveis globais, ponteiros e referências possa ser otimizada.
int limit; void do_something() { ... if (value > limit) value = limit;
Isso é completamente contrário ao espírito do C ++. O padrão da linguagem diz que, se você modificar uma variável e esperar ver essa modificação em outro encadeamento, deverá dizer explicitamente o seguinte: use uma operação atômica ou organize o acesso à memória (geralmente usando um objeto de sincronização).
Então, faça exatamente isso.