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
foo()
e bar()
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>
tem exchange
e 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)
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.
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)