นี่คือสิ่งที่ C ++ กำหนดว่าเป็น Data Race ที่ทำให้เกิดพฤติกรรมที่ไม่ได้กำหนดแม้ว่าคอมไพเลอร์ตัวหนึ่งจะเกิดขึ้นเพื่อสร้างโค้ดที่ทำในสิ่งที่คุณหวังไว้ในเครื่องเป้าหมาย คุณต้องใช้std::atomic
เพื่อให้ได้ผลลัพธ์ที่เชื่อถือได้ แต่คุณสามารถใช้กับมันได้memory_order_relaxed
หากคุณไม่สนใจการเรียงลำดับใหม่ ดูด้านล่างสำหรับตัวอย่างรหัสบางอย่างและเอาท์พุท asm fetch_add
ใช้
แต่ก่อนอื่นภาษาแอสเซมบลีเป็นส่วนหนึ่งของคำถาม:
เนื่องจาก num ++ เป็นคำสั่งเดียว ( add dword [num], 1
) เราสามารถสรุปได้ว่า num ++ เป็นอะตอมมิกในกรณีนี้หรือไม่
คำแนะนำการใช้หน่วยความจำปลายทาง (นอกเหนือจากร้านค้าบริสุทธิ์) จะอ่าน-modify เขียนการดำเนินงานที่เกิดขึ้นในขั้นตอนหลายภายใน ไม่มีการลงทะเบียนทางสถาปัตยกรรมที่มีการแก้ไข แต่ CPU ที่มีการเก็บข้อมูลภายในในขณะที่มันส่งผ่านALU ไฟล์การลงทะเบียนจริงเป็นเพียงส่วนเล็ก ๆ ของการจัดเก็บข้อมูลภายในแม้แต่ CPU ที่ง่ายที่สุดโดยมีสลักเอาท์พุทของสเตจเดียวเป็นอินพุตสำหรับสเตจอื่น ฯลฯ
การดำเนินการของหน่วยความจำจาก CPU อื่นสามารถมองเห็นได้ทั่วโลกระหว่างโหลดและจัดเก็บ นั่นคือสองเธรดที่ทำงานadd dword [num], 1
ในลูปจะเข้าสู่ร้านค้าของกันและกัน (ดูคำตอบของ @ Margaretสำหรับแผนผังที่ดี) หลังจากเพิ่ม 40k จากแต่ละเธรดสองเธรดตัวนับอาจเพิ่มขึ้น ~ 60k (ไม่ใช่ 80k) บนฮาร์ดแวร์ x86 แบบมัลติคอร์จริงเท่านั้น
"Atomic" จากคำภาษากรีกแปลว่าแบ่งแยกไม่ได้หมายความว่าผู้สังเกตการณ์ไม่สามารถมองเห็นการดำเนินการเป็นขั้นตอนแยกกันได้ การเกิดขึ้นทั้งทางร่างกาย / ทางไฟฟ้าทันทีสำหรับบิตทั้งหมดพร้อมกันเป็นเพียงวิธีหนึ่งในการทำสิ่งนี้ให้สำเร็จสำหรับการโหลดหรือการจัดเก็บ แต่นั่นก็ไม่สามารถทำได้สำหรับการดำเนินการ ALU ฉันได้เข้าไปดูรายละเอียดเพิ่มเติมเกี่ยวกับการโหลดที่บริสุทธิ์และร้านค้าที่บริสุทธิ์ในคำตอบของฉันเกี่ยวกับ Atomicity ใน x86ในขณะที่คำตอบนี้เน้นที่การอ่าน - แก้ไข - เขียน
lock
คำนำหน้าสามารถนำไปใช้เป็นจำนวนมากอ่านแก้ไขเขียน (ปลายทางของหน่วยความจำ) คำแนะนำที่จะทำให้การดำเนินการทั้งหมดของอะตอมที่เกี่ยวกับผู้สังเกตการณ์ที่เป็นไปได้ทั้งหมดในระบบ (แกนและอุปกรณ์อื่น ๆ DMA ไม่สโคปติดยาเสพติดเข้ากับขาของ CPU) นั่นคือเหตุผลที่มันมีอยู่ (ดูคำถาม & คำตอบนี้ด้วย)
ดังนั้นlock add dword [num], 1
เป็นอะตอม แกน CPU ที่รันคำสั่งนั้นจะเก็บสายแคชไว้ในสถานะ Modified ในแคช L1 ส่วนตัวจากเมื่อโหลดอ่านข้อมูลจากแคชจนกว่าที่เก็บจะยอมรับผลลัพธ์ของมันกลับสู่แคช สิ่งนี้จะช่วยป้องกันไม่ให้แคชอื่น ๆ ในระบบมีสำเนาของแคชบรรทัด ณ จุดใด ๆ จากการโหลดไปยังที่จัดเก็บตามกฎของโปรโตคอลการเชื่อมโยงแคช MESI (หรือรุ่น MOESI / MESIF ที่ใช้โดย multi-core AMD / Intel CPUs ตามลำดับ) ดังนั้นการดำเนินการโดยแกนอื่น ๆ ดูเหมือนจะเกิดขึ้นก่อนหรือหลังไม่ใช่ระหว่าง
หากไม่มีส่วนlock
นำหน้าแกนหลักอื่นสามารถเป็นเจ้าของแคชไลน์และแก้ไขได้หลังจากโหลดของเรา แต่ก่อนหน้าร้านค้าของเราเพื่อให้ร้านค้าอื่น ๆ สามารถมองเห็นได้ทั่วโลกระหว่างโหลดและร้านค้าของเรา คำตอบอื่น ๆ อีกหลายข้อผิดพลาดและอ้างว่าlock
คุณไม่ได้รับสำเนาของแคชบรรทัดเดียวกัน สิ่งนี้ไม่สามารถเกิดขึ้นได้ในระบบที่มีแคชที่ต่อเนื่องกัน
(หากlock
คำสั่ง ed ทำงานบนหน่วยความจำที่มีช่วงแคชสองบรรทัดจะต้องใช้เวลามากขึ้นในการตรวจสอบให้แน่ใจว่าการเปลี่ยนแปลงของทั้งสองส่วนของวัตถุยังคงเป็นอะตอมในขณะที่มันแพร่กระจายไปยังผู้สังเกตการณ์ทุกคน ต้องล็อคบัสหน่วยความจำทั้งหมดจนกว่าข้อมูลจะเข้าสู่หน่วยความจำอย่าวางตัวแปรอะตอมของคุณผิด!)
โปรดทราบว่าlock
คำนำหน้ายังเปลี่ยนคำสั่งเป็นกำแพงหน่วยความจำเต็ม (เช่นMFENCE ) หยุดการจัดเรียงเวลาทำงานใหม่ทั้งหมดและให้ความสอดคล้องตามลำดับ (ดูโพสต์บล็อกเจฟฟ์ Preshing ยอดเยี่ยม . โพสต์อื่น ๆ ของเขาเป็นอย่างดีในทุกเกินไปอย่างชัดเจนและอธิบายมากของสิ่งที่ดีเกี่ยวกับการเขียนโปรแกรมล็อคฟรีจาก x86 และรายละเอียดฮาร์ดแวร์อื่น ๆ กับกฎระเบียบของ C ++.)
บนเครื่องยูนิโพรเซสเซอร์หรือในกระบวนการแบบเธรดเดี่ยวคำสั่งRMWเดียวจริงๆแล้วคืออะตอมมิกโดยไม่มีlock
คำนำหน้า วิธีเดียวสำหรับรหัสอื่นในการเข้าถึงตัวแปรที่ใช้ร่วมกันคือเพื่อให้ CPU ทำการสลับบริบทซึ่งไม่สามารถเกิดขึ้นได้ในระหว่างการเรียนการสอน ดังนั้นธรรมดาdec dword [num]
สามารถซิงโครไนซ์ระหว่างโปรแกรมแบบเธรดเดี่ยวและตัวจัดการสัญญาณหรือในโปรแกรมแบบมัลติเธรดที่ทำงานบนเครื่องแกนเดียว ดูครึ่งหลังของคำตอบของฉันสำหรับคำถามอื่นและความคิดเห็นที่อยู่ใต้นั้นซึ่งฉันอธิบายในรายละเอียดเพิ่มเติม
กลับไปที่ C ++:
เป็นการใช้ทั้งหมดnum++
โดยไม่แจ้งให้คอมไพเลอร์ทราบว่าคุณต้องการรวบรวมเพื่อนำไปใช้งานอ่าน - แก้ไข - เขียน:
;; Valid compiler output for num++
mov eax, [num]
inc eax
mov [num], eax
นี่เป็นไปได้มากถ้าคุณใช้ค่าในnum
ภายหลัง: คอมไพเลอร์จะเก็บมันไว้ในการลงทะเบียนหลังจากการเพิ่ม ดังนั้นแม้ว่าคุณจะตรวจสอบว่าnum++
คอมไพล์ของตัวเองการเปลี่ยนรหัสรอบสามารถส่งผลกระทบต่อมัน
(หากไม่ต้องการค่าในภายหลังขอinc dword [num]
แนะนำซีพียู x86 ที่ทันสมัยจะเรียกใช้คำสั่ง RMW หน่วยความจำปลายทางอย่างน้อยมีประสิทธิภาพเท่ากับการใช้สามคำสั่งแยกกันสนุกจริง ๆ : gcc -O3 -m32 -mtune=i586
จะปล่อยสิ่งนี้จริง ๆเพราะเพนเทอร์เซียมของ P5 ไม่ต้องถอดรหัสคำแนะนำที่ซับซ้อนให้กับการทำงานจุลภาคแบบง่าย ๆ หลาย ๆ วิธี P6 และสถาปัตยกรรมจุลภาคในภายหลังดูคำแนะนำของตารางคำแนะนำ / สถาปัตยกรรมจุลภาคของ Agner Fogสำหรับข้อมูลเพิ่มเติมและx86 ติดแท็ก wiki สำหรับลิงก์ที่มีประโยชน์มากมาย (รวมถึงคู่มือ ISA ของ Intel x86 ซึ่งมีให้บริการฟรีในรูปแบบ PDF)
อย่าสับสนรุ่นหน่วยความจำเป้าหมาย (x86) กับรุ่นหน่วยความจำ C ++
การจัดเรียงใหม่รวบรวมเวลาที่ได้รับอนุญาต อีกส่วนหนึ่งของสิ่งที่คุณได้รับจาก std :: atomic คือการควบคุมการเรียงลำดับเวลาใหม่เพื่อให้แน่ใจว่าคุณnum++
สามารถมองเห็นได้ทั่วโลกหลังจากการดำเนินการอื่น ๆ
ตัวอย่างแบบคลาสสิก: การจัดเก็บข้อมูลบางอย่างลงในบัฟเฟอร์เพื่อให้เธรดอื่นดูแล้วตั้งค่าสถานะ แม้ว่า x86 ไม่โหลดซื้อ / flag.store(1, std::memory_order_release);
ร้านค้าปล่อยฟรีคุณยังคงต้องบอกคอมไพเลอร์ไม่ได้ที่จะสั่งซื้อใหม่โดยใช้
คุณอาจคาดหวังว่ารหัสนี้จะซิงโครไนซ์กับเธรดอื่น ๆ :
// 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++;
แต่มันจะไม่ คอมไพเลอร์มีอิสระที่จะย้ายflag++
ข้ามการเรียกใช้ฟังก์ชัน (ถ้ามันอินไลน์ฟังก์ชั่นหรือรู้ว่ามันไม่ได้ดูflag
) จากนั้นก็จะสามารถเพิ่มประสิทธิภาพการปรับเปลี่ยนออกไปอย่างสิ้นเชิงเพราะไม่ได้flag
volatile
(และไม่มี c ++ volatile
ไม่ได้เป็นตัวแทนที่มีประโยชน์สำหรับมาตรฐาน :: อะตอม. มาตรฐาน :: อะตอมจะทำให้คอมไพเลอร์ถือว่าค่าที่ในหน่วยความจำสามารถแก้ไขได้ถ่ายทอดสดคล้ายกับvolatile
แต่มีมากขึ้นไปกว่านั้น. ยังvolatile std::atomic<int> foo
ไม่ได้เป็น เช่นเดียวกับstd::atomic<int> foo
ที่หารือกับ @Richard Hodges)
การกำหนดข้อมูลการแข่งขันในตัวแปรที่ไม่ใช่อะตอมมิกเป็น Undefined Behavior คือสิ่งที่ช่วยให้คอมไพเลอร์ยังคงยกโหลดและเก็บออกจากลูปและการเพิ่มประสิทธิภาพอื่น ๆ อีกมากมายสำหรับหน่วยความจำที่หลายเธรดอาจมีการอ้างอิง (ดูบล็อก LLVM นี้สำหรับข้อมูลเพิ่มเติมเกี่ยวกับวิธีที่ UB เปิดใช้งานการปรับให้เหมาะสมของคอมไพเลอร์)
ดังที่ฉันกล่าวถึงคำนำหน้าx86lock
เป็นอุปสรรคหน่วยความจำเต็มดังนั้นการใช้num.fetch_add(1, std::memory_order_relaxed);
สร้างรหัสเดียวกันบน x86 เป็นnum++
(ค่าเริ่มต้นคือความสอดคล้องตามลำดับ) แต่มันมีประสิทธิภาพมากขึ้นในสถาปัตยกรรมอื่น ๆ (เช่น ARM) แม้แต่ใน x86 การผ่อนคลายยังอนุญาตให้ทำการเรียงลำดับเวลาใหม่ได้มากขึ้น
นี่คือสิ่งที่ GCC ทำกับ x86 สำหรับฟังก์ชั่นบางอย่างที่ทำงานกับstd::atomic
ตัวแปรทั่วโลก
ดูแหล่งที่มาชุมนุม + รหัสภาษาที่จัดรูปแบบเป็นอย่างดีในคอมไพเลอร์สำรวจ Godbolt คุณสามารถเลือกสถาปัตยกรรมเป้าหมายอื่น ๆ รวมถึง ARM, MIPS และ PowerPC เพื่อดูรหัสภาษาแอสเซมบลีชนิดใดที่คุณได้รับจากอะตอมมิกส์สำหรับเป้าหมายเหล่านั้น
#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
ขอให้สังเกตว่าจำเป็นต้องใช้ MFENCE (กำแพงกั้นเต็มรูปแบบ) หลังจากร้านค้าที่มีความสอดคล้องตามลำดับ โดยทั่วไปแล้วจะมีการสั่ง x86 อย่างยิ่ง แต่อนุญาตให้ทำการเรียงลำดับ StoreLoad ใหม่ได้ การมีบัฟเฟอร์ของร้านค้านั้นเป็นสิ่งจำเป็นสำหรับประสิทธิภาพที่ดีของซีพียูที่ไม่ทำงานตามสั่ง หน่วยความจำการจัดเรียงใหม่ของJeff Preshing ใน Actแสดงผลที่ตามมาจากการไม่ใช้ MFENCE พร้อมรหัสจริงเพื่อแสดงการจัดเรียงใหม่ที่เกิดขึ้นบนฮาร์ดแวร์จริง
Re: การอภิปรายในความคิดเห็นเกี่ยวกับคำตอบ @Richard Hodges เกี่ยวกับคอมไพเลอร์ที่รวม std :: atomic num++; num-=2;
operation ไว้ในnum--;
คำสั่งเดียว :
คำถามและคำตอบแยกต่างหากในหัวข้อเดียวกันนี้: ทำไมจึงไม่คอมไพเลอร์รวม std ซ้ำซ้อน :: atomic write? ที่คำตอบของฉันคืนสิ่งที่ฉันเขียนด้านล่างมากมาย
คอมไพเลอร์ปัจจุบันยังไม่ได้ทำสิ่งนี้ (แต่) แต่ไม่ใช่เพราะพวกเขาไม่ได้รับอนุญาต C ++ WG21 / P0062R1: เมื่อใดที่คอมไพเลอร์ควรปรับแต่งอะตอมมิกให้เหมาะสม? กล่าวถึงความคาดหวังว่าโปรแกรมเมอร์หลายคนมีคอมไพเลอร์จะไม่ทำการเพิ่มประสิทธิภาพ "น่าประหลาดใจ" และสิ่งที่มาตรฐานสามารถทำได้เพื่อให้โปรแกรมเมอร์ควบคุม N4455กล่าวถึงตัวอย่างมากมายของสิ่งต่าง ๆ ที่สามารถปรับให้เหมาะสมรวมถึงอันนี้ มันชี้ให้เห็นว่าการทำอินไลน์และการแพร่กระจายอย่างต่อเนื่องสามารถแนะนำสิ่งต่าง ๆ เช่นfetch_or(0)
ที่อาจจะกลายเป็นเพียงแค่load()
(แต่ยังคงได้รับและเผยแพร่ความหมาย) แม้ว่าแหล่งต้นฉบับไม่ได้มี ops อะตอมที่ซ้ำซ้อนอย่างเห็นได้ชัด
เหตุผลที่แท้จริงที่คอมไพเลอร์ไม่ได้ทำ (ยัง) คือ: (1) ไม่มีใครเขียนโค้ดที่ซับซ้อนที่จะอนุญาตให้คอมไพเลอร์ทำอย่างปลอดภัย (โดยที่ไม่ผิด) และ (2) อาจละเมิดหลักการอย่างน้อย แปลกใจ รหัสที่ล็อคได้ยากพอที่จะเขียนอย่างถูกต้องตั้งแต่แรก ดังนั้นอย่าคิดมากกับการใช้อาวุธปรมาณู: พวกมันไม่ถูกและไม่เพิ่มประสิทธิภาพมากนัก ไม่ใช่เรื่องง่ายเสมอไปที่จะหลีกเลี่ยงการทำงานของอะตอมที่ซ้ำซ้อนด้วยstd::shared_ptr<T>
เนื่องจากไม่มีรุ่นที่ไม่ใช่อะตอม (แม้ว่าคำตอบอย่างใดอย่างหนึ่งที่นี่ให้วิธีที่ง่ายในการกำหนด a shared_ptr_unsynchronized<T>
gcc)
เดินทางกลับไปnum++; num-=2;
รวบรวมราวกับว่ามันถูกnum--
: คอมไพเลอร์จะได้รับอนุญาตที่จะทำนี้เว้นแต่เป็นnum
volatile std::atomic<int>
หากสามารถจัดลำดับใหม่ได้กฎ as-if อนุญาตให้คอมไพเลอร์ตัดสินใจ ณ เวลารวบรวมที่มันเกิดขึ้นเสมอ ไม่มีสิ่งใดรับประกันได้ว่าผู้สังเกตการณ์จะเห็นค่ากลาง ( num++
ผลลัพธ์)
เช่นถ้าสั่งซื้อสินค้าที่ไม่มีอะไรจะปรากฏทั่วโลกระหว่างการดำเนินการเหล่านี้เข้ากันได้กับความต้องการสั่งซื้อของแหล่งที่มา (ตามไปที่ C ++ กฎสำหรับเครื่องนามธรรมไม่สถาปัตยกรรมเป้าหมาย) คอมไพเลอร์สามารถปล่อยซิงเกิ้ลlock dec dword [num]
แทน/lock inc dword [num]
lock sub dword [num], 2
num++; num--
ไม่สามารถหายไปได้เพราะมันยังคงมีซิงโครไนซ์กับความสัมพันธ์กับเธรดอื่น ๆ ที่มองnum
และเป็นทั้งโหลดโหลดและรีลีสสโตร์ซึ่งไม่สามารถเรียงลำดับการดำเนินการอื่นในเธรดนี้ได้ สำหรับ x86 สิ่งนี้อาจรวบรวมเป็น MFENCE แทนที่จะเป็นlock add dword [num], 0
(เช่นnum += 0
)
ดังที่กล่าวไว้ในPR0062การรวมกันของ ops ปรมาณูที่ไม่ได้อยู่ใกล้เคียงกันมากขึ้นในเวลารวบรวมอาจไม่ดี (เช่นตัวนับความคืบหน้าจะได้รับการอัปเดตเพียงครั้งเดียวในตอนท้ายแทนที่จะเป็นซ้ำทุกครั้ง) แต่ก็สามารถช่วย atomic inc / dec of ref จะนับเมื่อสำเนาของ a shared_ptr
ถูกสร้างและทำลายถ้าคอมไพเลอร์สามารถพิสูจน์ได้ว่าshared_ptr
มีวัตถุอื่นอยู่สำหรับอายุการใช้งานทั้งหมดของชั่วคราว)
แม้แต่num++; num--
การรวมกันอาจทำให้ความเป็นธรรมของการนำล็อคไปใช้เมื่อเธรดหนึ่งปลดล็อกและล็อคอีกครั้งทันที ถ้ามันไม่เคยถูกปล่อยออกมาใน asm แม้กระทั่งกลไกการอนุญาโตตุลาการของฮาร์ดแวร์จะไม่เปิดโอกาสให้เธรดอื่นคว้าล็อคที่จุดนั้น
ด้วยปัจจุบัน gcc6.2 และ clang3.9 คุณยังคงได้รับlock
การดำเนินการแยกต่างหากแม้memory_order_relaxed
ในกรณีที่ปรับให้เหมาะสมที่สุดอย่างเห็นได้ชัด ( Godbolt compiler explorerเพื่อให้คุณสามารถดูว่ารุ่นล่าสุดแตกต่างกันหรือไม่)
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
เป็นอะตอม