Saya ingin menulis kode portabel (Intel, ARM, PowerPC ...) yang memecahkan varian masalah klasik:
Initially: X=Y=0
Thread A:
X=1
if(!Y){ do something }
Thread B:
Y=1
if(!X){ do something }
di mana tujuannya adalah untuk menghindari situasi di mana kedua utas melakukansomething
. (Tidak apa-apa jika tidak ada yang berjalan; ini bukan mekanisme berjalan-tepat-sekali.) Harap perbaiki saya jika Anda melihat beberapa kekurangan dalam alasan saya di bawah ini.
Saya sadar, bahwa saya dapat mencapai tujuan dengan memory_order_seq_cst
atom store
dan load
s sebagai berikut:
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();
}
yang mencapai tujuan, karena harus ada beberapa urutan total tunggal pada
{x.store(1), y.store(1), y.load(), x.load()}
acara tersebut, yang harus setuju dengan urutan program "tepi":
x.store(1)
"di TO adalah sebelum"y.load()
y.store(1)
"di TO adalah sebelum"x.load()
dan jika foo()
dipanggil, maka kami memiliki tepi tambahan:
y.load()
"membaca nilai sebelumnya"y.store(1)
dan jika bar()
dipanggil, maka kami memiliki tepi tambahan:
x.load()
"membaca nilai sebelumnya"x.store(1)
dan semua tepi ini digabungkan bersama akan membentuk sebuah siklus:
x.store(1)
"in TO is before" y.load()
"read value before" y.store(1)
"in TO is before" x.load()
"read value before"x.store(true)
yang melanggar fakta bahwa pesanan tidak memiliki siklus.
Saya sengaja menggunakan istilah non-standar "di TO is before" dan "membaca value before" sebagai kebalikan dari istilah standar seperti happens-before
, karena saya ingin meminta umpan balik tentang kebenaran asumsi saya bahwa tepi ini memang menyiratkan happens-before
hubungan, dapat digabungkan bersama dalam satu grafik, dan siklus dalam grafik gabungan tersebut dilarang. Saya tidak yakin tentang hal itu. Yang saya tahu adalah kode ini menghasilkan hambatan yang benar pada Intel gcc & clang dan pada ARM gcc
Sekarang, masalah saya yang sebenarnya sedikit lebih rumit, karena saya tidak memiliki kendali atas "X" - itu tersembunyi di balik beberapa makro, templat dll dan mungkin lebih lemah daripada seq_cst
Saya bahkan tidak tahu apakah "X" adalah variabel tunggal, atau konsep lain (misalnya semaphore atau mutex yang ringan). Yang saya tahu adalah bahwa saya memiliki dua makro set()
dan check()
yang check()
mengembalikan true
"setelah" thread lain telah disebut set()
. (Hal ini juga diketahui bahwa set
dan check
adalah benang-aman dan tidak dapat membuat UB data ras.)
Jadi secara konseptual set()
agak seperti "X = 1" dan check()
seperti "X", tetapi saya tidak memiliki akses langsung ke atom yang terlibat, jika ada.
void thread_a(){
set();
if(!y.load()) foo();
}
void thread_b(){
y.store(1);
if(!check()) bar();
}
Saya khawatir, itu set()
mungkin diterapkan secara internal sebagai x.store(1,std::memory_order_release)
dan / atau check()
mungkin x.load(std::memory_order_acquire)
. Atau secara hipotetis std::mutex
bahwa satu utas membuka dan yang lain sedang masuk try_lock
; dalam standar ISO std::mutex
hanya dijamin memiliki memperoleh dan melepaskan pemesanan, bukan seq_cst.
Jika ini masalahnya, maka check()
jika tubuh dapat "dipesan ulang" sebelumnya y.store(true)
( Lihat jawaban Alex di mana mereka menunjukkan bahwa ini terjadi pada PowerPC ).
Ini akan sangat buruk, karena sekarang urutan kejadian ini dimungkinkan:
thread_b()
pertama memuat nilai lamax
(0
)thread_a()
mengeksekusi semuanya termasukfoo()
thread_b()
mengeksekusi semuanya termasukbar()
Jadi, keduanya foo()
dan bar()
dipanggil, yang harus saya hindari. Apa pilihan saya untuk mencegah itu?
Opsi A
Cobalah untuk memaksa penghalang Store-Load. Ini, dalam praktiknya, dapat dicapai dengan std::atomic_thread_fence(std::memory_order_seq_cst);
- seperti yang dijelaskan oleh Alex dalam jawaban berbeda semua kompiler yang diuji memancarkan pagar penuh:
- x86_64: MFENCE
- PowerPC: hwsync
- Itanuim: mf
- ARMv7 / ARMv8: dmb ish
- MIPS64: sinkronisasi
Masalah dengan pendekatan ini adalah, bahwa saya tidak dapat menemukan jaminan dalam aturan C ++, yang std::atomic_thread_fence(std::memory_order_seq_cst)
harus diterjemahkan ke penghalang memori penuh. Sebenarnya, konsep atomic_thread_fence
s dalam C ++ tampaknya berada pada tingkat abstraksi yang berbeda dari konsep perakitan hambatan memori dan lebih banyak berurusan dengan hal-hal seperti "operasi atom apa yang disinkronkan dengan apa". Apakah ada bukti teoritis bahwa implementasi di bawah ini mencapai tujuan?
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();
}
Opsi B
Gunakan kontrol yang kami miliki atas Y untuk mencapai sinkronisasi, dengan menggunakan operasi memory_order_ac__rel baca-modifikasi-tulis pada 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();
}
Idenya di sini adalah bahwa akses ke satu atom ( y
) harus berupa urutan tunggal yang disetujui semua pengamat, jadi fetch_add
sebelum exchange
atau sebaliknya.
Jika fetch_add
sebelum exchange
maka bagian "release" fetch_add
disinkronkan dengan bagian "memperoleh" exchange
dan dengan demikian semua efek samping set()
harus terlihat oleh pelaksana kode check()
, jadi bar()
tidak akan dipanggil.
Kalau tidak, exchange
adalah sebelumnya fetch_add
, maka fetch_add
akan melihat 1
dan tidak menelepon foo()
. Jadi, tidak mungkin untuk memanggil keduanya foo()
dan bar()
. Apakah alasan ini benar?
Opsi C
Gunakan atom dummy, untuk memperkenalkan "ujung" yang mencegah bencana. Pertimbangkan pendekatan berikut:
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();
}
Jika Anda pikir masalahnya di sini adalah masalah atomic
lokal, maka bayangkan memindahkannya ke ruang lingkup global, dengan alasan berikut tampaknya tidak menjadi masalah bagi saya, dan saya sengaja menulis kode sedemikian rupa untuk mengekspos betapa lucunya bahwa itu dummy1 dan dummy2 benar-benar terpisah.
Mengapa ini bisa berhasil? Nah, harus ada beberapa urutan total tunggal {dummy1.store(13), y.load(), y.store(1), dummy2.load()}
yang harus konsisten dengan urutan program "tepi":
dummy1.store(13)
"di TO adalah sebelum"y.load()
y.store(1)
"di TO adalah sebelum"dummy2.load()
(Toko seq_cst + load mudah-mudahan membentuk C ++ yang setara dengan penghalang memori penuh termasuk StoreLoad, seperti yang mereka lakukan dalam asm pada ISA nyata termasuk bahkan AArch64 di mana tidak diperlukan instruksi penghalang terpisah.)
Sekarang, kami memiliki dua kasus untuk dipertimbangkan: y.store(1)
sebelum y.load()
atau sesudah dalam urutan total.
Jika y.store(1)
sebelum y.load()
maka foo()
tidak akan dipanggil dan kita aman.
Jika y.load()
sebelumnya y.store(1)
, lalu menggabungkannya dengan dua sisi yang sudah kita miliki dalam urutan program, kami menyimpulkan bahwa:
dummy1.store(13)
"di TO adalah sebelum"dummy2.load()
Sekarang, dummy1.store(13)
ini adalah operasi rilis, yang melepaskan efek dari set()
, dan dummy2.load()
merupakan operasi perolehan, jadi check()
harus melihat efek dari set()
dan dengan demikian bar()
tidak akan dipanggil dan kami aman.
Apakah benar di sini berpikir bahwa check()
akan melihat hasil set()
? Bisakah saya menggabungkan "edge" dari berbagai jenis ("order program" alias Sequencing Before, "total order", "before release", "after memperoleh") seperti itu? Saya memiliki keraguan serius tentang hal ini: Aturan C ++ sepertinya berbicara tentang hubungan "sinkronisasi-dengan" antara toko dan memuat di lokasi yang sama - di sini tidak ada situasi seperti itu.
Perhatikan bahwa kita hanya khawatir tentang kasus di mana dumm1.store
ini dikenal (melalui penalaran lainnya) untuk menjadi sebelum dummy2.load
di urutan seq_cst keseluruhan. Jadi jika mereka mengakses variabel yang sama, beban akan melihat nilai yang disimpan dan disinkronkan dengannya.
(Alasan memory-barrier / reordering untuk implementasi di mana muatan atom dan toko mengkompilasi setidaknya untuk hambatan memori 1 arah (dan operasi seq_cst tidak dapat dipesan ulang: mis. Toko seq_cst tidak dapat melewati beban seq_cst) adalah bahwa ada beban / toko setelah dummy2.load
pasti menjadi terlihat oleh utas lainnya setelah itu y.store
. Dan juga untuk utas lainnya, ... sebelumnya y.load
.)
Anda dapat bermain dengan implementasi Opsi A, B, C saya di https://godbolt.org/z/u3dTa8
foo()
dan bar()
dari keduanya dipanggil.
compare_exchange_*
untuk melakukan operasi RMW pada bool atom tanpa mengubah nilainya (cukup tetapkan yang diharapkan dan baru dengan nilai yang sama).
atomic<bool>
memiliki exchange
dan compare_exchange_weak
. Yang terakhir dapat digunakan untuk melakukan dummy RMW dengan (berusaha) CAS (benar, benar) atau salah, salah. Gagal atau secara atomik menggantikan nilainya dengan dirinya sendiri. (Dalam x86-64 asm, tipuan dengan itu lock cmpxchg16b
adalah bagaimana Anda melakukan pemuatan atom 16-byte yang dijamin; tidak efisien tetapi tidak seburuk mengambil kunci yang terpisah.)
foo()
atau bar()
akan dipanggil. Saya tidak ingin membawa banyak elemen "dunia nyata" kode, untuk menghindari "Anda pikir Anda memiliki masalah X tetapi Anda memiliki masalah seperti Y" jenis tanggapan. Tapi, jika seseorang benar-benar perlu tahu apa latar belakang lantai: set()
benar-benar some_mutex_exit()
, check()
adalah try_enter_some_mutex()
, y
adalah "ada beberapa pelayan", foo()
adalah "keluar tanpa membangunkan siapa pun", bar()
adalah "menunggu wakup" ... Tapi, saya menolak untuk bahas desain ini di sini - saya tidak bisa mengubahnya dengan benar.
std::atomic_thread_fence(std::memory_order_seq_cst)
kompilasi ke penghalang penuh, tetapi karena seluruh konsep adalah detail implementasi Anda tidak akan menemukan disebutkan dalam standar. (Model memori CPU biasanya yang didefinisikan dalam hal apa reorerings diperbolehkan relatif terhadap konsistensi berurutan misalnya x86 adalah seq-cst + toko penyangga w / forwarding.)