Apa itu std :: atomic?


172

Saya mengerti itu std::atomic<>adalah benda atom. Tetapi atom sampai sejauh mana? Menurut pemahaman saya, operasi bisa bersifat atom. Apa sebenarnya yang dimaksud dengan membuat objek atom? Misalnya jika ada dua utas yang secara bersamaan mengeksekusi kode berikut:

a = a + 12;

Lalu apakah seluruh operasi (katakanlah add_twelve_to(int)) atomik? Atau apakah ada perubahan pada atom variabel (begitu operator=())?


9
Anda perlu menggunakan sesuatu seperti a.fetch_add(12)jika Anda menginginkan RMW atom.
Kerrek SB

Ya itu yang saya tidak mengerti. Yang dimaksud dengan membuat objek atom. Jika ada antarmuka itu bisa saja dibuat atom dengan mutex atau monitor.

2
@AaryamanSagar itu memecahkan masalah efisiensi. Mutex dan monitor membawa overhead komputasi. Menggunakan std::atomicmemungkinkan perpustakaan standar memutuskan apa yang diperlukan untuk mencapai atomicity.
Drew Dormann

1
@AaryamanSagar: std::atomic<T>adalah jenis yang memungkinkan untuk operasi atom. Itu tidak secara ajaib membuat hidup Anda lebih baik, Anda masih harus tahu apa yang ingin Anda lakukan dengannya. Ini untuk kasus penggunaan yang sangat spesifik, dan penggunaan operasi atom (pada objek) umumnya sangat halus dan perlu dipikirkan dari perspektif non-lokal. Jadi, kecuali Anda sudah tahu itu dan mengapa Anda menginginkan operasi atom, tipenya mungkin tidak banyak berguna bagi Anda.
Kerrek SB

Jawaban:


188

Setiap instantiasi dan spesialisasi penuh dari std :: atomic <> mewakili tipe yang dapat digunakan secara bersamaan oleh thread yang berbeda (instansinya), tanpa memunculkan perilaku yang tidak terdefinisi:

Objek tipe atom adalah satu-satunya objek C ++ yang bebas dari ras data; yaitu, jika satu utas menulis ke objek atom sedangkan utas lain membacanya, perilakunya didefinisikan dengan baik.

Selain itu, akses ke objek atom dapat membangun sinkronisasi antar-thread dan memesan akses memori non-atom seperti yang ditentukan oleh std::memory_order.

std::atomic<>membungkus operasi yang, dalam pra-C ++ 11 kali, harus dilakukan menggunakan (misalnya) fungsi yang saling terkait dengan MSVC atau atom bultin dalam kasus GCC.

Selain itu, std::atomic<>memberi Anda lebih banyak kontrol dengan mengizinkan berbagai pesanan memori yang menentukan sinkronisasi dan batasan pemesanan. Jika Anda ingin membaca lebih lanjut tentang atom C ++ 11 dan model memori, tautan ini mungkin berguna:

Perhatikan bahwa, untuk kasus penggunaan umum, Anda mungkin akan menggunakan operator aritmatika yang kelebihan beban atau rangkaian lainnya :

std::atomic<long> value(0);
value++; //This is an atomic op
value += 5; //And so is this

Karena sintaksis operator tidak memungkinkan Anda menentukan urutan memori, operasi ini akan dilakukan dengan std::memory_order_seq_cst, karena ini adalah urutan default untuk semua operasi atom dalam C ++ 11. Ini menjamin konsistensi berurutan (total global order) antara semua operasi atom.

Namun, dalam beberapa kasus, ini mungkin tidak diperlukan (dan tidak ada yang gratis), jadi Anda mungkin ingin menggunakan formulir yang lebih eksplisit:

std::atomic<long> value {0};
value.fetch_add(1, std::memory_order_relaxed); // Atomic, but there are no synchronization or ordering constraints
value.fetch_add(5, std::memory_order_release); // Atomic, performs 'release' operation

Sekarang, contoh Anda:

a = a + 12;

tidak akan mengevaluasi ke op atom tunggal: itu akan menghasilkan a.load()(yang merupakan atom itu sendiri), kemudian penambahan antara nilai ini dan 12dan a.store()(juga atom) dari hasil akhir. Seperti yang saya catat sebelumnya, std::memory_order_seq_cstakan digunakan di sini.

Namun, jika Anda menulis a += 12, itu akan menjadi operasi atom (seperti yang saya sebutkan sebelumnya) dan kira-kira setara dengan a.fetch_add(12, std::memory_order_seq_cst).

Adapun komentar Anda:

Reguler intmemiliki muatan dan penyimpanan atom. Apa gunanya membungkusnya atomic<>?

Pernyataan Anda hanya berlaku untuk arsitektur yang memberikan jaminan atomicity untuk toko dan / atau muatan. Ada arsitektur yang tidak melakukan ini. Selain itu, biasanya diperlukan bahwa operasi harus dilakukan pada alamat word- / dword-aligned menjadi atom std::atomic<>adalah sesuatu yang dijamin atom pada setiap platform, tanpa persyaratan tambahan. Selain itu, Anda dapat menulis kode seperti ini:

void* sharedData = nullptr;
std::atomic<int> ready_flag = 0;

// Thread 1
void produce()
{
    sharedData = generateData();
    ready_flag.store(1, std::memory_order_release);
}

// Thread 2
void consume()
{
    while (ready_flag.load(std::memory_order_acquire) == 0)
    {
        std::this_thread::yield();
    }

    assert(sharedData != nullptr); // will never trigger
    processData(sharedData);
}

Perhatikan bahwa kondisi pernyataan akan selalu benar (dan karenanya, tidak akan pernah memicu), sehingga Anda selalu dapat yakin bahwa data siap setelah whileloop keluar. Itu karena:

  • store()untuk flag dilakukan setelah sharedDatadiatur (kami menganggap bahwa generateData()selalu mengembalikan sesuatu yang bermanfaat, khususnya, tidak pernah kembali NULL) dan menggunakan std::memory_order_releaseurutan:

memory_order_release

Operasi toko dengan urutan memori ini melakukan operasi rilis : tidak ada membaca atau menulis di utas saat ini dapat disusun ulang setelah toko ini. Semua tulisan di utas saat ini terlihat di utas lain yang memperoleh variabel atom yang sama

  • sharedDatadigunakan setelah whileloop keluar, dan dengan demikian setelah load()dari flag akan mengembalikan nilai bukan nol. load()menggunakan std::memory_order_acquirepesanan:

std::memory_order_acquire

Operasi beban dengan urutan memori ini melakukan operasi perolehan pada lokasi memori yang terpengaruh: tidak ada membaca atau menulis di utas saat ini yang dapat dipesan ulang sebelum memuat ini. Semua menulis di utas lain yang melepaskan variabel atom yang sama terlihat di utas saat ini .

Ini memberi Anda kontrol tepat atas sinkronisasi dan memungkinkan Anda menentukan secara eksplisit bagaimana kode Anda mungkin / mungkin tidak / akan / tidak akan berperilaku. Ini tidak akan mungkin jika hanya jaminannya adalah atomisitas itu sendiri. Terutama ketika datang ke model sinkronisasi yang sangat menarik seperti pemesanan rilis-konsumsi .


2
Apakah sebenarnya ada arsitektur yang tidak memiliki muatan atom dan menyimpan untuk primitif seperti ints?

7
Ini bukan hanya tentang atomisitas. ini juga tentang pemesanan, perilaku dalam sistem multi-core, dll. Anda mungkin ingin membaca artikel ini .
Mateusz Grzejek

4
@AaryamanSagar Jika saya tidak salah, bahkan pada x86 membaca dan menulis adalah HANYA atom jika sejajar dengan batas kata.
v.shashenko

@MateuszGrzejek Saya telah mengambil referensi ke tipe atom. Bisakah Anda berbaik hati memverifikasi apakah berikut ini masih akan menjamin operasi atom pada objek tugas ideone.com/HpSwqo
xAditya3393

3
@TimMB Ya, biasanya, Anda akan memiliki (setidaknya) dua situasi, di mana urutan eksekusi dapat diubah: (1) kompiler dapat menyusun ulang instruksi (sebanyak standar memungkinkan) untuk memberikan kinerja yang lebih baik dari kode output (berdasarkan penggunaan register CPU, prediksi, dll.) dan (2) CPU dapat menjalankan instruksi dalam urutan yang berbeda untuk, misalnya, meminimalkan jumlah titik sinkronisasi cache. Batasan pemesanan yang disediakan untuk std::atomic( std::memory_order) berfungsi persis dengan tujuan membatasi pemesanan ulang yang diizinkan terjadi.
Mateusz Grzejek

20

Saya mengerti bahwa std::atomic<>membuat objek atom.

Itu masalah perspektif ... Anda tidak dapat menerapkannya pada objek yang berubah-ubah dan menjadikan operasinya menjadi atom, tetapi spesialisasi yang disediakan untuk sebagian besar tipe dan pointer integral dapat digunakan.

a = a + 12;

std::atomic<>tidak (ekspresi penggunaan template untuk) menyederhanakan ini untuk operasi atom tunggal, sebaliknya operator T() const volatile noexceptanggota melakukan sebuah atom load()dari a, maka dua belas ditambahkan, dan operator=(T t) noexceptmelakukan store(t).


Itulah yang ingin saya tanyakan. Int reguler memiliki muatan dan penyimpanan atom. Apa gunanya membungkusnya dengan atom <>

8
@AaryamanSagar Memodifikasi normal inttidak dengan mudah memastikan perubahan terlihat dari utas lainnya, juga tidak membacanya memastikan Anda melihat perubahan utas lainnya, dan beberapa hal seperti my_int += 3tidak dijamin dilakukan secara atomis kecuali Anda menggunakannya std::atomic<>- mereka mungkin melibatkan ambil, lalu tambahkan, lalu simpan urutan, di mana beberapa utas lain yang mencoba memperbarui nilai yang sama mungkin masuk setelah pengambilan dan sebelum toko, dan klik pembaruan utas Anda.
Tony Delroy

" Hanya memodifikasi int normal tidak bisa memastikan perubahan terlihat dari utas lainnya " Ini lebih buruk dari itu: setiap upaya untuk mengukur bahwa visibilitas akan menghasilkan UB.
curiousguy

8

std::atomic ada karena banyak ISA memiliki dukungan perangkat keras langsung untuk itu

Apa yang dikatakan standar C ++ std::atomictelah dianalisis dalam jawaban lain.

Jadi sekarang mari kita lihat apa yang std::atomicdikompilasi untuk mendapatkan wawasan yang berbeda.

Hasil utama dari percobaan ini adalah bahwa CPU modern memiliki dukungan langsung untuk operasi integer atom, misalnya awalan LOCK di x86, dan std::atomicpada dasarnya ada sebagai antarmuka portabel untuk instruksi tersebut: Apa arti instruksi "kunci" dalam perakitan x86? Di aarch64, LDADD akan digunakan.

Dukungan ini memungkinkan untuk alternatif yang lebih cepat untuk metode yang lebih umum seperti std::mutex, yang dapat membuat bagian multi-instruksi atom menjadi lebih kompleks, dengan biaya lebih lambat daripada std::atomickarena std::mutexmembuat futexpanggilan sistem di Linux, yang jauh lebih lambat daripada instruksi pengguna lahan yang dipancarkan oleh std::atomic, lihat juga: Apakah std :: mutex membuat pagar?

Mari kita pertimbangkan program multi-utas berikut ini yang meningkatkan variabel global di beberapa utas, dengan mekanisme sinkronisasi berbeda tergantung pada yang menentukan preprosesor digunakan.

main.cpp

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>

size_t niters;

#if STD_ATOMIC
std::atomic_ulong global(0);
#else
uint64_t global = 0;
#endif

void threadMain() {
    for (size_t i = 0; i < niters; ++i) {
#if LOCK
        __asm__ __volatile__ (
            "lock incq %0;"
            : "+m" (global),
              "+g" (i) // to prevent loop unrolling
            :
            :
        );
#else
        __asm__ __volatile__ (
            ""
            : "+g" (i) // to prevent he loop from being optimized to a single add
            : "g" (global)
            :
        );
        global++;
#endif
    }
}

int main(int argc, char **argv) {
    size_t nthreads;
    if (argc > 1) {
        nthreads = std::stoull(argv[1], NULL, 0);
    } else {
        nthreads = 2;
    }
    if (argc > 2) {
        niters = std::stoull(argv[2], NULL, 0);
    } else {
        niters = 10;
    }
    std::vector<std::thread> threads(nthreads);
    for (size_t i = 0; i < nthreads; ++i)
        threads[i] = std::thread(threadMain);
    for (size_t i = 0; i < nthreads; ++i)
        threads[i].join();
    uint64_t expect = nthreads * niters;
    std::cout << "expect " << expect << std::endl;
    std::cout << "global " << global << std::endl;
}

GitHub hulu .

Kompilasi, jalankan, dan bongkar:

comon="-ggdb3 -O3 -std=c++11 -Wall -Wextra -pedantic main.cpp -pthread"
g++ -o main_fail.out                    $common
g++ -o main_std_atomic.out -DSTD_ATOMIC $common
g++ -o main_lock.out       -DLOCK       $common

./main_fail.out       4 100000
./main_std_atomic.out 4 100000
./main_lock.out       4 100000

gdb -batch -ex "disassemble threadMain" main_fail.out
gdb -batch -ex "disassemble threadMain" main_std_atomic.out
gdb -batch -ex "disassemble threadMain" main_lock.out

Output kondisi ras yang sangat "salah" untuk main_fail.out:

expect 400000
global 100000

dan output "benar" deterministik dari yang lain:

expect 400000
global 400000

Pembongkaran dari main_fail.out:

   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     mov    0x29b5(%rip),%rcx        # 0x5140 <niters>
   0x000000000000278b <+11>:    test   %rcx,%rcx
   0x000000000000278e <+14>:    je     0x27b4 <threadMain()+52>
   0x0000000000002790 <+16>:    mov    0x29a1(%rip),%rdx        # 0x5138 <global>
   0x0000000000002797 <+23>:    xor    %eax,%eax
   0x0000000000002799 <+25>:    nopl   0x0(%rax)
   0x00000000000027a0 <+32>:    add    $0x1,%rax
   0x00000000000027a4 <+36>:    add    $0x1,%rdx
   0x00000000000027a8 <+40>:    cmp    %rcx,%rax
   0x00000000000027ab <+43>:    jb     0x27a0 <threadMain()+32>
   0x00000000000027ad <+45>:    mov    %rdx,0x2984(%rip)        # 0x5138 <global>
   0x00000000000027b4 <+52>:    retq

Pembongkaran dari main_std_atomic.out:

   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     cmpq   $0x0,0x29b4(%rip)        # 0x5140 <niters>
   0x000000000000278c <+12>:    je     0x27a6 <threadMain()+38>
   0x000000000000278e <+14>:    xor    %eax,%eax
   0x0000000000002790 <+16>:    lock addq $0x1,0x299f(%rip)        # 0x5138 <global>
   0x0000000000002799 <+25>:    add    $0x1,%rax
   0x000000000000279d <+29>:    cmp    %rax,0x299c(%rip)        # 0x5140 <niters>
   0x00000000000027a4 <+36>:    ja     0x2790 <threadMain()+16>
   0x00000000000027a6 <+38>:    retq   

Pembongkaran dari main_lock.out:

Dump of assembler code for function threadMain():
   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     cmpq   $0x0,0x29b4(%rip)        # 0x5140 <niters>
   0x000000000000278c <+12>:    je     0x27a5 <threadMain()+37>
   0x000000000000278e <+14>:    xor    %eax,%eax
   0x0000000000002790 <+16>:    lock incq 0x29a0(%rip)        # 0x5138 <global>
   0x0000000000002798 <+24>:    add    $0x1,%rax
   0x000000000000279c <+28>:    cmp    %rax,0x299d(%rip)        # 0x5140 <niters>
   0x00000000000027a3 <+35>:    ja     0x2790 <threadMain()+16>
   0x00000000000027a5 <+37>:    retq

Kesimpulan:

  • versi non-atom menyimpan global ke register, dan menambah register.

    Oleh karena itu, pada akhirnya, sangat mungkin empat penulisan terjadi kembali ke global dengan nilai "salah" yang sama 100000.

  • std::atomickompilasi ke lock addq. Awalan LOCK membuat yang berikut incmengambil, memodifikasi, dan memperbarui memori secara atom.

  • prefiks LOCK inline assembly eksplisit kami mengkompilasi ke hal yang hampir sama dengan std::atomic, kecuali bahwa kami incdigunakan sebagai ganti add. Tidak yakin mengapa GCC memilih add, mengingat INC kami menghasilkan decoding 1 byte lebih kecil.

ARMv8 dapat menggunakan LDAXR + STLXR atau LDADD di CPU yang lebih baru: Bagaimana cara memulai utas di C biasa?

Diuji di Ubuntu 19.10 AMD64, GCC 9.2.1, Lenovo ThinkPad P51.

Dengan menggunakan situs kami, Anda mengakui telah membaca dan memahami Kebijakan Cookie dan Kebijakan Privasi kami.
Licensed under cc by-sa 3.0 with attribution required.