ฉันต้องการเขียนโค้ดแบบพกพา (Intel, ARM, PowerPC ... ) ซึ่งแก้ปัญหาคลาสสิก:
Initially: X=Y=0
Thread A:
X=1
if(!Y){ do something }
Thread B:
Y=1
if(!X){ do something }
ซึ่งเป้าหมายคือการหลีกเลี่ยงสถานการณ์ที่หัวข้อทั้งสองจะทำ something
(ไม่เป็นไรหากไม่มีสิ่งใดทำงานนี่ไม่ใช่กลไกที่ทำงานเหมือนครั้งเดียว) โปรดแก้ไขให้ฉันถ้าคุณเห็นข้อบกพร่องบางอย่างในการให้เหตุผลด้านล่าง
ฉันรู้ว่าฉันสามารถบรรลุเป้าหมายด้วยmemory_order_seq_cst
อะตอมstore
และload
s ดังต่อไปนี้:
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();
}
ซึ่งบรรลุเป้าหมายเพราะจะต้องมีคำสั่งซื้อทั้งหมดเพียงคำสั่งเดียวใน
{x.store(1), y.store(1), y.load(), x.load()}
เหตุการณ์ซึ่งจะต้องเห็นด้วยกับ "ขอบ" ของคำสั่งโปรแกรม:
x.store(1)
"ในถึงก่อน"y.load()
y.store(1)
"ในถึงก่อน"x.load()
และถ้าfoo()
ถูกเรียกเราก็จะมีขอบเพิ่มเติม:
y.load()
"อ่านค่าก่อน"y.store(1)
และถ้าbar()
ถูกเรียกเราก็จะมีขอบเพิ่มเติม:
x.load()
"อ่านค่าก่อน"x.store(1)
และขอบทั้งหมดเหล่านี้รวมกันจะก่อให้เกิดวงจร:
x.store(1)
"ในถึงคือก่อน" y.load()
"อ่านค่าก่อน" y.store(1)
"ในถึงคือก่อน" x.load()
"อ่านค่าก่อน"x.store(true)
ซึ่งเป็นการละเมิดความจริงที่ว่าคำสั่งซื้อไม่มีรอบ
ฉันจงใจใช้คำที่ไม่ได้มาตรฐาน "ใน TO คือก่อน" และ "อ่านค่ามาก่อน" ซึ่งตรงข้ามกับคำมาตรฐานเช่นhappens-before
เพราะฉันต้องการที่จะขอความคิดเห็นเกี่ยวกับความถูกต้องของสมมติฐานของฉันที่ขอบเหล่านี้บ่งบอกถึงhappens-before
ความสัมพันธ์จริง ๆกราฟและวงจรในกราฟรวมดังกล่าวเป็นสิ่งต้องห้าม ฉันไม่แน่ใจเกี่ยวกับเรื่องนี้ สิ่งที่ฉันรู้คือรหัสนี้สร้างอุปสรรคที่ถูกต้องใน Intel gcc & clang และ ARM gcc
ตอนนี้ปัญหาที่แท้จริงของฉันนั้นซับซ้อนกว่าเล็กน้อยเนื่องจากฉันไม่สามารถควบคุม "X" ได้ - มันซ่อนอยู่หลังมาโครเทมเพลต ฯลฯ และอาจจะอ่อนแอกว่า seq_cst
ฉันไม่รู้ด้วยซ้ำว่า "X" เป็นตัวแปรเดียวหรือมีแนวคิดอื่น (เช่นเซมาฟอร์น้ำหนักเบาหรือ mutex) ทั้งหมดที่ผมรู้ก็คือว่าผมมีสองแมโครset()
และcheck()
ดังกล่าวว่าcheck()
ผลตอบแทนtrue
"หลัง" set()
หัวข้ออื่นได้เรียกว่า ( เป็นที่รู้จักกันว่าset
และcheck
มีความปลอดภัยต่อเธรดและไม่สามารถสร้าง UB ของ data-race ได้)
ดังนั้นคอนเซ็ปต์set()
จึงค่อนข้างคล้ายกับ "X = 1" และcheck()
ก็เหมือนกับ "X" แต่ฉันไม่มีสิทธิ์เข้าถึงอะตอมมิกส์ที่เกี่ยวข้องโดยตรงถ้ามี
void thread_a(){
set();
if(!y.load()) foo();
}
void thread_b(){
y.store(1);
if(!check()) bar();
}
ผมกังวลว่าset()
อาจจะมีการดำเนินการภายในเป็นx.store(1,std::memory_order_release)
และ / หรืออาจจะcheck()
x.load(std::memory_order_acquire)
หรือสมมุติstd::mutex
ว่าเธรดหนึ่งถูกปลดล็อกและอีกอันหนึ่งคือtry_lock
ไอเอ็นจี ในมาตรฐาน ISO std::mutex
รับประกันว่าจะได้รับและอนุมัติการสั่งซื้อเท่านั้นไม่ใช่ seq_cst
หากเป็นกรณีนี้check()
ถ้าร่างกายสามารถ "จัดลำดับใหม่" มาก่อนy.store(true)
( ดูคำตอบของ Alexที่พวกเขาแสดงให้เห็นว่าสิ่งนี้เกิดขึ้นใน PowerPC )
สิ่งนี้จะไม่ดีจริง ๆ เนื่องจากตอนนี้ลำดับเหตุการณ์เป็นไปได้:
thread_b()
ก่อนโหลดค่าเก่าของx
(0
)thread_a()
ดำเนินการทุกอย่างรวมถึงfoo()
thread_b()
ดำเนินการทุกอย่างรวมถึงbar()
ดังนั้นทั้งคู่foo()
และbar()
ถูกเรียกซึ่งฉันต้องหลีกเลี่ยง ตัวเลือกของฉันในการป้องกันสิ่งนั้นคืออะไร?
ตัวเลือก A
ลองบังคับอุปสรรคโหลดร้านค้า ในทางปฏิบัตินี้สามารถทำได้โดยstd::atomic_thread_fence(std::memory_order_seq_cst);
- ตามที่อธิบายโดยAlex ในคำตอบที่ต่างออกไปคอมไพเลอร์ที่ผ่านการทดสอบทั้งหมดที่ปล่อยออกมาเต็มรั้ว:
- x86_64: MFENCE
- PowerPC: hwsync
- Itanuim: mf
- ARMv7 / ARMv8: dmb ish
- MIPS64: ซิงค์
ปัญหาเกี่ยวกับวิธีการนี้คือฉันไม่สามารถหาการรับประกันใด ๆ ในกฎ C ++ ที่std::atomic_thread_fence(std::memory_order_seq_cst)
ต้องแปลเป็นกำแพงหน่วยความจำเต็ม ที่จริงแล้วแนวคิดของatomic_thread_fence
s ใน C ++ ดูเหมือนว่าจะอยู่ในระดับที่แตกต่างกันของนามธรรมมากกว่าแนวคิดการชุมนุมของอุปสรรคหน่วยความจำและจัดการกับสิ่งต่าง ๆ เช่น "การดำเนินการปรมาณูประสานกับสิ่งที่" มีข้อพิสูจน์ทางทฤษฎีใดบ้างที่การดำเนินการด้านล่างบรรลุเป้าหมายหรือไม่
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();
}
ตัวเลือก B
ใช้การควบคุมที่เรามีมากกว่า Y เพื่อให้เกิดการซิงโครไนซ์โดยใช้การดำเนินการ read-modified-write memory_order_acq_rel บน 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();
}
แนวคิดในที่นี้คือการเข้าถึงอะตอมเดียว ( y
) จะต้องเป็นคำสั่งเดียวที่ผู้สังเกตการณ์ทุกคนเห็นด้วยดังนั้นทั้งfetch_add
ก่อนexchange
หรือในทางกลับกัน
หากfetch_add
ก่อนหน้านี้exchange
ส่วน "ปล่อย" ของการfetch_add
ซิงโครไนซ์กับส่วน "ได้รับ" ของexchange
และดังนั้นผลข้างเคียงทั้งหมดของset()
จะต้องมองเห็นได้ในการดำเนินการรหัสcheck()
ดังนั้นbar()
จะไม่ถูกเรียก
มิฉะนั้นexchange
ก่อนfetch_add
แล้วfetch_add
จะเห็นและไม่เรียก1
foo()
ดังนั้นจึงเป็นไปไม่ได้ที่จะเรียกทั้งสองและfoo()
bar()
เหตุผลนี้ถูกต้องหรือไม่
ตัวเลือก C
ใช้ dummy atomics เพื่อแนะนำ "edge" ซึ่งป้องกันภัยพิบัติ พิจารณาแนวทางต่อไปนี้:
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();
}
หากคุณคิดว่าปัญหาของที่นี่เป็นatomic
ของท้องถิ่นลองจินตนาการถึงการย้ายไปสู่ขอบเขตทั่วโลกด้วยเหตุผลดังต่อไปนี้มันไม่ปรากฏว่ามีความสำคัญสำหรับฉันและฉันจงใจเขียนโค้ดในลักษณะที่แสดงว่ามันตลกอย่างไร และ dummy2 นั้นแยกจากกันโดยสิ้นเชิง
ทำไมบนโลกนี้อาจใช้งานได้? ต้องมีคำสั่งรวมทั้งหมดบางคำสั่ง{dummy1.store(13), y.load(), y.store(1), dummy2.load()}
ซึ่งจะต้องสอดคล้องกับ "ขอบ" ของคำสั่งโปรแกรม:
dummy1.store(13)
"ในถึงก่อน"y.load()
y.store(1)
"ในถึงก่อน"dummy2.load()
(seq_cst store + load หวังว่าจะสร้าง C ++ เทียบเท่ากับกำแพงหน่วยความจำเต็มรวมทั้ง StoreLoad เหมือนที่ทำใน asm บน ISAs จริงรวมถึง AArch64 ที่ไม่จำเป็นต้องมีคำสั่งกีดกันแยกต่างหาก)
ขณะนี้เรามีสองกรณีที่ต้องพิจารณา: อาจy.store(1)
เป็นก่อนy.load()
หรือหลังตามลำดับทั้งหมด
ถ้าy.store(1)
เป็นก่อนy.load()
แล้วfoo()
จะไม่ถูกเรียกและเรามีความปลอดภัย
ถ้าy.load()
เป็นy.store(1)
เช่นนั้นให้รวมกับขอบทั้งสองที่เรามีอยู่ในลำดับของโปรแกรมเราอนุมานว่า:
dummy1.store(13)
"ในถึงก่อน"dummy2.load()
ตอนนี้dummy1.store(13)
คือการดำเนินการปล่อยซึ่งปล่อยผลกระทบของset()
และdummy2.load()
เป็นการดำเนินการที่ได้รับดังนั้นcheck()
ควรเห็นผลกระทบของset()
และbar()
จะไม่ถูกเรียกและเรามีความปลอดภัย
มันถูกต้องที่นี่ที่จะคิดว่าcheck()
จะเห็นผลของset()
? ฉันสามารถรวม "ขอบ" ของชนิดต่าง ๆ ("ลำดับของโปรแกรม" หรือที่เรียกว่า Sequenced Before, "รวมทั้งหมด", "ก่อนที่จะปล่อย", "หลังจากได้รับ") แบบนั้นได้หรือไม่? ฉันมีข้อสงสัยอย่างจริงจังเกี่ยวกับเรื่องนี้: กฎ C ++ ดูเหมือนจะพูดถึงความสัมพันธ์ระหว่าง "การซิงโครไนซ์ - กับ" ระหว่างร้านค้าและโหลดในตำแหน่งเดียวกัน - ที่นี่ไม่มีสถานการณ์ดังกล่าว
โปรดทราบว่าเรากำลังกังวลเพียง แต่เกี่ยวกับกรณีที่dumm1.store
เป็นที่รู้จักกัน (ผ่านเหตุผลอื่น ๆ ) ให้เป็นก่อนdummy2.load
ในการสั่งซื้อรวม seq_cst ดังนั้นหากพวกเขาเข้าถึงตัวแปรเดียวกันโหลดจะเห็นค่าที่เก็บไว้และซิงโครไนซ์กับมัน
(เหตุผลของหน่วยความจำ - อุปสรรค / การเรียงลำดับเหตุผลใหม่สำหรับการใช้งานที่โหลดอะตอมและจัดเก็บรวบรวมอย่างน้อยหนึ่งอุปสรรคหน่วยความจำทางเดียว (และการดำเนินงาน seq_cst ไม่สามารถจัดลำดับใหม่: เช่นที่เก็บ seq_cst ไม่สามารถผ่านโหลด seq_cst) ร้านค้าหลังจากdummy2.load
แน่นอนจะปรากฏให้เห็นกระทู้อื่น ๆหลังจาก y.store
และในทำนองเดียวกันสำหรับหัวข้ออื่น ๆ ... ก่อนy.load
.)
คุณสามารถเล่นกับการใช้งานตัวเลือก A, B, C ของฉันได้ที่https://godbolt.org/z/u3dTa8
foo()
และbar()
จากการถูกเรียกทั้งสอง
compare_exchange_*
เพื่อดำเนินการ RMW บนอะตอมบูลโดยไม่ต้องเปลี่ยนค่าของมัน (เพียงตั้งค่าที่คาดไว้และใหม่เป็นค่าเดียวกัน)
atomic<bool>
มีและexchange
compare_exchange_weak
หลังสามารถใช้ในการทำ RMW ปลอมโดย (พยายาม) CAS (จริงจริง) หรือเท็จเท็จ ไม่ว่าจะล้มเหลวหรือแทนที่ค่าด้วยตนเอง (ใน x86-64 asm เคล็ดลับlock cmpxchg16b
นั้นคือวิธีที่คุณทำการโหลดอะตอมขนาด 16 ไบต์แบบไม่มีการรับประกัน แต่ไม่มีประสิทธิภาพ แต่แย่กว่าการล็อคแบบแยกกัน)
foo()
มิได้bar()
จะถูกเรียกว่า ฉันไม่ต้องการนำองค์ประกอบหลายอย่างของ "โลกแห่งความจริง" ของรหัสเพื่อหลีกเลี่ยง "คุณคิดว่าคุณมีปัญหา X แต่คุณมีปัญหา Y" การตอบสนองแบบ แต่ถ้าใครจริงๆต้องการที่จะรู้ว่าสิ่งที่เป็นชั้นพื้นหลัง: set()
มันsome_mutex_exit()
, check()
เป็นtry_enter_some_mutex()
, y
คือ "มีบริกรบาง" foo()
คือ "ทางออกโดยไม่ต้องตื่นขึ้นมาทุกคน" bar()
คือ "รอ wakup" ... แต่ฉันปฏิเสธที่จะ พูดคุยเกี่ยวกับการออกแบบนี้ที่นี่ - ฉันไม่สามารถเปลี่ยนได้จริงๆ
std::atomic_thread_fence(std::memory_order_seq_cst)
จะรวบรวมเป็นอุปสรรคเต็มรูปแบบ แต่เนื่องจากแนวคิดทั้งหมดเป็นรายละเอียดการใช้งานคุณจะไม่พบ พูดถึงมันในมาตรฐานใด ๆ (โดยปกติหน่วยความจำซีพียูรุ่นจะถูกกำหนดในแง่ของสิ่งที่ reorerings ได้รับอนุญาตเมื่อเทียบกับความสอดคล้องตามลำดับเช่น x86 คือ seq-cst + บัฟเฟอร์ร้านค้า w / ส่งต่อ)