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_cst
atômicas store
s e load
s 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-before
relaçã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 set
e 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::mutex
que um segmento está desbloqueando e outro está try_lock
ing; no padrão ISO, std::mutex
apenas é 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 dex
(0
)thread_a()
executa tudo, incluindofoo()
thread_b()
executa tudo, incluindobar()
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_fence
s 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 exchange
ou vice-versa.
Se fetch_add
for anterior exchange
, a parte "release" da fetch_add
sincroniza com a parte "adquirir" exchange
e, 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_add
verá 1
e 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 é atomic
s, 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.load
na 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.load
definitivamente 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
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)foo()
ebar()
de que ambos sejam chamados.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).atomic<bool>
temexchange
ecompare_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 comlock cmpxchg16b
é como você faz cargas de 16 bytes garantidos-atômica;. Ruim ineficiente, mas menos do que tomar um bloqueio separado)foo()
nembar()
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()
é realmentesome_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.Respostas:
As opções A e B são soluções válidas.
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
dummy1
edummy2
. 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()
echeck()
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:
Ou seja,
check()
vê esse valor armazenadoset
ouy.load()
vê o valor gravadoy.store()
(as operações emy
podem até ser usadasmemory_order_relaxed
).Opção C:
O padrão C ++ 17 declara [32.4.3, p1347]:
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).
fonte
y.load()
não vê efeito dey.store(1)
, então podemos provar pelas regras que em S,atomic_thread_fence
de thread_a é anterior aatomic_thread_fence
thread_b. O que não vejo é como chegar a essa conclusão de que osset()
efeitos colaterais são visíveischeck()
.set
echeck
possa 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 compartilhaday
.No primeiro exemplo, a
y.load()
leitura de 0 não implica que issoy.load()
ocorra antesy.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 anteriory.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?
fonte
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!check()
(veja meu comentário à minha pergunta para obter o significado real deset,check,foo,bar
). Eu acho que poderia funcionar em seuif(!x2.load()){ if(check())x2.store(0); else bar(); }
lugar.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 (
stlr
a 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
y
as 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=13
ey
sã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
set
relação ad1=13
se 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
set
Sequenced-Befored1=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
dummy1
edummy2
, 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 oset()
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
dummy2
nã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.
fonte
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.sync
antes da loja, nada depois. godbolt.org/z/mAr72P Mas as cargas seq-cst precisam de algumas barreiras dos dois lados.Mas nada é garantido para ter "seq_cst ordering", pois
seq_cst
não é propriedade de nenhuma operação.seq_cst
é uma garantia sobre todas as operações de uma determinada implementaçãostd::atomic
ou de uma classe atômica alternativa. Como tal, sua pergunta não é verdadeira.fonte