std :: atomic คืออะไร?


174

ฉันเข้าใจว่าstd::atomic<>เป็นวัตถุอะตอม แต่ปรมาณูในระดับใด เพื่อความเข้าใจของฉันการดำเนินการสามารถเป็นอะตอม การทำวัตถุอะตอมหมายถึงอะไรกันแน่? ตัวอย่างเช่นถ้ามีสองกระทู้พร้อมกันรันรหัสต่อไปนี้:

a = a + 12;

ถ้าอย่างนั้นการปฎิบัติการทั้งหมดadd_twelve_to(int)คืออะไร? หรือมีการเปลี่ยนแปลงกับตัวแปรอะตอม (เช่นoperator=())?


9
คุณต้องใช้บางอย่างเช่นa.fetch_add(12)ถ้าคุณต้องการอะตอมมิก RMW
Kerrek SB

ใช่นั่นคือสิ่งที่ฉันไม่เข้าใจ อะไรคือความหมายโดยการทำให้วัตถุอะตอม หากมีอินเทอร์เฟซก็สามารถทำอะตอมด้วย mutex หรือจอภาพ

2
@AaryamanSagar จะแก้ไขปัญหาเรื่องประสิทธิภาพ mutexes และตรวจสอบการดำเนินการค่าใช้จ่ายในการคำนวณ การใช้std::atomicช่วยให้ไลบรารี่มาตรฐานตัดสินใจว่าอะไรคือสิ่งจำเป็นเพื่อให้ได้อะตอมมิก
Drew Dormann

1
@AaryamanSagar: std::atomic<T>เป็นประเภทที่อนุญาตให้ปฏิบัติการปรมาณู มันไม่ได้ทำให้ชีวิตของคุณดีขึ้นอย่างน่าอัศจรรย์คุณยังต้องรู้ว่าคุณต้องการทำอะไรกับมัน สำหรับกรณีการใช้งานที่เฉพาะเจาะจงและการใช้งานการดำเนินการปรมาณู (บนวัตถุ) นั้นบอบบางและจำเป็นต้องพิจารณาจากมุมมองที่ไม่ใช่ของท้องถิ่น ดังนั้นหากคุณไม่ทราบแล้วและสาเหตุที่คุณต้องการปฏิบัติการปรมาณูประเภทนี้อาจไม่เหมาะกับคุณมากนัก
Kerrek SB

คำตอบ:


188

แต่ละอินสแตนซ์และความเชี่ยวชาญเต็มรูปแบบของstd :: atomic <>หมายถึงประเภทที่เธรดที่แตกต่างกันสามารถทำงานได้พร้อมกัน (อินสแตนซ์ของพวกเขา) โดยไม่เพิ่มพฤติกรรมที่ไม่ได้กำหนด:

อ็อบเจกต์ประเภทอะตอมมิกเป็นวัตถุ C ++ เพียงชิ้นเดียวที่ปลอดจากการแย่งข้อมูล นั่นคือถ้าหนึ่งเธรดเขียนไปยังวัตถุอะตอมมิกในขณะที่เธรดอื่นอ่านจากนั้นลักษณะการทำงานที่ถูกกำหนดอย่างดี

std::memory_orderนอกจากนี้การเข้าถึงไปยังวัตถุอะตอมอาจสร้างการประสานระหว่างด้ายและหน่วยความจำเพื่อที่ไม่ใช่อะตอมเข้าถึงตามที่ระบุโดย

std::atomic<>wraps การดำเนินงานที่ pre-C ++ 11 ครั้งจะต้องดำเนินการโดยใช้ฟังก์ชั่น interlocked (ตัวอย่าง) ด้วย MSVC หรือbultins อะตอมมิกในกรณีของ GCC

นอกจากนี้ยังstd::atomic<>ช่วยให้คุณควบคุมได้มากขึ้นโดยการอนุญาตต่างๆการสั่งซื้อหน่วยความจำที่ระบุการประสานและการสั่งซื้อ จำกัด หากคุณต้องการอ่านเพิ่มเติมเกี่ยวกับอะตอมมิกเซอร์และรูปแบบหน่วยความจำ C ++ 11 ลิงก์เหล่านี้อาจมีประโยชน์:

โปรดทราบว่าสำหรับกรณีการใช้งานทั่วไปคุณอาจใช้ตัวดำเนินการทางคณิตศาสตร์มากเกินไปหรือชุดอื่น :

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

เนื่องจากไวยากรณ์ของตัวดำเนินการไม่อนุญาตให้คุณระบุลำดับหน่วยความจำการดำเนินการเหล่านี้จะถูกดำเนินการด้วยstd::memory_order_seq_cstเนื่องจากนี่เป็นลำดับเริ่มต้นสำหรับการปฏิบัติการปรมาณูทั้งหมดใน C ++ 11 ซึ่งรับประกันความสอดคล้องตามลำดับ

อย่างไรก็ตามในบางกรณีสิ่งนี้อาจไม่จำเป็น (และไม่มีอะไรมาให้ฟรี) ดังนั้นคุณอาจต้องการใช้แบบฟอร์มที่ชัดเจนยิ่งขึ้น:

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

ตอนนี้ตัวอย่างของคุณ:

a = a + 12;

จะไม่ประเมินเป็น op ปรมาณูเดียว: มันจะส่งผลให้a.load()(ซึ่งก็คืออะตอมตัวเอง) จากนั้นเพิ่มระหว่างค่านี้และ12และa.store()(ยังอะตอม) ของผลสุดท้าย ดังที่ฉันได้กล่าวไว้ก่อนหน้านี้std::memory_order_seq_cstจะใช้ที่นี่

แต่ถ้าคุณเขียนa += 12มันจะเป็นงานอะตอม (ที่ผมตั้งข้อสังเกตมาก่อน) a.fetch_add(12, std::memory_order_seq_cst)และมีความใกล้เคียงกับ

สำหรับความคิดเห็นของคุณ:

ปกติintมีโหลดและเก็บอะตอม จุดที่ห่อด้วยatomic<>คืออะไร?

คำสั่งของคุณเป็นจริงสำหรับสถาปัตยกรรมที่ให้การรับประกันอะตอมมิกสำหรับร้านค้าและ / หรือโหลด มีสถาปัตยกรรมที่ไม่ทำเช่นนี้ นอกจากนี้ก็มักจะต้องว่าการดำเนินการจะต้องมีการดำเนินการเกี่ยวกับที่อยู่ word- / DWORD ชิดจะเป็นอะตอมstd::atomic<>เป็นสิ่งที่รับประกันได้ว่าจะอะตอมบนทุกแพลตฟอร์มโดยไม่มีข้อกำหนดเพิ่มเติม นอกจากนี้ยังช่วยให้คุณสามารถเขียนรหัสเช่นนี้:

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);
}

โปรดทราบว่าเงื่อนไขการยืนยันจะเป็นจริงเสมอ (และจะไม่มีการกระตุ้น) ดังนั้นคุณจึงมั่นใจได้ว่าข้อมูลพร้อมหลังจากwhileลูปออก นั่นเป็นเพราะ:

  • store()เพื่อตั้งค่าสถานะหลังจากดำเนินการแล้วsharedData(เราคิดว่าgenerateData()จะส่งคืนสิ่งที่มีประโยชน์เสมอโดยเฉพาะไม่กลับมาNULL) และใช้std::memory_order_releaseคำสั่ง:

memory_order_release

การดำเนินการจัดเก็บที่มีลำดับหน่วยความจำนี้ดำเนินการปล่อย : ไม่มีการอ่านหรือเขียนในเธรดปัจจุบันสามารถจัดลำดับใหม่ หลังจากที่จัดเก็บนี้ การเขียนทั้งหมดในเธรดปัจจุบันสามารถมองเห็นได้ในเธรดอื่นที่รับตัวแปรอะตอมมิกเดียวกัน

  • sharedDataถูกใช้หลังจากwhileออกจากลูปและดังนั้นหลังจากload()จากแฟล็กจะส่งคืนค่าที่ไม่เป็นศูนย์ load()ใช้std::memory_order_acquireคำสั่ง:

std::memory_order_acquire

การดำเนินการโหลดกับการสั่งซื้อหน่วยความจำดำเนินการซื้อการดำเนินการเกี่ยวกับที่ตั้งของหน่วยความจำที่ได้รับผลกระทบ: ไม่มีอ่านหรือเขียนในหัวข้อปัจจุบันสามารถจัดลำดับใหม่ก่อนที่จะโหลดนี้ เขียนทั้งหมดในหัวข้ออื่น ๆ ที่ปล่อยตัวแปรอะตอมเดียวกันจะมองเห็นในหัวข้อปัจจุบัน

สิ่งนี้ช่วยให้คุณสามารถควบคุมการซิงโครไนซ์ได้อย่างแม่นยำและช่วยให้คุณระบุได้อย่างชัดเจนว่าโค้ดของคุณอาจ / ไม่ / จะ / ไม่ทำงานอย่างไร สิ่งนี้จะเป็นไปไม่ได้หากรับประกันได้เพียงว่าเป็นอะตอมมิก โดยเฉพาะอย่างยิ่งเมื่อมันมาถึงรุ่นซิงค์ที่น่าสนใจมากเช่นปล่อยบริโภคสั่งซื้อ


2
มีสถาปัตยกรรมที่ไม่มีโหลดและเก็บอะตอมแบบดั้งเดิมintหรือไม่?

7
มันไม่เพียงเกี่ยวกับอะตอมมิก ก็ยังเกี่ยวกับการสั่งซื้อพฤติกรรมในระบบแบบ multi-core ฯลฯ คุณอาจต้องการที่จะอ่านบทความนี้
Mateusz Grzejek

4
@AaryamanSagar ถ้าฉันไม่เข้าใจผิดแม้ใน x86 อ่านและเขียนเป็นอะตอมเท่านั้นถ้าอยู่ในขอบเขตของคำ
v.shashenko

@MateuszGrzejek ฉันได้ทำการอ้างอิงไปยังประเภทอะตอม คุณช่วยกรุณาตรวจสอบว่าสิ่งต่อไปนี้จะยังคงรับประกันการทำงานของอะตอมในการกำหนดวัตถุ ideone.com/HpSwqo
xAditya3393

3
@TimMB ใช่โดยปกติคุณจะมี (อย่างน้อย) สองสถานการณ์ซึ่งลำดับการดำเนินการอาจเปลี่ยนแปลง: (1) คอมไพเลอร์สามารถเรียงลำดับคำสั่งใหม่ (เท่าที่มาตรฐานอนุญาต) เพื่อให้ประสิทธิภาพที่ดียิ่งขึ้น (ขึ้นอยู่กับการใช้งานการลงทะเบียน CPU การคาดคะเน ฯลฯ ) และ (2) CPU สามารถดำเนินการคำสั่งตามลำดับที่แตกต่างกันเช่นลดจำนวนจุดซิงค์แคช ข้อ จำกัด ในการสั่งซื้อที่จัดเตรียมไว้สำหรับstd::atomic( std::memory_order) ทำหน้าที่ตรงตามวัตถุประสงค์ของการ จำกัด การเรียงลำดับใหม่ที่ได้รับอนุญาตให้เกิดขึ้น
Mateusz Grzejek

20

ฉันเข้าใจว่าstd::atomic<>ทำให้อะตอมเป็นวัตถุ

นั่นเป็นเรื่องของมุมมอง ... คุณไม่สามารถนำไปใช้กับวัตถุที่กำหนดเองได้และทำให้การปฏิบัติการของพวกเขากลายเป็นอะตอม แต่สามารถใช้ความเชี่ยวชาญเฉพาะประเภท (ส่วนใหญ่) และพอยน์เตอร์ที่ครบถ้วนได้

a = a + 12;

std::atomic<>ไม่ได้ (การแสดงออกการใช้แม่แบบ) ลดความซับซ้อนนี้เพื่อดำเนินการอะตอมเดี่ยวแทนoperator T() const volatile noexceptสมาชิกไม่อะตอมload()ของaแล้วสิบสองจะมีการเพิ่มและไม่ได้operator=(T t) noexceptstore(t)


นั่นคือสิ่งที่ฉันต้องการถาม int ปกติมีโหลดและเก็บอะตอม ประเด็นของการห่อหุ้มด้วยอะตอมคืออะไร <>

8
@AaryamanSagar เพียงแค่ปรับปกติintไม่ portably ให้แน่ใจว่าการเปลี่ยนแปลงจะมองเห็นได้จากหัวข้ออื่น ๆ หรือไม่อ่านมันให้แน่ใจว่าคุณเห็นการเปลี่ยนแปลงหัวข้ออื่น ๆ และสิ่งที่บางคนชอบที่my_int += 3จะไม่รับประกันว่าจะทำได้อะตอมเว้นแต่คุณจะใช้std::atomic<>- พวกเขาอาจเกี่ยวข้องกับ การดึงข้อมูลจากนั้นเพิ่มจากนั้นจัดเก็บลำดับโดยที่เธรดอื่น ๆ พยายามอัปเดตค่าเดียวกันอาจเข้ามาหลังจากการดึงข้อมูลและก่อนการจัดเก็บและปิดกั้นการอัปเดตเธรดของคุณ
Tony Delroy

" เพียงการปรับเปลี่ยน int ปกติไม่สามารถตรวจสอบให้แน่ใจว่าการเปลี่ยนแปลงนั้นสามารถมองเห็นได้จากกระทู้อื่น ๆ " มันแย่กว่านั้น: ความพยายามใด ๆ ในการวัดการมองเห็นนั้นจะส่งผลให้เกิด UB
curiousguy

8

std::atomic มีอยู่เพราะ ISAs จำนวนมากสนับสนุนฮาร์ดแวร์โดยตรง

สิ่งที่มาตรฐาน C ++ กล่าวถึงstd::atomicได้รับการวิเคราะห์ในคำตอบอื่น ๆ

ดังนั้นตอนนี้เรามาดูสิ่งที่std::atomicรวบรวมเพื่อให้ได้ความเข้าใจที่แตกต่าง

ประเด็นหลักจากการทดลองนี้ก็คือซีพียูสมัยใหม่มีการรองรับโดยตรงสำหรับการดำเนินการจำนวนเต็มของอะตอมตัวอย่างเช่นคำนำหน้า LOCK ใน x86 และstd::atomicโดยพื้นฐานแล้วเป็นอินเทอร์เฟซแบบพกพาสำหรับการบุกรุกเหล่านั้น: คำสั่ง "ล็อค" หมายถึงอะไร ใน aarch64 จะใช้LDADD

การสนับสนุนนี้ช่วยให้ทางเลือกที่รวดเร็วกว่าสำหรับวิธีการทั่วไปมากขึ้นเช่นstd::mutexซึ่งสามารถสร้างอะตอมส่วนคำสั่งที่ซับซ้อนได้หลายแบบในราคาที่ถูกกว่าstd::atomicเพราะstd::mutexจะทำให้การfutexเรียกระบบใน Linux ซึ่งเป็นวิธีที่ช้ากว่าคำแนะนำของstd::atomicผู้ใช้ ดูเพิ่มเติมที่: std :: mutex สร้างรั้วหรือไม่

ลองพิจารณาโปรแกรมแบบมัลติเธรดต่อไปนี้ซึ่งเพิ่มตัวแปรโกลบอลในหลายเธรดพร้อมกับกลไกการซิงโครไนซ์ที่แตกต่างกันขึ้นอยู่กับการกำหนดพรีโปรเซสเซอร์

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 ต้นน้ำ

รวบรวมเรียกใช้และถอดแยกชิ้นส่วน:

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

เป็นไปได้อย่างยิ่งที่สภาพการแข่งขันจะ“ ผิด” สำหรับmain_fail.out:

expect 400000
global 100000

และเอาท์พุท "ขวา" ของผู้อื่น:

expect 400000
global 400000

ถอดชิ้นส่วนของ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

ถอดชิ้นส่วนของ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   

ถอดชิ้นส่วนของ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

สรุป:

  • เวอร์ชันที่ไม่ใช่อะตอมมิกจะบันทึกโกลบอลเป็นรีจิสเตอร์และเพิ่มการลงทะเบียน

    ดังนั้นในตอนท้ายการเขียนสี่อย่างน่าจะเกิดขึ้นกลับมาเป็นโลกที่มีค่า "ผิด" 100000เท่ากัน

  • std::atomiclock addqคอมไพล์ คำนำหน้า LOCK ทำให้การincดึงข้อมูลแก้ไขและอัปเดตหน่วยความจำแบบอะตอมต่อไปนี้

  • อย่างชัดเจนประกอบแบบอินไลน์ LOCK เราคำนำหน้าคอมไพล์ไปเกือบสิ่งเดียวกันเช่นstd::atomicยกเว้นว่าเราจะนำมาใช้แทนinc addไม่แน่ใจว่าทำไม GCC เลือกaddเนื่องจากอิงค์ของเราสร้างการถอดรหัสขนาดเล็กลง 1 ไบต์

ARMv8 สามารถใช้ LDAXR + STLXR หรือ LDADD ในซีพียูรุ่นใหม่กว่า: ฉันจะเริ่มกระทู้ใน C ธรรมดาได้อย่างไร

ทดสอบใน Ubuntu 19.10 AMD64, GCC 9.2.1, Lenovo ThinkPad P51

โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.