Ini benar-benar apa yang didefinisikan oleh C ++ sebagai Data Race yang menyebabkan Perilaku Tidak Terdefinisi, bahkan jika satu kompiler menghasilkan kode yang melakukan apa yang Anda harapkan pada beberapa mesin target. Anda perlu menggunakan std::atomic
untuk hasil yang andal, tetapi Anda dapat menggunakannya memory_order_relaxed
jika Anda tidak peduli tentang pemesanan ulang. Lihat di bawah untuk beberapa contoh kode dan output asm yang digunakan fetch_add
.
Tetapi pertama-tama, bahasa majelis merupakan bagian dari pertanyaan:
Karena num ++ adalah satu instruksi ( add dword [num], 1
), dapatkah kita menyimpulkan bahwa num ++ adalah atom dalam kasus ini?
Instruksi tujuan-memori (selain penyimpanan murni) adalah operasi baca-modifikasi-tulis yang terjadi dalam beberapa langkah internal . Tidak ada register arsitektural yang dimodifikasi, tetapi CPU harus menyimpan data secara internal ketika mengirimkannya melalui ALU -nya . File register sebenarnya hanya sebagian kecil dari penyimpanan data di dalam bahkan CPU paling sederhana, dengan kait yang menahan output dari satu tahap sebagai input untuk tahap lain, dll., Dll.
Operasi memori dari CPU lain dapat menjadi terlihat secara global antara beban dan penyimpanan. Yaitu dua utas berjalan add dword [num], 1
dalam satu lingkaran akan menginjak toko masing-masing. (Lihat @ Margaret jawaban untuk diagram yang bagus). Setelah peningkatan 40k dari masing-masing dua utas, penghitung mungkin hanya naik ~ 60k (bukan 80k) pada perangkat keras x86 multi-core nyata.
"Atomic", dari kata Yunani yang berarti tak terpisahkan, berarti bahwa tidak ada pengamat yang dapat melihat operasi sebagai langkah terpisah. Terjadi secara fisik / listrik secara instan untuk semua bit secara bersamaan adalah salah satu cara untuk mencapai ini untuk beban atau penyimpanan, tetapi itu bahkan tidak mungkin untuk operasi ALU. Saya masuk ke lebih banyak detail tentang muatan murni dan penyimpanan murni dalam jawaban saya untuk Atomicity pada x86 , sementara jawaban ini berfokus pada baca-modifikasi-tulis.
The lock
prefix dapat diterapkan untuk banyak membaca-memodifikasi-write (tujuan memori) instruksi untuk membuat seluruh operasi atom terhadap semua pengamat mungkin dalam sistem (core lainnya dan perangkat DMA, bukan sebuah oscilloscope terhubung ke pin CPU). Itu sebabnya itu ada. (Lihat juga T&J ini ).
Begitu lock add dword [num], 1
juga atom . Inti CPU yang menjalankan instruksi itu akan menjaga agar garis cache tetap tersemat dalam status Dimodifikasi dalam cache L1 privatnya sejak saat beban membaca data dari cache hingga toko mengembalikan hasilnya ke cache. Ini mencegah cache lain dalam sistem dari memiliki salinan garis cache pada titik mana pun dari beban ke penyimpanan, sesuai dengan aturan protokol koherensi cache MESI (atau versi MOESI / MESIF yang digunakan oleh multi-core AMD / CPU Intel, masing-masing). Dengan demikian, operasi oleh core lain tampaknya terjadi baik sebelum atau sesudah, bukan selama.
Tanpa lock
awalan, inti lain dapat mengambil kepemilikan dari garis cache dan memodifikasinya setelah memuat kami tetapi sebelum toko kami, sehingga toko lain akan terlihat secara global di antara beban dan toko kami. Beberapa jawaban lain salah, dan klaim tanpa lock
Anda akan mendapatkan salinan yang bertentangan dari baris cache yang sama. Ini tidak pernah bisa terjadi dalam sistem dengan cache yang koheren.
(Jika lock
instruksi ed beroperasi pada memori yang membentang dua garis cache, dibutuhkan lebih banyak pekerjaan untuk memastikan perubahan pada kedua bagian objek tetap atom saat mereka menyebar ke semua pengamat, sehingga tidak ada pengamat dapat melihat robek. CPU mungkin harus mengunci seluruh bus memori hingga data mengenai memori. Jangan selaraskan variabel atom Anda!)
Perhatikan bahwa lock
awalan juga mengubah instruksi menjadi penghalang memori penuh (seperti MFENCE ), menghentikan semua penataan ulang run-time dan dengan demikian memberikan konsistensi berurutan. (Lihat posting blog Jeff Preshing yang luar biasa . Posnya yang lain juga sangat bagus, dan dengan jelas menjelaskan banyak hal bagus tentang pemrograman bebas kunci , mulai dari x86 dan detail perangkat keras lainnya hingga aturan C ++.)
Pada mesin uniprocessor, atau dalam proses berulir tunggal, satu instruksi RMW sebenarnya adalah atomik tanpa lock
awalan. Satu-satunya cara bagi kode lain untuk mengakses variabel yang dibagikan adalah untuk CPU melakukan saklar konteks, yang tidak dapat terjadi di tengah instruksi. Jadi suatu dataran dec dword [num]
dapat menyinkronkan antara program single-threaded dan pengendali sinyal, atau dalam program multi-threaded yang berjalan pada mesin single-core. Lihat bagian kedua dari jawaban saya pada pertanyaan lain , dan komentar di bawahnya, di mana saya menjelaskan ini secara lebih rinci.
Kembali ke C ++:
Ini benar-benar palsu untuk digunakan num++
tanpa memberitahu kompiler bahwa Anda memerlukannya untuk dikompilasi ke implementasi read-memodifikasi-write tunggal:
;; Valid compiler output for num++
mov eax, [num]
inc eax
mov [num], eax
Ini sangat mungkin jika Anda menggunakan nilai num
nanti: kompiler akan membuatnya tetap hidup di register setelah kenaikan. Jadi, bahkan jika Anda memeriksa bagaimana num++
kompilasi sendiri, mengubah kode di sekitarnya dapat memengaruhinya.
(Jika nilainya tidak diperlukan nanti, inc dword [num]
lebih disukai; CPU x86 modern akan menjalankan instruksi RMW tujuan-memori setidaknya seefisien menggunakan tiga instruksi terpisah. Fakta menyenangkan: gcc -O3 -m32 -mtune=i586
sebenarnya akan mengeluarkan ini , karena (Pentium) pipa superscalar P5 tidak dapat memecahkan kode instruksi kompleks ke beberapa operasi mikro sederhana seperti P6 dan kemudian arsitektur mikro. Lihat tabel instruksi Agner Fog / panduan arsitektur mikro untuk info lebih lanjut, danx86 beri tag wiki untuk banyak tautan berguna (termasuk manual Intel x86 ISA, yang tersedia secara bebas dalam format PDF)).
Jangan bingung antara model memori target (x86) dengan model memori C ++
Penataan ulang waktu kompilasi diizinkan . Bagian lain dari apa yang Anda dapatkan dengan std :: atomic adalah kontrol atas penyusunan ulang waktu kompilasi, untuk memastikan Andanum++
menjadi terlihat secara global hanya setelah beberapa operasi lainnya.
Contoh klasik: Menyimpan beberapa data ke dalam buffer untuk utas lainnya untuk dilihat, lalu mengatur bendera. Meskipun x86 memang mendapatkan toko beban / rilis secara gratis, Anda masih harus memberi tahu kompiler untuk tidak memesan ulang dengan menggunakan flag.store(1, std::memory_order_release);
.
Anda mungkin mengharapkan bahwa kode ini akan disinkronkan dengan utas lainnya:
// flag is just a plain int global, not std::atomic<int>.
flag--; // This isn't a real lock, but pretend it's somehow meaningful.
modify_a_data_structure(&foo); // doesn't look at flag, and the compilers knows this. (Assume it can see the function def). Otherwise the usual don't-break-single-threaded-code rules come into play!
flag++;
Tapi itu tidak akan terjadi. Kompiler bebas untuk memindahkan flag++
seluruh panggilan fungsi (jika inline fungsi atau tahu bahwa itu tidak melihat flag
). Maka itu dapat mengoptimalkan modifikasi sepenuhnya, karena flag
tidak genap volatile
. (Dan tidak, C ++ volatile
bukan pengganti yang berguna untuk std :: atomic. Std :: atomic membuat kompiler berasumsi bahwa nilai-nilai dalam memori dapat dimodifikasi secara asinkron mirip dengan volatile
, tetapi ada lebih banyak daripada itu. Selain itu, volatile std::atomic<int> foo
bukan sama seperti std::atomic<int> foo
, sebagaimana dibahas dengan @Richard Hodges.)
Mendefinisikan perlombaan data pada variabel non-atomik sebagai Perilaku Tidak Terdefinisi adalah apa yang memungkinkan kompiler masih mengangkat beban dan menenggelamkan toko keluar dari loop, dan banyak optimisasi lain untuk memori yang mungkin memiliki referensi lebih dari beberapa thread. (Lihat blog LLVM ini untuk informasi lebih lanjut tentang bagaimana UB mengaktifkan optimisasi kompiler.)
Seperti yang saya sebutkan, awalan x86lock
adalah penghalang memori penuh, jadi menggunakan num.fetch_add(1, std::memory_order_relaxed);
menghasilkan kode yang sama pada x86 seperti num++
(standarnya adalah konsistensi berurutan), tetapi bisa jauh lebih efisien pada arsitektur lain (seperti ARM). Bahkan pada x86, santai memungkinkan penyusunan ulang waktu kompilasi lebih banyak.
Inilah yang sebenarnya dilakukan GCC pada x86, untuk beberapa fungsi yang beroperasi pada std::atomic
variabel global.
Lihat kode bahasa sumber + rakitan yang diformat dengan baik di explorer compiler Godbolt . Anda dapat memilih arsitektur target lain, termasuk ARM, MIPS, dan PowerPC, untuk melihat jenis kode bahasa rakitan yang Anda dapatkan dari atom untuk target tersebut.
#include <atomic>
std::atomic<int> num;
void inc_relaxed() {
num.fetch_add(1, std::memory_order_relaxed);
}
int load_num() { return num; } // Even seq_cst loads are free on x86
void store_num(int val){ num = val; }
void store_num_release(int val){
num.store(val, std::memory_order_release);
}
// Can the compiler collapse multiple atomic operations into one? No, it can't.
# g++ 6.2 -O3, targeting x86-64 System V calling convention. (First argument in edi/rdi)
inc_relaxed():
lock add DWORD PTR num[rip], 1 #### Even relaxed RMWs need a lock. There's no way to request just a single-instruction RMW with no lock, for synchronizing between a program and signal handler for example. :/ There is atomic_signal_fence for ordering, but nothing for RMW.
ret
inc_seq_cst():
lock add DWORD PTR num[rip], 1
ret
load_num():
mov eax, DWORD PTR num[rip]
ret
store_num(int):
mov DWORD PTR num[rip], edi
mfence ##### seq_cst stores need an mfence
ret
store_num_release(int):
mov DWORD PTR num[rip], edi
ret ##### Release and weaker doesn't.
store_num_relaxed(int):
mov DWORD PTR num[rip], edi
ret
Perhatikan bagaimana MFENCE (penghalang penuh) diperlukan setelah konsistensi sekuensial menyimpan. x86 sangat tertata secara umum, tetapi pemesanan ulang StoreLoad diizinkan. Memiliki buffer toko sangat penting untuk kinerja yang baik pada CPU out-of-order pipelined. Memory Reordering Jeff Preshing yang Terperangkap dalam Undang-Undang menunjukkan konsekuensi dari tidak menggunakan MFENCE, dengan kode nyata untuk menunjukkan pemesanan ulang terjadi pada perangkat keras nyata.
Re: diskusi dalam komentar pada jawaban @Richard Hodges tentang kompiler yang menggabungkan std :: num++; num-=2;
operasi atom menjadi satu num--;
instruksi :
T&J terpisah pada topik yang sama: Mengapa kompiler tidak menggabungkan redundant std :: atomic wrote? , di mana jawaban saya banyak menyatakan kembali apa yang saya tulis di bawah ini.
Kompiler saat ini tidak benar-benar melakukan ini (belum), tetapi bukan karena mereka tidak diizinkan. C ++ WG21 / P0062R1: Kapan kompiler harus mengoptimalkan atom? membahas harapan yang dimiliki oleh banyak programmer bahwa kompiler tidak akan membuat optimisasi yang "mengejutkan", dan apa yang dapat dilakukan standar untuk memberikan kendali kepada programmer. N4455 membahas banyak contoh hal yang dapat dioptimalkan, termasuk yang ini. Ini menunjukkan bahwa inlining dan propagasi konstan dapat memperkenalkan hal-hal seperti fetch_or(0)
yang mungkin dapat berubah menjadi hanya load()
(tetapi masih memiliki dan melepaskan semantik), bahkan ketika sumber aslinya tidak memiliki operasi atom yang jelas berlebihan.
Alasan sebenarnya kompiler tidak melakukannya (belum) adalah: (1) tidak ada yang menulis kode rumit yang akan memungkinkan kompiler melakukannya dengan aman (tanpa pernah salah), dan (2) berpotensi melanggar prinsip paling tidak kejutan . Kode bebas kunci cukup sulit untuk menulis dengan benar. Jadi jangan santai dalam penggunaan senjata atom Anda: mereka tidak murah dan tidak banyak mengoptimalkan. Tidak selalu mudah untuk menghindari operasi atom yang berlebihan std::shared_ptr<T>
, karena tidak ada versi non-atomnya (meskipun salah satu jawaban di sini memberikan cara mudah untuk mendefinisikan a shared_ptr_unsynchronized<T>
untuk gcc).
Mendapatkan kembali ke num++; num-=2;
kompilasi seolah-olah itu num--
: Compiler diperbolehkan untuk melakukan hal ini, kecuali num
adalah volatile std::atomic<int>
. Jika pemesanan ulang dimungkinkan, aturan as-if memungkinkan kompiler untuk memutuskan pada waktu kompilasi bahwa itu selalu terjadi seperti itu. Tidak ada yang menjamin bahwa pengamat dapat melihat nilai-nilai perantara ( num++
hasilnya).
Yaitu jika pemesanan di mana tidak ada yang menjadi terlihat secara global antara operasi ini kompatibel dengan persyaratan pemesanan sumber (sesuai dengan aturan C ++ untuk mesin abstrak, bukan arsitektur target), kompiler dapat memancarkan satu lock dec dword [num]
bukan lock inc dword [num]
/ lock sub dword [num], 2
.
num++; num--
tidak dapat menghilang, karena masih memiliki hubungan Sinkronisasi Dengan dengan utas lain yang melihatnya num
, dan keduanya merupakan akuisisi-perolehan dan penyimpanan-rilis yang melarang penataan ulang operasi lain di utas ini. Untuk x86, ini mungkin bisa dikompilasi ke MFENCE, bukan lock add dword [num], 0
(yaitu num += 0
).
Seperti dibahas dalam PR0062 , penggabungan yang lebih agresif dari ops atom yang tidak berdekatan pada waktu kompilasi dapat menjadi buruk (misalnya penghitung kemajuan hanya akan diperbarui sekali pada akhir daripada setiap iterasi), tetapi juga dapat membantu kinerja tanpa kerugian (misalnya melewatkan atom inc / dec of ref dihitung ketika salinan a shared_ptr
dibuat dan dihancurkan, jika kompiler dapat membuktikan bahwa shared_ptr
objek lain ada untuk seluruh umur sementara.)
Bahkan num++; num--
penggabungan dapat merusak keadilan penerapan kunci ketika satu utas membuka dan mengunci kembali segera. Jika itu tidak pernah benar-benar dirilis di ASM, bahkan mekanisme arbitrase perangkat keras tidak akan memberikan utas lain kesempatan untuk mengambil kunci pada saat itu.
Dengan gcc6.2 dan clang3.9 saat ini, Anda masih mendapatkan lock
operasi ed terpisah bahkan dengan memory_order_relaxed
dalam kasus yang paling jelas dioptimalkan. ( Godbolt compiler explorer sehingga Anda dapat melihat apakah versi terbaru berbeda.)
void multiple_ops_relaxed(std::atomic<unsigned int>& num) {
num.fetch_add( 1, std::memory_order_relaxed);
num.fetch_add(-1, std::memory_order_relaxed);
num.fetch_add( 6, std::memory_order_relaxed);
num.fetch_add(-5, std::memory_order_relaxed);
//num.fetch_add(-1, std::memory_order_relaxed);
}
multiple_ops_relaxed(std::atomic<unsigned int>&):
lock add DWORD PTR [rdi], 1
lock sub DWORD PTR [rdi], 1
lock add DWORD PTR [rdi], 6
lock sub DWORD PTR [rdi], 5
ret
add
atom?