O programa multithreading travou no modo otimizado, mas é executado normalmente em -O0

68

Eu escrevi um simples programa multithreading da seguinte maneira:

static bool finished = false;

int func()
{
    size_t i = 0;
    while (!finished)
        ++i;
    return i;
}

int main()
{
    auto result=std::async(std::launch::async, func);
    std::this_thread::sleep_for(std::chrono::seconds(1));
    finished=true;
    std::cout<<"result ="<<result.get();
    std::cout<<"\nmain thread id="<<std::this_thread::get_id()<<std::endl;
}

Ele se comporta normalmente no modo de depuração no Visual studio ou -O0no gc c e imprime o resultado após 1segundos. Mas ficou preso e não imprime nada no modo Release ou -O1 -O2 -O3.

sz ppeter
fonte
Comentários não são para discussão prolongada; esta conversa foi movida para o bate-papo .
Samuel Liew

Respostas:

100

Dois tópicos, acessando um não-atômica, variável não vigiado são UB Isto diz respeito finished. Você pode fazer finisheddo tipo std::atomic<bool>para corrigir isso.

Minha correção:

#include <iostream>
#include <future>
#include <atomic>

static std::atomic<bool> finished = false;

int func()
{
    size_t i = 0;
    while (!finished)
        ++i;
    return i;
}

int main()
{
    auto result=std::async(std::launch::async, func);
    std::this_thread::sleep_for(std::chrono::seconds(1));
    finished=true;
    std::cout<<"result ="<<result.get();
    std::cout<<"\nmain thread id="<<std::this_thread::get_id()<<std::endl;
}

Resultado:

result =1023045342
main thread id=140147660588864

Demonstração ao vivo no coliru


Alguém pode pensar: 'É um bool- provavelmente um pouco. Como isso pode ser não atômico? (Eu fiz quando comecei com o multi-threading.)

Mas observe que a falta de rasgo não é a única coisa que std::atomiclhe dá. Também torna bem definido o acesso simultâneo de leitura e gravação de vários threads, impedindo o compilador de assumir que a leitura da variável sempre verá o mesmo valor.

Criar um boolnão-atômico e desprotegido pode causar problemas adicionais:

  • O compilador pode decidir otimizar a variável em um registro ou até vários acessos CSE em um e elevar a carga de um loop.
  • A variável pode ser armazenada em cache para um núcleo de CPU. (Na vida real, CPUs têm caches coerentes . Isto não é um problema real, mas padrão do C ++ é suficiente solta para cobrir hipotética C ++ implementações na memória não-coerente compartilhado onde atomic<bool>com memory_order_relaxedloja / carga iria trabalhar, mas onde volatilenão. Usando volátil para isso seria UB, embora funcione na prática em implementações reais de C ++.)

Para impedir que isso aconteça, o compilador deve ser informado explicitamente para não fazer isso.


Estou um pouco surpreso com a discussão em evolução sobre a possível relação volatilecom esse problema. Assim, eu gostaria de gastar meus dois centavos:

Scheff
fonte
4
Dei uma olhada func()e pensei: "Eu poderia otimizar isso" O otimizador não liga para threads e detecta o loop infinito, e felizmente o transformará em um "tempo (Verdadeiro)" Se olharmos para o Godbolt .org / z / Tl44iN , podemos ver isso. Se terminar, Trueele retorna. Se não, ele vai para um salto de volta incondicional de si (um loop infinito) no rótulo.L5
Baldrickk
2
@val: basicamente não há razão para abusar volatiledo C ++ 11 porque você pode obter asm idêntico ao atomic<T>e std::memory_order_relaxed. No entanto, ele funciona em hardware real: os caches são coerentes, portanto, uma instrução de carregamento não pode continuar lendo um valor obsoleto uma vez que uma loja em outro núcleo se compromete a armazenar em cache lá. (MESI)
Peter Cordes
5
@ PeterCordes O uso volatileainda é de UB. Você realmente nunca deve assumir que algo que é definitivo e claramente o UB é seguro apenas porque você não consegue pensar em como isso pode dar errado e funcionou quando você o tentou. Isso tem queimado as pessoas repetidas vezes.
David Schwartz
2
@Damon Mutexes tem liberação / aquisição de semântica. O compilador não tem permissão para otimizar a leitura se um mutex foi bloqueado antes, protegendo-o finishedcom um std::mutextrabalho (sem volatileou atomic). De fato, você pode substituir todos os átomos por um valor "simples" + esquema mutex; ainda funcionaria e seria mais lento. atomic<T>é permitido usar um mutex interno; somente atomic_flagé garantido sem bloqueios.
Erlkoenig 25/10/19
42

A resposta de Scheff descreve como corrigir seu código. Eu pensei em acrescentar um pouco de informação sobre o que realmente está acontecendo neste caso.

Compilei seu código no godbolt usando o nível de otimização 1 ( -O1). Sua função compila da seguinte maneira:

func():
  cmp BYTE PTR finished[rip], 0
  jne .L4
.L5:
  jmp .L5
.L4:
  mov eax, 0
  ret

Então o que está acontecendo aqui? Primeiro, temos uma comparação: cmp BYTE PTR finished[rip], 0- isso verifica se finishedé falso ou não.

Se é não falsa (aka true) devemos sair do loop na primeira execução. Isto conseguido jne .L4pelo qual j umps quando n ot e qua a etiqueta .L4em que o valor de i( 0) é armazenada num registo para utilização posterior e a função retorna.

Se for falso, no entanto, passamos para

.L5:
  jmp .L5

Este é um salto incondicional, para rotular .L5que por acaso é o próprio comando de salto.

Em outras palavras, o encadeamento é colocado em um loop ocupado infinito.

Então, por que isso aconteceu?

No que diz respeito ao otimizador, os threads estão fora de seu alcance. Ele assume que outros threads não estão lendo ou gravando variáveis ​​simultaneamente (porque isso seria UB de corrida de dados). Você precisa dizer que ele não pode otimizar acessos ausentes. É aqui que a resposta de Scheff entra. Não vou me incomodar em repeti-lo.

Como o otimizador não é informado de que a finishedvariável pode potencialmente mudar durante a execução da função, ela vê que finishednão é modificada pela própria função e assume que é constante.

O código otimizado fornece os dois caminhos de código que resultarão da entrada na função com um valor bool constante; ou ele executa o loop infinitamente, ou o loop nunca é executado.

no -O0compilador (como esperado) não otimiza o corpo do loop e a comparação:

func():
  push rbp
  mov rbp, rsp
  mov QWORD PTR [rbp-8], 0
.L148:
  movzx eax, BYTE PTR finished[rip]
  test al, al
  jne .L147
  add QWORD PTR [rbp-8], 1
  jmp .L148
.L147:
  mov rax, QWORD PTR [rbp-8]
  pop rbp
  ret

portanto, a função, quando não otimizada, funciona, a falta de atomicidade aqui normalmente não é um problema, porque o código e o tipo de dados são simples. Provavelmente, o pior que podemos encontrar aqui é um valor ique está fora de um para o que deveria ser.

Um sistema mais complexo com estruturas de dados tem muito mais probabilidade de resultar em dados corrompidos ou execução incorreta.

Baldrickk
fonte
3
O C ++ 11 faz com que os threads e um modelo de memória com reconhecimento de thread façam parte da própria linguagem. Isso significa que os compiladores não podem inventar gravações nem para atomicvariáveis ​​no código que não escreve essas variáveis. por exemplo, if (cond) foo=1;não pode ser transformado em ASM, foo = cond ? 1 : foo;porque essa carga + armazenamento (não um RMW atômico) pode interferir na gravação de outro encadeamento. Compiladores já estavam evitando coisas assim porque queria ser útil para escrever programas multi-threaded, mas C ++ 11 tornou oficial que compiladores tinha para não quebrar o código em que 2 threads escrever a[1]ea[2]
Peter Cordes
2
Mas sim, que não seja exagero sobre como compiladores não estão cientes de tópicos em tudo , sua resposta está correta. O UB de corrida de dados é o que permite elevar cargas de variáveis ​​não atômicas, incluindo globais, e outras otimizações agressivas que desejamos para o código de thread único. Programação MCU - a otimização do C ++ O2 é interrompida enquanto o loop na eletrônica. SE é a minha versão desta explicação.
Peter Cordes
11
@PeterCordes: Uma vantagem do Java usando um GC é que a memória de objetos não será reciclada sem uma barreira global de memória entre o uso antigo e o novo, o que significa que qualquer núcleo que examine um objeto sempre verá algum valor que ele possui. realizada algum tempo após a publicação da referência. Embora as barreiras de memória global possam ser muito caras se forem usadas com frequência, elas podem reduzir bastante a necessidade de barreiras de memória em outros lugares, mesmo quando usadas com moderação.
Supercat
11
Sim, eu sabia que era isso que você estava tentando dizer, mas não acho que sua redação 100% signifique isso. Dizendo que o otimizador "os ignora completamente". não é certo: é sabido que ignorar verdadeiramente o encadeamento durante a otimização pode envolver coisas como carregar / modificar um byte no armazenamento de palavras / palavras, o que na prática causou bugs nos quais o acesso de um encadeamento a um caractere ou campo de bits pisa em um escreva para um membro struct adjacente. Veja lwn.net/Articles/478657 para a história completa e como apenas o modelo de memória C11 / C ++ 11 torna essa otimização ilegal, não apenas indesejável na prática.
Peter Cordes
11
Não, isso é bom. Obrigado @ PeterCordes. Agradeço a melhoria.
Baldrickk
5

Por uma questão de completude na curva de aprendizado; você deve evitar o uso de variáveis ​​globais. Você fez um bom trabalho ao torná-lo estático, para que ele seja local na unidade de tradução.

Aqui está um exemplo:

class ST {
public:
    int func()
    {
        size_t i = 0;
        while (!finished)
            ++i;
        return i;
    }
    void setFinished(bool val)
    {
        finished = val;
    }
private:
    std::atomic<bool> finished = false;
};

int main()
{
    ST st;
    auto result=std::async(std::launch::async, &ST::func, std::ref(st));
    std::this_thread::sleep_for(std::chrono::seconds(1));
    st.setFinished(true);
    std::cout<<"result ="<<result.get();
    std::cout<<"\nmain thread id="<<std::this_thread::get_id()<<std::endl;
}

Ao vivo na wandbox

Esquecimento
fonte
11
Também pode declarar finishedcomo staticdentro do bloco de funções. Ele ainda será inicializado apenas uma vez e, se for inicializado com uma constante, isso não exigirá bloqueio.
Davislor 23/10/19
Os acessos finishedtambém poderiam usar std::memory_order_relaxedcargas e lojas mais baratas ; não há necessidade de pedido errado. outras variáveis ​​em qualquer segmento. Mas não tenho certeza se a sugestão de @ Davislor staticfaz sentido; se você tivesse vários threads de contagem de spin, não seria necessário pará-los com o mesmo sinalizador. Você deseja escrever a inicialização de finisheduma maneira que seja compilada apenas na inicialização, mas não em um armazenamento atômico. (Como você está fazendo com a finished = false;sintaxe C ++ 17 do inicializador padrão. Godbolt.org/z/EjoKgq ).
Peter Cordes
@PeterCordes Colocar o sinalizador em um objeto permite que exista mais de um, para diferentes pools de threads, como você diz. O design original tinha um único sinalizador para todos os segmentos, no entanto.
Davislor 19/01