Chính xác thì std :: nguyên tử là gì?


172

Tôi hiểu đó std::atomic<>là một vật thể nguyên tử. Nhưng nguyên tử đến mức nào? Theo hiểu biết của tôi một hoạt động có thể là nguyên tử. Chính xác thì có nghĩa là gì khi chế tạo một vật thể nguyên tử? Ví dụ: nếu có hai luồng đồng thời thực thi đoạn mã sau:

a = a + 12;

Sau đó là toàn bộ hoạt động (nói add_twelve_to(int)) nguyên tử? Hoặc là những thay đổi được thực hiện cho nguyên tử biến (vì vậy operator=())?


9
Bạn cần sử dụng một cái gì đó như a.fetch_add(12)nếu bạn muốn có một RMW nguyên tử.
Kerrek SB

Đúng là những gì tôi không hiểu. Điều gì có nghĩa là làm cho một nguyên tử đối tượng. Nếu có một giao diện, đơn giản là nó có thể được chế tạo nguyên tử với một mutex hoặc một màn hình.

2
@AaryamanSagar nó giải quyết vấn đề hiệu quả. Mutexes và màn hình mang trên đầu tính toán. Sử dụng std::atomiccho phép thư viện tiêu chuẩn quyết định những gì cần thiết để đạt được tính nguyên tử.
Drew Dormann

1
@AaryamanSagar: std::atomic<T>là loại cho phép hoạt động nguyên tử. Nó không kỳ diệu làm cho cuộc sống của bạn tốt hơn, bạn vẫn phải biết những gì bạn muốn làm với nó. Đó là cho một trường hợp sử dụng rất cụ thể và việc sử dụng các hoạt động nguyên tử (trên đối tượng) nói chung là rất tinh tế và cần phải được suy nghĩ từ góc độ phi địa phương. Vì vậy, trừ khi bạn đã biết điều đó và tại sao bạn muốn hoạt động nguyên tử, loại này có thể không được sử dụng nhiều cho bạn.
Kerrek SB

Câu trả lời:


188

Mỗi khởi tạo và chuyên môn hóa đầy đủ của std :: nguyên tử <> đại diện cho một loại mà các luồng khác nhau có thể hoạt động đồng thời (các thể hiện của chúng), mà không đưa ra hành vi không xác định:

Các đối tượng của các kiểu nguyên tử là các đối tượng C ++ duy nhất không có các cuộc đua dữ liệu; nghĩa là, nếu một luồng ghi vào một đối tượng nguyên tử trong khi một luồng khác đọc từ nó, thì hành vi được xác định rõ.

Ngoài ra, truy cập vào các đối tượng nguyên tử có thể thiết lập đồng bộ hóa giữa các luồng và ra lệnh truy cập bộ nhớ phi nguyên tử theo quy định của std::memory_order.

std::atomic<>kết thúc các hoạt động, trong tiền C ++ 11 lần, phải được thực hiện bằng cách sử dụng (ví dụ) các chức năng lồng vào nhau với MSVC hoặc bultin nguyên tử trong trường hợp GCC.

Ngoài ra, std::atomic<>cung cấp cho bạn quyền kiểm soát nhiều hơn bằng cách cho phép các đơn đặt hàng bộ nhớ khác nhau chỉ định các ràng buộc đồng bộ hóa và đặt hàng. Nếu bạn muốn đọc thêm về nguyên tử và mô hình bộ nhớ C ++ 11, các liên kết này có thể hữu ích:

Lưu ý rằng, đối với các trường hợp sử dụng thông thường, có thể bạn sẽ sử dụng các toán tử số học quá tải hoặc một tập hợp khác của chúng :

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

Vì cú pháp toán tử không cho phép bạn chỉ định thứ tự bộ nhớ, các thao tác này sẽ được thực hiện với std::memory_order_seq_cst , vì đây là thứ tự mặc định cho tất cả các hoạt động nguyên tử trong C ++ 11. Nó đảm bảo tính nhất quán tuần tự (tổng thứ tự toàn cầu) giữa tất cả các hoạt động nguyên tử.

Tuy nhiên, trong một số trường hợp, điều này có thể không bắt buộc (và không có gì miễn phí), vì vậy bạn có thể muốn sử dụng hình thức rõ ràng hơn:

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

Bây giờ, ví dụ của bạn:

a = a + 12;

sẽ không đánh giá một op nguyên tử duy nhất: nó sẽ dẫn đến a.load()(chính là nguyên tử), sau đó thêm vào giữa giá trị này 12a.store()(cũng là nguyên tử) của kết quả cuối cùng. Như tôi đã lưu ý trước đó, std::memory_order_seq_cstsẽ được sử dụng ở đây.

Tuy nhiên, nếu bạn viết a += 12, nó sẽ là một hoạt động nguyên tử (như tôi đã lưu ý trước đó) và gần tương đương với a.fetch_add(12, std::memory_order_seq_cst).

Đối với bình luận của bạn:

Một thường xuyên intcó tải nguyên tử và các cửa hàng. Điểm của gói nó là atomic<>gì?

Tuyên bố của bạn chỉ đúng với các kiến ​​trúc cung cấp sự đảm bảo về tính nguyên tử như vậy cho các cửa hàng và / hoặc tải. Có những kiến ​​trúc không làm điều này. Ngoài ra, thông thường các thao tác phải được thực hiện trên địa chỉ liên kết từ / từ được định nghĩa là nguyên tử std::atomic<>là điều được đảm bảo là nguyên tử trên mọi nền tảng, không có yêu cầu bổ sung. Hơn nữa, nó cho phép bạn viết mã như thế này:

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

Lưu ý rằng điều kiện xác nhận sẽ luôn đúng (và do đó, sẽ không bao giờ kích hoạt), vì vậy bạn luôn có thể chắc chắn rằng dữ liệu đã sẵn sàng sau khi whilethoát khỏi vòng lặp. Đó là bởi vì:

  • store()cờ được thực hiện sau khi sharedDatađược đặt (chúng tôi giả định rằng generateData()luôn trả về một cái gì đó hữu ích, đặc biệt, không bao giờ trả lại NULL) và sử dụng std::memory_order_releasethứ tự:

memory_order_release

Một hoạt động lưu trữ với thứ tự bộ nhớ này thực hiện thao tác phát hành: không đọc hoặc ghi trong luồng hiện tại có thể được sắp xếp lại sau cửa hàng này. Tất cả các ghi trong luồng hiện tại được hiển thị trong các luồng khác có cùng biến nguyên tử

  • sharedDatađược sử dụng sau khi whilethoát khỏi vòng lặp và do đó, load()từ cờ sẽ trả về giá trị khác không. load()sử dụng std::memory_order_acquirethứ tự:

std::memory_order_acquire

Một hoạt động tải với thứ tự bộ nhớ này thực hiện thao tác thu nhận trên vị trí bộ nhớ bị ảnh hưởng: không thể đọc hoặc ghi trong luồng hiện tại trước khi tải này. Tất cả các ghi trong các luồng khác giải phóng cùng một biến nguyên tử có thể nhìn thấy trong luồng hiện tại .

Điều này cho phép bạn kiểm soát chính xác việc đồng bộ hóa và cho phép bạn chỉ định rõ ràng cách mã của bạn có thể / có thể không / sẽ / không hành xử. Điều này sẽ không thể xảy ra nếu chỉ bảo đảm là chính nguyên tử. Đặc biệt là khi nói đến các mô hình đồng bộ rất thú vị như thứ tự phát hành tiêu thụ .


2
Có thực sự các kiến ​​trúc không có tải nguyên tử và lưu trữ cho các nguyên thủy như ints?

7
Nó không chỉ về nguyên tử. đó cũng là về thứ tự, hành vi trong các hệ thống đa lõi, v.v. Bạn có thể muốn đọc bài viết này .
Mateusz Grzejek

4
@AaryamanSagar Nếu tôi không nhầm, ngay cả trên x86, đọc và ghi chỉ là nguyên tử nếu được căn chỉnh trên ranh giới từ.
v.shashenko

@MateuszGrzejek Tôi đã tham khảo một loại nguyên tử. Bạn có thể vui lòng xác minh nếu sau đây vẫn đảm bảo hoạt động nguyên tử trên phân công đối tượng ideone.com/HpSwqo
xAditya3393

3
@TimMB Có, thông thường, bạn sẽ có (ít nhất) hai tình huống, trong đó thứ tự thực hiện có thể bị thay đổi: (1) trình biên dịch có thể sắp xếp lại các hướng dẫn (theo tiêu chuẩn cho phép) để cung cấp hiệu suất tốt hơn cho mã đầu ra (dựa trên việc sử dụng các thanh ghi CPU, dự đoán, v.v.) và (2) CPU có thể thực hiện các hướng dẫn theo một thứ tự khác, ví dụ, để giảm thiểu số lượng điểm đồng bộ hóa bộ đệm. Các ràng buộc đặt hàng được cung cấp cho std::atomic( std::memory_order) phục vụ chính xác mục đích giới hạn các lần sắp xếp lại được phép xảy ra.
Mateusz Grzejek

20

Tôi hiểu rằng std::atomic<>làm cho một đối tượng nguyên tử.

Đó là vấn đề về phối cảnh ... bạn không thể áp dụng nó cho các đối tượng tùy ý và để các hoạt động của chúng trở thành nguyên tử, nhưng có thể sử dụng các chuyên môn được cung cấp cho (hầu hết) các loại và con trỏ tích phân.

a = a + 12;

std::atomic<>không (sử dụng mẫu biểu để) đơn giản hóa này cho một người hoạt động nguyên tử duy nhất, thay vì các operator T() const volatile noexceptthành viên thực hiện một nguyên tử load()của a, sau đó mười hai được thêm vào, và operator=(T t) noexceptlàm một store(t).


Đó là những gì tôi muốn hỏi. Một int thông thường có tải nguyên tử và các cửa hàng. Điểm quan trọng của việc gói nó với nguyên tử <>

8
@AaryamanSagar Chỉ đơn giản là sửa đổi một bình thường intkhông đảm bảo có thể nhìn thấy sự thay đổi có thể nhìn thấy từ các luồng khác, cũng như không đọc nó đảm bảo bạn thấy các thay đổi của các luồng khác và một số điều như my_int += 3không được đảm bảo thực hiện nguyên tử trừ khi bạn sử dụng std::atomic<>- chúng có thể liên quan tìm nạp, sau đó thêm, sau đó lưu chuỗi, trong đó một số luồng khác đang cố cập nhật giá trị tương tự có thể xuất hiện sau khi tìm nạp và trước cửa hàng và ghi lại cập nhật của luồng.
Tony Delroy

" Chỉ đơn giản là sửa đổi một int bình thường không đảm bảo có thể nhìn thấy sự thay đổi từ các luồng khác " Điều tồi tệ hơn thế: mọi nỗ lực để đo lường khả năng hiển thị đó sẽ dẫn đến UB.
tò mò

8

std::atomic tồn tại bởi vì nhiều ISA có hỗ trợ phần cứng trực tiếp cho nó

Những gì tiêu chuẩn C ++ nói về std::atomicđã được phân tích trong các câu trả lời khác.

Vì vậy, bây giờ hãy xem những gì std::atomicbiên dịch để có được một loại hiểu biết khác nhau.

Điểm nổi bật chính của thí nghiệm này là các CPU hiện đại có hỗ trợ trực tiếp cho các hoạt động số nguyên tử, ví dụ tiền tố LOCK trong x86 và std::atomicvề cơ bản tồn tại dưới dạng giao diện di động cho các nội dung đó: Lệnh "khóa" có nghĩa gì trong lắp ráp x86? Trong aarch64, LDADD sẽ được sử dụng.

Sự hỗ trợ này cho phép thay thế nhanh hơn cho các phương thức tổng quát hơn, chẳng hạn như std::mutexcó thể tạo ra các phần đa hướng dẫn phức tạp hơn, với chi phí chậm hơn std::atomicstd::mutexnó thực hiện futexcác cuộc gọi hệ thống trong Linux, chậm hơn so với các hướng dẫn sử dụng được phát ra từ std::atomic, xem thêm: Std :: mutex có tạo ra hàng rào không?

Chúng ta hãy xem xét chương trình đa luồng sau đây làm tăng biến toàn cục trên nhiều luồng, với các cơ chế đồng bộ hóa khác nhau tùy thuộc vào định nghĩa tiền xử lý nào được sử dụng.

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 ngược dòng .

Biên dịch, chạy và tháo rời:

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

Rất có khả năng đầu ra điều kiện cuộc đua "sai" cho main_fail.out:

expect 400000
global 100000

và đầu ra "đúng" xác định của những người khác:

expect 400000
global 400000

Tháo gỡ 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

Tháo gỡ 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   

Tháo gỡ 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

Kết luận:

  • phiên bản phi nguyên tử lưu toàn cầu vào một thanh ghi và tăng thanh ghi.

    Do đó, cuối cùng, rất có thể bốn bài viết trở lại toàn cầu với cùng giá trị "sai" 100000.

  • std::atomicbiên dịch thành lock addq. Tiền tố LOCK thực hiện quá trình inctìm nạp, sửa đổi và cập nhật bộ nhớ sau đây .

  • tiền tố LOCK lắp ráp nội tuyến rõ ràng của chúng tôi biên dịch thành gần giống như std::atomic, ngoại trừ việc chúng tôi incđược sử dụng thay vì add. Không chắc chắn tại sao GCC chọn add, xem xét rằng INC của chúng tôi tạo ra giải mã nhỏ hơn 1 byte.

ARMv8 có thể sử dụng LDAXR + STLXR hoặc LDADD trong các CPU mới hơn: Làm cách nào để bắt đầu các luồng trong C đơn giản?

Đã thử nghiệm trong Ubuntu 19.10 AMD64, GCC 9.2.1, Lenovo ThinkPad P51.

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.