Como alcançar uma barreira StoreLoad no C ++ 11?

13

Quero escrever um código portátil (Intel, ARM, PowerPC ...) que resolva uma variante de um problema clássico:

Initially: X=Y=0

Thread A:
  X=1
  if(!Y){ do something }
Thread B:
  Y=1
  if(!X){ do something }

em que o objetivo é evitar uma situação em que os dois threads estejam funcionandosomething . (Não há problema em nada; o mecanismo não é executado exatamente uma vez.) Por favor, corrija-me se você encontrar algumas falhas no meu raciocínio abaixo.

Estou ciente de que posso alcançar o objetivo com memory_order_seq_cstatômicas stores e loads como se segue:

std::atomic<int> x{0},y{0};
void thread_a(){
  x.store(1);
  if(!y.load()) foo();
}
void thread_b(){
  y.store(1);
  if(!x.load()) bar();
}

que atinge a meta, porque deve haver um único pedido total nos
{x.store(1), y.store(1), y.load(), x.load()}eventos, que deve concordar com as "arestas" da ordem do programa:

  • x.store(1) "em TO é antes" y.load()
  • y.store(1) "em TO é antes" x.load()

e se foo()foi chamado, então temos uma vantagem adicional:

  • y.load() "lê o valor antes" y.store(1)

e se bar()foi chamado, então temos uma vantagem adicional:

  • x.load() "lê o valor antes" x.store(1)

e todas essas arestas combinadas formariam um ciclo:

x.store(1)"em TO está antes" y.load()"lê o valor antes" y.store(1)"em TO está antes" x.load()"lê valor antes"x.store(true)

o que viola o fato de que os pedidos não têm ciclos.

Uso intencionalmente termos não-padrão "em TO é antes" e "lê valor antes" em oposição a termos padrão como happens-before, porque quero solicitar feedback sobre a exatidão de minha suposição de que essas arestas realmente implicam happens-beforerelação, podem ser combinadas em uma única gráfico e o ciclo nesse gráfico combinado é proibido. Eu não tenho certeza sobre isso. O que eu sei é que esse código produz barreiras corretas no Intel gcc & clang e no ARM gcc


Agora, meu problema real é um pouco mais complicado, porque não tenho controle sobre o "X" - ele está escondido atrás de algumas macros, modelos etc. e pode ser mais fraco do que seq_cst

Eu nem sei se "X" é uma variável única ou algum outro conceito (por exemplo, um semáforo ou mutex leve). Tudo o que sei é que tenho duas macros set()e check()isso check()retorna true"depois" de outro segmento ter sido chamado set(). (Também é conhecido sete checké seguro para threads e não pode criar UB de corrida de dados.)

Então, conceitualmente, set()é algo como "X = 1" e check()é como "X", mas não tenho acesso direto aos átomos envolvidos, se houver.

void thread_a(){
  set();
  if(!y.load()) foo();
}
void thread_b(){
  y.store(1);
  if(!check()) bar();
}

Estou preocupado, que set()possa ser implementado internamente como x.store(1,std::memory_order_release)e / ou check()pode ser x.load(std::memory_order_acquire). Ou, hipoteticamente, std::mutexque um segmento está desbloqueando e outro está try_locking; no padrão ISO, std::mutexapenas é garantido que você obtenha e libere pedidos, e não seq_cst.

Se for esse o caso, check()o corpo poderá ser "reordenado" antes y.store(true)( consulte a resposta de Alex, onde eles demonstram que isso acontece no PowerPC ).
Isso seria muito ruim, pois agora essa sequência de eventos é possível:

  • thread_b()primeiro carrega o valor antigo de x( 0)
  • thread_a() executa tudo, incluindo foo()
  • thread_b() executa tudo, incluindo bar()

Então, ambos foo()e bar()fui chamado, o que eu tive que evitar. Quais são as minhas opções para evitar isso?


Opção A

Tente forçar a barreira Store-Load. Isso, na prática, pode ser alcançado por std::atomic_thread_fence(std::memory_order_seq_cst);- como explicado por Alex em uma resposta diferente - todos os compiladores testados emitiram uma cerca completa:

  • x86_64: MFENCE
  • PowerPC: hwsync
  • Itanuim: mf
  • ARMv7 / ARMv8: dmb ish
  • MIPS64: sincronização

O problema com essa abordagem é que eu não consegui encontrar nenhuma garantia nas regras C ++, que std::atomic_thread_fence(std::memory_order_seq_cst)devem se traduzir em barreira de memória total. Na verdade, o conceito de atomic_thread_fences em C ++ parece estar em um nível diferente de abstração do que o conceito de montagem de barreiras de memória e lida mais com coisas como "que operação atômica sincroniza com o que". Existe alguma prova teórica de que a implementação abaixo atinja o objetivo?

void thread_a(){
  set();
  std::atomic_thread_fence(std::memory_order_seq_cst)
  if(!y.load()) foo();
}
void thread_b(){
  y.store(true);
  std::atomic_thread_fence(std::memory_order_seq_cst)
  if(!check()) bar();
}

Opção B

Use o controle que temos sobre Y para obter a sincronização, usando operações de leitura-modificação-gravação memory_order_acq_rel em Y:

void thread_a(){
  set();
  if(!y.fetch_add(0,std::memory_order_acq_rel)) foo();
}
void thread_b(){
  y.exchange(1,std::memory_order_acq_rel);
  if(!check()) bar();
}

A idéia aqui é que o acesso a um único atômico ( y) deve formar uma única ordem com a qual todos os observadores concordam, de modo que fetch_addé anterior exchangeou vice-versa.

Se fetch_addfor anterior exchange, a parte "release" da fetch_addsincroniza com a parte "adquirir" exchangee, portanto, todos os efeitos colaterais set()devem estar visíveis para a execução do código check(), portanto bar(), não serão chamados.

Caso contrário, exchangeé antes fetch_add, então o fetch_addverá 1e não chama foo(). Portanto, é impossível chamar ambos foo()e bar(). Esse raciocínio está correto?


Opção C

Use atômicos simulados para introduzir "arestas" que evitam desastres. Considere a seguinte abordagem:

void thread_a(){
  std::atomic<int> dummy1{};
  set();
  dummy1.store(13);
  if(!y.load()) foo();
}
void thread_b(){
  std::atomic<int> dummy2{};
  y.store(1);
  dummy2.load();
  if(!check()) bar();
}

Se você acha que o problema aqui é atomics, local, imagine movê-los para o escopo global. No seguinte raciocínio, isso não parece me importar, e eu intencionalmente escrevi o código de maneira a expor o quão engraçado é esse manequim1 e dummy2 são completamente separados.

Por que na Terra isso pode funcionar? Bem, deve haver uma única ordem total, {dummy1.store(13), y.load(), y.store(1), dummy2.load()}que deve ser consistente com as "arestas" da ordem do programa:

  • dummy1.store(13) "em TO é antes" y.load()
  • y.store(1) "em TO é antes" dummy2.load()

(Espera-se que um seq_cst store + load forme o equivalente em C ++ de uma barreira de memória completa, incluindo StoreLoad, como acontece com as ISAs reais, incluindo até o AArch64, onde não são necessárias instruções de barreira separadas.)

Agora, temos dois casos a considerar: y.store(1)é antes y.load()ou depois na ordem total.

Se y.store(1)for antes y.load(), foo()não será chamado e estamos seguros.

Se y.load()for anterior y.store(1), combinando-o com as duas arestas que já temos na ordem do programa, deduzimos que:

  • dummy1.store(13) "em TO é antes" dummy2.load()

Agora, dummy1.store(13)é uma operação de lançamento, que libera efeitos de set()e dummy2.load()é uma operação de aquisição, portanto, check()deve ver os efeitos de set()e, portanto bar(), não será chamado e estamos seguros.

É correto aqui pensar que check()verá os resultados set()? Posso combinar as "arestas" de vários tipos ("ordem do programa", aka Sequenced Before, "total order", "before release", "after adquirir") assim? Tenho sérias dúvidas sobre isso: as regras do C ++ parecem falar sobre as relações "sincronizadas com" entre a loja e a carga no mesmo local - aqui não existe essa situação.

Observe que estamos preocupados apenas com o caso em que dumm1.storeé conhecido (por outro motivo) antes dummy2.loadna ordem total seq_cst. Portanto, se eles estivessem acessando a mesma variável, a carga teria visto o valor armazenado e sincronizado com ele.

(O raciocínio de barreira da memória / reordenação para implementações em que cargas e lojas atômicas são compiladas a pelo menos barreiras de memória unidirecional (e as operações seq_cst não podem reordenar: por exemplo, um armazenamento seq_cst não pode passar uma carga seq_cst) é que qualquer carga / armazena depois dummy2.loaddefinitivamente fica visível para outros threads depois y.store . E da mesma forma para o outro thread, ... antes y.load.)


Você pode jogar com minha implementação das Opções A, B, C em https://godbolt.org/z/u3dTa8

qbolec
fonte
11
O modelo de memória C ++ não tem nenhum conceito de reordenação StoreLoad, apenas Sincroniza com e acontece antes. (E o UB em corridas de dados em objetos não atômicos, diferentemente do hardware real.) Em todas as implementações reais que eu conheço, std::atomic_thread_fence(std::memory_order_seq_cst)compila com uma barreira completa, mas como todo o conceito é um detalhe de implementação, você não encontrará qualquer menção a isso no padrão. (Os modelos de memória da CPU geralmente são definidos em termos de quais reorientações são permitidas em relação à consistência seqüencial. Por exemplo, x86 é seq-cst + um buffer de loja com encaminhamento)
Peter Cordes
@ PeterCordes obrigado, eu poderia não ter sido claro na minha escrita. Eu queria transmitir o que você escreveu na seção "Opção A". Eu sei que o título da minha pergunta usa a palavra "StoreLoad" e que "StoreLoad" é ​​um conceito de um mundo completamente diferente. Meu problema é como mapear esse conceito em C ++. Ou, se não puder ser mapeado diretamente, como atingir o objetivo que propus: impedir foo()e bar()de que ambos sejam chamados.
qbolec
11
Você pode usar compare_exchange_*para executar uma operação RMW em um bool atômico sem alterar seu valor (basta definir o esperado e o novo no mesmo valor).
mpoeter
11
@ Fararor e qbolec: atomic<bool>tem exchangee compare_exchange_weak. O último pode ser usado para fazer um RMW fictício (tentando) CAS (verdadeiro, verdadeiro) ou falso, falso. Ele falha ou substitui atomicamente o valor por si próprio. (Em ASM x86-64, esse truque com lock cmpxchg16bé como você faz cargas de 16 bytes garantidos-atômica;. Ruim ineficiente, mas menos do que tomar um bloqueio separado)
Peter Cordes
11
@ PeterCordes sim, eu sei que pode acontecer que nem foo()nem bar()seja chamado. Eu não queria trazer para muitos elementos do "mundo real" do código, para evitar "você acha que tem o problema X, mas tem o problema Y" do tipo de respostas. Mas, se alguém realmente precisa saber qual é o andar de fundo: set()é realmente some_mutex_exit(), check()é try_enter_some_mutex(), yé "existem alguns garçons", foo()é "sai sem acordar ninguém", bar()é "espera para acordar" ... Mas, eu me recuso a discuta esse design aqui - não posso mudá-lo realmente.
qbolec

Respostas:

5

As opções A e B são soluções válidas.

  • Opção A: realmente não importa o que uma cerca seq-cst se traduz, o padrão C ++ define claramente o que garante. Eu os expus neste post: Quando uma cerca de memory_order_seq_cst é útil?
  • Opção B: sim, seu raciocínio está correto. Todas as modificações em algum objeto têm uma única ordem total (a ordem de modificação), para que você possa usá-lo para sincronizar os threads e garantir a visibilidade de todos os efeitos colaterais.

No entanto, a opção C não é válida! Uma relação de sincronização com só pode ser estabelecida por operações de aquisição / liberação no mesmo objeto . No seu caso, você tem dois objetos completamente diferentes e indepent dummy1e dummy2. Mas estes não podem ser usados ​​para estabelecer uma relação de antes do acontecido. De fato, como as variáveis ​​atômicas são puramente locais (ou seja, são tocadas apenas por um encadeamento), o compilador é livre para removê-las com base na regra como se .

Atualizar

Opção A:
Assumo set()e check()opero com algum valor atômico. Então temos a seguinte situação (-> denota sequenciado antes ):

  • set()-> fence1(seq_cst)->y.load()
  • y.store(true)-> fence2(seq_cst)->check()

Portanto, podemos aplicar a seguinte regra:

Para operações atômicas A e B em um objeto atômico M , onde A modifica M e B leva seu valor, se houver memory_order_seq_cstcercas X e Y, de modo que A seja sequenciado antes de X , Y seja sequenciado antes de B e X preceda Y em S , então B observa os efeitos de A ou uma modificação posterior de M em sua ordem de modificação.

Ou seja, check()vê esse valor armazenado setou y.load()vê o valor gravado y.store()(as operações em ypodem até ser usadas memory_order_relaxed).

Opção C:
O padrão C ++ 17 declara [32.4.3, p1347]:

Deve haver um único pedido total S em todas as memory_order_seq_cstoperações, consistente com o pedido "acontece antes" e os pedidos de modificação para todos os locais afetados.

A palavra importante aqui é "consistente". Isso implica que se uma operação de A acontece-antes de uma operação B , então A deve preceder B em S . No entanto, implicação lógica é um one-way street, por isso não podemos inferir a inversa: só porque alguma operação C precede uma operação D em S não implica que C acontece antes D .

Em particular, duas operações seq-cst em dois objetos separados não podem ser usadas para estabelecer uma relação de ocorrência antes, mesmo que as operações sejam totalmente ordenadas em S. Se você deseja ordenar operações em objetos separados, é necessário consultar seq-cst -grades (consulte a Opção A).

mpoeter
fonte
Não é óbvio que a opção C é inválida. As operações seq-cst, mesmo em objetos particulares, ainda podem solicitar outras operações em algum grau. Concordamos que não há sincronizações - com, mas não nos importamos com que foo ou bar é executado (ou aparentemente nenhum), apenas que ambos não são executados. O relacionamento sequenciado antes e a ordem total das operações seq-cst (que devem existir) acho que nos dão isso.
Peter Cordes
Obrigado @mpoeter. Você poderia, por favor, elaborar sobre a Opção A. Qual das três opções em sua resposta se aplica aqui? Se IIUC y.load()não vê efeito de y.store(1), então podemos provar pelas regras que em S, atomic_thread_fencede thread_a é anterior a atomic_thread_fencethread_b. O que não vejo é como chegar a essa conclusão de que os set()efeitos colaterais são visíveis check().
qbolec
11
@qbolec: Atualizei minha resposta com mais detalhes sobre a opção A.
mpoeter
11
Sim, uma operação seq-cst local ainda faria parte do pedido total único S em todas as operações seq-cst. Mas S é "apenas" consistente com o acontece-antes da ordem e de modificação de ordens , ou seja, se um acontece-antes de B , então A deve preceder B em S . Mas o inverso não é garantido, ou seja, só porque A precede B em S , que não pode deduzir , que A acontece, antes B .
mpoeter
11
Bem, supondo que sete checkpossa ser executado com segurança em paralelo, eu provavelmente usaria a Opção A, especialmente se isso for crítico para o desempenho, pois evita contenção na variável compartilhada y.
mpoeter 04/02
1

No primeiro exemplo, a y.load()leitura de 0 não implica que isso y.load()ocorra antes y.store(1).

No entanto, isso implica que é mais cedo no pedido total único, graças à regra que uma carga seq_cst retorna o valor do último armazenamento seq_cst no pedido total ou o valor de algum armazenamento não seq_cst que não acontece antes (que neste caso não existe). Portanto, se y.store(1)fosse anterior y.load()ao pedido total, y.load()teria retornado 1.

A prova ainda está correta porque o único pedido total não possui um ciclo.

Que tal esta solução?

std::atomic<int> x2{0},y{0};

void thread_a(){
  set();
  x2.store(1);
  if(!y.load()) foo();
}

void thread_b(){
  y.store(1);
  if(!x2.load()) bar();
}
Tomek Czajka
fonte
O problema do OP é que eu não tenho controle sobre o "X" - ele está atrás de macros de invólucro ou algo assim e pode não ser o armazenamento / carregamento seq-cst. Eu atualizei a pergunta para destacar isso melhor.
Peter Cordes
@ PeterCordes A idéia era criar outro "x" sobre o qual ele tem controle. Vou renomeá-lo para "x2" na minha resposta para torná-lo mais claro. Tenho certeza de que estou perdendo algum requisito, mas se o único requisito é garantir que foo () e bar () não sejam chamados, isso satisfaz isso.
Tomek Czajka
O mesmo ocorreria, if(false) foo();mas acho que o OP também não quer: P Ponto interessante, mas acho que o OP deseja que as chamadas condicionais sejam baseadas nas condições que eles especificam!
Peter Cordes
11
Olá @TomekCzajka, obrigado por reservar um tempo para propor uma nova solução. Não funcionaria no meu caso particular, pois omite efeitos colaterais importantes de check()(veja meu comentário à minha pergunta para obter o significado real de set,check,foo,bar). Eu acho que poderia funcionar em seu if(!x2.load()){ if(check())x2.store(0); else bar(); }lugar.
qbolec 07/02
1

O @mpoeter explicou por que as opções A e B são seguras.

Na prática, em implementações reais, acho que a opção A só precisa std::atomic_thread_fence(std::memory_order_seq_cst) no segmento A, não no B.

os armazenamentos seq-cst na prática incluem uma barreira de memória completa ou, no AArch64, pelo menos não pode reordenar com cargas posteriores de aquisição ou seq_cst ( stlra liberação seqüencial deve ser drenada do buffer de armazenamento antes deldar poder ser lida no cache).

Os mapeamentos C ++ -> asm têm a opção de colocar o custo de drenar o buffer da loja em lojas ou cargas atômicas. A escolha sensata para implementações reais é tornar as cargas atômicas baratas, para que as lojas seq_cst incluam uma barreira completa (incluindo StoreLoad). Enquanto as cargas seq_cst são as mesmas que as adquiridas na maioria.

(Mas não o POWER; mesmo as cargas precisam de sincronização pesada = barreira completa para interromper o encaminhamento de loja de outros encadeamentos SMT no mesmo núcleo, o que pode levar à reordenação da IRIW, porque seq_cst exige que todos os encadeamentos sejam capazes de concordar com a ordem de todas as operações seq_cst Duas gravações atômicas em locais diferentes em threads diferentes sempre serão vistas na mesma ordem por outros threads? )

(Obviamente, para uma garantia formal de segurança, precisamos de uma barreira para promover o conjunto de aquisição / liberação () -> check () em um seq_cst sincronizado com. Também funcionaria para um conjunto descontraído, eu acho, mas um verificação relaxada pode reordenar com a barra do ponto de vista de outros segmentos.)


Penso que o verdadeiro problema com a opção C é que depende de algum observador hipotético que possa sincronizar com yas operações fictícias. E, portanto, esperamos que o compilador preserve essa ordem ao criar asm para um ISA baseado em barreira.

Isso será verdade na prática em ISAs reais; ambos os threads incluem uma barreira completa ou equivalente e os compiladores (ainda) não otimizam a atômica. Mas é claro que "compilar em um ISA baseado em barreira" não faz parte do padrão ISO C ++. O cache compartilhado coerente é o observador hipotético que existe para o raciocínio asm, mas não para o raciocínio ISO C ++.

Para que a opção C funcione, precisamos de um pedido como dummy1.store(13);/ y.load()/ set();(como visto no Thread B) para violar alguma regra ISO C ++ .

O thread que executa essas instruções deve se comportar como se set() executado primeiro (por causa do Sequenced Before). Tudo bem, a ordenação da memória em tempo de execução e / ou a reorganização do tempo de compilação das operações ainda podem fazer isso.

As duas seq_cst ops d1=13e ysão consistentes com o Sequenced Before (ordem do programa). set()não participa da ordem global necessária para seq_cst ops porque não é seq_cst.

O encadeamento B não é sincronizado com o dummy1.store, portanto, não ocorre nenhum requisito anterior em setrelação a d1=13se aplica , mesmo que essa atribuição seja uma operação de liberação.

Não vejo nenhuma outra violação possível da regra; Não consigo encontrar aqui nada que seja necessário para ser consistente com o setSequenced-Before d1=13.

O raciocínio "dummy1.store libera set ()" é a falha. Essa ordem se aplica apenas a um observador real que sincroniza - com ele ou em asm. Como o @mpoeter respondeu, a existência do pedido total seq_cst não cria ou implica relacionamentos anteriores e isso é a única coisa que garante formalmente o pedido fora do seq_cst.

Qualquer tipo de CPU "normal" com cache compartilhado coerente em que essa reordenação possa realmente ocorrer em tempo de execução não parece plausível. (Mas se um compilador pudesse remover dummy1e dummy2, claramente, teríamos um problema, acho que isso é permitido pelo padrão.)

Mas como o modelo de memória C ++ não é definido em termos de buffer de armazenamento, cache coerente compartilhado ou testes decisivos de reordenação permitida, as coisas exigidas pela sanidade não são formalmente exigidas pelas regras C ++. Talvez isso seja intencional para permitir a otimização de até mesmo as variáveis ​​seq_cst que acabam sendo privadas de thread. (Os compiladores atuais não fazem isso, é claro, ou qualquer outra otimização de objetos atômicos.)

Uma implementação em que um encadeamento realmente pode ser visto por set()último, enquanto outro pode ver o set()primeiro parece implausível. Nem o POWER poderia fazer isso; a carga e a loja seq_cst incluem barreiras completas para o POWER. (Eu sugeri nos comentários que a reordenação do IRIW pode ser relevante aqui; as regras acq / rel do C ++ são fracas o suficiente para acomodar isso, mas a total falta de garantias fora da sincronização - com ou outro acontece - antes que as situações sejam muito mais fracas do que qualquer HW. )

C ++ não garante nada para não-seq_cst a menos que haja realmente é um observador, e, em seguida, apenas para esse observador. Sem um, estamos no território felino de Schroedinger. Ou, se duas árvores caem na floresta, uma cai antes da outra? (Se é uma floresta grande, a relatividade geral diz que depende do observador e que não há um conceito universal de simultaneidade.)


O @mpoeter sugeriu que um compilador pudesse remover as operações fictícias de carregamento e armazenamento, mesmo em objetos seq_cst.

Eu acho que pode estar correto quando eles podem provar que nada pode ser sincronizado com uma operação. por exemplo, um compilador que pode ver que dummy2não escapa à função provavelmente pode remover essa carga seq_cst.

Isso tem pelo menos uma consequência do mundo real: se compilar para o AArch64, isso permitiria que um armazenamento seq_cst anterior reorganizasse na prática com operações relaxadas posteriores, o que não seria possível com um armazenamento seq_cst + carga drenando o buffer de armazenamento antes de qualquer cargas posteriores podem ser executadas.

É claro que os compiladores atuais não otimizam a atômica, mesmo que o ISO C ++ não a proíba; esse é um problema não resolvido para o comitê de padrões.

Isso é permitido, penso eu, porque o modelo de memória C ++ não possui um observador implícito ou um requisito de que todos os threads concordem em solicitar. Ele fornece algumas garantias com base em caches coerentes, mas não exige que a visibilidade de todos os threads seja simultânea.

Peter Cordes
fonte
Bom resumo! Concordo que, na prática , provavelmente seria suficiente se apenas o segmento A tivesse uma cerca seq-cst. No entanto, com base no padrão C ++, não teríamos a garantia necessária de que veríamos o valor mais recente set(), portanto, eu ainda usaria a cerca no segmento B também. Suponho que uma loja descontraída com uma cerca seq-cst geraria quase o mesmo código que uma loja seq-cst.
mpoeter
@ metro: sim, eu estava falando apenas na prática, não formalmente. Adicionada uma nota no final dessa seção. E sim, na prática, na maioria dos ISAs, acho que uma loja seq_cst geralmente é apenas uma loja simples (descontraída) + uma barreira. Ou não; no POWER, uma loja seq-cst faz um (pesado) sync antes da loja, nada depois. godbolt.org/z/mAr72P Mas as cargas seq-cst precisam de algumas barreiras dos dois lados.
Peter Cordes
1

no padrão ISO std :: mutex é garantido apenas para adquirir e liberar pedidos, não seq_cst.

Mas nada é garantido para ter "seq_cst ordering", pois seq_cstnão é propriedade de nenhuma operação.

seq_csté uma garantia sobre todas as operações de uma determinada implementação std::atomicou de uma classe atômica alternativa. Como tal, sua pergunta não é verdadeira.

curiousguy
fonte