Tôi muốn viết mã di động (Intel, ARM, PowerPC ...) để giải quyết một biến thể của một vấn đề cổ điển:
Initially: X=Y=0
Thread A:
X=1
if(!Y){ do something }
Thread B:
Y=1
if(!X){ do something }
trong đó mục tiêu là để tránh một tình huống trong đó cả hai luồng đang làmsomething
. (Sẽ ổn nếu không có thứ gì chạy; đây không phải là cơ chế chạy chính xác một lần.) Vui lòng sửa cho tôi nếu bạn thấy một số sai sót trong lý luận của tôi dưới đây.
Tôi biết rằng tôi có thể đạt được mục tiêu với memory_order_seq_cst
các nguyên tử store
và load
s như sau:
std::atomic<int> x{0},y{0};
void thread_a(){
x.store(1);
if(!y.load()) foo();
}
void thread_b(){
y.store(1);
if(!x.load()) bar();
}
đạt được mục tiêu, bởi vì phải có một số thứ tự duy nhất trên các
{x.store(1), y.store(1), y.load(), x.load()}
sự kiện, phải đồng ý với "các cạnh" của thứ tự chương trình:
x.store(1)
"trong TO là trước"y.load()
y.store(1)
"trong TO là trước"x.load()
và nếu foo()
được gọi, thì chúng ta có thêm cạnh:
y.load()
"đọc giá trị trước"y.store(1)
và nếu bar()
được gọi, thì chúng ta có thêm cạnh:
x.load()
"đọc giá trị trước"x.store(1)
và tất cả các cạnh được kết hợp với nhau sẽ tạo thành một chu kỳ:
x.store(1)
"trong TO là trước" y.load()
"đọc giá trị trước" y.store(1)
"trong TO là trước" x.load()
"đọc giá trị trước"x.store(true)
vi phạm thực tế là các đơn đặt hàng không có chu kỳ.
Tôi cố tình sử dụng các thuật ngữ phi tiêu chuẩn "trong TO là trước" và "đọc giá trị trước" trái ngược với các thuật ngữ tiêu chuẩn như happens-before
, bởi vì tôi muốn thu hút phản hồi về tính chính xác của giả định rằng các cạnh này thực sự ngụ ý happens-before
mối quan hệ, có thể được kết hợp với nhau trong một đồ thị và chu trình trong đồ thị kết hợp như vậy bị cấm. Tôi không chắc chắn về điều đó. Những gì tôi biết là mã này tạo ra các rào cản chính xác trên Intel gcc & clang và trên ARM gcc
Bây giờ, vấn đề thực sự của tôi phức tạp hơn một chút, vì tôi không kiểm soát được "X" - nó ẩn đằng sau một số macro, mẫu, v.v. và có thể yếu hơn seq_cst
Tôi thậm chí không biết "X" là một biến đơn hay một số khái niệm khác (ví dụ: semaphore hoặc mutex trọng lượng nhẹ). Tất cả những gì tôi biết là tôi có hai macro set()
và check()
như vậy check()
trả về true
"sau" một luồng khác đã được gọi set()
. (Người ta cũng biết rằng set
và check
an toàn theo luồng và không thể tạo ra cuộc đua dữ liệu UB.)
Vì vậy, về mặt khái niệm set()
có phần giống như "X = 1" và check()
giống như "X", nhưng tôi không có quyền truy cập trực tiếp vào các nguyên tử liên quan, nếu có.
void thread_a(){
set();
if(!y.load()) foo();
}
void thread_b(){
y.store(1);
if(!check()) bar();
}
Tôi lo lắng, điều đó set()
có thể được thực hiện trong nội bộ x.store(1,std::memory_order_release)
và / hoặc check()
có thể x.load(std::memory_order_acquire)
. Hoặc theo giả thuyết std::mutex
rằng một chủ đề đang mở khóa và một chủ đề khác đang diễn try_lock
ra; trong tiêu chuẩn ISO std::mutex
chỉ được đảm bảo có yêu cầu mua và phát hành, không phải seq_cst.
Nếu đây là trường hợp, thì check()
nếu cơ thể có thể được "sắp xếp lại" trước đó y.store(true)
( Xem câu trả lời của Alex nơi họ chứng minh rằng điều này xảy ra trên PowerPC ).
Điều này sẽ thực sự tồi tệ, vì bây giờ chuỗi sự kiện này là có thể:
thread_b()
đầu tiên tải giá trị cũ củax
(0
)thread_a()
thực hiện mọi thứ kể cảfoo()
thread_b()
thực hiện mọi thứ kể cảbar()
Vì vậy, cả hai foo()
và bar()
được gọi, mà tôi phải tránh. Lựa chọn của tôi để ngăn chặn điều đó là gì?
Lựa chọn A
Cố gắng buộc hàng rào Store-Load. Điều này, trong thực tế, có thể đạt được bằng cách std::atomic_thread_fence(std::memory_order_seq_cst);
- như Alex đã giải thích trong một câu trả lời khác, tất cả các trình biên dịch được thử nghiệm đều phát ra một hàng rào đầy đủ:
- x86_64: SẮC
- PowerPC: hwsync
- Itanuim: mf
- ARMv7 / ARMv8: dmb là
- MIPS64: đồng bộ hóa
Vấn đề với cách tiếp cận này là, tôi không thể tìm thấy bất kỳ sự đảm bảo nào trong các quy tắc C ++, mà std::atomic_thread_fence(std::memory_order_seq_cst)
phải dịch sang hàng rào bộ nhớ đầy đủ. Trên thực tế, khái niệm atomic_thread_fence
s trong C ++ dường như ở một mức độ trừu tượng khác với khái niệm lắp ráp các rào cản bộ nhớ và liên quan nhiều hơn đến những thứ như "hoạt động nguyên tử đồng bộ hóa với cái gì". Có bằng chứng lý thuyết nào cho thấy việc thực hiện dưới đây đạt được mục tiêu không?
void thread_a(){
set();
std::atomic_thread_fence(std::memory_order_seq_cst)
if(!y.load()) foo();
}
void thread_b(){
y.store(true);
std::atomic_thread_fence(std::memory_order_seq_cst)
if(!check()) bar();
}
Lựa chọn B
Sử dụng kiểm soát chúng tôi có trên Y để đạt được đồng bộ hóa, bằng cách sử dụng các thao tác đọc-sửa-ghi memory_order_acq_rel trên Y:
void thread_a(){
set();
if(!y.fetch_add(0,std::memory_order_acq_rel)) foo();
}
void thread_b(){
y.exchange(1,std::memory_order_acq_rel);
if(!check()) bar();
}
Ý tưởng ở đây là việc truy cập vào một nguyên tử ( y
) phải được tạo thành một thứ tự duy nhất mà tất cả các nhà quan sát đồng ý, vì vậy hoặc fetch_add
là trước exchange
hoặc ngược lại.
Nếu fetch_add
là trước exchange
đó thì phần "phát hành" fetch_add
đồng bộ hóa với phần "thu nhận" exchange
và do đó tất cả các tác dụng phụ set()
phải được hiển thị để thực thi mã check()
, do đó bar()
sẽ không được gọi.
Nếu không, exchange
là trước fetch_add
, sau đó fetch_add
sẽ thấy 1
và không gọi foo()
. Vì vậy, không thể gọi cả hai foo()
và bar()
. Liệu lý luận này có đúng không?
Tùy chọn C
Sử dụng nguyên tử giả, để giới thiệu "các cạnh" ngăn ngừa thảm họa. Xem xét phương pháp sau:
void thread_a(){
std::atomic<int> dummy1{};
set();
dummy1.store(13);
if(!y.load()) foo();
}
void thread_b(){
std::atomic<int> dummy2{};
y.store(1);
dummy2.load();
if(!check()) bar();
}
Nếu bạn nghĩ rằng vấn đề ở đây là atomic
cục bộ, thì hãy tưởng tượng việc chuyển chúng sang phạm vi toàn cầu, theo lý do sau đây, điều đó dường như không quan trọng với tôi, và tôi đã cố tình viết mã theo cách để phơi bày sự buồn cười đó như thế nào và dummy2 hoàn toàn riêng biệt.
Tại sao trên trái đất này có thể làm việc? Chà, phải có một số thứ tự duy nhất {dummy1.store(13), y.load(), y.store(1), dummy2.load()}
phải phù hợp với "cạnh" thứ tự chương trình:
dummy1.store(13)
"trong TO là trước"y.load()
y.store(1)
"trong TO là trước"dummy2.load()
(Cửa hàng seq_cst + tải hy vọng tạo thành C ++ tương đương với hàng rào bộ nhớ đầy đủ bao gồm StoreLoad, giống như chúng hoạt động trên các ISA thực sự, kể cả AArch64, trong đó không yêu cầu các rào cản riêng biệt.)
Bây giờ, chúng tôi có hai trường hợp để xem xét: hoặc y.store(1)
là trước y.load()
hoặc sau trong tổng số thứ tự.
Nếu y.store(1)
là trước y.load()
đó thì foo()
sẽ không được gọi và chúng tôi an toàn.
Nếu y.load()
là trước y.store(1)
đó, sau đó kết hợp nó với hai cạnh mà chúng ta đã có theo thứ tự chương trình, chúng tôi suy luận rằng:
dummy1.store(13)
"trong TO là trước"dummy2.load()
Bây giờ, dummy1.store(13)
là một hoạt động phát hành, phát hành các hiệu ứng set()
và dummy2.load()
là một hoạt động có được, vì vậy check()
sẽ thấy các hiệu ứng set()
và do đó bar()
sẽ không được gọi và chúng tôi an toàn.
Có đúng ở đây để nghĩ rằng check()
sẽ thấy kết quả của set()
? Tôi có thể kết hợp các "cạnh" của các loại khác nhau ("thứ tự chương trình" hay còn gọi là Trình tự trước, "tổng thứ tự", "trước khi phát hành", "sau khi có được") như thế không? Tôi có nghi ngờ nghiêm trọng về điều này: Các quy tắc C ++ dường như nói về mối quan hệ "đồng bộ hóa với" giữa cửa hàng và tải trên cùng một vị trí - ở đây không có tình huống như vậy.
Lưu ý rằng chúng tôi chỉ lo lắng về trường hợp dumm1.store
được biết đến (thông qua lập luận khác) được trước dummy2.load
trong tổng seq_cst trật tự. Vì vậy, nếu họ đã truy cập cùng một biến, tải sẽ thấy giá trị được lưu trữ và được đồng bộ hóa với nó.
(Rào cản bộ nhớ / sắp xếp lại lý do cho việc triển khai trong đó tải nguyên tử và lưu trữ biên dịch thành ít nhất các rào cản bộ nhớ 1 chiều (và các hoạt động seq_cst không thể sắp xếp lại: ví dụ: cửa hàng seq_cst không thể vượt qua tải seq_cst) lưu trữ sau khi dummy2.load
chắc chắn trở nên hiển thị cho các chủ đề khác sau y.store
. Và tương tự cho các chủ đề khác, ... trước đó y.load
.)
Bạn có thể chơi với việc triển khai Tùy chọn A, B, C của tôi tại https://godbolt.org/z/u3dTa8
foo()
và bar()
từ cả hai được gọi.
compare_exchange_*
để thực hiện thao tác RMW trên bool nguyên tử mà không thay đổi giá trị của nó (chỉ cần đặt kỳ vọng và mới cho cùng một giá trị).
atomic<bool>
có exchange
và compare_exchange_weak
. Cái sau có thể được sử dụng để làm một RMW giả bằng cách (cố gắng) CAS (đúng, đúng) hoặc sai, sai. Nó hoặc thất bại hoặc nguyên tử thay thế giá trị với chính nó. (Trong x86-64 asm, mẹo đó lock cmpxchg16b
là cách bạn thực hiện tải 16 byte nguyên tử được bảo đảm; không hiệu quả nhưng ít tệ hơn so với khóa riêng.)
foo()
cũng bar()
sẽ không được gọi. Tôi không muốn mang đến nhiều yếu tố "thế giới thực" của mã, để tránh "bạn nghĩ rằng bạn có vấn đề X nhưng bạn có vấn đề về Y". Nhưng, nếu người ta thực sự cần biết tầng nền là gì: set()
thực sự some_mutex_exit()
, check()
là try_enter_some_mutex()
, y
"có một số người phục vụ", foo()
là "thoát mà không đánh thức bất cứ ai", bar()
là "chờ cho wakup" ... Nhưng, tôi từ chối thảo luận về thiết kế này ở đây - tôi không thể thay đổi nó thực sự.
std::atomic_thread_fence(std::memory_order_seq_cst)
sẽ biên dịch thành một rào cản đầy đủ, nhưng vì toàn bộ khái niệm là một chi tiết triển khai mà bạn sẽ không tìm thấy bất kỳ đề cập đến nó trong tiêu chuẩn. (Các mô hình bộ nhớ CPU thường được xác định theo nghĩa các phép ghi lại được cho phép liên quan đến tính nhất quán tuần tự. Ví dụ: x86 là seq-cst + bộ đệm lưu trữ w / chuyển tiếp)