Bisakah num ++ menjadi atom untuk 'int num'?

153

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 ):

Masukkan deskripsi gambar 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 lockawalan. 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 ++).

Leo Heinsaar
sumber
65
Siapa yang bilang itu addatom?
Slava
6
mengingat bahwa salah satu fitur atom adalah pencegahan
pengubahan urutan
19
Saya juga ingin menunjukkan bahwa jika ini adalah atom pada platform Anda, tidak ada jaminan bahwa itu akan berada di pltaform lain. Jadilah platform mandiri dan ungkapkan niat Anda dengan menggunakan a std::atomic<int>.
NathanOliver
8
Selama pelaksanaan addinstruksi itu, inti lain dapat mencuri alamat memori itu dari cache inti ini dan memodifikasinya. Pada CPU x86, addinstruksi perlu lockawalan jika alamat perlu dikunci dalam cache selama operasi.
David Schwartz
21
Mungkin saja operasi apa pun terjadi sebagai "atom." Yang harus Anda lakukan adalah beruntung dan tidak pernah kebetulan melakukan apa pun yang akan mengungkapkan bahwa itu bukan atom. Atom hanya berharga sebagai jaminan . Mengingat bahwa Anda sedang melihat kode perakitan, pertanyaannya adalah apakah arsitektur tertentu itu terjadi untuk memberi Anda jaminan dan apakah kompiler memberikan jaminan bahwa itulah implementasi tingkat perakitan yang mereka pilih.
Cort Ammon

Jawaban:

197

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::atomicuntuk hasil yang andal, tetapi Anda dapat menggunakannya memory_order_relaxedjika 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], 1dalam 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 lockprefix 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 lockawalan, 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 lockAnda akan mendapatkan salinan yang bertentangan dari baris cache yang sama. Ini tidak pernah bisa terjadi dalam sistem dengan cache yang koheren.

(Jika lockinstruksi 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 lockawalan 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 lockawalan. 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 numnanti: 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=i586sebenarnya 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, dan 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 flagtidak genap volatile. (Dan tidak, C ++ volatilebukan 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> foobukan 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::atomicvariabel 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 numadalah 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_ptrdibuat dan dihancurkan, jika kompiler dapat membuktikan bahwa shared_ptrobjek 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 lockoperasi ed terpisah bahkan dengan memory_order_relaxeddalam 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
Peter Cordes
sumber
1
"[menggunakan instruksi terpisah] dulunya lebih efisien ... tetapi CPU x86 modern sekali lagi menangani operasi RMW setidaknya seefisien" - masih lebih efisien dalam hal di mana nilai yang diperbarui akan digunakan kemudian dalam fungsi yang sama dan ada register gratis yang tersedia bagi kompiler untuk menyimpannya (dan variabelnya tidak ditandai volatile, tentu saja). Ini berarti bahwa sangat mungkin apakah kompiler menghasilkan instruksi tunggal atau ganda untuk operasi tergantung pada sisa kode dalam fungsi, bukan hanya baris tunggal yang dipermasalahkan.
Periata Breatta
@PeriataBreatta: ya, poin bagus. Dalam asm, Anda dapat menggunakan mov eax, 1 xadd [num], eax(tanpa awalan kunci) untuk mengimplementasikan peningkatan pasca num++, tetapi bukan itu yang dilakukan kompiler.
Peter Cordes
3
@ DavidC.Rankin: Jika Anda memiliki pengeditan yang ingin Anda lakukan, jangan ragu. Saya tidak ingin membuat CW ini. Ini masih pekerjaan saya (dan kekacauan saya: P). Saya akan membereskan beberapa setelah permainan Ultimate [frisbee] saya :)
Peter Cordes
1
Jika bukan komunitas wiki, maka mungkin tautan pada wiki tag yang sesuai. (baik tag x86 dan atom?). Ini bernilai linkage tambahan daripada pengembalian penuh harapan dengan pencarian generik pada SO (Jika saya tahu lebih baik di mana itu harus sesuai dalam hal itu, saya akan melakukannya. Saya harus menggali lebih jauh ke dalam daftar yang harus dilakukan & tidak boleh dilakukan dengan tag wiki linkage)
David C. Rankin
1
Seperti biasa - jawaban yang bagus! Perbedaan yang baik antara koherensi dan atomisitas (di mana sebagian yang lain salah)
Leeor
39

... dan sekarang mari kita aktifkan optimisasi:

f():
        rep ret

Oke, mari kita beri kesempatan:

void f(int& num)
{
  num = 0;
  num++;
  --num;
  num += 6;
  num -=5;
  --num;
}

hasil:

f(int&):
        mov     DWORD PTR [rdi], 0
        ret

utas mengamati lain (bahkan mengabaikan penundaan sinkronisasi cache) tidak memiliki kesempatan untuk mengamati perubahan individu.

dibandingkan dengan:

#include <atomic>

void f(std::atomic<int>& num)
{
  num = 0;
  num++;
  --num;
  num += 6;
  num -=5;
  --num;
}

dimana hasilnya adalah:

f(std::atomic<int>&):
        mov     DWORD PTR [rdi], 0
        mfence
        lock add        DWORD PTR [rdi], 1
        lock sub        DWORD PTR [rdi], 1
        lock add        DWORD PTR [rdi], 6
        lock sub        DWORD PTR [rdi], 5
        lock sub        DWORD PTR [rdi], 1
        ret

Sekarang, setiap modifikasi adalah: -

  1. diamati di utas lain, dan
  2. menghormati modifikasi serupa yang terjadi di utas lainnya.

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::atomics.

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:

void incdec(int& num) {
    ++num;
    --num;
}

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:

void incdec(int&) {
    // nada
}

Ini karena dalam model memori c ++, tidak ada kemungkinan utas lain mengamati hasil kenaikan. Ini tentu saja akan berbeda jika numitu volatile(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:

void incdec(std::atomic<int>& num) {
    ++num;
    --num;
}

numadalah 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:

#include <thread>
#include <atomic>

int main()
{
    for (int iter = 0 ; iter < 20 ; ++iter)
    {
        std::atomic<int> num = { 0 };
        std::thread t1([&] {
            for (int i = 0 ; i < 10000000 ; ++i)
            {
                ++num;
                --num;
            }
        });
        std::thread t2([&] {
            for (int i = 0 ; i < 10000000 ; ++i)
            {
                num = 100;
            }
        });
        
        t2.join();
        t1.join();
        std::cout << num << std::endl;
    }
}

output sampel:

99
99
99
99
99
100
99
99
100
100
100
100
99
99
100
99
99
100
100
99
Richard Hodges
sumber
5
Ini gagal menjelaskan bahwa add dword [rdi], 1itu bukan atom (tanpa lockawalan). 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.
Peter Cordes
3
Apa yang sebenarnya terjadi di sini adalah tidak ada yang mengimplementasikan optimasi ini di gcc, karena itu akan hampir tidak berguna dan mungkin lebih berbahaya daripada membantu. (Prinsip paling mengejutkan. Mungkin seseorang yang mengharapkan negara sementara akan terlihat kadang-kadang, dan ok dengan probabilty statistik. Atau mereka yang menggunakan hardware menonton-poin untuk mengganggu pada modifikasi.) Kebutuhan kode kunci bebas untuk hati-hati, jadi tidak akan ada apa pun untuk dioptimalkan. Mungkin berguna untuk mencarinya dan mencetak peringatan, untuk mengingatkan pembuat kode bahwa kode mereka mungkin tidak berarti apa yang mereka pikirkan!
Peter Cordes
2
Itu mungkin alasan bagi kompiler untuk tidak mengimplementasikan ini (prinsip paling tidak mengejutkan dan sebagainya). Mengamati itu akan mungkin dilakukan pada perangkat keras nyata. Namun, aturan pemesanan memori C ++ tidak mengatakan apa pun tentang jaminan apa pun bahwa satu thread memuat "merata" dengan ops thread lain di mesin abstrak C ++. Saya masih berpikir itu akan legal, tetapi programmer-bermusuhan.
Peter Cordes
2
Eksperimen pemikiran: Pertimbangkan implementasi C ++ pada sistem multi-tasking koperasi. Ini mengimplementasikan std :: thread dengan memasukkan titik hasil di mana diperlukan untuk menghindari kebuntuan, tetapi tidak di antara setiap instruksi. Saya kira Anda akan berpendapat bahwa sesuatu dalam standar C ++ memerlukan titik hasil antara num++dan num--. 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.
Peter Cordes
5
Demi finalitas, saya bertanya di milis diskusi std. Pertanyaan ini muncul 2 makalah yang tampaknya setuju dengan Peter, dan mengatasi kekhawatiran yang saya miliki tentang optimisasi tersebut: wg21.link/p0062 dan wg21.link/n4455 Terima kasih saya kepada Andy yang telah membawa ini menjadi perhatian saya.
Richard Hodges
38

Tanpa banyak komplikasi, instruksi seperti add DWORD PTR [rbp-4], 1ini 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.

AGENT 1          AGENT 2

load X              
inc C
                 load X
                 inc C
                 store X
store X

X hanya bertambah satu kali.

Margaret Bloom
sumber
7
@LeoHeinsaar Untuk itu, setiap chip memori akan membutuhkan Unit Logika Aritmatika (ALU) sendiri. Akibatnya, setiap chip memori adalah prosesor.
Richard Hodges
6
@LeoHeinsaar: petunjuk tujuan memori adalah operasi baca-modifikasi-tulis. Tidak ada register arsitektural yang dimodifikasi, tetapi CPU harus menyimpan data secara internal ketika mengirimkannya melalui ALU-nya. File register yang 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.
Peter Cordes
@PeterCordes Komentar Anda persis jawaban yang saya cari. Jawaban Margaret membuat saya curiga bahwa sesuatu seperti itu harus masuk ke dalam.
Leo Heinsaar
Mengubah komentar itu menjadi jawaban lengkap, termasuk menyapa bagian C ++ dari pertanyaan.
Peter Cordes
1
@PeterCordes Terima kasih, sangat detail dan pada semua poin. Itu jelas sebuah perlombaan data dan oleh karena itu perilaku yang tidak terdefinisi oleh standar C ++, saya hanya ingin tahu apakah dalam kasus di mana kode yang dihasilkan adalah apa yang saya posting dapat diasumsikan bahwa itu bisa berupa atom dll. Saya juga baru saja memeriksa setidaknya pengembang Intel manual dengan sangat jelas mendefinisikan atomicity sehubungan dengan operasi memori dan bukan instruksi instruksi, seperti yang saya asumsikan: "Operasi yang dikunci adalah atomik sehubungan dengan semua operasi memori lainnya dan semua peristiwa yang terlihat secara eksternal."
Leo Heinsaar
11

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

Sven Nilsson
sumber
3
lock xaddmengimplementasikan C ++ std :: atomic fetch_add, mengembalikan nilai yang lama. Jika Anda tidak membutuhkannya, kompiler akan menggunakan instruksi tujuan memori normal dengan lockawalan. lock addatau lock inc.
Peter Cordes
1
add [mem], 1masih tidak akan atom pada mesin SMP tanpa cache, lihat komentar saya di jawaban lain.
Peter Cordes
Lihat jawaban saya untuk lebih banyak detail tentang bagaimana tepatnya itu bukan atom. Juga akhir dari jawaban saya untuk pertanyaan terkait ini .
Peter Cordes
10

Karena baris 5, yang sesuai dengan num ++ adalah satu instruksi, dapatkah kita menyimpulkan bahwa num ++ adalah atom dalam kasus ini?

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 addtidak akan menjadi atom pada sistem multi-CPU, bahkan pada arsitektur x86.

Slava
sumber
9

Bahkan jika kompiler Anda selalu memancarkan ini sebagai operasi atom, mengakses numdari 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 ++numjika numbukan atom, misalnya

int main()
{
  std::unique_ptr<std::vector<int>> vec;
  int ready = 0;
  std::thread t{[&]
    {
       while (!ready);
       // use "vec" here
    });
  vec.reset(new std::vector<int>());
  ++ready;
  t.join();
}

Bahkan jika kita berasumsi secara optimis bahwa itu ++readyadalah "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 inisialisasi vectorke suatu titik setelah operasi kenaikan, menyebabkan kekacauan di utas baru. Dalam prakteknya, saya tidak akan terkejut sama sekali jika kompiler pengoptimalan menghapus readyvariabel 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) LOCKawalan atau pagar memori, untuk memaksimalkan operasi per detik.

Singkatnya, tanggung jawab alami pemrograman thread-safe adalah:

  1. Tugas Anda adalah menulis kode yang memiliki perilaku yang jelas di bawah aturan bahasa (dan khususnya model memori standar bahasa).
  2. Tugas kompiler Anda adalah untuk menghasilkan kode mesin yang memiliki perilaku yang jelas (dapat diamati) yang sama di bawah model memori arsitektur target.
  3. Tugas CPU Anda adalah mengeksekusi kode ini sehingga perilaku yang diamati kompatibel dengan model memori arsitekturnya sendiri.

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:

int main()
{
  std::unique_ptr<std::vector<int>> vec;
  std::atomic<int> ready{0}; // NOTE the use of the std::atomic template
  std::thread t{[&]
    {
       while (!ready);
       // use "vec" here
    });
  vec.reset(new std::vector<int>());
  ++ready;
  t.join();
}

Ini aman karena:

  1. Pemeriksaan readytidak dapat dioptimalkan jauh sesuai dengan aturan bahasa.
  2. The ++ready terjadi-sebelum cek yang melihat readytidak nol, dan operasi lainnya tidak dapat mengatur kembali sekitar operasi ini. Ini karena ++readydan 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 penulisan vecke setelah penambahan ready. 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.
Arne Vogel
sumber
1
Jika kompiler tidak dapat melihat semua kegunaan ready, itu mungkin akan dikompilasi while (!ready);menjadi sesuatu yang lebih seperti if(!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.
Peter Cordes
9

Pada mesin x86 single-core, addinstruksi 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 sebuah lockawalan. Mereka bisa memilih untuk memuat numke dalam register dan menyimpan hasil kenaikan dengan instruksi terpisah, dan kemungkinan akan melakukannya jika Anda menggunakan hasilnya.)


Catatan Kaki 1: lockAwalan ada bahkan pada 8086 asli karena perangkat I / O beroperasi bersamaan dengan CPU; driver pada sistem single-core perlu lock addsecara atom meningkatkan nilai dalam memori perangkat jika perangkat juga dapat memodifikasinya, atau sehubungan dengan akses DMA.

supercat
sumber
Itu bahkan tidak secara umum bersifat atom: Utas lain dapat memperbarui variabel yang sama pada saat yang sama dan hanya satu pembaruan yang diambil.
fuz
1
Pertimbangkan sistem multi-core. Tentu saja, dalam satu inti, instruksinya adalah atom, tetapi itu bukan atom sehubungan dengan keseluruhan sistem.
fuz
1
@ FuZxxl: Apa kata keempat dan kelima dari jawaban saya?
supercat
1
@supercat Jawaban Anda sangat menyesatkan karena hanya mempertimbangkan kasus inti tunggal yang langka saat ini dan memberikan OP rasa aman yang salah. Itu sebabnya saya berkomentar untuk mempertimbangkan kasus multi-core juga.
fuz
1
@FUZxxl: Saya melakukan edit untuk menjernihkan kemungkinan kebingungan bagi pembaca yang tidak memperhatikan bahwa ini tidak membicarakan CPU multicore modern yang normal. (Dan juga lebih spesifik tentang beberapa hal yang supercat tidak yakin). BTW, segala sesuatu dalam jawaban ini sudah menjadi milik saya, kecuali kalimat terakhir tentang bagaimana platform di mana baca-modifikasi-tulis adalah atom "gratis" jarang terjadi.
Peter Cordes
7

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.

JDługosz
sumber
1
Interupsi masih tidak perpecahan operasi RMW, sehingga mereka jangan masih melakukan sinkronisasi thread tunggal dengan penangan sinyal yang berjalan di thread yang sama. Tentu saja, ini hanya berfungsi jika ASM menggunakan instruksi tunggal, bukan memisahkan / memodifikasi / menyimpan. C ++ 11 dapat mengekspos fungsionalitas perangkat keras ini, tetapi tidak (mungkin karena itu hanya benar-benar berguna di kernel Uniprocessor untuk menyinkronkan dengan penangan interupsi, bukan di ruang pengguna dengan penangan sinyal). Juga arsitektur tidak memiliki petunjuk tujuan memori baca-modifikasi-tulis. Namun, itu hanya bisa dikompilasi seperti RMW atom santai pada non-x86
Peter Cordes
Meskipun seingat saya, menggunakan awalan Lock bukan kepalang mahal sampai superscal datang. Jadi tidak ada alasan untuk melihatnya sebagai memperlambat kode penting dalam 486, meskipun itu tidak diperlukan oleh program itu.
JDługosz
Ya maaf! Saya sebenarnya tidak membaca dengan seksama. Saya melihat awal paragraf dengan herring merah tentang decoding ke up, dan tidak selesai membaca untuk melihat apa yang sebenarnya Anda katakan. re: 486: Saya pikir saya telah membaca bahwa SMP yang paling awal adalah semacam Compaq 386, tetapi semantik penataan ingatannya tidak sama dengan apa yang dikatakan x86 ISA saat ini. Manual x86 saat ini bahkan mungkin menyebutkan SMP 486. Mereka jelas tidak umum bahkan di HPC (Beowulf Clusters) sampai hari PPro / Athlon XP, saya pikir.
Peter Cordes
1
@PeterCordes Oke. Tentu, dengan asumsi juga tidak ada DMA / pengamat perangkat - tidak cocok di area komentar untuk memasukkan yang juga. Terima kasih JDługosz untuk penambahan yang luar biasa (jawab serta komentar). Benar-benar menyelesaikan diskusi.
Leo Heinsaar
3
@ Leo: Satu poin kunci yang belum disebutkan: CPU out-of-order melakukan pemesanan ulang hal-hal secara internal, tetapi aturan utamanya adalah bahwa untuk satu inti , mereka mempertahankan ilusi instruksi yang berjalan satu per satu, secara berurutan. (Dan ini termasuk interupsi yang memicu sakelar konteks). Nilai-nilai mungkin disimpan secara elektrik ke dalam memori yang rusak, tetapi inti tunggal yang dijalankan semuanya melacak semua penataan ulang yang dilakukannya sendiri, untuk menjaga ilusi. Inilah sebabnya mengapa Anda tidak memerlukan penghalang memori untuk setara ASM a = 1; b = a;untuk memuat dengan benar 1 yang baru saja Anda simpan.
Peter Cordes
4

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:

100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100

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 membaca numberulang kali, maka kemungkinan interleaving yang valid adalah bahwa thread B tidak pernah membaca antara num++dan num--. 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:

while (working())
    progress++;  // atomic, global

(yaitu bayangkan beberapa utas lainnya memperbarui UI bilah kemajuan berdasarkan progress)

Bisakah kompiler mengubahnya menjadi:

int local = 0;
while (working())
    local++;

progress += local;

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 progressitu juga volatile, ini masih valid:

int local = 0;
while (working())
    local++;

while (local--)
    progress++;

: - /

tony
sumber
Jawaban ini tampaknya hanya menjawab pertanyaan sampingan yang saya dan Richard renungkan. Kami akhirnya diselesaikan itu: ternyata keluar ya itu, C ++ standar tidak memungkinkan penggabungan dari operasi pada non volatilebenda 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.
Peter Cordes
Ya, "Tidak" saya benar-benar merupakan jawaban untuk semua alasan. Jika pertanyaannya adalah "bisakah ++ menjadi atom pada beberapa kompiler / implementasi", jawabannya pasti. Sebagai contoh, sebuah kompiler dapat memutuskan untuk menambahkan lockke 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 ...)
tony
1
Perhatikan itu And just remove the incr/decr entirely.tidak benar. Ini masih merupakan operasi akuisisi dan pelepasan num. 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.
Peter Cordes
@PeterCordes ah ya, setuju.
tony
2

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.

Damon
sumber
2
Beban dan penyimpanan masing-masing atom secara terpisah, tetapi seluruh operasi baca-modifikasi-tulis secara keseluruhan jelas bukan atom. Tembolok adalah koheren, jadi tidak pernah dapat menyimpan salinan yang bertentangan dari baris yang sama ( en.wikipedia.org/wiki/MESI_protocol ). Inti lain bahkan tidak dapat memiliki salinan baca-saja sementara inti ini memilikinya dalam keadaan Dimodifikasi. Apa yang membuatnya non-atom adalah bahwa inti yang melakukan RMW dapat kehilangan kepemilikan dari garis cache antara beban dan toko.
Peter Cordes
2
Juga, tidak, seluruh garis cache tidak selalu ditransfer secara atomis. Lihat jawaban ini , di mana secara eksperimental diperlihatkan bahwa multi-socket Opteron membuat 16B SSE menyimpan non-atomik dengan mentransfer baris cache dalam 8B bongkahan dengan hypertransport, meskipun mereka merupakan atom untuk CPU satu-socket dengan tipe yang sama (karena beban / perangkat keras toko memiliki jalur 16B ke L1 cache). x86 hanya menjamin atomicity untuk muatan terpisah atau menyimpan hingga 8B.
Peter Cordes
Meninggalkan perataan ke kompiler tidak berarti bahwa memori akan disejajarkan pada batas 4-byte. Compiler dapat memiliki opsi atau pragma untuk mengubah batas penyelarasan. Ini berguna, misalnya, untuk beroperasi pada data yang padat di aliran jaringan.
Dmitry Rubanovich
2
Sophistries, tidak ada yang lain. Integer dengan penyimpanan otomatis yang bukan merupakan bagian dari struct seperti yang ditunjukkan dalam contoh akan benar -benar disejajarkan dengan benar. Mengklaim sesuatu yang berbeda sama sekali konyol. Garis cache serta semua POD berukuran PoT (power-of-two) dan sejajar - pada setiap arsitektur non-ilusi di dunia. Matematika mengatakan bahwa setiap PoT yang disejajarkan dengan benar cocok dengan tepat satu (tidak pernah lebih) dari PoT lain dengan ukuran yang sama atau lebih besar. Karena itu pernyataan saya benar.
Damon
1
@ Damon, contoh yang diberikan dalam pertanyaan tidak menyebutkan struct, tetapi tidak mempersempit pertanyaan hanya pada situasi di mana integer bukan bagian dari struct. POD paling pasti dapat memiliki ukuran PoT dan tidak dapat disejajarkan PoT. Lihatlah jawaban ini untuk contoh-contoh sintaks: stackoverflow.com/a/11772340/1219722 . Jadi itu hampir tidak "menyesatkan" karena POD yang dideklarasikan sedemikian rupa digunakan dalam kode jaringan sedikit banyak dalam kode kehidupan nyata.
Dmitry Rubanovich
2

Bahwa output compiler tunggal, pada arsitektur CPU tertentu, dengan optimasi dinonaktifkan (karena gcc bahkan tidak mengkompilasi ++ke addketika 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 mengakses numdi thread), dan salah lagian, karena addini bukan atom di x86.

Perhatikan bahwa atomik (menggunakan lockawalan 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":

void inc(int& x)
{
    ++x;
}

Ini mengkompilasi menjadi:

inc(int&):
    incl    (%rdi)
    retq

Menambah int yang dilewatkan dengan referensi, cara atom:

#include <atomic>

void inc(std::atomic<int>& x)
{
    ++x;
}

Contoh ini, yang tidak jauh lebih kompleks daripada cara biasa, hanya mendapatkan lockawalan ditambahkan ke inclinstruksi - tetapi hati-hati, seperti yang dinyatakan sebelumnya ini tidak murah. Hanya karena perakitan terlihat pendek bukan berarti cepat.

inc(std::atomic<int>&):
    lock            incl    (%rdi)
    retq
Asu
sumber
-2

Ketika kompiler Anda hanya menggunakan satu instruksi untuk kenaikan dan mesin Anda berulir tunggal, kode Anda aman. ^^

Bonita Montero
sumber
-3

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.

Xirema
sumber
"Karena pada mesin x86, penambahan integer 32-bit sebenarnya adalah atom." dapatkah Anda memberikan tautan ke dokumentasi yang membuktikannya?
Slava
8
Itu juga bukan atom pada x86. Ini single-core-safe, tetapi jika ada beberapa core (dan ada) itu tidak atomik sama sekali.
Harold
Apakah x86 addsebenarnya 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 sebabnya lockawalan ada untuk instruksi; satu-satunya atom yang berguna addberlaku untuk memori dereferensi, dan menggunakan lockawalan untuk memastikan garis cache terkunci selama durasi operasi .
ShadowRanger
@Slava @Harold @ShadowRanger Saya memperbarui jawabannya. addadalah atom, tetapi saya menjelaskan bahwa itu tidak menyiratkan bahwa kode tersebut aman bagi ras, karena perubahan tidak langsung terlihat secara global.
Xirema
3
@Xirema yang membuatnya "tidak atomik" menurut definisi
harold