Đâ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_relaxed
nế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], 1
trong 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 lock
tiề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ó lock
tiề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ó lock
bạ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 lock
lệ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 lock
tiề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ự là nguyên tử không có lock
tiề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ị num
sau 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=i586
Thự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àx86 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ì flag
thậm chí không volatile
. (Và không có, C ++ volatile
khô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> foo
khô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::atomic
biế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 num
có volatile 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 lock
thao tác ed riêng biệt ngay cả memory_order_relaxed
trong 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
add
là nguyên tử?