Num ++ có thể là nguyên tử cho 'int num' không?


153

Nói chung, for int num, num++(hoặc ++num), như một hoạt động đọc-sửa đổi-ghi, không phảinguyên tử . Nhưng tôi thường thấy các trình biên dịch, ví dụ GCC , tạo mã sau cho nó ( thử tại đây ):

Nhập mô tả hình ảnh ở đây

Vì dòng 5, tương ứng với num++một hướng dẫn, chúng ta có thể kết luận đó num++ là nguyên tử trong trường hợp này không?

Và nếu vậy, điều đó có nghĩa là việc tạo ra như vậy num++có thể được sử dụng trong các tình huống đồng thời (đa luồng) mà không có bất kỳ nguy hiểm nào về cuộc đua dữ liệu (ví dụ: chúng ta không cần phải tạo ra nó, std::atomic<int>và áp đặt các chi phí liên quan, vì nó nguyên tử nào)?

CẬP NHẬT

Lưu ý rằng câu hỏi này không phải là liệu gia tăng có phải nguyên tử hay không (nó không phải và đó là dòng mở đầu của câu hỏi). Đó là liệu nó thể trong các kịch bản cụ thể hay không, tức là liệu bản chất một lệnh có thể được khai thác để tránh chi phí của locktiền tố hay không. Và, như câu trả lời được chấp nhận đề cập trong phần về máy không xử lý, cũng như câu trả lời này , cuộc trò chuyện trong các bình luận của nó và những người khác giải thích, nó có thể (mặc dù không phải với C hoặc C ++).


65
Ai bảo bạn addlà nguyên tử?
Slava

6
cho rằng một trong những tính năng của nguyên tử là ngăn chặn các loại sắp xếp lại cụ thể trong quá trình tối ưu hóa, không, bất kể tính nguyên tử của hoạt động thực tế
jaggedSpire

19
Tôi cũng muốn chỉ ra rằng nếu đây là nguyên tử trên nền tảng của bạn thì không có gì đảm bảo rằng nó sẽ ở trên một pltaform khác. Hãy độc lập nền tảng và thể hiện ý định của bạn bằng cách sử dụng a std::atomic<int>.
NathanOliver

8
Trong quá trình thực hiện lệnh đó add, một lõi khác có thể đánh cắp địa chỉ bộ nhớ đó từ bộ đệm của lõi này và sửa đổi nó. Trên CPU x86, addlệnh cần locktiền tố nếu địa chỉ cần được khóa trong bộ đệm trong suốt thời gian hoạt động.
David Schwartz

21
Có thể cho bất kỳ hoạt động nào là "nguyên tử". Tất cả bạn phải làm là nhận được may mắn và không bao giờ xảy ra để thực hiện bất cứ điều gì sẽ tiết lộ rằng nó không phải là nguyên tử. Nguyên tử chỉ có giá trị như một sự đảm bảo . Cho rằng bạn đang xem mã lắp ráp, câu hỏi đặt ra là liệu kiến ​​trúc cụ thể đó có xảy ra để cung cấp cho bạn sự đảm bảo hay không liệu trình biên dịch có đảm bảo rằng đó là triển khai cấp độ lắp ráp mà họ chọn hay không.
Cort Ammon

Câu trả lời:


197

Đây hoàn toàn là những gì C ++ định nghĩa là Cuộc đua dữ liệu gây ra Hành vi không xác định, ngay cả khi một trình biên dịch tình cờ tạo ra mã đã làm những gì bạn hy vọng trên một số máy đích. Bạn cần sử dụng std::atomicđể có kết quả đáng tin cậy, nhưng bạn có thể sử dụng nó memory_order_relaxednếu bạn không quan tâm đến việc sắp xếp lại. Xem bên dưới để biết một số mã ví dụ và đầu ra asm bằng cách sử dụng fetch_add.


Nhưng trước tiên, phần ngôn ngữ lắp ráp của câu hỏi:

Vì num ++ là một lệnh ( add dword [num], 1), chúng ta có thể kết luận rằng num ++ là nguyên tử trong trường hợp này không?

Các hướng dẫn đích bộ nhớ (trừ các cửa hàng thuần túy) là các hoạt động đọc-sửa đổi-ghi xảy ra trong nhiều bước nội bộ . Không có thanh ghi kiến ​​trúc nào được sửa đổi, nhưng CPU phải giữ dữ liệu bên trong trong khi nó gửi nó thông qua ALU của nó . Tệp đăng ký thực tế chỉ là một phần nhỏ của bộ lưu trữ dữ liệu bên trong CPU đơn giản nhất, với các chốt giữ đầu ra của một giai đoạn làm đầu vào cho một giai đoạn khác, v.v., v.v.

Hoạt động bộ nhớ từ các CPU khác có thể hiển thị toàn cầu giữa tải và lưu trữ. Tức là hai luồng chạy add dword [num], 1trong một vòng lặp sẽ bước trên các cửa hàng của nhau. (Xem câu trả lời của @ Margaret cho một sơ đồ đẹp). Sau khi tăng 40k từ mỗi hai luồng, bộ đếm có thể chỉ tăng ~ 60k (không phải 80k) trên phần cứng x86 đa lõi thực sự.


"Nguyên tử", từ tiếng Hy Lạp có nghĩa là không thể chia cắt, có nghĩa là không người quan sát nào có thể xem hoạt động là các bước riêng biệt. Xảy ra đồng thời vật lý / điện ngay lập tức cho tất cả các bit chỉ là một cách để đạt được điều này cho tải hoặc lưu trữ, nhưng điều đó thậm chí không thể đối với hoạt động ALU. Tôi đã đi sâu vào chi tiết hơn rất nhiều về tải thuần túy và các cửa hàng thuần túy trong câu trả lời của tôi cho Nguyên tử trên x86 , trong khi câu trả lời này tập trung vào đọc-sửa đổi-ghi.

Các locktiền tố có thể được áp dụng cho nhiều đọc-chỉnh sửa-ghi (đích bộ nhớ) hướng dẫn để thực hiện toàn bộ hoạt động nguyên tử đối với tất cả các quan sát viên có thể trong hệ thống với (lõi khác và các thiết bị DMA, không phải là một dao động nối với các chân CPU). Đó là lý do tại sao nó tồn tại. (Xem thêm phần hỏi đáp này ).

Nguyên tử lock add dword [num], 1 cũng vậy . Một lõi CPU chạy hướng dẫn đó sẽ giữ cho dòng bộ đệm được ghim ở trạng thái Sửa đổi trong bộ đệm L1 riêng của nó từ khi tải đọc dữ liệu từ bộ đệm cho đến khi cửa hàng đưa kết quả trở lại vào bộ đệm. Điều này ngăn không cho bất kỳ bộ đệm nào khác trong hệ thống có một bản sao của dòng bộ đệm tại bất kỳ điểm nào từ tải đến lưu trữ, theo các quy tắc của giao thức kết hợp bộ đệm MESI (hoặc các phiên bản MOESI / MESIF của nó được sử dụng bởi AMD đa lõi / CPU Intel, tương ứng). Do đó, các hoạt động của các lõi khác dường như xảy ra trước hoặc sau, không phải trong thời gian.

Nếu không có locktiền tố, một lõi khác có thể sở hữu dòng bộ đệm và sửa đổi nó sau khi tải nhưng trước cửa hàng của chúng tôi, để cửa hàng khác sẽ hiển thị trên toàn cầu ở giữa tải và lưu trữ của chúng tôi. Một số câu trả lời khác hiểu sai điều này và tuyên bố rằng không có lockbạn nhận được các bản sao mâu thuẫn của cùng một dòng bộ đệm. Điều này không bao giờ có thể xảy ra trong một hệ thống có bộ nhớ kết hợp.

(Nếu một locklệnh ed hoạt động trên bộ nhớ kéo dài hai dòng bộ đệm, thì phải mất nhiều công sức hơn để đảm bảo các thay đổi cho cả hai phần của đối tượng vẫn nguyên tử khi chúng truyền tới tất cả các quan sát viên, vì vậy không người quan sát nào có thể nhìn thấy bị rách. phải khóa toàn bộ bus bộ nhớ cho đến khi dữ liệu chạm vào bộ nhớ. Đừng đánh giá sai các biến nguyên tử của bạn!)

Lưu ý rằng locktiền tố cũng biến một lệnh thành một hàng rào bộ nhớ đầy đủ (như MFENCE ), dừng tất cả việc sắp xếp lại thời gian chạy và do đó mang lại sự thống nhất tuần tự. (Xem bài đăng trên blog tuyệt vời của Jeff Preshing . Các bài đăng khác của anh ấy cũng rất tuyệt vời và giải thích rõ ràng rất nhiều nội dung hay về lập trình không khóa , từ x86 và các chi tiết phần cứng khác cho đến quy tắc C ++.)


Trên một máy không xử lý, hoặc trong một quy trình đơn luồng , một lệnh RMW duy nhất thực sự nguyên tử không có locktiền tố. Cách duy nhất để mã khác truy cập vào biến được chia sẻ là CPU thực hiện chuyển đổi ngữ cảnh, điều này không thể xảy ra ở giữa một lệnh. Vì vậy, một đồng bằng dec dword [num]có thể đồng bộ hóa giữa chương trình đơn luồng và trình xử lý tín hiệu của nó hoặc trong chương trình đa luồng chạy trên máy đơn lõi. Xem nửa sau câu trả lời của tôi cho một câu hỏi khác , và các ý kiến ​​dưới đó, nơi tôi giải thích điều này chi tiết hơn.


Quay lại C ++:

Nó hoàn toàn không có thật để sử dụng num++mà không báo cho trình biên dịch rằng bạn cần nó để biên dịch thành một triển khai đọc-sửa đổi-ghi:

;; Valid compiler output for num++
mov   eax, [num]
inc   eax
mov   [num], eax

Điều này rất có thể nếu bạn sử dụng giá trị numsau này: trình biên dịch sẽ giữ cho nó tồn tại trong một thanh ghi sau khi tăng. Vì vậy, ngay cả khi bạn tự kiểm tra cách num++biên dịch, việc thay đổi mã xung quanh có thể ảnh hưởng đến nó.

(Nếu giá trị là không cần thiết sau đó, inc dword [num]được ưa thích; CPU x86 hiện đại sẽ chạy một hướng dẫn RMW bộ nhớ điểm đến ít nhất một cách hiệu quả như sử dụng ba hướng dẫn riêng Fun thực tế:. gcc -O3 -m32 -mtune=i586Thực sự sẽ phát ra này , bởi vì (Pentium) didn đường ống superscalar P5 của sẽ giải mã các hướng dẫn phức tạp thành nhiều thao tác vi mô đơn giản theo cách P6 và các kiến ​​trúc vi mô sau này thực hiện. Xem bảng hướng dẫn / hướng dẫn vi kiến ​​trúc của Agner Fog để biết thêm thông tin và thẻ wiki cho nhiều liên kết hữu ích (bao gồm cả hướng dẫn sử dụng ISA x86 của Intel, có sẵn miễn phí dưới dạng PDF)).


Đừng nhầm lẫn mô hình bộ nhớ đích (x86) với mô hình bộ nhớ C ++

Sắp xếp lại thời gian biên dịch được cho phép . Phần khác của những gì bạn nhận được với std :: nguyên tử là kiểm soát sắp xếp lại thời gian biên dịch, để đảm bảo rằng bạnnum++sẽ hiển thị toàn cầu chỉ sau một số hoạt động khác.

Ví dụ cổ điển: Lưu trữ một số dữ liệu vào bộ đệm để xem một luồng khác, sau đó đặt cờ. Mặc dù x86 có được các cửa hàng tải / phát hành miễn phí, bạn vẫn phải yêu cầu trình biên dịch không sắp xếp lại bằng cách sử dụng flag.store(1, std::memory_order_release);.

Bạn có thể mong đợi rằng mã này sẽ đồng bộ hóa với các luồng khác:

// 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++;

Nhưng nó sẽ không. Trình biên dịch có thể tự do di chuyển flag++lệnh gọi hàm (nếu nó nội tuyến hàm hoặc biết rằng nó không nhìn vào flag). Sau đó, nó có thể tối ưu hóa hoàn toàn việc sửa đổi, bởi vì flagthậm chí không volatile. (Và không có, C ++ volatilekhông phải là một sự thay thế hữu ích cho std :: std :: nguyên tử. Nguyên tử không làm cho trình biên dịch cho rằng giá trị trong bộ nhớ có thể được sửa đổi đồng bộ tương tự như volatile, nhưng có nhiều hơn nữa để nó hơn. Ngoài ra, volatile std::atomic<int> fookhông phải là giống như std::atomic<int> foo, như đã thảo luận với @Richard Hodges.)

Xác định các cuộc đua dữ liệu trên các biến không nguyên tử là Hành vi không xác định là điều cho phép trình biên dịch vẫn tải và lưu trữ các vòng lặp và nhiều tối ưu hóa khác cho bộ nhớ mà nhiều luồng có thể tham chiếu đến. (Xem blog LLVM này để biết thêm về cách UB kích hoạt tối ưu hóa trình biên dịch.)


Như tôi đã đề cập, tiền tố x86lock là một rào cản bộ nhớ đầy đủ, do đó, việc sử dụng num.fetch_add(1, std::memory_order_relaxed);tạo cùng mã trên x86 như num++(mặc định là tính nhất quán tuần tự), nhưng nó có thể hiệu quả hơn nhiều đối với các kiến ​​trúc khác (như ARM). Ngay cả trên x86, thư giãn cho phép sắp xếp lại thời gian biên dịch nhiều hơn.

Đây là những gì GCC thực sự làm trên x86, cho một vài chức năng hoạt động trên một std::atomicbiến toàn cục.

Xem mã ngôn ngữ nguồn + lắp ráp được định dạng độc đáo trên trình thám hiểm trình biên dịch Godbolt . Bạn có thể chọn các kiến ​​trúc đích khác, bao gồm ARM, MIPS và PowerPC, để xem loại mã ngôn ngữ lắp ráp nào bạn nhận được từ nguyên tử cho các mục tiêu đó.

#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

Lưu ý cách thức MFENCE (một rào cản đầy đủ) là cần thiết sau khi lưu trữ nhất quán tuần tự. x86 được đặt hàng mạnh mẽ nói chung, nhưng sắp xếp lại StoreLoad được cho phép. Có một bộ đệm lưu trữ là điều cần thiết để có hiệu năng tốt trên một CPU không theo thứ tự. Bộ nhớ sắp xếp lại bộ nhớ của Jeff Preshing bị bắt trong Đạo luật cho thấy hậu quả của việc không sử dụng MFENCE, với mã thực để hiển thị sắp xếp lại xảy ra trên phần cứng thực.


Re: thảo luận trong các bình luận về câu trả lời của @Richard Hodges về trình biên dịch hợp nhất std :: num++; num-=2;hoạt động nguyên tử thành một num--;hướng dẫn :

Một câu hỏi và trả lời riêng về cùng một chủ đề: Tại sao trình biên dịch không hợp nhất std :: nguyên tử viết? , nơi câu trả lời của tôi nhắc lại rất nhiều những gì tôi đã viết dưới đây.

Trình biên dịch hiện tại không thực sự làm điều này (chưa), nhưng không phải vì chúng không được phép. C ++ WG21 / P0062R1: Khi nào trình biên dịch nên tối ưu hóa nguyên tử? thảo luận về kỳ vọng rằng nhiều lập trình viên có trình biên dịch sẽ không tối ưu hóa "đáng ngạc nhiên" và những gì tiêu chuẩn có thể làm để cung cấp cho các lập trình viên kiểm soát. N4455 thảo luận về nhiều ví dụ về những điều có thể được tối ưu hóa, bao gồm cả điều này. Nó chỉ ra rằng nội tuyến và lan truyền liên tục có thể giới thiệu những thứ như fetch_or(0)có thể biến thành load()(nhưng vẫn có được và giải phóng ngữ nghĩa), ngay cả khi nguồn ban đầu không có bất kỳ ops nguyên tử dư thừa rõ ràng nào.

Những lý do thực sự khiến trình biên dịch không làm điều đó (chưa) là: (1) không ai viết mã phức tạp cho phép trình biên dịch làm điều đó một cách an toàn (mà không bao giờ hiểu sai) và (2) nó có khả năng vi phạm nguyên tắc tối thiểu ngạc nhiên . Mã khóa không đủ khó để viết chính xác ở vị trí đầu tiên. Vì vậy, đừng bình thường trong việc sử dụng vũ khí nguyên tử của bạn: chúng không rẻ và không tối ưu hóa nhiều. Tuy nhiên, không phải lúc nào cũng dễ dàng để tránh các hoạt động nguyên tử dư thừa std::shared_ptr<T>, vì không có phiên bản phi nguyên tử của nó (mặc dù một trong những câu trả lời ở đây đưa ra một cách dễ dàng để xác định một shared_ptr_unsynchronized<T>gcc).


Quay trở lại num++; num-=2;biên dịch như thể nó là num--: Trình biên dịch được phép làm điều này, trừ khi numvolatile std::atomic<int>. Nếu sắp xếp lại là có thể, quy tắc as-if cho phép trình biên dịch quyết định tại thời điểm biên dịch rằng nó luôn xảy ra theo cách đó. Không có gì đảm bảo rằng người quan sát có thể thấy các giá trị trung gian ( num++kết quả).

Tức là nếu thứ tự không có gì hiển thị trên toàn cầu giữa các hoạt động này tương thích với các yêu cầu đặt hàng của nguồn (theo quy tắc C ++ cho máy trừu tượng, không phải kiến ​​trúc đích), trình biên dịch có thể phát ra một lock dec dword [num]thay vì lock inc dword [num]/ lock sub dword [num], 2.

num++; num--không thể biến mất, bởi vì nó vẫn có mối quan hệ Đồng bộ hóa với các luồng khác num, và đó là cả tải có được và cửa hàng phát hành không cho phép sắp xếp lại các hoạt động khác trong luồng này. Đối với x86, điều này có thể có thể biên dịch thành MẠNH, thay vì lock add dword [num], 0(nghĩa là num += 0).

Như đã thảo luận trong PR0062 , việc sáp nhập mạnh mẽ hơn các op nguyên tử không liền kề vào thời gian biên dịch có thể rất tệ (ví dụ: bộ đếm tiến trình chỉ được cập nhật một lần vào cuối thay vì mỗi lần lặp), nhưng nó cũng có thể giúp hiệu suất không bị giảm (ví dụ: bỏ qua nguyên tử inc / dec của ref tính khi một bản sao của a shared_ptrđược tạo và hủy, nếu trình biên dịch có thể chứng minh rằng một shared_ptrđối tượng khác tồn tại trong toàn bộ tuổi thọ của tạm thời.)

Ngay cả việc num++; num--hợp nhất cũng có thể ảnh hưởng đến sự công bằng của việc triển khai khóa khi một luồng mở khóa và khóa lại ngay lập tức. Nếu nó không bao giờ thực sự được phát hành trong asm, ngay cả các cơ chế trọng tài phần cứng sẽ không tạo cơ hội cho chủ đề khác nắm lấy khóa tại thời điểm đó.


Với gcc6.2 và clang3.9 hiện tại, bạn vẫn có được các lockthao tác ed riêng biệt ngay cả memory_order_relaxedtrong trường hợp tối ưu hóa rõ ràng nhất. ( Trình thám hiểm trình biên dịch Godbolt để bạn có thể xem các phiên bản mới nhất có khác không.)

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

1
"[sử dụng các hướng dẫn riêng biệt] được sử dụng để hiệu quả hơn ... nhưng CPU x86 hiện đại một lần nữa xử lý các hoạt động của RMW ít nhất là hiệu quả" - vẫn hiệu quả hơn trong trường hợp giá trị cập nhật sẽ được sử dụng sau này trong cùng chức năng và có một thanh ghi miễn phí có sẵn để trình biên dịch lưu trữ nó (và tất nhiên biến không được đánh dấu là không ổn định). Điều này có nghĩa là rất có khả năng trình biên dịch tạo ra một lệnh đơn hay nhiều cho hoạt động phụ thuộc vào phần còn lại của mã trong hàm, không chỉ là dòng đơn trong câu hỏi.
Periata Breatta

@PeriataBreatta: vâng, điểm tốt. Trong asm, bạn có thể sử dụng mov eax, 1 xadd [num], eax(không có tiền tố khóa) để thực hiện tăng sau num++, nhưng đó không phải là trình biên dịch làm.
Peter Cordes

3
@ DavidC.Rankin: Nếu bạn có bất kỳ chỉnh sửa nào bạn muốn thực hiện, hãy thoải mái. Tôi không muốn làm CW này, mặc dù. Đó vẫn là công việc của tôi (và mớ hỗn độn của tôi: P). Tôi sẽ dọn dẹp một số thứ sau trò chơi [frĩaee] tối thượng của mình :)
Peter Cordes

1
Nếu không phải wiki cộng đồng, thì có thể một liên kết trên wiki thẻ thích hợp. (cả thẻ x86 và thẻ nguyên tử?). Đó là giá trị liên kết bổ sung chứ không phải là một sự trở lại đầy hy vọng bởi một tìm kiếm chung trên SO (Nếu tôi biết rõ hơn về vấn đề nào phù hợp với vấn đề đó, tôi sẽ làm điều đó. Tôi sẽ phải nghiên cứu sâu hơn về việc không và không nên gắn thẻ liên kết wiki)
David C. Rankin

1
Như mọi khi - câu trả lời tuyệt vời! Phân biệt tốt giữa sự gắn kết và nguyên tử (trong đó một số người khác đã hiểu sai)
Leeor

39

... và bây giờ hãy cho phép tối ưu hóa:

f():
        rep ret

OK, hãy cho nó một cơ hội:

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

kết quả:

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

một luồng quan sát khác (thậm chí bỏ qua sự chậm trễ đồng bộ hóa bộ đệm) không có cơ hội để quan sát các thay đổi riêng lẻ.

so với:

#include <atomic>

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

kết quả là:

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

Bây giờ, mỗi sửa đổi là: -

  1. có thể quan sát trong một chủ đề khác, và
  2. tôn trọng các sửa đổi tương tự xảy ra trong các chủ đề khác.

tính nguyên tử không chỉ ở cấp độ chỉ dẫn, nó liên quan đến toàn bộ đường ống từ bộ xử lý, thông qua bộ nhớ cache, đến bộ nhớ và trở lại.

Thêm thông tin

Về hiệu quả của việc tối ưu hóa các bản cập nhật của std::atomics.

Tiêu chuẩn c ++ có quy tắc 'như thể', theo đó trình biên dịch cho phép sắp xếp lại mã và thậm chí viết lại mã với điều kiện là kết quả có các hiệu ứng có thể quan sát chính xác (bao gồm cả các hiệu ứng phụ) như thể nó đã thực hiện đơn giản mã.

Quy tắc as-if là bảo thủ, đặc biệt liên quan đến nguyên tử.

xem xét:

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

Do không có khóa mutex, nguyên tử hoặc bất kỳ cấu trúc nào khác ảnh hưởng đến trình tự liên luồng, tôi sẽ lập luận rằng trình biên dịch có thể tự do viết lại hàm này dưới dạng NOP, ví dụ:

void incdec(int&) {
    // nada
}

Điều này là do trong mô hình bộ nhớ c ++, không có khả năng một luồng khác quan sát kết quả của sự gia tăng. Nó sẽ tất nhiên là khác nhau nếu numvolatile(sức ảnh hưởng hành vi phần cứng). Nhưng trong trường hợp này, chức năng này sẽ là chức năng duy nhất sửa đổi bộ nhớ này (nếu không chương trình không được định dạng đúng).

Tuy nhiên, đây là một trò chơi bóng khác nhau:

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

numlà một nguyên tử. Thay đổi đối với nó phải được quan sát đối với các chủ đề khác đang xem. Những thay đổi mà chính các luồng thực hiện (chẳng hạn như đặt giá trị thành 100 ở giữa mức tăng và giảm) sẽ có tác động rất sâu rộng đến giá trị cuối cùng của num.

Đây là một bản 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;
    }
}

đầu ra mẫu:

99
99
99
99
99
100
99
99
100
100
100
100
99
99
100
99
99
100
100
99

5
Điều này không giải thích rằng đó không phảiadd dword [rdi], 1 là nguyên tử (không có tiền tố). Tải là nguyên tử, và cửa hàng là nguyên tử, nhưng không có gì ngăn cản một luồng khác sửa đổi dữ liệu giữa tải và cửa hàng. Vì vậy, các cửa hàng có thể bước vào một sửa đổi được thực hiện bởi một chủ đề khác. Xem jfdube.wordpress.com/2011/11/30/under Hiểu-atomic-operations . Ngoài ra, các bài viết không khóa của Jeff Preshing là cực kỳ tốt , và anh ấy có đề cập đến vấn đề cơ bản của RMW trong bài viết giới thiệu đó. lock
Peter Cordes

3
Điều thực sự đang diễn ra ở đây là không ai thực hiện tối ưu hóa này trong gcc, bởi vì nó sẽ gần như vô dụng và có thể nguy hiểm hơn là hữu ích. (Nguyên tắc ít gây ngạc nhiên nhất. Có thể đôi khi ai đó đang mong đợi trạng thái tạm thời có thể nhìn thấy và ổn với xác suất thống kê. Hoặc họ đang sử dụng các điểm quan sát phần cứng để làm gián đoạn sửa đổi.) Mã không khóa cần được chế tạo cẩn thận, vì vậy sẽ không có gì để tối ưu hóa. Có thể hữu ích khi tìm kiếm nó và in một cảnh báo, để cảnh báo cho người viết mã rằng mã của họ có thể không có nghĩa là những gì họ nghĩ!
Peter Cordes

2
Đó có lẽ là một lý do cho các trình biên dịch không thực hiện điều này (nguyên tắc ít gây ngạc nhiên nhất và vân vân). Quan sát rằng sẽ có thể trong thực tế trên phần cứng thực sự. Tuy nhiên, các quy tắc đặt hàng bộ nhớ C ++ không nói bất cứ điều gì về bất kỳ đảm bảo nào rằng tải của một luồng trộn "đều" với các hoạt động của luồng khác trong máy trừu tượng C ++. Tôi vẫn nghĩ rằng nó sẽ hợp pháp, nhưng lập trình viên thù địch.
Peter Cordes

2
Thử nghiệm tư duy: Xem xét việc triển khai C ++ trên hệ thống đa tác vụ hợp tác. Nó thực hiện std :: thread bằng cách chèn các điểm năng suất khi cần để tránh bế tắc, nhưng không phải giữa mọi hướng dẫn. Tôi đoán bạn sẽ tranh luận rằng một cái gì đó trong tiêu chuẩn C ++ đòi hỏi một điểm lợi tức giữa num++num--. Nếu bạn có thể tìm thấy một phần trong tiêu chuẩn yêu cầu điều đó, nó sẽ giải quyết điều này. Tôi khá chắc chắn rằng nó chỉ yêu cầu rằng không có nhà quan sát nào có thể nhìn thấy một sự sắp xếp sai, điều này không đòi hỏi một năng suất ở đó. Vì vậy, tôi nghĩ rằng đó chỉ là một vấn đề chất lượng thực hiện.
Peter Cordes

5
Vì lợi ích của tài chính, tôi đã hỏi trong danh sách gửi thư thảo luận std. Câu hỏi này xuất hiện 2 bài báo dường như vừa đồng tình với Peter, vừa giải quyết những lo ngại mà tôi có về những tối ưu như vậy: wg21.link/p0062wg21.link/n4455 Tôi cảm ơn Andy, người đã chú ý đến tôi.
Richard Hodges

38

Không có nhiều phức tạp, một hướng dẫn như kiểu add DWORD PTR [rbp-4], 1rất CISC.

Nó thực hiện ba thao tác: tải toán hạng từ bộ nhớ, tăng nó, lưu trữ toán hạng trở lại bộ nhớ.
Trong các hoạt động này, CPU thu nhận và giải phóng bus hai lần, ở giữa bất kỳ tác nhân nào khác cũng có thể có được nó và điều này vi phạm nguyên tử.

AGENT 1          AGENT 2

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

X chỉ được tăng một lần.


7
@LeoHeinsaar Để làm được điều đó, mỗi chip bộ nhớ sẽ cần Đơn vị logic số học (ALU) của riêng nó. Trên thực tế, nó sẽ yêu cầu mỗi chip bộ nhớ một bộ xử lý.
Richard Hodges

6
@LeoHeinsaar: hướng dẫn bộ nhớ đích là các hoạt động đọc-sửa đổi-ghi. Không có thanh ghi kiến ​​trúc nào được sửa đổi, nhưng CPU phải giữ dữ liệu bên trong trong khi nó gửi nó thông qua ALU của nó. Tệp đăng ký thực tế chỉ là một phần nhỏ của bộ lưu trữ dữ liệu bên trong CPU đơn giản nhất, với các chốt giữ đầu ra của một giai đoạn làm đầu vào cho một giai đoạn khác, v.v.
Peter Cordes

@PeterCordes Nhận xét của bạn chính xác là câu trả lời tôi đang tìm kiếm. Câu trả lời của Margaret khiến tôi nghi ngờ rằng một cái gì đó như thế phải diễn ra bên trong.
Leo Heinsaar

Biến nhận xét đó thành một câu trả lời đầy đủ, bao gồm cả việc giải quyết phần C ++ của câu hỏi.
Peter Cordes

1
@PeterCordes Cảm ơn, rất chi tiết và trên tất cả các điểm. Đây rõ ràng là một cuộc đua dữ liệu và do đó không xác định được hành vi của tiêu chuẩn C ++, tôi chỉ tò mò liệu trong trường hợp mã được tạo ra có phải là thứ tôi đã đăng hay không, có thể cho rằng đó có thể là nguyên tử, v.v. Tôi cũng chỉ kiểm tra rằng ít nhất là nhà phát triển Intel hướng dẫn sử dụng xác định rất rõ tính nguyên tử đối với các hoạt động của bộ nhớ và không thể phân biệt hướng dẫn, như tôi giả định: "Các hoạt động bị khóa là nguyên tử đối với tất cả các hoạt động bộ nhớ khác và tất cả các sự kiện có thể nhìn thấy bên ngoài."
Leo Heinsaar

11

Các hướng dẫn thêm không phải là nguyên tử. Nó tham chiếu bộ nhớ và hai lõi bộ xử lý có thể có bộ đệm cục bộ khác nhau của bộ nhớ đó.

IIRC biến thể nguyên tử của lệnh add được gọi là lock xadd


3
lock xaddthực hiện C ++ std :: nguyên tử fetch_add, trả về giá trị cũ. Nếu bạn không cần điều đó, trình biên dịch sẽ sử dụng các hướng dẫn đích bộ nhớ thông thường với locktiền tố. lock addhoặc lock inc.
Peter Cordes

1
add [mem], 1vẫn sẽ không phải là nguyên tử trên máy SMP không có bộ đệm, hãy xem nhận xét của tôi về các câu trả lời khác.
Peter Cordes

Xem câu trả lời của tôi để biết thêm chi tiết về chính xác nó không phải là nguyên tử. Cũng kết thúc câu trả lời của tôi về câu hỏi liên quan này .
Peter Cordes

10

Vì dòng 5, tương ứng với num ++ là một hướng dẫn, chúng ta có thể kết luận rằng num ++ là nguyên tử trong trường hợp này không?

Thật nguy hiểm khi đưa ra kết luận dựa trên lắp ráp được tạo ra "kỹ thuật đảo ngược". Ví dụ, dường như bạn đã biên dịch mã của mình với tối ưu hóa bị vô hiệu hóa, nếu không trình biên dịch sẽ vứt bỏ biến đó hoặc tải trực tiếp 1 vào nó mà không cần gọioperator++ . Vì lắp ráp được tạo có thể thay đổi đáng kể, dựa trên cờ tối ưu hóa, CPU mục tiêu, v.v., kết luận của bạn dựa trên cát.

Ngoài ra, ý tưởng của bạn rằng một hướng dẫn lắp ráp có nghĩa là một hoạt động là nguyên tử là sai. Điều này addsẽ không phải là nguyên tử trên các hệ thống nhiều CPU, ngay cả trên kiến ​​trúc x86.


9

Ngay cả khi trình biên dịch của bạn luôn phát ra điều này như một hoạt động nguyên tử, truy cập num từ bất kỳ luồng nào khác đồng thời sẽ tạo thành một cuộc đua dữ liệu theo tiêu chuẩn C ++ 11 và C ++ 14 và chương trình sẽ có hành vi không xác định.

Nhưng nó còn tệ hơn thế. Đầu tiên, như đã được đề cập, hướng dẫn được tạo bởi trình biên dịch khi tăng một biến có thể phụ thuộc vào mức tối ưu hóa. Thứ hai, trình biên dịch có thể sắp xếp lại các truy cập bộ nhớ khác xung quanh ++numnếu numkhông phải là nguyên tử, vd

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

Ngay cả khi chúng ta giả sử một cách lạc quan ++readylà "nguyên tử" và trình biên dịch tạo ra vòng kiểm tra khi cần thiết (như tôi đã nói, đó là UB và do đó trình biên dịch có thể tự do loại bỏ nó, thay thế bằng một vòng lặp vô hạn, v.v.), trình biên dịch vẫn có thể di chuyển gán con trỏ, hoặc thậm chí tệ hơn là khởi tạo vectorđiểm đến sau thao tác tăng, gây ra sự hỗn loạn trong luồng mới. Trong thực tế, tôi sẽ không ngạc nhiên chút nào nếu trình biên dịch tối ưu hóa đã loại bỏ hoàn toàn readybiến và vòng kiểm tra, vì điều này không ảnh hưởng đến hành vi có thể quan sát được theo quy tắc ngôn ngữ (trái với hy vọng riêng tư của bạn).

Trên thực tế, tại hội nghị Hội nghị C ++ năm ngoái, tôi đã nghe từ hai người nhà phát triển trình biên dịch rằng họ rất vui khi thực hiện tối ưu hóa khiến các chương trình đa luồng được viết một cách ngây thơ, miễn là các quy tắc ngôn ngữ cho phép, ngay cả khi thấy cải thiện hiệu suất nhỏ trong các chương trình được viết chính xác.

Cuối cùng, ngay cả khi bạn không quan tâm đến tính di động và trình biên dịch của bạn rất tuyệt vời, CPU bạn đang sử dụng rất có thể thuộc loại CISC siêu cấp và sẽ chia nhỏ các hướng dẫn thành micro-op, sắp xếp lại và / hoặc suy đoán thực thi chúng, đến một mức độ chỉ bị giới hạn bằng cách đồng bộ hóa các nguyên thủy như (trên Intel) LOCKtiền tố hoặc hàng rào bộ nhớ, để tối đa hóa các hoạt động mỗi giây.

Để làm cho một câu chuyện dài ngắn, trách nhiệm tự nhiên của lập trình an toàn luồng là:

  1. Nhiệm vụ của bạn là viết mã có hành vi được xác định rõ theo quy tắc ngôn ngữ (và đặc biệt là mô hình bộ nhớ tiêu chuẩn ngôn ngữ).
  2. Nhiệm vụ của nhà biên dịch của bạn là tạo mã máy có hành vi được xác định rõ (có thể quan sát) theo mô hình bộ nhớ của kiến ​​trúc đích.
  3. Nhiệm vụ của CPU của bạn là thực thi mã này để hành vi được quan sát tương thích với mô hình bộ nhớ của kiến ​​trúc riêng.

Nếu bạn muốn làm theo cách riêng của mình, nó có thể chỉ hoạt động trong một số trường hợp, nhưng hãy hiểu rằng bảo hành là vô hiệu và bạn sẽ tự chịu trách nhiệm cho bất kỳ trường hợp không mong muốn nào kết quả . :-)

PS: Ví dụ được viết chính xác:

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

Điều này là an toàn vì:

  1. Việc kiểm tra ready không thể được tối ưu hóa theo quy tắc ngôn ngữ.
  2. Việc ++ready xảy ra - trước khi kiểm tra xem readykhông phải là 0 và các hoạt động khác có thể được sắp xếp lại xung quanh các hoạt động này. Điều này là do ++readyvà kiểm tra là nhất quán liên tục , đó là một thuật ngữ khác được mô tả trong mô hình bộ nhớ C ++ và cấm sắp xếp lại cụ thể này. Do đó, trình biên dịch không được sắp xếp lại các hướng dẫn và cũng phải thông báo cho CPU rằng nó không được hoãn ghi veclại sau khi tăng ready. Tuần tự nhất quán là sự đảm bảo mạnh nhất về nguyên tử trong tiêu chuẩn ngôn ngữ. Đảm bảo ít hơn (và rẻ hơn về mặt lý thuyết), ví dụ như thông qua các phương pháp khácstd::atomic<T>, nhưng những thứ này chắc chắn chỉ dành cho các chuyên gia và có thể không được tối ưu hóa bởi các nhà phát triển trình biên dịch, vì chúng hiếm khi được sử dụng.

1
Nếu trình biên dịch không thể thấy tất cả các cách sử dụng ready, nó có thể sẽ biên dịch while (!ready);thành một cái gì đó giống như if(!ready) { while(true); }. Upvote: một phần quan trọng của std :: nguyên tử đang thay đổi ngữ nghĩa để giả định sửa đổi không đồng bộ tại bất kỳ điểm nào. Có nó là UB bình thường là những gì cho phép trình biên dịch tải trọng tải và chìm các cửa hàng ra khỏi các vòng lặp.
Peter Cordes

9

Trên máy x86 lõi đơn, một addlệnh thường sẽ là nguyên tử đối với mã khác trên CPU 1 . Một ngắt không thể tách một lệnh xuống giữa.

Việc thực hiện không theo thứ tự là cần thiết để duy trì ảo giác các lệnh thực thi từng lệnh một trong một lõi, do đó, bất kỳ lệnh nào chạy trên cùng CPU sẽ xảy ra hoàn toàn trước hoặc hoàn toàn sau khi thêm.

Các hệ thống x86 hiện đại là đa lõi, vì vậy trường hợp đặc biệt không có bộ xử lý không áp dụng.

Nếu một người đang nhắm mục tiêu vào một PC nhúng nhỏ và không có kế hoạch di chuyển mã sang bất cứ thứ gì khác, bản chất nguyên tử của lệnh "thêm" có thể được khai thác. Mặt khác, các nền tảng nơi hoạt động vốn là nguyên tử đang ngày càng khan hiếm.

(Điều này không giúp bạn nếu bạn đang viết bằng C ++, mặc dù. Trình biên dịch không có một tùy chọn để yêu cầu num++để biên dịch một add bộ nhớ đích hoặc xadd mà không cần một locktiền tố. Họ có thể chọn để tải numvào một thanh ghi và lưu trữ kết quả gia tăng với một hướng dẫn riêng và có thể sẽ làm điều đó nếu bạn sử dụng kết quả đó.)


Chú thích 1: lockTiền tố tồn tại ngay cả trên 8086 ban đầu vì các thiết bị I / O hoạt động đồng thời với CPU; các trình điều khiển trên hệ thống lõi đơn cần lock addtăng nguyên tử một giá trị trong bộ nhớ thiết bị nếu thiết bị cũng có thể sửa đổi nó hoặc liên quan đến truy cập DMA.


Nó thậm chí không phải là nguyên tử: Một luồng khác có thể cập nhật cùng một biến cùng một lúc và chỉ có một cập nhật được thực hiện.
fuz

1
Hãy xem xét một hệ thống đa lõi. Tất nhiên, trong một lõi, hướng dẫn là nguyên tử, nhưng nó không phải là nguyên tử đối với toàn bộ hệ thống.
fuz

1
@FUZxxl: Từ thứ tư và thứ năm trong câu trả lời của tôi là gì?
supercat

1
@supercat Câu trả lời của bạn rất sai lệch vì nó chỉ xem xét trường hợp hiếm hoi hiện nay của một lõi đơn và mang lại cho OP cảm giác an toàn sai lầm. Đó là lý do tại sao tôi nhận xét để xem xét trường hợp đa lõi, quá.
fuz

1
@FUZxxl: Tôi đã thực hiện một chỉnh sửa để xóa sự nhầm lẫn tiềm ẩn cho những độc giả không nhận thấy rằng điều này không nói về CPU đa lõi hiện đại thông thường. (Và cũng cụ thể hơn về một số thứ mà siêu xe không chắc chắn). BTW, mọi thứ trong câu trả lời này đã có sẵn trong tôi, ngoại trừ câu cuối cùng về cách các nền tảng trong đó đọc-sửa đổi-ghi là nguyên tử "miễn phí" là hiếm.
Peter Cordes

7

Trước đây khi các máy tính x86 có một CPU, việc sử dụng một lệnh duy nhất đảm bảo rằng các ngắt sẽ không phân chia đọc / sửa đổi / ghi và nếu bộ nhớ cũng không được sử dụng làm bộ đệm DMA, thì thực tế nó là nguyên tử (và C ++ không đề cập đến các chủ đề trong tiêu chuẩn, vì vậy điều này không được giải quyết).

Khi hiếm khi có bộ xử lý kép (ví dụ Pentium Pro ổ cắm kép) trên máy tính để bàn của khách hàng, tôi đã sử dụng hiệu quả điều này để tránh tiền tố LOCK trên máy lõi đơn và cải thiện hiệu suất.

Ngày nay, nó chỉ giúp chống lại nhiều luồng được đặt cùng một mối quan hệ CPU, vì vậy các luồng bạn lo lắng sẽ chỉ phát huy khi hết thời gian và chạy luồng khác trên cùng CPU (lõi). Điều đó không thực tế.

Với bộ xử lý x86 / x64 hiện đại, lệnh đơn được chia thành nhiều vi lệnh và hơn nữa việc đọc và ghi bộ nhớ được đệm. Vì vậy, các luồng khác nhau chạy trên các CPU khác nhau sẽ không chỉ xem đây là phi nguyên tử mà còn có thể thấy các kết quả không nhất quán liên quan đến những gì nó đọc từ bộ nhớ và những gì nó giả định rằng các luồng khác đã đọc đến thời điểm đó: bạn cần thêm hàng rào bộ nhớ để khôi phục lại lành mạnh hành vi.


1
Ngắt vẫn không hoạt động RMW chia, vì vậy họ làm vẫn đồng bộ hóa một chủ đề duy nhất với bộ xử lý tín hiệu mà chạy trong cùng một thread. Tất nhiên, điều này chỉ hoạt động nếu asm sử dụng một lệnh duy nhất, không tách riêng tải / sửa đổi / lưu trữ. C ++ 11 có thể làm lộ chức năng phần cứng này, nhưng nó không (có lẽ vì nó chỉ thực sự hữu ích trong các hạt nhân Uniprocessor để đồng bộ hóa với các trình xử lý ngắt, không phải trong không gian người dùng với các trình xử lý tín hiệu). Ngoài ra các kiến ​​trúc không có hướng dẫn đọc-sửa-ghi-ghi-đích. Tuy nhiên, nó chỉ có thể biên dịch giống như một RMW nguyên tử thoải mái trên phi x86
Peter Cordes

Mặc dù tôi nhớ lại, sử dụng tiền tố Khóa không đắt một cách vô lý cho đến khi các bộ siêu thay đổi xuất hiện. Vì vậy, không có lý do gì để nhận thấy nó làm chậm mã quan trọng trong 486, mặc dù chương trình đó không cần thiết.
JDługosz

Vâng xin lôi! Tôi đã không thực sự đọc kỹ. Tôi đã thấy phần đầu của đoạn văn với cá trích đỏ về việc giải mã thành các vòng, và không đọc xong để xem những gì bạn thực sự nói. re: 486: Tôi nghĩ rằng tôi đã đọc rằng SMP sớm nhất là một loại Compaq 386, nhưng ngữ nghĩa theo thứ tự bộ nhớ của nó không giống với những gì mà x86 ISA hiện đang nói. Hướng dẫn sử dụng x86 hiện tại thậm chí có thể đề cập đến SMP 486. Tuy nhiên, chúng chắc chắn không phổ biến ngay cả trong HPC (cụm Beowulf) cho đến ngày PPro / Athlon XP.
Peter Cordes

1
@PeterCordes Ok. Chắc chắn, giả sử cũng không có người quan sát DMA / thiết bị - không phù hợp trong khu vực bình luận để bao gồm cả người đó. Cảm ơn JDługosz vì sự bổ sung tuyệt vời (câu trả lời cũng như ý kiến). Thực sự hoàn thành các cuộc thảo luận.
Leo Heinsaar

3
@Leo: Một điểm quan trọng chưa được đề cập: CPU không theo thứ tự sắp xếp lại mọi thứ bên trong, nhưng nguyên tắc vàng là đối với một lõi , chúng sẽ bảo vệ ảo giác các lệnh chạy cùng một lúc. (Và điều này bao gồm các ngắt kích hoạt chuyển đổi ngữ cảnh). Các giá trị có thể được lưu trữ điện vào bộ nhớ không theo thứ tự, nhưng lõi đơn mà mọi thứ đang chạy sẽ theo dõi tất cả các thứ tự sắp xếp lại, để duy trì ảo ảnh. Đây là lý do tại sao bạn không cần một rào cản bộ nhớ tương đương a = 1; b = a;với tải asm mà bạn vừa lưu trữ.
Peter Cordes

4

Số https://www.youtube.com/watch?v=31g0YE61PLQ (Đó chỉ là một liên kết đến cảnh "Không" từ "Văn phòng")

Bạn có đồng ý rằng đây sẽ là một đầu ra có thể cho chương trình:

đầu ra mẫu:

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

Nếu vậy, trình biên dịch có thể tự do làm đầu ra duy nhất có thể cho chương trình, theo bất kỳ cách nào trình biên dịch muốn. tức là một main () chỉ đưa ra 100 giây.

Đây là quy tắc "như thể nếu".

Và bất kể đầu ra là gì, bạn có thể nghĩ về đồng bộ hóa luồng theo cùng một cách - nếu luồng A thực hiện num++; num--;và luồng B đọc numlặp lại, thì khả năng xen kẽ hợp lệ là luồng B không bao giờ đọc giữa num++num-- . Vì sự xen kẽ đó là hợp lệ, trình biên dịch có thể tự do biến nó thành khả năng xen kẽ duy nhất. Và chỉ cần loại bỏ hoàn toàn tăng / giảm.

Có một số ý nghĩa thú vị ở đây:

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

(tức là tưởng tượng một số luồng khác cập nhật giao diện người dùng thanh tiến trình dựa trên progress)

Trình biên dịch có thể biến điều này thành:

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

progress += local;

có lẽ đó là hợp lệ Nhưng có lẽ không phải là những gì lập trình viên đã hy vọng :-(

Ủy ban vẫn đang làm việc về những thứ này. Hiện tại nó "hoạt động" vì trình biên dịch không tối ưu hóa nguyên tử nhiều. Nhưng điều đó đang thay đổi.

Và ngay cả khi progresscũng biến động, điều này vẫn sẽ hợp lệ:

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

while (local--)
    progress++;

: - /


Câu trả lời này dường như chỉ trả lời câu hỏi phụ mà Richard và tôi đang cân nhắc. Cuối cùng chúng tôi đã giải quyết nó: hóa ra là có, tiêu chuẩn C ++ không cho phép hợp nhất các hoạt động trên các volatileđối tượng phi nguyên tử, khi nó không phá vỡ bất kỳ quy tắc nào khác. Hai tài liệu thảo luận tiêu chuẩn thảo luận chính xác điều này (các liên kết trong nhận xét của Richard ), một liên kết sử dụng cùng một ví dụ về bộ đếm tiến trình. Vì vậy, đây là vấn đề chất lượng thực hiện cho đến khi C ++ chuẩn hóa các cách để ngăn chặn vấn đề này.
Peter Cordes

Vâng, "Không" của tôi thực sự là một câu trả lời cho toàn bộ lý luận. Nếu câu hỏi chỉ là "có thể num ++ là nguyên tử trên một số trình biên dịch / thực hiện", thì câu trả lời là chắc chắn. Ví dụ, một trình biên dịch có thể quyết định thêm lockvào mọi hoạt động. Hoặc một số trình biên dịch + kết hợp bộ xử lý đơn trong đó không sắp xếp lại (tức là "ngày tốt") mọi thứ đều là nguyên tử. Nhưng ý nghĩa của điều đó là gì? Bạn không thể thực sự dựa vào nó. Trừ khi bạn biết đó là hệ thống bạn đang viết. (Ngay cả khi đó, tốt hơn là nguyên tử <int> không thêm ops nào trên hệ thống đó. Vì vậy, bạn vẫn nên viết mã tiêu chuẩn ...)
tony

1
Lưu ý rằng điều đó And just remove the incr/decr entirely.không hoàn toàn đúng. Nó vẫn là một hoạt động mua và phát hành trên num. Trên x86, num++;num--có thể biên dịch thành chỉ NHIỀU, nhưng chắc chắn không phải là không có gì. (Trừ khi phân tích toàn bộ chương trình của nhà biên dịch có thể chứng minh rằng không có gì đồng bộ hóa với sửa đổi num đó và sẽ không có vấn đề gì nếu một số cửa hàng từ trước đó bị trì hoãn cho đến sau khi tải từ đó. Trường hợp sử dụng -lock-right-ngay, bạn vẫn có hai phần quan trọng riêng biệt (có thể sử dụng mo_relaxed), không phải là một phần lớn.
Peter Cordes

@PeterCordes ah có, đồng ý.
tony

2

Đúng nhưng...

Nguyên tử không phải là những gì bạn muốn nói. Có lẽ bạn đang hỏi sai.

Sự gia tăng chắc chắn là nguyên tử . Trừ khi bộ lưu trữ bị căn chỉnh sai (và vì bạn không căn chỉnh cho trình biên dịch, nên không), nó nhất thiết phải được căn chỉnh trong một dòng bộ đệm duy nhất. Thiếu các hướng dẫn phát trực tuyến không lưu trữ đặc biệt, mỗi lần ghi đều đi qua bộ đệm. Các dòng bộ đệm hoàn chỉnh đang được đọc và viết một cách nguyên tử, không bao giờ có gì khác biệt.
Tất nhiên, dữ liệu nhỏ hơn so với bộ nhớ cache cũng được ghi nguyên bản (vì dòng bộ đệm xung quanh là).

Nó có an toàn không?

Đây là một câu hỏi khác nhau, và có ít nhất hai lý do chính đáng để trả lời với một câu "Không!" .

Đầu tiên, có khả năng một lõi khác có thể có một bản sao của dòng bộ đệm đó trong L1 (L2 trở lên thường được chia sẻ, nhưng L1 thường là mỗi lõi!) Và đồng thời sửa đổi giá trị đó. Tất nhiên điều đó cũng xảy ra về mặt nguyên tử, nhưng bây giờ bạn có hai giá trị "chính xác" (chính xác, nguyên tử, đã sửa đổi) - cái nào là giá trị thực sự chính xác bây giờ?
Tất nhiên, CPU sẽ sắp xếp nó ra. Nhưng kết quả có thể không như bạn mong đợi.

Thứ hai, có thứ tự bộ nhớ, hoặc diễn đạt khác nhau - trước khi đảm bảo. Điều quan trọng nhất về hướng dẫn nguyên tử không nhiều đến mức chúng là nguyên tử . Nó đang đặt hàng.

Bạn có khả năng thực thi một bảo đảm rằng mọi thứ xảy ra theo trí nhớ đều được hiện thực hóa theo một thứ tự được bảo đảm, được xác định rõ ràng trong đó bạn có bảo đảm "xảy ra trước". Thứ tự này có thể là "thoải mái" (đọc là: không có gì cả) hoặc nghiêm ngặt như bạn cần.

Ví dụ: bạn có thể đặt một con trỏ tới một số khối dữ liệu (giả sử, kết quả của một số tính toán) và sau đó giải phóng nguyên bản cờ "dữ liệu đã sẵn sàng". Bây giờ, bất cứ ai có được lá cờ này sẽ được dẫn đến suy nghĩ rằng con trỏ là hợp lệ. Và thực sự, nó sẽ luôn là một con trỏ hợp lệ, không bao giờ có gì khác biệt. Đó là bởi vì việc ghi vào con trỏ đã xảy ra - trước khi hoạt động nguyên tử.


2
Tải và cửa hàng là mỗi nguyên tử riêng biệt, nhưng toàn bộ hoạt động đọc-sửa-ghi nói chung chắc chắn không phải là nguyên tử. Bộ nhớ cache được kết hợp chặt chẽ, vì vậy không bao giờ có thể giữ các bản sao xung đột của cùng một dòng ( en.wikipedia.org/wiki/MESI_protatio ). Một lõi khác thậm chí không thể có một bản sao chỉ đọc trong khi lõi này có nó ở trạng thái Sửa đổi. Điều làm cho nó không phải là nguyên tử là lõi làm RMW có thể mất quyền sở hữu dòng bộ đệm giữa tải và cửa hàng.
Peter Cordes

2
Ngoài ra, không, toàn bộ dòng bộ đệm không phải lúc nào cũng được chuyển xung quanh nguyên tử. Xem câu trả lời này , trong đó nó đã chứng minh bằng thực nghiệm rằng Opteron đa ổ cắm tạo ra SSB 16B lưu trữ phi nguyên tử bằng cách chuyển các dòng bộ đệm trong các khối 8B với hypertransport, mặc dù chúng nguyên tử cho các CPU một ổ cắm cùng loại (vì tải / phần cứng lưu trữ có đường dẫn 16B đến bộ đệm L1). x86 chỉ đảm bảo tính nguyên tử cho các tải riêng biệt hoặc lưu trữ lên tới 8B.
Peter Cordes

Để căn chỉnh cho trình biên dịch không có nghĩa là bộ nhớ sẽ được căn chỉnh trên ranh giới 4 byte. Trình biên dịch có thể có các tùy chọn hoặc pragma để thay đổi ranh giới căn chỉnh. Điều này rất hữu ích, ví dụ, để vận hành trên dữ liệu được đóng gói chặt chẽ trong các luồng mạng.
Dmitry Rubanovich

2
Ngụy biện, không có gì khác. Một số nguyên có lưu trữ tự động không phải là một phần của cấu trúc như trong ví dụ sẽ hoàn toàn được căn chỉnh chính xác. Yêu cầu bất cứ điều gì khác nhau chỉ là hoàn toàn ngớ ngẩn. Các dòng bộ đệm cũng như tất cả các POD đều có kích thước và căn chỉnh PoT (sức mạnh của hai) - trên bất kỳ kiến ​​trúc không ảo tưởng nào trên thế giới. Toán học cho rằng bất kỳ PoT nào được căn chỉnh chính xác đều phù hợp với chính xác một (không bao giờ hơn) của bất kỳ PoT nào khác có cùng kích thước hoặc lớn hơn. Tuyên bố của tôi là do đó chính xác.
Damon

1
@Damon, ví dụ được đưa ra trong câu hỏi không đề cập đến cấu trúc, nhưng nó không thu hẹp câu hỏi chỉ trong các tình huống mà số nguyên không phải là một phần của cấu trúc. POD chắc chắn nhất có thể có kích thước PoT và không được căn chỉnh PoT. Hãy xem câu trả lời này cho các ví dụ cú pháp: stackoverflow.com/a/11772340/1219722 . Vì vậy, nó hầu như không phải là "ngụy biện" bởi vì các POD được khai báo theo cách như vậy được sử dụng trong mã mạng khá nhiều trong mã thực tế.
Dmitry Rubanovich

2

Đó là kết quả một trình biên dịch đơn lẻ, trên một kiến trúc cụ thể CPU, với tối ưu hóa bị vô hiệu hóa (vì gcc thậm chí không biên dịch ++để addkhi tối ưu hóa trong một ví dụ nhanh chóng & bẩn ), dường như ngụ ý incrementing cách này là nguyên tử không có nghĩa đây là tiêu chuẩn tuân thủ ( bạn sẽ gây ra hành vi không xác định khi cố gắng truy cập numtrong một luồng) và dù sao cũng sai, vì không phảiadd là nguyên tử trong x86.

Lưu ý rằng các nguyên tử (sử dụng locktiền tố hướng dẫn) tương đối nặng trên x86 ( xem câu trả lời có liên quan này ), nhưng vẫn ít hơn đáng kể so với một mutex, không phù hợp lắm trong trường hợp sử dụng này.

Kết quả sau đây được lấy từ clang ++ 3.8 khi biên dịch với -Os.

Tăng một int bằng cách tham chiếu, cách "thông thường":

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

Điều này biên dịch thành:

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

Tăng một int thông qua tham chiếu, cách nguyên tử:

#include <atomic>

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

Ví dụ này, không phức tạp hơn nhiều so với cách thông thường, chỉ cần lockthêm tiền tố vào inclhướng dẫn - nhưng hãy cẩn thận, như đã nói trước đây, điều này không rẻ. Chỉ vì lắp ráp có vẻ ngắn không có nghĩa là nó nhanh.

inc(std::atomic<int>&):
    lock            incl    (%rdi)
    retq

-2

Khi trình biên dịch của bạn chỉ sử dụng một lệnh duy nhất cho mức tăng và máy của bạn là một luồng đơn, mã của bạn an toàn. ^^


-3

Hãy thử biên dịch cùng mã trên máy không phải x86 và bạn sẽ nhanh chóng thấy kết quả lắp ráp rất khác nhau.

Lý do num++ xuất hiện là nguyên tử là vì trên các máy x86, việc tăng số nguyên 32 bit trên thực tế là nguyên tử (giả sử không có truy xuất bộ nhớ). Nhưng điều này không được đảm bảo bởi tiêu chuẩn c ++, cũng không có khả năng là trường hợp trên một máy không sử dụng tập lệnh x86. Vì vậy, mã này không phải là nền tảng an toàn chéo từ các điều kiện chủng tộc.

Bạn cũng không đảm bảo chắc chắn rằng mã này an toàn với Điều kiện cuộc đua ngay cả trên kiến ​​trúc x86, vì x86 không thiết lập tải và lưu trữ vào bộ nhớ trừ khi được hướng dẫn cụ thể để làm như vậy. Vì vậy, nếu nhiều luồng cố gắng cập nhật đồng thời biến này, chúng có thể sẽ tăng các giá trị được lưu trong bộ nhớ cache (lỗi thời)

Lý do, sau đó, chúng ta có std::atomic<int>và như vậy là khi bạn làm việc với một kiến ​​trúc nơi tính nguyên tử của các tính toán cơ bản không được đảm bảo, bạn có một cơ chế sẽ buộc trình biên dịch tạo mã nguyên tử.


"là bởi vì trên các máy x86, trên thực tế, việc tăng số nguyên 32 bit là nguyên tử." bạn có thể cung cấp liên kết đến tài liệu chứng minh nó?
Slava

8
Nó cũng không phải là nguyên tử trên x86. Nó đơn lõi an toàn, nhưng nếu có nhiều lõi (và có) thì nó hoàn toàn không phải là nguyên tử.
harold

Là x86 addthực sự được đảm bảo nguyên tử? Tôi sẽ không ngạc nhiên nếu số gia đăng ký là nguyên tử, nhưng điều đó hầu như không hữu ích; để làm tăng số thanh ghi hiển thị cho một luồng khác, nó cần phải có trong bộ nhớ, nó sẽ yêu cầu các hướng dẫn bổ sung để tải và lưu trữ nó, loại bỏ tính nguyên tử. Hiểu biết của tôi là đây là lý do tại sao locktiền tố tồn tại cho các hướng dẫn; nguyên tử hữu ích duy nhất addáp dụng cho bộ nhớ bị loại bỏ và sử dụng locktiền tố để đảm bảo dòng bộ đệm được khóa trong suốt thời gian hoạt động .
ShadowRanger

@Slava @Harold @ShadowRanger Tôi đã cập nhật câu trả lời. addlà nguyên tử, nhưng tôi đã nói rõ rằng điều đó không ngụ ý rằng mã là điều kiện chủng tộc an toàn, bởi vì những thay đổi không thể nhìn thấy được trên toàn cầu ngay lập tức.
Xirema

3
@Xirema làm cho nó "không phải là nguyên tử" theo định nghĩa mặc dù
harold
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.