Secara umum, untuk int num
, num++
(atau ++num
), sebagai operasi baca-modifikasi-tulis, bukan atom . Tapi saya sering melihat kompiler, misalnya GCC , menghasilkan kode berikut untuk itu ( coba di sini ):
Karena baris 5, yang sesuai dengan num++
satu instruksi, dapatkah kita menyimpulkan bahwa num++
atom dalam hal ini?
Dan jika demikian, apakah ini berarti bahwa yang dihasilkan num++
dapat digunakan dalam skenario bersamaan (multi-threaded) tanpa bahaya ras data (yaitu kita tidak perlu membuatnya, misalnya, std::atomic<int>
dan membebankan biaya terkait, karena itu pokoknya atom)?
MEMPERBARUI
Perhatikan bahwa pertanyaan ini bukan apakah kenaikan itu atomik (bukan dan itu adalah dan merupakan garis pembuka pertanyaan). Entah itu bisa dalam skenario tertentu, yaitu apakah sifat satu instruksi dapat dalam kasus tertentu dieksploitasi untuk menghindari overhead lock
awalan. Dan, seperti jawaban yang diterima menyebutkan di bagian tentang mesin uniprocessor, serta jawaban ini , percakapan dalam komentar dan yang lainnya menjelaskan, itu bisa (walaupun tidak dengan C atau C ++).
add
atom?std::atomic<int>
.add
instruksi itu, inti lain dapat mencuri alamat memori itu dari cache inti ini dan memodifikasinya. Pada CPU x86,add
instruksi perlulock
awalan jika alamat perlu dikunci dalam cache selama operasi.Jawaban:
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 menggunakannyamemory_order_relaxed
jika Anda tidak peduli tentang pemesanan ulang. Lihat di bawah untuk beberapa contoh kode dan output asm yang digunakanfetch_add
.Tetapi pertama-tama, bahasa majelis merupakan bagian dari pertanyaan:
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 tanpalock
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 datarandec 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:Ini sangat mungkin jika Anda menggunakan nilai
num
nanti: kompiler akan membuatnya tetap hidup di register setelah kenaikan. Jadi, bahkan jika Anda memeriksa bagaimananum++
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 Anda
num++
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:
Tapi itu tidak akan terjadi. Kompiler bebas untuk memindahkan
flag++
seluruh panggilan fungsi (jika inline fungsi atau tahu bahwa itu tidak melihatflag
). Maka itu dapat mengoptimalkan modifikasi sepenuhnya, karenaflag
tidak genapvolatile
. (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 denganvolatile
, tetapi ada lebih banyak daripada itu. Selain itu,volatile std::atomic<int> foo
bukan sama sepertistd::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 x86
lock
adalah penghalang memori penuh, jadi menggunakannum.fetch_add(1, std::memory_order_relaxed);
menghasilkan kode yang sama pada x86 sepertinum++
(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.
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 satunum--;
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 hanyaload()
(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 ashared_ptr_unsynchronized<T>
untuk gcc).Mendapatkan kembali ke
num++; num-=2;
kompilasi seolah-olah itunum--
: Compiler diperbolehkan untuk melakukan hal ini, kecualinum
adalahvolatile 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]
bukanlock inc dword [num]
/lock sub dword [num], 2
.num++; num--
tidak dapat menghilang, karena masih memiliki hubungan Sinkronisasi Dengan dengan utas lain yang melihatnyanum
, 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, bukanlock add dword [num], 0
(yaitunum += 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 bahwashared_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 denganmemory_order_relaxed
dalam kasus yang paling jelas dioptimalkan. ( Godbolt compiler explorer sehingga Anda dapat melihat apakah versi terbaru berbeda.)sumber
mov eax, 1
xadd [num], eax
(tanpa awalan kunci) untuk mengimplementasikan peningkatan pascanum++
, tetapi bukan itu yang dilakukan kompiler.... dan sekarang mari kita aktifkan optimisasi:
Oke, mari kita beri kesempatan:
hasil:
utas mengamati lain (bahkan mengabaikan penundaan sinkronisasi cache) tidak memiliki kesempatan untuk mengamati perubahan individu.
dibandingkan dengan:
dimana hasilnya adalah:
Sekarang, setiap modifikasi adalah: -
atomicity tidak hanya pada tingkat instruksi, itu melibatkan seluruh pipa dari prosesor, melalui cache, ke memori dan kembali.
Info lebih lanjut
Mengenai efek optimasi dari pembaruan
std::atomic
s.Standar c ++ memiliki aturan 'seolah-olah', yang memungkinkan kompiler untuk menyusun ulang kode, dan bahkan menulis ulang kode asalkan hasilnya memiliki efek yang dapat diamati sama persis (termasuk efek samping) seolah-olah ia hanya menjalankan Anda kode.
Aturan seolah-olah konservatif, terutama yang melibatkan atom.
mempertimbangkan:
Karena tidak ada kunci mutex, atomik atau konstruksi lainnya yang memengaruhi urutan antar-thread, saya berpendapat bahwa kompiler bebas untuk menulis ulang fungsi ini sebagai NOP, misalnya:
Ini karena dalam model memori c ++, tidak ada kemungkinan utas lain mengamati hasil kenaikan. Ini tentu saja akan berbeda jika
num
ituvolatile
(kekuatan pengaruh perilaku hardware). Tetapi dalam kasus ini, fungsi ini akan menjadi satu-satunya fungsi yang memodifikasi memori ini (jika tidak program ini salah bentuk).Namun, ini adalah permainan bola yang berbeda:
num
adalah atom. Perubahan untuk itu harus dapat dilihat oleh utas lain yang menonton. Perubahan yang dibuat sendiri oleh thread (seperti menetapkan nilai ke 100 di antara kenaikan dan penurunan) akan memiliki efek yang sangat luas pada nilai akhirnya dari num.Ini demo:
output sampel:
sumber
add dword [rdi], 1
itu bukan atom (tanpalock
awalan). Muatannya adalah atom, dan store adalah atom, tetapi tidak ada yang menghentikan utas lainnya untuk memodifikasi data antara beban dan toko. Jadi toko dapat menginjak modifikasi yang dibuat oleh utas lain. Lihat jfdube.wordpress.com/2011/11/30/understanding-atomic-operations . Juga, artikel bebas kunci Jeff Preshing sangat bagus , dan dia menyebutkan masalah RMW dasar dalam artikel intro itu.num++
dannum--
. Jika Anda dapat menemukan bagian dalam standar yang mensyaratkan itu, itu akan menyelesaikan ini. Saya cukup yakin itu hanya mensyaratkan bahwa tidak ada pengamat yang bisa melihat pemesanan ulang yang salah, yang tidak memerlukan hasil di sana. Jadi saya pikir itu hanya masalah kualitas implementasi.Tanpa banyak komplikasi, instruksi seperti
add DWORD PTR [rbp-4], 1
ini sangat bergaya CISC.Ini melakukan tiga operasi: memuat operan dari memori, menambahkannya, menyimpan operan kembali ke memori.
Selama operasi ini CPU mendapatkan dan melepaskan bus dua kali, di antara agen lain dapat memperolehnya juga dan ini melanggar atomicity.
X hanya bertambah satu kali.
sumber
Instruksi add tidak atomik. Ini referensi memori, dan dua core prosesor mungkin memiliki cache lokal yang berbeda dari memori itu.
IIRC varian atom dari instruksi add disebut kunci xadd
sumber
lock xadd
mengimplementasikan C ++ std :: atomicfetch_add
, mengembalikan nilai yang lama. Jika Anda tidak membutuhkannya, kompiler akan menggunakan instruksi tujuan memori normal denganlock
awalan.lock add
ataulock inc
.add [mem], 1
masih tidak akan atom pada mesin SMP tanpa cache, lihat komentar saya di jawaban lain.Berbahaya mengambil kesimpulan berdasarkan perakitan yang dihasilkan "rekayasa terbalik". Sebagai contoh, Anda tampaknya telah mengkompilasi kode Anda dengan optimasi dinonaktifkan, jika tidak kompiler akan membuang variabel itu atau memuat 1 langsung ke sana tanpa meminta
operator++
. Karena rakitan yang dihasilkan dapat berubah secara signifikan, berdasarkan flag optimasi, CPU target, dll., Kesimpulan Anda didasarkan pada pasir.Juga, ide Anda bahwa satu instruksi perakitan berarti operasi adalah atom juga salah. Ini
add
tidak akan menjadi atom pada sistem multi-CPU, bahkan pada arsitektur x86.sumber
Bahkan jika kompiler Anda selalu memancarkan ini sebagai operasi atom, mengakses
num
dari utas lain secara bersamaan akan membentuk perlombaan data sesuai dengan standar C ++ 11 dan C ++ 14 dan program akan memiliki perilaku yang tidak terdefinisi.Tapi itu lebih buruk dari itu. Pertama, seperti yang telah disebutkan, instruksi yang dihasilkan oleh kompiler ketika menambah variabel mungkin tergantung pada level optimisasi. Kedua, kompiler dapat menyusun ulang akses memori lain di sekitarnya
++num
jikanum
bukan atom, misalnyaBahkan jika kita berasumsi secara optimis bahwa itu
++ready
adalah "atomik", dan bahwa kompiler menghasilkan loop pemeriksaan sesuai kebutuhan (seperti yang saya katakan, itu adalah UB dan karenanya kompiler bebas untuk menghapusnya, menggantinya dengan loop tak terbatas, dll.), kompiler mungkin masih memindahkan penunjuk pointer, atau bahkan lebih buruk inisialisasivector
ke suatu titik setelah operasi kenaikan, menyebabkan kekacauan di utas baru. Dalam prakteknya, saya tidak akan terkejut sama sekali jika kompiler pengoptimalan menghapusready
variabel dan loop pemeriksaan sepenuhnya, karena ini tidak mempengaruhi perilaku yang dapat diamati di bawah aturan bahasa (sebagai lawan dari harapan pribadi Anda).Bahkan, pada konferensi Meeting C ++ tahun lalu, saya telah mendengar dari dua pengembang kompiler bahwa mereka dengan senang hati mengimplementasikan optimisasi yang membuat program multi-threaded yang ditulis secara naif menjadi tidak sopan, selama aturan bahasa mengizinkannya, bahkan jika peningkatan kinerja kecil terlihat dalam program yang ditulis dengan benar.
Terakhir, bahkan jika Anda tidak peduli tentang portabilitas, dan kompiler Anda secara ajaib bagus, CPU yang Anda gunakan sangat mungkin dari jenis CISC superscalar dan akan memecah instruksi menjadi operasi mikro, menyusun ulang dan / atau secara spekulatif menjalankannya, sampai batas tertentu hanya dibatasi dengan menyinkronkan primitif seperti (pada Intel)
LOCK
awalan atau pagar memori, untuk memaksimalkan operasi per detik.Singkatnya, tanggung jawab alami pemrograman thread-safe adalah:
Jika Anda ingin melakukannya dengan cara Anda sendiri, mungkin hanya berfungsi dalam beberapa kasus, tetapi pahami bahwa garansi tidak berlaku, dan Anda akan bertanggung jawab penuh atas hasil yang tidak diinginkan . :-)
PS: Contoh yang ditulis dengan benar:
Ini aman karena:
ready
tidak dapat dioptimalkan jauh sesuai dengan aturan bahasa.++ready
terjadi-sebelum cek yang melihatready
tidak nol, dan operasi lainnya tidak dapat mengatur kembali sekitar operasi ini. Ini karena++ready
dan pemeriksaan konsisten secara berurutan , yang merupakan istilah lain yang dijelaskan dalam model memori C ++ dan yang melarang pemesanan ulang spesifik ini. Oleh karena itu kompiler tidak boleh menyusun ulang instruksi, dan juga harus memberi tahu CPU bahwa itu tidak boleh mis. Menunda penulisanvec
ke setelah penambahanready
. Konsisten secara berurutan adalah jaminan terkuat mengenai atom dalam standar bahasa. Jaminan yang lebih kecil (dan secara teoritis lebih murah) tersedia misalnya melalui metode lain daristd::atomic<T>
, tetapi ini jelas hanya untuk para ahli, dan mungkin tidak banyak dioptimalkan oleh pengembang kompiler, karena mereka jarang digunakan.sumber
ready
, itu mungkin akan dikompilasiwhile (!ready);
menjadi sesuatu yang lebih sepertiif(!ready) { while(true); }
. Upvoted: bagian kunci dari std :: atomic adalah mengubah semantik untuk mengasumsikan modifikasi asinkron pada titik mana pun. Setelah itu menjadi UB biasanya adalah apa yang memungkinkan kompiler untuk mengangkat beban dan menenggelamkan toko keluar dari loop.Pada mesin x86 single-core,
add
instruksi umumnya akan berupa atom sehubungan dengan kode lain pada CPU 1 . Interupsi tidak dapat membagi instruksi tunggal di tengah.Eksekusi out-of-order diperlukan untuk mempertahankan ilusi instruksi mengeksekusi satu per satu agar dalam satu inti, sehingga setiap instruksi yang berjalan pada CPU yang sama akan terjadi sepenuhnya sebelum atau sepenuhnya setelah penambahan.
Sistem x86 modern adalah multi-core, sehingga case khusus uniprocessor tidak berlaku.
Jika seseorang menargetkan PC tertanam kecil dan tidak memiliki rencana untuk memindahkan kode ke hal lain, sifat atom dari instruksi "tambah" dapat dieksploitasi. Di sisi lain, platform di mana operasi secara inheren atom menjadi semakin langka.
(Ini tidak membantu Anda jika Anda menulis berada di C ++, meskipun. Compiler tidak memiliki pilihan untuk mengharuskan
num++
untuk mengkompilasi sebuah add memori-tujuan atau xadd tanpa sebuahlock
awalan. Mereka bisa memilih untuk memuatnum
ke dalam register dan menyimpan hasil kenaikan dengan instruksi terpisah, dan kemungkinan akan melakukannya jika Anda menggunakan hasilnya.)Catatan Kaki 1:
lock
Awalan ada bahkan pada 8086 asli karena perangkat I / O beroperasi bersamaan dengan CPU; driver pada sistem single-core perlulock add
secara atom meningkatkan nilai dalam memori perangkat jika perangkat juga dapat memodifikasinya, atau sehubungan dengan akses DMA.sumber
Kembali pada hari ketika komputer x86 memiliki satu CPU, penggunaan instruksi tunggal memastikan bahwa interupsi tidak akan membagi baca / modifikasi / tulis dan jika memori tidak akan digunakan sebagai buffer DMA juga, itu adalah fakta atom (dan C ++ tidak menyebutkan utas dalam standar, jadi ini tidak diatasi).
Ketika jarang memiliki prosesor ganda (mis. Dual-socket Pentium Pro) pada desktop pelanggan, saya secara efektif menggunakan ini untuk menghindari awalan LOCK pada mesin single-core dan meningkatkan kinerja.
Hari ini, itu hanya akan membantu melawan beberapa utas yang semuanya diatur ke afinitas CPU yang sama, sehingga utas yang Anda khawatirkan hanya akan ikut bermain melalui irisan waktu yang kedaluwarsa dan menjalankan utas lainnya pada CPU (inti) yang sama. Itu tidak realistis.
Dengan prosesor x86 / x64 modern, instruksi tunggal dipecah menjadi beberapa operasi mikro dan selanjutnya membaca dan menulis memori buffered. Jadi utas yang berbeda berjalan pada CPU yang berbeda tidak hanya akan melihat ini sebagai non-atomik tetapi mungkin melihat hasil yang tidak konsisten mengenai apa yang dibaca dari memori dan apa yang diasumsikan utas lain telah membaca ke titik waktu: Anda perlu menambahkan pagar memori untuk mengembalikan waras tingkah laku.
sumber
a = 1; b = a;
untuk memuat dengan benar 1 yang baru saja Anda simpan.Tidak. Https://www.youtube.com/watch?v=31g0YE61PLQ (Itu hanya tautan ke adegan "Tidak" dari "The Office")
Apakah Anda setuju bahwa ini akan menjadi output yang mungkin untuk program:
output sampel:
Jika demikian, maka kompiler bebas untuk menjadikannya satu - satunya output yang mungkin untuk program, dengan cara apa pun yang diinginkan kompiler. yaitu main () yang hanya mengeluarkan 100-an.
Ini adalah aturan "seolah-olah".
Dan terlepas dari output, Anda dapat memikirkan sinkronisasi utas dengan cara yang sama - jika thread A tidak
num++; num--;
dan thread B membacanum
berulang kali, maka kemungkinan interleaving yang valid adalah bahwa thread B tidak pernah membaca antaranum++
dannum--
. Karena interleaving itu valid, kompiler bebas untuk membuat interleaving satu - satunya yang mungkin. Dan cukup hapus semua incr / decr.Ada beberapa implikasi yang menarik di sini:
(yaitu bayangkan beberapa utas lainnya memperbarui UI bilah kemajuan berdasarkan
progress
)Bisakah kompiler mengubahnya menjadi:
mungkin itu valid. Tapi mungkin bukan apa yang diharapkan oleh programmer :-(
Panitia masih mengerjakan hal ini. Saat ini "berfungsi" karena kompiler tidak banyak mengoptimalkan atom. Tapi itu berubah.
Dan bahkan jika
progress
itu juga volatile, ini masih valid:: - /
sumber
volatile
benda atom, ketika tidak melanggar aturan lainnya. Dua dokumen diskusi standar membahas persis ini (tautan dalam komentar Richard ), satu menggunakan contoh counter-counter yang sama. Jadi ini adalah masalah kualitas implementasi hingga C ++ membuat standar cara untuk mencegahnya.lock
ke setiap operasi. Atau beberapa kombinasi compiler + uniprocessor di mana tidak ada pemesanan ulang (yaitu "hari-hari yang baik") semuanya atom. Tapi apa gunanya itu? Anda tidak dapat benar-benar bergantung padanya. Kecuali Anda tahu itu sistem yang Anda tulis. (Meski begitu, lebih baik atom <int> tidak menambahkan op tambahan pada sistem itu. Jadi, Anda masih harus menulis kode standar ...)And just remove the incr/decr entirely.
tidak benar. Ini masih merupakan operasi akuisisi dan pelepasannum
. Pada x86,num++;num--
bisa dikompilasi menjadi hanya MFENCE, tapi jelas bukan apa-apa. (Kecuali jika seluruh program analisis kompiler dapat membuktikan bahwa tidak ada yang sinkron dengan modifikasi num, dan bahwa tidak masalah jika beberapa toko dari sebelum yang ditunda sampai setelah banyak dari setelah itu.) Misalnya jika ini adalah membuka dan kembali case -lock-right-away-use, Anda masih memiliki dua bagian kritis yang terpisah (mungkin menggunakan mo_relaxed), bukan yang besar.Ya tapi...
Atom bukanlah yang ingin Anda katakan. Anda mungkin bertanya hal yang salah.
Peningkatan itu tentu saja atom . Kecuali jika penyimpanan tidak selaras (dan karena Anda meninggalkan perataan ke kompiler, itu tidak), maka ia harus disejajarkan dalam satu baris cache. Pendek instruksi khusus non-caching streaming, masing-masing dan setiap menulis melewati cache. Garis cache lengkap sedang dibaca dan ditulis secara atom, tidak pernah berbeda.
Data yang lebih kecil dari cacheline, tentu saja, juga ditulis secara atom (karena garis cache di sekitarnya).
Apakah ini aman?
Ini adalah pertanyaan yang berbeda, dan setidaknya ada dua alasan bagus untuk menjawab dengan pasti "Tidak!" .
Pertama, ada kemungkinan bahwa core lain mungkin memiliki salinan garis cache di L1 (L2 dan ke atas biasanya dibagi, tetapi L1 biasanya per-core!), Dan secara bersamaan memodifikasi nilai itu. Tentu saja itu terjadi secara atomis juga, tetapi sekarang Anda memiliki dua nilai "benar" (benar, atom, dimodifikasi) - mana yang benar-benar benar sekarang?
CPU akan mengatasinya entah bagaimana, tentu saja. Tetapi hasilnya mungkin tidak seperti yang Anda harapkan.
Kedua, ada pemesanan memori, atau kata-kata yang berbeda terjadi sebelum jaminan. Hal yang paling penting tentang instruksi atom adalah tidak sebanyak itu atom . Ini pemesanan.
Anda memiliki kemungkinan untuk menerapkan jaminan bahwa segala sesuatu yang terjadi berdasarkan ingatan direalisasikan dalam beberapa jaminan, urutan yang ditetapkan dengan baik di mana Anda memiliki jaminan "terjadi sebelum". Pemesanan ini mungkin "santai" (baca: tidak ada sama sekali) atau seketat yang Anda butuhkan.
Misalnya, Anda dapat mengatur pointer ke beberapa blok data (katakanlah, hasil dari beberapa perhitungan) dan kemudian secara atomik melepaskan bendera "data siap". Sekarang, siapa pun yang memperoleh bendera ini akan dituntun untuk berpikir bahwa penunjuk itu valid. Dan memang, itu akan selalu menjadi pointer yang valid, tidak pernah ada yang berbeda. Itu karena penulisan ke penunjuk terjadi sebelum operasi atom.
sumber
Bahwa output compiler tunggal, pada arsitektur CPU tertentu, dengan optimasi dinonaktifkan (karena gcc bahkan tidak mengkompilasi
++
keadd
ketika mengoptimalkan dalam contoh cepat & kotor ), tampaknya menyiratkan incrementing cara ini atom tidak berarti ini adalah standar-compliant ( Anda akan menyebabkan perilaku undefined ketika mencoba untuk mengaksesnum
di thread), dan salah lagian, karenaadd
ini bukan atom di x86.Perhatikan bahwa atomik (menggunakan
lock
awalan instruksi) relatif berat pada x86 ( lihat jawaban yang relevan ini ), tetapi masih sangat kurang dari sebuah mutex, yang tidak terlalu tepat dalam kasus penggunaan ini.Hasil berikut diambil dari dentang ++ 3.8 saat dikompilasi dengan
-Os
.Menambahkan int dengan referensi, cara "biasa":
Ini mengkompilasi menjadi:
Menambah int yang dilewatkan dengan referensi, cara atom:
Contoh ini, yang tidak jauh lebih kompleks daripada cara biasa, hanya mendapatkan
lock
awalan ditambahkan keincl
instruksi - tetapi hati-hati, seperti yang dinyatakan sebelumnya ini tidak murah. Hanya karena perakitan terlihat pendek bukan berarti cepat.sumber
Ketika kompiler Anda hanya menggunakan satu instruksi untuk kenaikan dan mesin Anda berulir tunggal, kode Anda aman. ^^
sumber
Coba kompilasi kode yang sama pada mesin non-x86, dan Anda akan dengan cepat melihat hasil perakitan yang sangat berbeda.
Alasannya
num++
tampaknya atom adalah karena pada mesin x86, penambahan integer 32-bit sebenarnya adalah atomik (dengan asumsi tidak ada pengambilan memori yang terjadi). Tapi ini tidak dijamin oleh standar c ++, juga tidak mungkin terjadi pada mesin yang tidak menggunakan set instruksi x86. Jadi kode ini tidak aman lintas platform dari kondisi balapan.Anda juga tidak memiliki jaminan kuat bahwa kode ini aman dari Kondisi Balap bahkan pada arsitektur x86, karena x86 tidak mengatur banyak dan menyimpan ke memori kecuali diperintahkan secara khusus untuk melakukannya. Jadi, jika beberapa utas mencoba untuk memperbarui variabel ini secara bersamaan, mereka mungkin akhirnya menambah nilai yang di-cache (ketinggalan jaman)
Alasannya, yang kita miliki
std::atomic<int>
dan seterusnya adalah agar ketika Anda bekerja dengan arsitektur di mana atomicity dari komputasi dasar tidak dijamin, Anda memiliki mekanisme yang akan memaksa kompiler untuk menghasilkan kode atom.sumber
add
sebenarnya dijamin atom? Saya tidak akan terkejut jika kenaikan register adalah atom, tapi itu hampir tidak berguna; untuk membuat kenaikan register terlihat oleh utas lain, ia harus ada dalam memori, yang akan membutuhkan instruksi tambahan untuk memuat dan menyimpannya, menghilangkan atomisitasnya. Pemahaman saya adalah bahwa inilah sebabnyalock
awalan ada untuk instruksi; satu-satunya atom yang bergunaadd
berlaku untuk memori dereferensi, dan menggunakanlock
awalan untuk memastikan garis cache terkunci selama durasi operasi .add
adalah atom, tetapi saya menjelaskan bahwa itu tidak menyiratkan bahwa kode tersebut aman bagi ras, karena perubahan tidak langsung terlihat secara global.