ข้อความเกริ่นนำ SMP สำหรับ Android

แพลตฟอร์ม Android 3.0 ขึ้นไปได้รับการเพิ่มประสิทธิภาพเพื่อรองรับสถาปัตยกรรมแบบหลายโปรเซสเซอร์ เอกสารนี้จะแนะนำปัญหาที่อาจเกิดขึ้นเมื่อเขียนโค้ดแบบมัลติเธรดสำหรับระบบมัลติโปรเซสเซอร์แบบสมมาตรในภาษาโปรแกรม C, C++ และ Java (ต่อไปนี้จะเรียกว่า "Java" เพื่อความกระชับ) โดยมีจุดประสงค์เพื่อเป็นข้อมูลเบื้องต้นสำหรับนักพัฒนาแอป Android ไม่ใช่การอภิปรายเกี่ยวกับเรื่องนี้อย่างสมบูรณ์

บทนำ

SMP เป็นตัวย่อของ "Symmetric Multi-Processor" ซึ่งอธิบายการออกแบบที่แกน CPU ที่เหมือนกัน 2 แกนขึ้นไปแชร์การเข้าถึงหน่วยความจำหลัก จนกระทั่งเมื่อไม่กี่ปีที่ผ่านมา อุปกรณ์ Android ทั้งหมดเป็น UP (Uni-Processor)

อุปกรณ์ Android ส่วนใหญ่ (หรือทั้งหมด) มี CPU หลายตัวเสมอ แต่ในอดีตมีเพียงตัวเดียวเท่านั้นที่ใช้เรียกใช้แอปพลิเคชัน ขณะที่ตัวอื่นๆ จัดการฮาร์ดแวร์ต่างๆ ของอุปกรณ์ (เช่น วิทยุ) CPU อาจมีสถาปัตยกรรมที่แตกต่างกัน และโปรแกรมที่ทำงานบน CPU เหล่านั้นไม่สามารถใช้หน่วยความจำหลักเพื่อสื่อสารกันได้

อุปกรณ์ Android ส่วนใหญ่ที่ขายในปัจจุบันสร้างขึ้นตามการออกแบบ SMP ซึ่งทำให้นักพัฒนาซอฟต์แวร์ทำงานได้ซับซ้อนขึ้นเล็กน้อย ภาวะแข่งขันในโปรแกรมแบบมัลติเธรดอาจไม่ทำให้เกิดปัญหาที่มองเห็นได้ใน Uniprocessor แต่อาจล้มเหลวเป็นประจำเมื่อมีเธรดตั้งแต่ 2 เธรดขึ้นไปทำงานพร้อมกันในคอร์ที่ต่างกัน นอกจากนี้ โค้ดอาจมีแนวโน้มที่จะล้มเหลวมากหรือน้อยเมื่อทำงานบนสถาปัตยกรรมโปรเซสเซอร์ที่แตกต่างกัน หรือแม้แต่ในการใช้งานสถาปัตยกรรมเดียวกันที่แตกต่างกัน โค้ดที่ผ่านการทดสอบอย่างละเอียดบน x86 อาจทำงานผิดพลาดอย่างมากบน ARM โค้ดอาจเริ่มทำงานไม่สำเร็จเมื่อคอมไพล์ใหม่ด้วยคอมไพเลอร์ที่ทันสมัยกว่า

ส่วนที่เหลือของเอกสารนี้จะอธิบายเหตุผลและบอกสิ่งที่คุณต้องทำเพื่อให้แน่ใจว่าโค้ดทำงานอย่างถูกต้อง

โมเดลความสอดคล้องของหน่วยความจำ: เหตุใด SMP จึงแตกต่างออกไปเล็กน้อย

นี่คือภาพรวมที่รวดเร็วและน่าสนใจของหัวข้อที่ซับซ้อน บางส่วนอาจไม่สมบูรณ์ แต่ไม่ควรมีส่วนใดที่ทำให้เข้าใจผิดหรือไม่ถูกต้อง ดังที่คุณจะเห็นในส่วนถัดไป รายละเอียดที่นี่มักจะไม่สำคัญ

ดูการอ่านเพิ่มเติมที่ส่วนท้ายของเอกสารเพื่อดูคำแนะนำเกี่ยวกับการจัดการเรื่องนี้อย่างละเอียดมากขึ้น

โมเดลความสอดคล้องกันของหน่วยความจำ หรือที่มักเรียกว่า "โมเดลหน่วยความจำ" อธิบายการรับประกันที่ภาษาโปรแกรมหรือสถาปัตยกรรมฮาร์ดแวร์มีให้เกี่ยวกับการเข้าถึงหน่วยความจำ เช่น หากคุณเขียนค่าไปยังที่อยู่ A แล้วเขียนค่าไปยังที่อยู่ B โมเดลอาจรับประกันว่าแกน CPU ทุกแกนจะเห็นการเขียนเหล่านั้นเกิดขึ้นตามลำดับนั้น

โมเดลที่โปรแกรมเมอร์ส่วนใหญ่คุ้นเคยคือความสอดคล้องตามลำดับ ซึ่งอธิบายไว้ดังนี้ (Adve & Gharachorloo)

  • การดำเนินการทั้งหมดในหน่วยความจำจะทำงานทีละรายการ
  • การดำเนินการทั้งหมดในชุดข้อความเดียวจะปรากฏขึ้นเพื่อดำเนินการตามลำดับที่อธิบายไว้ในโปรแกรมของโปรเซสเซอร์นั้น

สมมติชั่วคราวว่าเรามีคอมไพเลอร์หรืออินเทอร์พรีเตอร์ที่เรียบง่ายมากซึ่งไม่มีอะไรซับซ้อน โดยจะแปลการกำหนดค่าในซอร์สโค้ดเป็นคำสั่งโหลดและจัดเก็บตามลำดับที่สอดคล้องกันอย่างถูกต้อง โดยมีคำสั่ง 1 รายการต่อการเข้าถึง 1 ครั้ง และเราจะสมมติว่าแต่ละเทรดทำงานบนโปรเซสเซอร์ของตัวเองเพื่อความง่าย

หากคุณดูโค้ดบางส่วนและเห็นว่าโค้ดนั้นอ่านและเขียนจากหน่วยความจำในสถาปัตยกรรม CPU ที่สอดคล้องกันตามลำดับ คุณจะทราบว่าโค้ดนั้นจะอ่านและเขียนตามลำดับที่คาดไว้ เป็นไปได้ว่า CPU กำลังจัดเรียงคำสั่งใหม่และหน่วงเวลาการอ่านและเขียน แต่โค้ดที่ทำงานบนอุปกรณ์ไม่มีทางบอกได้ว่า CPU กำลังทำอย่างอื่นนอกเหนือจากการดำเนินการตามคำสั่งในลักษณะที่ตรงไปตรงมา (เราจะไม่สนใจ I/O ของไดรเวอร์อุปกรณ์ที่แมปหน่วยความจำ)

เพื่อแสดงให้เห็นถึงประเด็นเหล่านี้ การพิจารณาข้อมูลโค้ดเล็กๆ ซึ่งมักเรียกว่าการทดสอบแบบลิตมัสจะมีประโยชน์

ต่อไปนี้เป็นตัวอย่างง่ายๆ ที่มีโค้ดทำงานใน 2 เทรด

ชุดข้อความ 1 ชุดข้อความ 2
A = 3
B = 5
reg0 = B
reg1 = A

ในตัวอย่าง Litmus นี้และตัวอย่างในอนาคตทั้งหมด ตำแหน่งหน่วยความจำจะแสดงด้วยตัวอักษรพิมพ์ใหญ่ (A, B, C) และรีจิสเตอร์ CPU จะขึ้นต้นด้วย "reg" หน่วยความจำทั้งหมดจะเริ่มต้นที่ 0 ระบบจะดำเนินการตามคำสั่งจากบนลงล่าง ในที่นี้ เทรด 1 จะจัดเก็บค่า 3 ไว้ที่ตำแหน่ง A และจัดเก็บค่า 5 ไว้ที่ตำแหน่ง B เทรด 2 โหลดค่าจากตำแหน่ง B ไปยัง reg0 แล้วโหลดค่าจากตำแหน่ง A ไปยัง reg1 (โปรดทราบว่าเราเขียนตามลำดับหนึ่งและอ่านตามลำดับอื่น)

ระบบจะถือว่าเทรด 1 และเทรด 2 ทำงานในแกน CPU ที่ต่างกัน คุณควรตั้งสมมติฐานนี้เสมอเมื่อนึกถึงโค้ดแบบ Multi-Thread

ความสอดคล้องตามลำดับรับประกันว่าหลังจากที่ทั้ง 2 เทรดดำเนินการเสร็จสิ้นแล้ว รีจิสเตอร์จะอยู่ในสถานะใดสถานะหนึ่งต่อไปนี้

ลงทะเบียน รัฐ
reg0=5, reg1=3 เป็นไปได้ (ชุดข้อความ 1 ทำงานก่อน)
reg0=0, reg1=0 เป็นไปได้ (เทรด 2 ทำงานก่อน)
reg0=0, reg1=3 เป็นไปได้ (การดำเนินการพร้อมกัน)
reg0=5, reg1=0 ไม่เคยใช้

หากต้องการเข้าสู่สถานการณ์ที่เราเห็น B=5 ก่อนที่จะเห็นการจัดเก็บไปยัง A การอ่านหรือการเขียนจะต้องเกิดขึ้นไม่เป็นไปตามลำดับ ในเครื่องที่สอดคล้องกันตามลำดับ เหตุการณ์ดังกล่าวจะเกิดขึ้นไม่ได้

โดยปกติแล้ว โปรเซสเซอร์แบบเดี่ยว ซึ่งรวมถึง x86 และ ARM จะมีความสอดคล้องกันตามลำดับ เทรดจะปรากฏขึ้นเพื่อดำเนินการในลักษณะสลับกัน เนื่องจากเคอร์เนลของระบบปฏิบัติการจะสลับไปมาระหว่างเทรด ระบบ SMP ส่วนใหญ่ รวมถึง x86 และ ARM ไม่สอดคล้องกันตามลำดับ ตัวอย่างเช่น เป็นเรื่องปกติที่ฮาร์ดแวร์จะบัฟเฟอร์ที่เก็บข้อมูลระหว่างทางไปยังหน่วยความจำ เพื่อไม่ให้เข้าถึงหน่วยความจำทันทีและมองเห็นได้โดยคอร์อื่นๆ

รายละเอียดจะแตกต่างกันอย่างมาก ตัวอย่างเช่น x86 แม้ว่าจะไม่สอดคล้องกันตามลำดับ แต่ก็ยังรับประกันได้ว่า reg0 = 5 และ reg1 = 0 จะยังคงเป็นไปไม่ได้ ระบบจะบัฟเฟอร์การจัดเก็บ แต่จะยังคงรักษาลำดับไว้ ในทางกลับกัน ARM ไม่ได้ทำเช่นนั้น ระบบจะไม่คงลำดับของ Store ที่บัฟเฟอร์ไว้ และ Store อาจไม่เข้าถึงคอร์อื่นๆ ทั้งหมดพร้อมกัน ความแตกต่างเหล่านี้มีความสำคัญต่อนักเขียนโปรแกรมแอสเซมบลี อย่างไรก็ตาม ดังที่เราจะเห็นด้านล่าง โปรแกรมเมอร์ C, C++ หรือ Java สามารถและควรเขียนโปรแกรมในลักษณะที่ซ่อนความแตกต่างทางสถาปัตยกรรมดังกล่าว

จนถึงตอนนี้ เราได้ตั้งสมมติฐานที่ไม่เป็นจริงว่ามีเพียงฮาร์ดแวร์เท่านั้นที่จัดเรียงคำสั่งใหม่ ในความเป็นจริง คอมไพเลอร์ยังจัดเรียงคำสั่งใหม่เพื่อปรับปรุงประสิทธิภาพด้วย ในตัวอย่างของเรา คอมไพเลอร์อาจตัดสินใจว่าโค้ดที่ตามมาใน Thread 2 ต้องการค่าของ reg1 ก่อนที่จะต้องการ reg0 จึงโหลด reg1 ก่อน หรือโค้ดก่อนหน้าบางโค้ดอาจโหลด A ไว้แล้ว และคอมไพเลอร์อาจตัดสินใจใช้ค่าดังกล่าวซ้ำแทนที่จะโหลด A อีกครั้ง ไม่ว่าในกรณีใดก็ตาม ระบบอาจจัดเรียงการโหลดไปยัง reg0 และ reg1 ใหม่

อนุญาตให้จัดลำดับการเข้าถึงตำแหน่งหน่วยความจำต่างๆ ใหม่ ไม่ว่าจะในฮาร์ดแวร์หรือในคอมไพเลอร์ เนื่องจากไม่ส่งผลต่อการดำเนินการของเทรดเดียว และสามารถปรับปรุงประสิทธิภาพได้อย่างมาก ดังที่เราจะเห็นว่าหากระมัดระวังเพียงเล็กน้อย เราก็สามารถป้องกันไม่ให้เกิดผลกระทบต่อผลลัพธ์ของโปรแกรมแบบมัลติเธรดได้เช่นกัน

เนื่องจากคอมไพเลอร์สามารถจัดลำดับการเข้าถึงหน่วยความจำใหม่ได้ ปัญหานี้จึงไม่ใช่เรื่องใหม่สำหรับ SMP แม้ในโปรเซสเซอร์เดียว คอมไพเลอร์ก็สามารถจัดลำดับการโหลดไปยัง reg0 และ reg1 ใหม่ในตัวอย่างของเรา และสามารถกำหนดเวลาเทรด 1 ระหว่างคำสั่งที่จัดลำดับใหม่ได้ แต่หากคอมไพเลอร์ไม่ได้จัดลำดับใหม่ เราอาจไม่เคยพบปัญหานี้เลย ใน ARM SMP ส่วนใหญ่ แม้จะไม่มีการจัดเรียงใหม่ของคอมไพเลอร์ แต่ก็อาจเห็นการจัดเรียงใหม่ได้หลังจากที่ดำเนินการสำเร็จเป็นจำนวนมาก โดยทั่วไปแล้ว SMP จะทำให้คุณมีแนวโน้มที่จะเห็นปัญหาที่เกิดขึ้นมาตลอด เว้นแต่ว่าคุณจะเขียนโปรแกรมในภาษา Assembly

การเขียนโปรแกรมที่ไม่มีการแย่งชิงข้อมูล

โชคดีที่โดยปกติแล้วมีวิธีง่ายๆ ที่จะหลีกเลี่ยงการคิดถึงรายละเอียดเหล่านี้ หากคุณปฏิบัติตามกฎง่ายๆ บางข้อ โดยปกติแล้วคุณจะลืมส่วนก่อนหน้าทั้งหมดได้ ยกเว้นส่วน "ความสอดคล้องตามลำดับ" แต่ปัญหาอื่นๆ อาจปรากฏขึ้นหากคุณละเมิดกฎเหล่านั้นโดยไม่ตั้งใจ

ภาษาโปรแกรมสมัยใหม่ส่งเสริมรูปแบบการเขียนโปรแกรมที่เรียกว่า "Data-Race-Free" ตราบใดที่คุณสัญญาว่าจะไม่ทำให้เกิด "การแย่งชิงข้อมูล" และหลีกเลี่ยงโครงสร้างบางอย่างที่บอกคอมไพเลอร์เป็นอย่างอื่น คอมไพเลอร์และฮาร์ดแวร์จะสัญญาว่าจะให้ผลลัพธ์ที่สอดคล้องกันตามลำดับ ซึ่งไม่ได้หมายความว่าหลีกเลี่ยงการจัดลำดับการเข้าถึงหน่วยความจำใหม่ แต่หมายความว่าหากคุณปฏิบัติตามกฎ คุณจะไม่สามารถบอกได้ว่ามีการจัดลำดับการเข้าถึงหน่วยความจำใหม่ ซึ่งก็เหมือนกับการบอกคุณว่าไส้กรอกเป็นอาหารที่อร่อยและน่ารับประทาน ตราบใดที่คุณสัญญาว่าจะไม่ไปเยี่ยมชมโรงงานไส้กรอก การแย่งชิงข้อมูลเป็นสิ่งที่เปิดเผยความจริงอันน่าเกลียดเกี่ยวกับการจัดลำดับหน่วยความจำใหม่

"การแย่งใช้ข้อมูล" คืออะไร

การแย่งชิงข้อมูลจะเกิดขึ้นเมื่อมีเทรดอย่างน้อย 2 เทรดเข้าถึงข้อมูลทั่วไปเดียวกันพร้อมกัน และมีเทรดอย่างน้อย 1 เทรดที่แก้ไขข้อมูลนั้น "ข้อมูลทั่วไป" หมายถึงข้อมูลที่ไม่ใช่ออบเจ็กต์การซิงค์ข้อมูลที่ออกแบบมาสำหรับการสื่อสารแบบเทรดโดยเฉพาะ Mutex, ตัวแปรเงื่อนไข, Java Volatile หรือออบเจ็กต์อะตอม C++ ไม่ใช่ข้อมูลทั่วไป และอนุญาตให้เข้าถึงแบบ Race ได้ แต่ใช้เพื่อป้องกันการแย่งชิงข้อมูลในออบเจ็กต์อื่นๆ

ในการพิจารณาว่าเทรด 2 เทรดเข้าถึงตำแหน่งหน่วยความจำเดียวกันพร้อมกันหรือไม่ เราสามารถละเว้นการพูดคุยเรื่องการจัดลำดับหน่วยความจำใหม่จากด้านบน และถือว่ามีความสอดคล้องกันตามลำดับ โปรแกรมต่อไปนี้ไม่มีการแย่งชิงข้อมูลหาก A และ B เป็นตัวแปรบูลีนธรรมดาที่มีค่าเริ่มต้นเป็นเท็จ

ชุดข้อความ 1 ชุดข้อความ 2
if (A) B = true if (B) A = true

เนื่องจากไม่มีการจัดเรียงการดำเนินการใหม่ เงื่อนไขทั้ง 2 รายการจะได้รับการประเมินเป็นเท็จ และจะไม่มีการอัปเดตตัวแปรใดๆ เลย ดังนั้นจึงไม่มีการแย่งชิงข้อมูล ไม่จำเป็นต้องคิดถึงสิ่งที่จะเกิดขึ้นหากมีการจัดลำดับการโหลดจาก A และจัดเก็บไปยัง B ในเทรด 1 ใหม่ คอมไพเลอร์ไม่ได้รับอนุญาตให้จัดลำดับเทรด 1 ใหม่โดยเขียนใหม่เป็น "B = true; if (!A) B = false" ซึ่งจะเหมือนกับการทำไส้กรอกกลางเมืองในเวลากลางวันแสกๆ

การแย่งใช้ข้อมูลมีการกำหนดไว้อย่างเป็นทางการในประเภทพื้นฐานที่มีอยู่แล้ว เช่น จำนวนเต็มและการอ้างอิงหรือพอยน์เตอร์ การกำหนดค่าให้กับ int ขณะที่อ่านค่าในเทรดอื่นพร้อมกันนั้นเป็นการแย่งชิงข้อมูลอย่างชัดเจน แต่ทั้งไลบรารีมาตรฐาน C++ และไลบรารี Java Collections เขียนขึ้นเพื่อให้คุณสามารถให้เหตุผลเกี่ยวกับการแย่งใช้ข้อมูลในระดับไลบรารีได้ด้วย โดยสัญญาว่าจะไม่ทำให้เกิดการแย่งชิงข้อมูล เว้นแต่จะมีการเข้าถึงคอนเทนเนอร์เดียวกันพร้อมกัน ซึ่งอย่างน้อย 1 รายการจะอัปเดตคอนเทนเนอร์ การอัปเดต set<T> ในชุดข้อความหนึ่งขณะที่อ่านพร้อมกันในอีกชุดข้อความหนึ่งช่วยให้ไลบรารีสามารถทำให้เกิดภาวะการแย่งชิงทรัพยากรข้อมูลได้ และอาจถืออย่างไม่เป็นทางการว่าเป็น "ภาวะการแย่งชิงทรัพยากรข้อมูลระดับไลบรารี" ในทางกลับกัน การอัปเดต set<T> 1 รายการในเทรดหนึ่งขณะที่อ่านรายการอื่นในอีกเทรดหนึ่งจะไม่ส่งผลให้เกิดการแย่งชิงข้อมูล เนื่องจากไลบรารีสัญญาว่าจะไม่ทำให้เกิดการแย่งชิงข้อมูล (ระดับต่ำ) ในกรณีดังกล่าว

โดยปกติแล้ว การเข้าถึงฟิลด์ต่างๆ ในโครงสร้างข้อมูลพร้อมกันจะไม่ทำให้เกิดภาวะการแย่งชิงทรัพยากร อย่างไรก็ตาม มีข้อยกเว้นที่สำคัญประการหนึ่งสำหรับกฎนี้ นั่นคือ ลำดับที่ต่อเนื่องกันของบิตฟิลด์ใน C หรือ C++ จะถือว่าเป็น "ตำแหน่งหน่วยความจำ" เดียว การเข้าถึงบิตฟิลด์ใดๆ ในลำดับดังกล่าวจะถือว่าเป็นการเข้าถึงบิตฟิลด์ทั้งหมดเพื่อวัตถุประสงค์ในการพิจารณาการมีอยู่ของ Data Race ซึ่งแสดงให้เห็นถึงความไร้ความสามารถของฮาร์ดแวร์ทั่วไปในการอัปเดตบิตแต่ละรายการโดยไม่ต้องอ่านและเขียนบิตที่อยู่ติดกันซ้ำ โปรแกรมเมอร์ Java ไม่มีความกังวลที่คล้ายกัน

การหลีกเลี่ยงการแย่งใช้ข้อมูล

ภาษาโปรแกรมสมัยใหม่มีกลไกการซิงค์ข้อมูลจำนวนมากเพื่อหลีกเลี่ยงการแย่งชิงข้อมูล เครื่องมือพื้นฐานที่สุดมีดังนี้

ล็อกหรือ Mutex
คุณสามารถใช้ Mutex (std::mutex หรือ pthread_mutex_t ของ C++11) หรือบล็อก synchronized ใน Java เพื่อให้แน่ใจว่าโค้ดบางส่วนจะไม่ทำงานพร้อมกันกับโค้ดส่วนอื่นๆ ที่เข้าถึงข้อมูลเดียวกัน เราจะเรียกสิ่งเหล่านี้และสิ่งอำนวยความสะดวกอื่นๆ ที่คล้ายกันโดยทั่วไปว่า "ล็อก" การรับล็อกที่เฉพาะเจาะจงอย่างสม่ำเสมอก่อนเข้าถึงโครงสร้างข้อมูลที่แชร์และปล่อยล็อกหลังจากนั้นจะช่วยป้องกันการแย่งชิงข้อมูลเมื่อเข้าถึงโครงสร้างข้อมูล นอกจากนี้ยังช่วยให้มั่นใจได้ว่าการอัปเดตและการเข้าถึงเป็นแบบอะตอม กล่าวคือ ไม่มีการอัปเดตอื่นๆ ในโครงสร้างข้อมูลที่สามารถทำงานในระหว่างนั้นได้ ซึ่งเป็นเครื่องมือที่พบบ่อยที่สุดในการป้องกันภาวะการแย่งชิงทรัพยากรข้อมูล การใช้บล็อก synchronized ของ Java หรือ lock_guard หรือ unique_lock ของ C++ ช่วยให้มั่นใจได้ว่าระบบจะปลดล็อกอย่างเหมาะสมในกรณีที่มีข้อยกเว้น
ตัวแปร Volatile/Atomic
Java มีช่อง volatile ที่รองรับการเข้าถึงพร้อมกันโดยไม่ทำให้เกิดการแย่งชิงข้อมูล ตั้งแต่ปี 2011 เป็นต้นมา C และ C++ รองรับตัวแปรและฟิลด์ atomic ที่มีความหมายคล้ายกัน โดยทั่วไปแล้ว ตัวแปรเหล่านี้จะใช้งานได้ยากกว่าการล็อก เนื่องจากจะช่วยให้มั่นใจได้ว่าการเข้าถึงตัวแปรเดียวแต่ละครั้งจะเป็นแบบอะตอมเท่านั้น (ใน C++ โดยปกติแล้วจะขยายไปถึงการดำเนินการอ่าน-แก้ไข-เขียนแบบง่าย เช่น การเพิ่มค่า Java ต้องใช้การเรียกใช้เมธอดพิเศษสำหรับเรื่องนี้) ตัวแปร volatile หรือ atomic ไม่สามารถใช้โดยตรงเพื่อป้องกันไม่ให้เทรดอื่นๆ รบกวนลำดับโค้ดที่ยาวกว่า ซึ่งแตกต่างจากล็อก

โปรดทราบว่า volatile มีความหมายที่แตกต่างกันมากใน C++ และ Java ใน C++ volatile ไม่ได้ป้องกันการแย่งชิงข้อมูล แม้ว่าโค้ดเก่ามักจะใช้เป็นวิธีแก้ปัญหาชั่วคราวสำหรับการไม่มีออบเจ็กต์ atomic เราไม่แนะนำให้ใช้ตัวเลือกนี้อีกต่อไป ใน C++ ให้ใช้ atomic<T> สำหรับตัวแปรที่หลายชุดข้อความเข้าถึงพร้อมกันได้ C++ volatile มีไว้สำหรับ รีจิสเตอร์อุปกรณ์และอื่นๆ ที่คล้ายกัน

ตัวแปร atomic ของ C/C++ หรือตัวแปร volatile ของ Java สามารถใช้เพื่อป้องกันการแย่งชิงข้อมูลในตัวแปรอื่นๆ ได้ หากมีการประกาศว่า flag มีประเภท atomic<bool> หรือ atomic_bool (C/C++) หรือ volatile boolean (Java) และในตอนแรกเป็นเท็จ ข้อมูลโค้ดต่อไปนี้จะไม่มีการแย่งชิงข้อมูล

ชุดข้อความ 1 ชุดข้อความ 2
A = ...
  flag = true
while (!flag) {}
... = A

เนื่องจากชุดข้อความ 2 รอให้มีการตั้งค่า flag การเข้าถึง A ในชุดข้อความ 2 จึงต้องเกิดขึ้นหลังจากนั้น และไม่พร้อมกันกับการกำหนดค่าให้กับ A ในชุดข้อความ 1 ดังนั้นจึงไม่มีการแย่งชิงข้อมูลใน A การแข่งขันใน flag ไม่นับเป็นการแข่งขันข้อมูล เนื่องจาก volatile/atomic access ไม่ใช่ "ordinary memory access"

การติดตั้งใช้งานจำเป็นต้องป้องกันหรือซ่อนการจัดลำดับหน่วยความจำใหม่ให้เพียงพอเพื่อให้โค้ดอย่างการทดสอบลิตมัสก่อนหน้าทำงานตามที่คาดไว้ ซึ่งโดยปกติแล้วจะทำให้การเข้าถึงหน่วยความจำแบบ Volatile/Atomic มีค่าใช้จ่ายสูงกว่าการเข้าถึงแบบปกติอย่างมาก

แม้ว่าตัวอย่างก่อนหน้านี้จะไม่มีการแย่งชิงข้อมูล แต่การล็อกร่วมกับ Object.wait() ใน Java หรือตัวแปรเงื่อนไขใน C/C++ มักจะเป็นโซลูชันที่ดีกว่าซึ่งไม่เกี่ยวข้องกับการรอในลูปขณะที่แบตเตอรี่หมด

เมื่อการจัดเรียงความทรงจำใหม่ปรากฏขึ้น

การเขียนโปรแกรมที่ไม่มีการแย่งชิงข้อมูลมักจะช่วยให้เราไม่ต้องจัดการกับปัญหาการจัดลำดับการเข้าถึงหน่วยความจำใหม่โดยตรง อย่างไรก็ตาม มีหลายกรณีที่การจัดเรียงใหม่จะปรากฏขึ้น ดังนี้
  1. หากโปรแกรมมีข้อบกพร่องที่ส่งผลให้เกิดการแย่งชิงข้อมูลโดยไม่ตั้งใจ การเปลี่ยนรูปแบบคอมไพเลอร์และฮาร์ดแวร์อาจปรากฏให้เห็น และลักษณะการทำงานของโปรแกรมอาจทำให้คุณประหลาดใจ ตัวอย่างเช่น หากเราลืมประกาศ flag volatile ในตัวอย่างก่อนหน้า เทรด 2 อาจเห็น A ที่ไม่ได้เริ่มต้น หรือคอมไพเลอร์อาจตัดสินใจว่าแฟล็กไม่สามารถเปลี่ยนแปลงได้ในระหว่างลูปของ Thread 2 และเปลี่ยนรูปแบบโปรแกรมเป็น
    ชุดข้อความ 1 ชุดข้อความ 2
    A = ...
      flag = true
    reg0 = flag; while (!reg0) {}
    ... = A
    เมื่อแก้ไขข้อบกพร่อง คุณอาจเห็นว่าลูปทำงานต่อไปเรื่อยๆ แม้ว่า flag จะเป็นจริงก็ตาม
  2. C++ มีสิ่งอำนวยความสะดวกสำหรับการผ่อนคลายความสอดคล้องตามลำดับอย่างชัดเจนแม้ว่าจะไม่มี Race Condition ก็ตาม การดำเนินการแบบอะตอมมิกสามารถรับอาร์กิวเมนต์ memory_order_... ที่ชัดเจนได้ ในทำนองเดียวกัน แพ็กเกจ java.util.concurrent.atomic มีชุดสิ่งอำนวยความสะดวกที่คล้ายกันซึ่งจำกัดมากกว่า โดยเฉพาะ lazySet() และโปรแกรมเมอร์ Java บางครั้งก็ใช้การแย่งชิงข้อมูลโดยเจตนาเพื่อให้ได้ผลลัพธ์ที่คล้ายกัน ทั้งหมดนี้ช่วยปรับปรุงประสิทธิภาพได้มาก แต่ก็ต้องแลกมาด้วยความซับซ้อนในการเขียนโปรแกรม เราจะพูดถึงเรื่องนี้โดยสังเขปด้านล่าง
  3. โค้ด C และ C++ บางโค้ดเขียนขึ้นในรูปแบบเก่า ซึ่งไม่สอดคล้องกับมาตรฐานภาษาปัจจุบันทั้งหมด โดยใช้ตัวแปร volatile แทนตัวแปร atomic และไม่อนุญาตให้จัดลำดับหน่วยความจำอย่างชัดแจ้งโดยการแทรกสิ่งที่เรียกว่า fences หรือ barriers ซึ่งต้องใช้การให้เหตุผลที่ชัดเจนเกี่ยวกับการจัดลำดับการเข้าถึงใหม่และความเข้าใจเกี่ยวกับโมเดลหน่วยความจำฮาร์ดแวร์ รูปแบบการเขียนโค้ดในลักษณะนี้ยังคงใช้ในเคอร์เนล Linux จึงไม่ควรใช้ในแอปพลิเคชัน Android ใหม่ และเราจะไม่พูดถึงเรื่องนี้อีกในที่นี้

ลองฝึก

การแก้ไขข้อบกพร่องเกี่ยวกับปัญหาความสอดคล้องของหน่วยความจำอาจเป็นเรื่องยากมาก หากการประกาศ lock, atomic หรือ volatile ที่ขาดหายไปทำให้โค้ดบางส่วนอ่านข้อมูลที่ล้าสมัย คุณอาจไม่สามารถหาสาเหตุได้โดยการตรวจสอบดัมพ์หน่วยความจำด้วยโปรแกรมแก้ไขข้อบกพร่อง เมื่อคุณสามารถออกคำค้นหาโปรแกรมแก้ไขข้อบกพร่องได้ คอร์ CPU อาจสังเกตเห็นชุดการเข้าถึงทั้งหมด และเนื้อหาของหน่วยความจำและรีจิสเตอร์ CPU จะปรากฏในสถานะ "เป็นไปไม่ได้"

สิ่งที่ไม่ควรทำใน C

เราจะแสดงตัวอย่างโค้ดที่ไม่ถูกต้องพร้อมวิธีแก้ไขง่ายๆ ก่อนที่จะทำเช่นนั้น เราต้องพูดถึงการใช้ฟีเจอร์ภาษาพื้นฐาน

C/C++ และ "volatile"

การประกาศ volatile ของ C และ C++ เป็นเครื่องมือที่มีวัตถุประสงค์พิเศษอย่างมาก ซึ่งจะป้องกันไม่ให้คอมไพเลอร์จัดลำดับใหม่หรือนำการเข้าถึง volatile ออก ซึ่งจะเป็นประโยชน์สำหรับโค้ดที่เข้าถึงรีจิสเตอร์ของอุปกรณ์ฮาร์ดแวร์ หน่วยความจำที่แมปกับตำแหน่งมากกว่า 1 ตำแหน่ง หรือเกี่ยวข้องกับ setjmp แต่ C และ C++ volatile ไม่เหมือนกับ Java volatile ตรงที่ไม่ได้ออกแบบมาสำหรับการสื่อสารแบบเทรด

ใน C และ C++ การเข้าถึงข้อมูล volatile อาจมีการจัดลำดับใหม่ด้วยการเข้าถึงข้อมูลที่ไม่ผันผวน และไม่มีการรับประกันความเป็นอะตอม ดังนั้นจึงไม่สามารถใช้ volatile เพื่อแชร์ข้อมูลที่แชร์ระหว่างเทรดในโค้ดแบบพกพาได้ แม้จะใช้ตัวประมวลผลเดียวก็ตาม โดยปกติแล้ว C volatile จะไม่ป้องกันการจัดลำดับการเข้าถึงใหม่โดยฮาร์ดแวร์ ดังนั้นตัวมันเองจึงมีประโยชน์น้อยลงไปอีกในสภาพแวดล้อม SMP แบบมัลติเธรด นี่คือเหตุผลที่ C11 และ C++11 รองรับออบเจ็กต์ atomic คุณควรใช้ตัวเลือกเหล่านั้นแทน

โค้ด C และ C++ รุ่นเก่าจำนวนมากยังคงใช้ volatile ในทางที่ผิดสำหรับการสื่อสารแบบเทรด ซึ่งมักจะทำงานได้อย่างถูกต้องสำหรับข้อมูลที่พอดีกับรีจิสเตอร์ของเครื่อง โดยมีเงื่อนไขว่าจะต้องใช้กับ Fence ที่ชัดเจนหรือในกรณีที่ลำดับหน่วยความจำไม่สำคัญ แต่ไม่รับประกันว่าจะทำงานได้อย่างถูกต้องกับคอมไพเลอร์ในอนาคต

ตัวอย่าง

ในกรณีส่วนใหญ่ คุณควรใช้การล็อก (เช่น pthread_mutex_t หรือ C++11 std::mutex) แทนการดำเนินการแบบอะตอม แต่เราจะใช้การดำเนินการแบบอะตอมเพื่อแสดงวิธีใช้ในสถานการณ์จริง

MyThing* gGlobalThing = NULL;  // Wrong!  See below.
void initGlobalThing()    // runs in Thread 1
{
    MyStruct* thing = malloc(sizeof(*thing));
    memset(thing, 0, sizeof(*thing));
    thing->x = 5;
    thing->y = 10;
    /* initialization complete, publish */
    gGlobalThing = thing;
}
void useGlobalThing()    // runs in Thread 2
{
    if (gGlobalThing != NULL) {
        int i = gGlobalThing->x;    // could be 5, 0, or uninitialized data
        ...
    }
}

แนวคิดในที่นี้คือเราจัดสรรโครงสร้าง เริ่มต้นฟิลด์ และ "เผยแพร่" โครงสร้างดังกล่าวในตอนท้ายด้วยการจัดเก็บไว้ในตัวแปรส่วนกลาง ณ จุดนั้น เทรดอื่นๆ จะเห็นออบเจ็กต์นั้นได้ แต่ก็ไม่เป็นไรเพราะออบเจ็กต์ได้รับการกำหนดค่าเริ่มต้นอย่างสมบูรณ์แล้ว ใช่ไหม

ปัญหาคืออาจมีการสังเกตเห็นการจัดเก็บไปยัง gGlobalThing ก่อนที่จะมีการเริ่มต้นฟิลด์ โดยปกติแล้วเป็นเพราะคอมไพเลอร์หรือโปรเซสเซอร์จัดลำดับการจัดเก็บไปยัง gGlobalThing และ thing->x ใหม่ เทรดอื่นที่อ่านจาก thing->x อาจเห็น 5, 0 หรือแม้แต่ข้อมูลที่ยังไม่ได้กำหนดค่าเริ่มต้น

ปัญหาหลักที่นี่คือการแย่งชิงข้อมูลใน gGlobalThing หากเทรด 1 เรียกใช้ initGlobalThing() ขณะที่เทรด 2 เรียกใช้ useGlobalThing() ระบบจะอ่าน gGlobalThing ได้ขณะที่กำลังเขียน

ซึ่งแก้ไขได้โดยประกาศ gGlobalThing เป็นอะตอม ใน C++11:

atomic<MyThing*> gGlobalThing(NULL);

วิธีนี้ช่วยให้มั่นใจได้ว่าการเขียนจะปรากฏในชุดข้อความอื่นๆ ตามลำดับที่เหมาะสม นอกจากนี้ยังรับประกันว่าจะป้องกันโหมดความล้มเหลวอื่นๆ บางโหมดที่อาจได้รับอนุญาต แต่ไม่น่าจะเกิดขึ้นในฮาร์ดแวร์ Android จริง เช่น ช่วยให้มั่นใจว่าเราจะไม่เห็นพอยน์เตอร์ gGlobalThing ที่เขียนเพียงบางส่วน

สิ่งที่ไม่ควรทำใน Java

เรายังไม่ได้พูดถึงฟีเจอร์บางอย่างของภาษา Java ที่เกี่ยวข้อง ดังนั้นเราจะมาดูฟีเจอร์เหล่านั้นกันก่อน

ในทางเทคนิคแล้ว Java ไม่จำเป็นต้องมีโค้ดที่ไม่มีการแย่งชิงข้อมูล และมีโค้ด Java จำนวนเล็กน้อยที่เขียนขึ้นอย่างระมัดระวังซึ่งทำงานได้อย่างถูกต้องในกรณีที่มีการแย่งชิงข้อมูล อย่างไรก็ตาม การเขียนโค้ดดังกล่าวเป็นเรื่องที่ยุ่งยากมาก และเราจะพูดถึงเรื่องนี้สั้นๆ ด้านล่าง ที่แย่ไปกว่านั้นคือ ผู้เชี่ยวชาญที่ระบุความหมายของรหัสดังกล่าวไม่เชื่ออีกต่อไปว่าข้อกำหนดนั้นถูกต้อง (ข้อกำหนดนี้ใช้ได้กับโค้ดที่ไม่มีการแย่งชิงข้อมูล)

ในตอนนี้ เราจะยึดตามโมเดลที่ไม่มีการแย่งชิงข้อมูล (Data Race) ซึ่ง Java ให้การรับประกันที่เหมือนกับ C และ C++ โดยพื้นฐาน ขอย้ำอีกครั้งว่าภาษานี้มีองค์ประกอบพื้นฐานบางอย่างที่ผ่อนคลายความสอดคล้องตามลำดับอย่างชัดเจน โดยเฉพาะการเรียก lazySet() และ weakCompareAndSet() ใน java.util.concurrent.atomic เช่นเดียวกับ C และ C++ เราจะละเว้นสิ่งเหล่านี้ไว้ก่อน

คีย์เวิร์ด "synchronized" และ "volatile" ของ Java

คีย์เวิร์ด "synchronized" มีกลไกการล็อกในตัวของภาษา Java ออบเจ็กต์ทุกรายการจะมี "มอนิเตอร์" ที่เชื่อมโยงอยู่ ซึ่งสามารถใช้เพื่อมอบสิทธิ์เข้าถึงแบบแยกกันโดยสิ้นเชิง หากเทรด 2 รายการพยายาม "ซิงค์" ในออบเจ็กต์เดียวกัน เทรดรายการหนึ่งจะรอจนกว่าอีกรายการจะเสร็จสมบูรณ์

ดังที่กล่าวไว้ข้างต้น volatile T ของ Java เทียบเท่ากับ atomic<T> ของ C++11 อนุญาตให้เข้าถึงช่อง volatile พร้อมกันได้ และไม่ส่งผลให้เกิดการแย่งชิงข้อมูล หากไม่พิจารณา lazySet() และอื่นๆ รวมถึงการแย่งชิงข้อมูล (Data Race) แล้ว งานของ Java VM คือการตรวจสอบว่าผลลัพธ์ยังคงสอดคล้องกันตามลำดับ

โดยเฉพาะอย่างยิ่ง หากเทรด 1 เขียนลงในช่อง volatile และเทรด 2 อ่านจากช่องเดียวกันนั้นในภายหลังและเห็นค่าที่เขียนใหม่ เทรด 2 จะรับประกันว่าจะเห็นการเขียนทั้งหมดที่เทรด 1 ทำไว้ก่อนหน้านี้ด้วย ในแง่ของผลกระทบต่อหน่วยความจำ การเขียนไปยังตัวแปร Volatile จะคล้ายกับการปล่อย Monitor และการอ่านจากตัวแปร Volatile จะคล้ายกับการรับ Monitor

มีความแตกต่างที่สำคัญอย่างหนึ่งจาก atomic ของ C++: หากเราเขียน volatile int x; ใน Java x++ จะเหมือนกับ x = x + 1 ซึ่งจะทำการโหลดแบบอะตอม เพิ่มผลลัพธ์ และทำการจัดเก็บแบบอะตอม การเพิ่มขึ้นโดยรวมจะไม่เป็นแบบอะตอมมิก ซึ่งต่างจาก C++ แต่ java.util.concurrent.atomic จะมีการดำเนินการเพิ่มขึ้นแบบอะตอมมิกแทน

ตัวอย่าง

ตัวอย่างการติดตั้งใช้งานตัวนับแบบโมโนโทนิกที่เรียบง่ายแต่ไม่ถูกต้องมีดังนี้ (Java theory and practice: Managing volatility)

class Counter {
    private int mValue;
    public int get() {
        return mValue;
    }
    public void incr() {
        mValue++;
    }
}

สมมติว่ามีการเรียกใช้ get() และ incr() จากหลายชุดข้อความ และเราต้องการตรวจสอบว่าทุกชุดข้อความเห็นจำนวนปัจจุบันเมื่อมีการเรียกใช้ get() ปัญหาที่เห็นได้ชัดที่สุดคือ mValue++ มีการดำเนินการ 3 อย่าง ได้แก่

  1. reg = mValue
  2. reg = reg + 1
  3. mValue = reg

หากมี 2 เทรดทำงานใน incr() พร้อมกัน การอัปเดตรายการใดรายการหนึ่งอาจสูญหายได้ หากต้องการทำให้การเพิ่มเป็นแบบอะตอม เราต้องประกาศ incr() "synchronized"

แต่ก็ยังใช้งานไม่ได้อยู่ดี โดยเฉพาะใน SMP แต่ก็ยังคงมีการแย่งชิงข้อมูลอยู่ เนื่องจาก get() สามารถเข้าถึง mValue พร้อมกันกับ incr() ได้ ภายใต้กฎของ Java การเรียก get() อาจปรากฏว่ามีการจัดลำดับใหม่โดยสัมพันธ์กับโค้ดอื่นๆ ตัวอย่างเช่น หากเราอ่านตัวนับ 2 ตัวติดต่อกัน ผลลัพธ์อาจดูไม่สอดคล้องกันเนื่องจากมีการจัดเรียงการเรียก get() ใหม่โดยฮาร์ดแวร์หรือคอมไพเลอร์ เราสามารถแก้ไขปัญหาได้โดยประกาศให้ get() เป็นแบบซิงโครไนซ์ การเปลี่ยนแปลงนี้ทำให้โค้ดถูกต้องอย่างชัดเจน

แต่เราได้เพิ่มความเป็นไปได้ที่จะเกิดการแย่งชิงล็อก ซึ่งอาจขัดขวางประสิทธิภาพการทำงาน แทนที่จะประกาศให้ get() ซิงค์กัน เราสามารถประกาศ mValue ด้วย "volatile" (โปรดทราบว่า incr() ยังคงต้องใช้ synchronize เนื่องจาก mValue++ ไม่ใช่การดำเนินการแบบอะตอมเดียว) นอกจากนี้ยังหลีกเลี่ยงการแย่งชิงข้อมูลทั้งหมดด้วย จึงรักษาความสอดคล้องตามลำดับไว้ได้ incr() จะช้าลงบ้าง เนื่องจากมีทั้งโอเวอร์เฮดการเข้า/ออกของจอภาพ และโอเวอร์เฮดที่เชื่อมโยงกับพื้นที่เก็บข้อมูลแบบผันผวน แต่ get() จะเร็วกว่า ดังนั้นแม้ว่าจะไม่มีการแย่งชิงทรัพยากร แต่ก็ถือว่าได้เปรียบหากการอ่านมีจำนวนมากกว่าการเขียนอย่างมาก (ดูวิธีนำบล็อกที่ซิงค์ออกทั้งหมดได้ที่ AtomicInteger)

ลองมาดูอีกตัวอย่างหนึ่งซึ่งมีรูปแบบคล้ายกับตัวอย่าง C ก่อนหน้านี้กัน

class MyGoodies {
    public int x, y;
}
class MyClass {
    static MyGoodies sGoodies;
    void initGoodies() {    // runs in thread 1
        MyGoodies goods = new MyGoodies();
        goods.x = 5;
        goods.y = 10;
        sGoodies = goods;
    }
    void useGoodies() {    // runs in thread 2
        if (sGoodies != null) {
            int i = sGoodies.x;    // could be 5 or 0
            ....
        }
    }
}

ซึ่งมีปัญหาเดียวกันกับโค้ด C นั่นคือมีการแย่งชิงข้อมูลใน sGoodies ดังนั้นจึงอาจสังเกตเห็นการกำหนดค่า sGoodies = goods ก่อนการเริ่มต้นฟิลด์ใน goods หากคุณประกาศ sGoodies ด้วยคีย์เวิร์ด volatile ความสอดคล้องตามลำดับจะได้รับการกู้คืน และสิ่งต่างๆ จะทำงานตามที่คาดไว้

โปรดทราบว่ามีเพียงการอ้างอิง sGoodies เท่านั้นที่มีความผันผวน แต่การเข้าถึงฟิลด์ภายในนั้นจะไม่ได้รับการตรวจสอบ เมื่อ sGoodies เป็น volatile และมีการรักษาลำดับหน่วยความจำอย่างเหมาะสมแล้ว จะไม่สามารถเข้าถึงฟิลด์พร้อมกันได้ คำสั่ง z = sGoodies.x จะทำการโหลดแบบผันผวนของ MyClass.sGoodies ตามด้วยการโหลดแบบไม่ผันผวนของ sGoodies.x หากคุณสร้างการอ้างอิงในเครื่อง MyGoodies localGoods = sGoodies z = localGoods.x ที่ตามมาจะไม่ทำการโหลดแบบผันผวน

สำนวนที่พบบ่อยกว่าในการเขียนโปรแกรม Java คือ "การล็อกที่ตรวจสอบซ้ำ" ที่ไม่เป็นที่นิยม

class MyClass {
    private Helper helper = null;
    public Helper getHelper() {
        if (helper == null) {
            synchronized (this) {
                if (helper == null) {
                    helper = new Helper();
                }
            }
        }
        return helper;
    }
}

แนวคิดคือเราต้องการมีอินสแตนซ์เดียวของออบเจ็กต์ Helper ที่เชื่อมโยงกับอินสแตนซ์ของ MyClass เราต้องสร้างเพียงครั้งเดียว ดังนั้นเราจึงสร้างและแสดงผลผ่านฟังก์ชัน getHelper() โดยเฉพาะ เพื่อหลีกเลี่ยงการแข่งขันที่เทรด 2 รายการสร้างอินสแตนซ์ เราจำเป็นต้องซิงค์การสร้างออบเจ็กต์ อย่างไรก็ตาม เราไม่ต้องการจ่ายค่าใช้จ่ายเพิ่มเติมสำหรับบล็อก "synchronized" ในทุกการเรียกใช้ ดังนั้นเราจะทำส่วนนั้นก็ต่อเมื่อ helper เป็นค่าว่างในขณะนั้น

ซึ่งมีภาวะการแย่งชิงทรัพยากรข้อมูลในช่อง helper โดยสามารถตั้งค่าพร้อมกันกับ helper == null ในชุดข้อความอื่นได้

หากต้องการดูว่าการดำเนินการนี้อาจล้มเหลวได้อย่างไร ให้พิจารณาโค้ดเดียวกันที่เขียนใหม่เล็กน้อยราวกับว่าคอมไพล์เป็นภาษาที่คล้ายกับ C (ฉันได้เพิ่มช่องจำนวนเต็ม 2 ช่องเพื่อแสดงกิจกรรมของคอนสตรัคเตอร์ Helper’s)

if (helper == null) {
    synchronized() {
        if (helper == null) {
            newHelper = malloc(sizeof(Helper));
            newHelper->x = 5;
            newHelper->y = 10;
            helper = newHelper;
        }
    }
    return helper;
}

ไม่มีสิ่งใดที่จะป้องกันไม่ให้ฮาร์ดแวร์หรือคอมไพเลอร์จัดลำดับการจัดเก็บไปยัง helper ใหม่ด้วยการจัดเก็บไปยังช่อง x/y เทรดอื่นอาจพบ helper ที่ไม่ใช่ค่าว่าง แต่ยังไม่ได้ตั้งค่าฟิลด์และพร้อมใช้งาน ดูรายละเอียดเพิ่มเติมและโหมดความล้มเหลวอื่นๆ ได้ที่ลิงก์ "'Double Checked Locking is Broken' Declaration" ในภาคผนวก หรือรายการที่ 71 ("Use lazy initialization judiciously") ในหนังสือ Effective Java, 2nd Edition ของ Josh Bloch

คุณแก้ไขปัญหานี้ได้ 2 วิธีดังนี้

  1. ทำสิ่งง่ายๆ และลบการตรวจสอบภายนอก วิธีนี้ช่วยให้มั่นใจได้ว่าเราจะไม่ตรวจสอบค่าของ helper นอกบล็อกที่ซิงค์
  2. ประกาศ helper เป็น Volatile การเปลี่ยนแปลงเล็กน้อยนี้จะทำให้โค้ดในตัวอย่าง J-3 ทำงานได้อย่างถูกต้องใน Java 1.5 ขึ้นไป (คุณอาจต้องใช้เวลาสักครู่เพื่อโน้มน้าวตัวเองว่านี่เป็นเรื่องจริง)

ต่อไปนี้เป็นภาพประกอบอีกภาพหนึ่งของลักษณะการทำงานของ volatile

class MyClass {
    int data1, data2;
    volatile int vol1, vol2;
    void setValues() {    // runs in Thread 1
        data1 = 1;
        vol1 = 2;
        data2 = 3;
    }
    void useValues() {    // runs in Thread 2
        if (vol1 == 2) {
            int l1 = data1;    // okay
            int l2 = data2;    // wrong
        }
    }
}

เมื่อดูที่ useValues() หากเทรด 2 ยังไม่เห็นการอัปเดตเป็น vol1 ก็จะไม่ทราบว่ามีการตั้งค่า data1 หรือ data2 แล้วหรือยัง เมื่อเห็นการอัปเดตเป็น vol1 ระบบจะทราบว่าสามารถเข้าถึง data1 ได้อย่างปลอดภัยและอ่านได้อย่างถูกต้องโดยไม่ทำให้เกิดสภาวะการแย่งชิงทรัพยากร อย่างไรก็ตาม ระบบไม่สามารถคาดเดาเกี่ยวกับ data2 ได้ เนื่องจากมีการจัดเก็บหลังจากจัดเก็บแบบผันผวน

โปรดทราบว่าไม่สามารถใช้ volatile เพื่อป้องกันการจัดลำดับใหม่ของการเข้าถึงหน่วยความจำอื่นๆ ที่แข่งขันกันเอง แต่ไม่รับประกันว่าจะสร้างคำสั่ง Machine Memory Fence โดยสามารถใช้เพื่อป้องกันการแย่งชิงข้อมูลด้วยการเรียกใช้โค้ดเมื่อเทรดอื่นเป็นไปตามเงื่อนไขที่กำหนดเท่านั้น

สิ่งที่ต้องทำ

ใน C/C++ ให้เลือกใช้คลาสการซิงค์ C++11 เช่น std::mutex หากไม่ ให้ใช้การดำเนินการ pthread ที่เกี่ยวข้อง ซึ่งรวมถึง Memory Fence ที่เหมาะสม ซึ่งจะให้ลักษณะการทำงานที่ถูกต้อง (สอดคล้องกันตามลำดับ เว้นแต่จะระบุไว้เป็นอย่างอื่น) และมีประสิทธิภาพในแพลตฟอร์ม Android ทุกเวอร์ชัน อย่าลืมใช้โซลูชันเหล่านั้นอย่างถูกต้อง ตัวอย่างเช่น โปรดทราบว่าการรอตัวแปรเงื่อนไขอาจแสดงผลโดยไม่มีสัญญาณ และควรปรากฏในลูป

คุณควรหลีกเลี่ยงการใช้ฟังก์ชันอะตอมโดยตรง เว้นแต่โครงสร้างข้อมูลที่คุณใช้จะเรียบง่ายมาก เช่น ตัวนับ การล็อกและปลดล็อก pthread mutex ต้องใช้การดำเนินการแบบอะตอมมิกอย่างละ 1 ครั้ง และมักมีค่าใช้จ่ายน้อยกว่าการไม่พบแคช 1 ครั้งหากไม่มีการแย่งชิงทรัพยากร ดังนั้นคุณจึงประหยัดได้ไม่มากนักด้วยการแทนที่การเรียก mutex ด้วยการดำเนินการแบบอะตอมมิก การออกแบบที่ไม่มีการล็อกสำหรับโครงสร้างข้อมูลที่ไม่ใช่เรื่องง่ายต้องใช้ความระมัดระวังมากขึ้นเพื่อให้แน่ใจว่าการดำเนินการระดับสูงในโครงสร้างข้อมูลจะปรากฏเป็นแบบอะตอม (โดยรวม ไม่ใช่แค่ส่วนที่เป็นอะตอมอย่างชัดเจน)

หากคุณใช้การดำเนินการแบบอะตอม การผ่อนคลายการจัดลำดับด้วย memory_order... หรือ lazySet() อาจให้ข้อได้เปรียบด้านประสิทธิภาพ แต่ต้องมีความเข้าใจที่ลึกซึ้งกว่าที่เราได้อธิบายไว้จนถึงตอนนี้ โค้ดที่มีอยู่จำนวนมากที่ใช้โค้ดเหล่านี้มักพบว่ามีข้อบกพร่องในภายหลัง หลีกเลี่ยงการใช้สิ่งเหล่านี้หากเป็นไปได้ หากกรณีการใช้งานของคุณไม่ตรงกับกรณีใดกรณีหนึ่งในส่วนถัดไป โปรดตรวจสอบว่าคุณเป็นผู้เชี่ยวชาญหรือได้ปรึกษาผู้เชี่ยวชาญแล้ว

หลีกเลี่ยงการใช้ volatile สำหรับการสื่อสารชุดข้อความใน C/C++

ใน Java ปัญหาการทำงานพร้อมกันมักจะแก้ไขได้ดีที่สุดโดยใช้คลาสยูทิลิตีที่เหมาะสมจากแพ็กเกจ java.util.concurrent โค้ดเขียนขึ้นมาอย่างดีและผ่านการทดสอบอย่างละเอียดบน SMP

สิ่งที่ปลอดภัยที่สุดที่คุณทำได้คือการทำให้วัตถุเปลี่ยนแปลงไม่ได้ ออบเจ็กต์จากคลาสต่างๆ เช่น String และ Integer ของ Java จะเก็บข้อมูลที่ไม่สามารถเปลี่ยนแปลงได้เมื่อสร้างออบเจ็กต์แล้ว เพื่อหลีกเลี่ยงโอกาสที่จะเกิดสภาวะการแย่งชิงทรัพยากรในออบเจ็กต์เหล่านั้น หนังสือ Effective Java, 2nd Ed. มีวิธีการเฉพาะใน "Item 15: Minimize Mutability" โดยเฉพาะอย่างยิ่งความสำคัญของการประกาศฟิลด์ Java เป็น "final" (Bloch)

แม้ว่าออบเจ็กต์จะเปลี่ยนแปลงไม่ได้ แต่โปรดทราบว่าการสื่อสารออบเจ็กต์ไปยังเทรดอื่นโดยไม่มีการซิงค์ข้อมูลใดๆ ถือเป็นการแย่งชิงข้อมูล บางครั้งวิธีนี้อาจยอมรับได้ใน Java (ดูด้านล่าง) แต่ต้องใช้ความระมัดระวังอย่างมาก และอาจส่งผลให้โค้ดเปราะบาง หากประสิทธิภาพไม่ได้มีความสำคัญอย่างยิ่ง ให้เพิ่มการประกาศ volatile ใน C++ การสื่อสารพอยน์เตอร์หรือการอ้างอิงไปยังออบเจ็กต์ที่เปลี่ยนแปลงไม่ได้โดยไม่มีการซิงค์ที่เหมาะสม เช่นเดียวกับการแย่งชิงข้อมูล ถือเป็นข้อบกพร่อง ในกรณีนี้ มีแนวโน้มที่จะส่งผลให้เกิดข้อขัดข้องเป็นระยะๆ เนื่องจากตัวอย่างเช่น เทรดที่รับอาจเห็นพอยน์เตอร์ตารางเมธอดที่ไม่ได้เริ่มต้นเนื่องจากการจัดลำดับร้านค้าใหม่

หากทั้งคลาสไลบรารีที่มีอยู่และคลาสที่เปลี่ยนแปลงไม่ได้ไม่เหมาะสม ควรใช้คำสั่ง synchronized ของ Java หรือ lock_guard / unique_lock ของ C++ เพื่อป้องกันการเข้าถึงฟิลด์ที่เข้าถึงได้มากกว่า 1 เทรด หาก Mutex ไม่เหมาะกับสถานการณ์ของคุณ คุณควรประกาศฟิลด์ที่แชร์ volatile หรือ atomic แต่คุณต้องระมัดระวังอย่างยิ่งในการทำความเข้าใจการโต้ตอบระหว่างเทรด การประกาศเหล่านี้จะไม่ช่วยให้คุณรอดพ้นจากข้อผิดพลาดในการเขียนโปรแกรมที่เกิดขึ้นพร้อมกันที่พบได้ทั่วไป แต่จะช่วยให้คุณหลีกเลี่ยงความล้มเหลวที่ลึกลับซึ่งเกี่ยวข้องกับการเพิ่มประสิทธิภาพคอมไพเลอร์และอุบัติเหตุ SMP

คุณควรหลีกเลี่ยงการ "เผยแพร่" การอ้างอิงไปยังออบเจ็กต์ เช่น การทำให้พร้อมใช้งานสำหรับชุดข้อความอื่นๆ ในคอนสตรักเตอร์ ซึ่งมีความสำคัญน้อยกว่าใน C++ หรือหากคุณทำตามคำแนะนำ "ไม่มีการแย่งใช้ข้อมูล" ใน Java แต่ก็เป็นคำแนะนำที่ดีเสมอ และจะมีความสำคัญอย่างยิ่งหากโค้ด Java ของคุณทำงานในบริบทอื่นๆ ที่โมเดลความปลอดภัยของ Java มีความสำคัญ และโค้ดที่ไม่น่าเชื่อถืออาจทำให้เกิดการแย่งชิงทรัพยากรข้อมูลโดยการเข้าถึงการอ้างอิงออบเจ็กต์ที่ "รั่วไหล" นั้น นอกจากนี้ยังเป็นสิ่งสำคัญหากคุณเลือกที่จะเพิกเฉยต่อคำเตือนของเราและใช้เทคนิคบางอย่างในส่วนถัดไป ดูรายละเอียดได้ที่ (เทคนิคการสร้างที่ปลอดภัยใน Java)

ข้อมูลเพิ่มเติมเกี่ยวกับลำดับหน่วยความจำแบบอ่อน

C++11 และเวอร์ชันที่ใหม่กว่ามีกลไกที่ชัดเจนสำหรับการผ่อนคลายการรับประกันความสอดคล้องตามลำดับสำหรับโปรแกรมที่ไม่มีการแย่งชิงข้อมูล อาร์กิวเมนต์ memory_order_relaxed, memory_order_acquire (โหลดเท่านั้น) และ memory_order_release (จัดเก็บเท่านั้น) ที่ชัดเจนสำหรับการดำเนินการแบบอะตอมแต่ละรายการจะให้การรับประกันที่เข้มงวดน้อยกว่า memory_order_seq_cst ซึ่งเป็นค่าเริ่มต้นและมักจะเป็นแบบโดยนัย memory_order_acq_rel ให้การรับประกันทั้ง memory_order_acquire และ memory_order_release สำหรับการดำเนินการอ่าน-แก้ไข-เขียนแบบอะตอมมิก memory_order_consume ยังไม่ได้ระบุหรือนำไปใช้อย่างเพียงพอที่จะเป็นประโยชน์ และควรละเว้นไปก่อนในตอนนี้

เมธอด lazySet ใน Java.util.concurrent.atomic คล้ายกับที่เก็บ memory_order_release ของ C++ บางครั้งตัวแปรธรรมดาของ Java จะใช้แทนการเข้าถึง memory_order_relaxed แม้ว่าจริงๆ แล้วจะอ่อนแอกว่าก็ตาม ซึ่งต่างจาก C++ ตรงที่ไม่มีกลไกที่แท้จริงสำหรับการเข้าถึงตัวแปรที่ประกาศเป็น volatile แบบไม่เรียงลำดับ

โดยทั่วไป คุณควรหลีกเลี่ยงการใช้ตัวเลือกเหล่านี้ เว้นแต่จะมีเหตุผลด้านประสิทธิภาพที่จำเป็นต้องใช้ ในสถาปัตยกรรมเครื่องที่มีการจัดลำดับแบบอ่อน เช่น ARM การใช้สถาปัตยกรรมเหล่านี้มักจะช่วยประหยัดรอบเครื่องได้ประมาณ 2-3 โหลสำหรับการดำเนินการแบบอะตอมแต่ละครั้ง ใน x86 ประสิทธิภาพที่เพิ่มขึ้นจะจำกัดอยู่ที่การจัดเก็บ และอาจสังเกตเห็นได้น้อยกว่า ประโยชน์อาจลดลงเมื่อมีจำนวนคอร์มากขึ้น ซึ่งอาจขัดกับความรู้สึก เนื่องจากระบบหน่วยความจำจะกลายเป็นปัจจัยที่จำกัดมากขึ้น

ความหมายทั้งหมดของอะตอมิกที่เรียงลำดับแบบอ่อนนั้นซับซ้อน โดยทั่วไปแล้ว การใช้คำสั่งเหล่านี้ต้องอาศัยความเข้าใจที่ถูกต้องเกี่ยวกับกฎของภาษา ซึ่งเราจะไม่พูดถึงในที่นี้ เช่น

  • คอมไพเลอร์หรือฮาร์ดแวร์สามารถย้ายการเข้าถึง memory_order_relaxed ไปยัง (แต่ไม่ใช่จาก) ส่วนสำคัญที่ถูกจำกัดโดยการรับและการปล่อยล็อก ซึ่งหมายความว่าร้านค้า memory_order_relaxed 2 แห่งอาจปรากฏไม่เรียงตามลำดับ แม้ว่าจะคั่นด้วยส่วนสำคัญก็ตาม
  • ตัวแปร Java ทั่วไปเมื่อใช้เป็นตัวนับที่ใช้ร่วมกันในทางที่ผิดอาจปรากฏในเทรดอื่นเพื่อลดค่า แม้ว่าจะเพิ่มขึ้นเพียงเทรดเดียวก็ตาม แต่ไม่เป็นเช่นนั้นสำหรับ C++ atomic memory_order_relaxed

เมื่อมีคำเตือนดังกล่าวแล้ว เราจะยกตัวอย่างสำนวนจำนวนเล็กน้อยที่ดูเหมือนจะครอบคลุม Use Case จำนวนมากสำหรับอะตอมิกที่มีการจัดลำดับแบบอ่อน ซึ่งหลายรายการใช้ได้กับ C++ เท่านั้น

การเข้าถึงที่ไม่ใช่การแข่งรถ

เป็นเรื่องปกติที่ตัวแปรจะเป็นแบบอะตอมเนื่องจากบางครั้งมีการอ่านพร้อมกันกับการเขียน แต่การเข้าถึงบางรายการก็ไม่มีปัญหานี้ ตัวอย่างเช่น ตัวแปรอาจต้องเป็นแบบอะตอมเนื่องจากมีการอ่านนอกส่วนสำคัญ แต่การอัปเดตทั้งหมดได้รับการปกป้องด้วยการล็อก ในกรณีนี้ การอ่านที่ได้รับการป้องกันโดยล็อกเดียวกันจะไม่สามารถแข่งขันกันได้ เนื่องจากไม่สามารถเขียนพร้อมกันได้ ในกรณีนี้ การเข้าถึงที่ไม่ใช่การแข่งขัน (การโหลดในกรณีนี้) สามารถใส่คำอธิบายประกอบด้วย memory_order_relaxed โดยไม่เปลี่ยนแปลงความถูกต้องของโค้ด C++ การติดตั้งใช้งานล็อกบังคับใช้ลำดับหน่วยความจำที่จำเป็นอยู่แล้วเกี่ยวกับการเข้าถึงโดยชุดข้อความอื่นๆ และ memory_order_relaxed ระบุว่าโดยพื้นฐานแล้วไม่จำเป็นต้องบังคับใช้ข้อจำกัดการจัดลำดับเพิ่มเติมสำหรับการเข้าถึงแบบอะตอมมิก

ไม่มีแอนะล็อกที่แท้จริงสำหรับสิ่งนี้ใน Java

ผลลัพธ์ไม่น่าเชื่อถือในแง่ของความถูกต้อง

เมื่อเราใช้การโหลดแบบ Racing เพื่อสร้างคำแนะนำเท่านั้น โดยทั่วไปแล้วก็ไม่จำเป็นต้องบังคับใช้ลำดับหน่วยความจำสำหรับการโหลดด้วย หากค่าไม่น่าเชื่อถือ เราก็ไม่สามารถใช้ผลลัพธ์เพื่ออนุมานเกี่ยวกับตัวแปรอื่นๆ ได้อย่างน่าเชื่อถือเช่นกัน ดังนั้นจึงไม่เป็นไรหากไม่มีการรับประกันการจัดลำดับหน่วยความจำ และมีการจัดหาอาร์กิวเมนต์ memory_order_relaxed ให้กับโหลด

ตัวอย่างที่พบบ่อยคือการใช้ compare_exchange ของ C++ เพื่อแทนที่ x ด้วย f(x) แบบอะตอม การโหลด x ครั้งแรกเพื่อคำนวณ f(x) ไม่จำเป็นต้องเชื่อถือได้ หากเราดำเนินการผิดพลาด compare_exchange จะไม่สำเร็จและเราจะลองอีกครั้ง การโหลด x ครั้งแรกสามารถใช้อาร์กิวเมนต์ memory_order_relaxed ได้ โดยลำดับหน่วยความจำสำหรับ compare_exchange จริงเท่านั้นที่มีความสำคัญ

ข้อมูลที่แก้ไขแบบอะตอมแต่ยังไม่ได้อ่าน

บางครั้งข้อมูลจะได้รับการแก้ไขพร้อมกันโดยหลายชุดข้อความ แต่จะไม่ได้รับการตรวจสอบจนกว่าการคำนวณแบบขนานจะเสร็จสมบูรณ์ ตัวอย่างที่ดีคือตัวนับที่เพิ่มขึ้นแบบอะตอม (เช่น การใช้ fetch_add() ใน C++ หรือ atomic_fetch_add_explicit() ใน C) โดยหลายเทรดพร้อมกัน แต่ผลลัพธ์ของการเรียกเหล่านี้จะถูกละเว้นเสมอ ระบบจะอ่านค่าผลลัพธ์เมื่อสิ้นสุดเท่านั้น หลังจากที่การอัปเดตทั้งหมดเสร็จสมบูรณ์

ในกรณีนี้ ไม่มีทางบอกได้ว่ามีการจัดลำดับการเข้าถึงข้อมูลนี้ใหม่หรือไม่ ดังนั้นโค้ด C++ จึงอาจใช้อาร์กิวเมนต์ memory_order_relaxed

ตัวอย่างที่พบบ่อยคือตัวนับเหตุการณ์แบบง่าย เนื่องจากเป็นกรณีที่พบได้บ่อย จึงควรสังเกตเกี่ยวกับกรณีนี้ดังนี้

  • การใช้ memory_order_relaxed ช่วยปรับปรุงประสิทธิภาพ แต่ก็อาจไม่สามารถแก้ปัญหาด้านประสิทธิภาพที่สำคัญที่สุดได้ นั่นคือการอัปเดตทุกครั้งต้องมีการเข้าถึงแบบพิเศษไปยังบรรทัดแคชที่เก็บตัวนับไว้ ซึ่งจะส่งผลให้ไม่พบแคชทุกครั้งที่ชุดข้อความใหม่เข้าถึงตัวนับ หากมีการอัปเดตบ่อยครั้งและสลับไปมาระหว่างชุดข้อความ การหลีกเลี่ยงการอัปเดตตัวนับที่แชร์ทุกครั้งจะทำได้เร็วกว่ามาก เช่น โดยใช้ตัวนับในชุดข้อความและรวมตัวนับเหล่านั้นในตอนท้าย
  • เทคนิคนี้ใช้ร่วมกับส่วนก่อนหน้าได้ โดยสามารถอ่านค่าโดยประมาณและค่าที่ไม่น่าเชื่อถือพร้อมกันขณะที่กำลังอัปเดต โดยการดำเนินการทั้งหมดจะใช้ memory_order_relaxed แต่สิ่งสำคัญคือต้องถือว่าค่าที่ได้นั้นไม่น่าเชื่อถือโดยสิ้นเชิง การที่จำนวนเพิ่มขึ้น 1 ครั้งไม่ได้หมายความว่าเทรดอื่นจะนับได้ว่าถึงจุดที่เพิ่มขึ้นแล้ว แต่โค้ดก่อนหน้าอาจจัดลำดับการเพิ่มใหม่แทน (สำหรับกรณีที่คล้ายกันที่เรากล่าวถึงก่อนหน้านี้ C++ รับประกันว่าการโหลดตัวนับดังกล่าวครั้งที่ 2 จะไม่แสดงผลค่าที่น้อยกว่าการโหลดก่อนหน้าในชุดข้อความเดียวกัน เว้นแต่ว่าตัวนับจะโอเวอร์โฟลว์)
  • เป็นเรื่องปกติที่จะพบโค้ดที่พยายามคำนวณค่าตัวนับโดยประมาณโดยการอ่านและเขียนแบบอะตอม (หรือไม่ใช่) แต่ไม่ได้ทำให้การเพิ่มค่าเป็นอะตอมทั้งหมด ข้อโต้แย้งที่พบบ่อยคือวิธีนี้ "ใกล้เคียงพอ" สำหรับตัวนับประสิทธิภาพหรือสิ่งที่คล้ายกัน โดยปกติแล้วไม่ เมื่อมีการอัปเดตบ่อยครั้ง (กรณีที่คุณอาจสนใจ) จำนวนที่นับได้ส่วนใหญ่มักจะหายไป ในอุปกรณ์แบบ Quad Core จำนวนการนับมากกว่าครึ่งหนึ่งอาจสูญหายไปตามปกติ (แบบฝึกหัดง่ายๆ: สร้างสถานการณ์ 2 เทรดที่มีการอัปเดตตัวนับ 1 ล้านครั้ง แต่มูลค่าตัวนับสุดท้ายคือ 1)

การสื่อสารด้วยธงอย่างง่าย

การจัดเก็บ memory_order_release (หรือการดำเนินการอ่าน-แก้ไข-เขียน) ช่วยให้มั่นใจว่าหากการโหลด memory_order_acquire (หรือการดำเนินการอ่าน-แก้ไข-เขียน) อ่านค่าที่เขียนในภายหลัง ก็จะสังเกตเห็นการจัดเก็บ (แบบธรรมดาหรือแบบอะตอม) ที่เกิดขึ้นก่อนการจัดเก็บ memory_order_release ด้วย ในทางกลับกัน การโหลดใดๆ ที่เกิดขึ้นก่อน memory_order_release จะไม่สังเกตเห็นการจัดเก็บใดๆ ที่เกิดขึ้นหลังจากการโหลด memory_order_acquire ซึ่งต่างจาก memory_order_relaxed ตรงที่วิธีนี้ช่วยให้สามารถใช้การดำเนินการแบบอะตอมดังกล่าวเพื่อสื่อสารความคืบหน้าของชุดข้อความหนึ่งไปยังอีกชุดข้อความหนึ่งได้

ตัวอย่างเช่น เราสามารถเขียนตัวอย่างการล็อกที่ตรวจสอบซ้ำจากด้านบนใหม่ใน C++ เป็น

class MyClass {
  private:
    atomic<Helper*> helper {nullptr};
    mutex mtx;
  public:
    Helper* getHelper() {
      Helper* myHelper = helper.load(memory_order_acquire);
      if (myHelper == nullptr) {
        lock_guard<mutex> lg(mtx);
        myHelper = helper.load(memory_order_relaxed);
        if (myHelper == nullptr) {
          myHelper = new Helper();
          helper.store(myHelper, memory_order_release);
        }
      }
      return myHelper;
    }
};

การโหลดแบบ Acquire และการจัดเก็บแบบ Release ช่วยให้มั่นใจได้ว่าหากเราเห็น helper ที่ไม่ใช่ค่า Null เราจะเห็นฟิลด์ของ ที่เริ่มต้นอย่างถูกต้องด้วย นอกจากนี้ เรายังได้รวมข้อสังเกตก่อนหน้านี้ที่ว่าภาระงานที่ไม่ใช่การแข่งขันสามารถใช้ memory_order_relaxed ได้

นักเขียนโปรแกรม Java อาจแสดง helper เป็น java.util.concurrent.atomic.AtomicReference<Helper> และใช้ lazySet() เป็น Release Store การดำเนินการโหลดจะยังคงใช้การเรียก get() แบบธรรมดาต่อไป

ในทั้ง 2 กรณี การปรับแต่งประสิทธิภาพของเรามุ่งเน้นไปที่เส้นทางการเริ่มต้น ซึ่งไม่น่าจะมีความสำคัญต่อประสิทธิภาพ การประนีประนอมที่อ่านง่ายขึ้นอาจเป็นดังนี้

    Helper* getHelper() {
      Helper* myHelper = helper.load(memory_order_acquire);
      if (myHelper != nullptr) {
        return myHelper;
      }
      lock_guard&ltmutex> lg(mtx);
      if (helper == nullptr) {
        helper = new Helper();
      }
      return helper;
    }

ซึ่งจะให้เส้นทางที่รวดเร็วเหมือนกัน แต่จะใช้การดำเนินการตามลำดับที่สอดคล้องกันตามค่าเริ่มต้นในเส้นทางที่ช้าซึ่งไม่สำคัญต่อประสิทธิภาพ

แม้ในกรณีนี้ helper.load(memory_order_acquire) ก็มีแนวโน้มที่จะสร้างโค้ดเดียวกันในสถาปัตยกรรมที่ Android รองรับในปัจจุบันเป็นการอ้างอิงแบบธรรมดา (สอดคล้องกันตามลำดับ) ไปยัง helper การเพิ่มประสิทธิภาพที่เป็นประโยชน์ที่สุดในที่นี้อาจเป็นการใช้ myHelper เพื่อกำจัดการโหลดครั้งที่ 2 แม้ว่าคอมไพเลอร์ในอนาคตอาจทำเช่นนั้นโดยอัตโนมัติ

การจัดลำดับการรับ/ปล่อยไม่ได้ป้องกันไม่ให้ร้านค้าเกิดความล่าช้าอย่างเห็นได้ชัด และไม่ได้รับประกันว่าร้านค้าจะปรากฏต่อชุดข้อความอื่นๆ ในลำดับที่สอดคล้องกัน ด้วยเหตุนี้ จึงไม่รองรับรูปแบบการเขียนโค้ดที่ซับซ้อนแต่พบได้บ่อย ซึ่งแสดงให้เห็นโดยอัลกอริทึมการยกเว้นร่วมกันของ Dekker กล่าวคือ เทรดทั้งหมดจะตั้งค่าแฟล็กก่อนเพื่อระบุว่าต้องการทำบางอย่าง หากเทรด t สังเกตเห็นว่าไม่มีเทรดอื่นพยายามทำอะไร ก็จะดำเนินการต่อได้อย่างปลอดภัยโดยรู้ว่าจะไม่มีการรบกวน เทรดอื่นๆ จะดำเนินการต่อไม่ได้เนื่องจากยังมีการตั้งค่าแฟล็กของ t อยู่ ซึ่งจะล้มเหลวหากมีการเข้าถึงแฟล็กโดยใช้การจัดลำดับการรับ/ปล่อย เนื่องจากไม่ได้ป้องกันไม่ให้แฟล็กของชุดข้อความปรากฏแก่ผู้อื่นในภายหลัง หลังจากที่ดำเนินการอย่างไม่ถูกต้อง memory_order_seq_cstเริ่มต้นจะป้องกันไม่ให้เกิดเหตุการณ์ดังกล่าว

ฟิลด์ที่เปลี่ยนแปลงไม่ได้

หากช่องออบเจ็กต์ได้รับการเริ่มต้นเมื่อใช้งานครั้งแรกและไม่มีการเปลี่ยนแปลงอีก ก็อาจเป็นไปได้ที่จะเริ่มต้นและอ่านช่องดังกล่าวในภายหลังโดยใช้การเข้าถึงที่เรียงลำดับแบบไม่เข้มงวด ใน C++ อาจประกาศเป็น atomic และเข้าถึงโดยใช้ memory_order_relaxed หรือใน Java อาจประกาศโดยไม่มี volatile และเข้าถึงโดยไม่มีมาตรการพิเศษ ซึ่งต้องเป็นไปตามเงื่อนไขต่อไปนี้ทั้งหมด

  • คุณควรจะบอกได้จากค่าของฟิลด์เองว่ามีการเริ่มต้นค่าแล้วหรือไม่ หากต้องการเข้าถึงฟิลด์ ค่าทดสอบและค่าที่แสดงผลของเส้นทางด่วนควรอ่านฟิลด์เพียงครั้งเดียว ใน Java การดำเนินการหลังเป็นสิ่งสำคัญ แม้ว่าการทดสอบฟิลด์จะเริ่มต้นแล้ว แต่การโหลดครั้งที่ 2 อาจอ่านค่าที่ยังไม่ได้เริ่มต้นก่อนหน้านี้ ใน C++ กฎ "อ่านครั้งเดียว" เป็นเพียงแนวทางปฏิบัติที่ดี
  • ทั้งการเริ่มต้นและการโหลดครั้งต่อๆ ไปต้องเป็นแบบอะตอมมิก โดยไม่ควรแสดงการอัปเดตบางส่วน สำหรับ Java ช่องไม่ควรเป็น long หรือ double สำหรับ C++ จำเป็นต้องมีการกำหนดค่าแบบอะตอมมิก การสร้างในตำแหน่งจะไม่ทำงาน เนื่องจากการสร้าง atomic ไม่ใช่อะตอมมิก
  • การเริ่มต้นซ้ำต้องปลอดภัย เนื่องจากหลายชุดข้อความอาจอ่านค่าที่ไม่ได้เริ่มต้นพร้อมกัน ใน C++ โดยทั่วไปจะเป็นไปตามข้อกำหนด "คัดลอกได้ง่าย" ที่กำหนดไว้สำหรับประเภทอะตอมทั้งหมด ประเภทที่มีพอยน์เตอร์ที่ซ้อนกันซึ่งเป็นเจ้าของจะต้องมีการจัดสรรคืนในคอนสตรักเตอร์การคัดลอก และจะไม่สามารถคัดลอกได้ง่าย สำหรับ Java จะยอมรับประเภทการอ้างอิงบางประเภท ดังนี้
  • การอ้างอิง Java จะจำกัดเฉพาะประเภทที่เปลี่ยนแปลงไม่ได้ซึ่งมีเฉพาะช่องสุดท้ายเท่านั้น ตัวสร้างของประเภทที่เปลี่ยนแปลงไม่ได้ไม่ควรเผยแพร่การอ้างอิงไปยังออบเจ็กต์ ในกรณีนี้ กฎฟิลด์ final ของ Java จะช่วยให้มั่นใจว่าหากผู้อ่านเห็นการอ้างอิง ก็จะเห็นฟิลด์ final ที่เริ่มต้นด้วย C++ ไม่มีกฎที่คล้ายกัน และพอยน์เตอร์ไปยังออบเจ็กต์ที่เป็นเจ้าของก็ไม่สามารถยอมรับได้ด้วยเหตุผลนี้เช่นกัน (นอกเหนือจากการละเมิดข้อกำหนด "คัดลอกได้ง่าย")

โน้ตการปิด

แม้ว่าเอกสารนี้จะให้ข้อมูลมากกว่าแค่ข้อมูลเบื้องต้น แต่ก็ไม่ได้ลงลึกมากนัก นี่เป็นหัวข้อที่กว้างและลึกซึ้งมาก ประเด็นที่ควรศึกษาเพิ่มเติมมีดังนี้

  • โมเดลหน่วยความจำ Java และ C++ จริงจะแสดงในรูปแบบของความสัมพันธ์เกิดขึ้นก่อน ซึ่งระบุว่าเมื่อใดที่รับประกันว่าการดำเนินการ 2 รายการจะเกิดขึ้นตามลำดับที่แน่นอน เมื่อเรากำหนดการแย่งใช้ข้อมูล เราได้พูดถึงการเข้าถึงหน่วยความจำ 2 ครั้งที่เกิดขึ้น "พร้อมกัน" อย่างไม่เป็นทางการ ซึ่งในทางเทคนิคแล้วหมายความว่าไม่มีเหตุการณ์ใดเกิดขึ้นก่อนอีกเหตุการณ์หนึ่ง การเรียนรู้คำจำกัดความที่แท้จริงของ happens-before และ synchronizes-with ในโมเดลหน่วยความจำ Java หรือ C++ จะเป็นประโยชน์ แม้ว่าแนวคิดที่เข้าใจง่ายเกี่ยวกับ "พร้อมกัน" โดยทั่วไปจะดีพอ แต่คำจำกัดความเหล่านี้ก็มีประโยชน์ โดยเฉพาะอย่างยิ่งหากคุณกำลังพิจารณาใช้การดำเนินการแบบอะตอมที่มีลำดับไม่แน่นอนใน C++ (ข้อกำหนด Java ปัจจุบันกำหนด lazySet() ไว้อย่างไม่เป็นทางการเท่านั้น)
  • สำรวจสิ่งที่คอมไพเลอร์ได้รับอนุญาตและไม่ได้รับอนุญาตให้ทำเมื่อจัดลำดับโค้ดใหม่ (ข้อกำหนด JSR-133 มีตัวอย่างที่ดีของการเปลี่ยนรูปแบบที่ถูกต้องตามกฎหมายซึ่งนำไปสู่ผลลัพธ์ที่ไม่คาดคิด)
  • ดูวิธีเขียนคลาสที่เปลี่ยนแปลงไม่ได้ใน Java และ C++ (มีรายละเอียดมากกว่าแค่ "อย่าเปลี่ยนแปลงอะไรหลังจากสร้าง")
  • ทำความเข้าใจคำแนะนำในส่วนการทำงานพร้อมกันของ Effective Java, 2nd Edition (เช่น คุณควรหลีกเลี่ยงการเรียกใช้เมธอดที่ควรจะถูกลบล้างขณะอยู่ภายในบล็อกที่ซิงค์ข้อมูล)
  • อ่าน API ของ java.util.concurrent และ java.util.concurrent.atomic เพื่อดูว่ามีอะไรบ้าง ลองใช้คำอธิบายประกอบการเกิดขึ้นพร้อมกัน เช่น @ThreadSafe และ @GuardedBy (จาก net.jcip.annotations)

ส่วนการอ่านเพิ่มเติมในภาคผนวกมีลิงก์ไปยังเอกสารและเว็บไซต์ที่จะช่วยให้เข้าใจหัวข้อเหล่านี้ได้ดียิ่งขึ้น

ภาคผนวก

การใช้พื้นที่เก็บข้อมูลการซิงค์ข้อมูล

(นี่ไม่ใช่สิ่งที่โปรแกรมเมอร์ส่วนใหญ่จะนำไปใช้ แต่การอภิปรายนี้ให้ความกระจ่าง)

สำหรับประเภทเล็กๆ ในตัว เช่น int และฮาร์ดแวร์ที่ Android รองรับ คำสั่งโหลดและจัดเก็บทั่วไปจะช่วยให้มั่นใจได้ว่าการจัดเก็บจะแสดงทั้งหมดหรือไม่แสดงเลยต่อโปรเซสเซอร์อื่นที่โหลดตำแหน่งเดียวกัน ดังนั้นจึงมีแนวคิดพื้นฐานบางอย่างเกี่ยวกับ "ความเป็นอะตอม" ให้โดยไม่มีค่าใช้จ่าย

อย่างที่เราเห็นก่อนหน้านี้ วิธีนี้ยังไม่เพียงพอ เพื่อให้มั่นใจถึงความสอดคล้องกันตามลำดับ เรายังต้องป้องกันการจัดลำดับการดำเนินการใหม่ และเพื่อให้มั่นใจว่าการดำเนินการกับหน่วยความจำจะปรากฏต่อกระบวนการอื่นๆ ในลำดับที่สอดคล้องกัน ปรากฏว่าอย่างหลังเป็นแบบอัตโนมัติในฮาร์ดแวร์ที่รองรับ Android โดยมีเงื่อนไขว่าเราต้องเลือกอย่างรอบคอบเพื่อบังคับใช้อย่างแรก ดังนั้นเราจึงละเว้นส่วนใหญ่ที่นี่

ลำดับการดำเนินการของหน่วยความจำจะได้รับการเก็บรักษาไว้โดยทั้งการป้องกันการจัดลำดับใหม่โดยคอมไพเลอร์ และการป้องกันการจัดลำดับใหม่โดยฮาร์ดแวร์ ในที่นี้เราจะเน้นที่อย่างหลัง

การจัดลำดับหน่วยความจำใน ARMv7, x86 และ MIPS จะบังคับใช้ด้วยคำสั่ง "fence" ซึ่งป้องกันไม่ให้คำสั่งที่ตามหลัง fence ปรากฏก่อนคำสั่งที่อยู่ก่อนหน้า fence (โดยทั่วไปแล้วคำสั่งเหล่านี้เรียกว่าคำสั่ง "Barrier" แต่ก็อาจทำให้สับสนกับ Barrier แบบ pthread_barrier ซึ่งทำได้มากกว่านี้) ความหมายที่แน่นอนของคำสั่ง Fence เป็นหัวข้อที่ค่อนข้างซับซ้อนซึ่งต้องอธิบายวิธีที่การรับประกันที่ได้จาก Fence ประเภทต่างๆ โต้ตอบกัน และวิธีที่การรับประกันเหล่านี้รวมกับการรับประกันการจัดลำดับอื่นๆ ที่ฮาร์ดแวร์มักจะให้มา นี่เป็นภาพรวมระดับสูง ดังนั้นเราจะข้ามรายละเอียดเหล่านี้ไป

การรับประกันการจัดลำดับขั้นพื้นฐานที่สุดคือการรับประกันที่ได้จากการดำเนินการแบบอะตอมของ C++ memory_order_acquire และ memory_order_release: การดำเนินการกับหน่วยความจำที่เกิดขึ้นก่อนการจัดเก็บแบบ Release ควรจะมองเห็นได้หลังจากการโหลดแบบ Acquire ใน ARMv7 ระบบจะบังคับใช้โดยทำดังนี้

  • นำหน้าคำสั่ง Store ด้วยคำสั่ง Fence ที่เหมาะสม ซึ่งจะป้องกันไม่ให้มีการจัดเรียงการเข้าถึงหน่วยความจำก่อนหน้าทั้งหมดใหม่ด้วยคำสั่ง Store (นอกจากนี้ยังป้องกันการจัดลำดับใหม่โดยไม่จำเป็นด้วยคำสั่งร้านค้าในภายหลัง)
  • ทำตามคำสั่งโหลดด้วยคำสั่ง Fence ที่เหมาะสม เพื่อป้องกันไม่ให้มีการจัดลำดับการโหลดใหม่ด้วยการเข้าถึงครั้งต่อๆ ไป (และอีกครั้งที่ให้การจัดลำดับที่ไม่จำเป็นด้วยการโหลดก่อนหน้าเป็นอย่างน้อย)

ซึ่งเพียงพอสำหรับการจัดลำดับการรับ/ปล่อยของ C++ ซึ่งจำเป็นแต่ไม่เพียงพอสำหรับ Java volatile หรือ C++ ที่สอดคล้องกันตามลำดับ atomic

หากต้องการดูว่าเราต้องการอะไรอีก ให้พิจารณาส่วนย่อยของอัลกอริทึมของ Dekker ที่เราได้กล่าวถึงไปก่อนหน้านี้ flag1 และ flag2 เป็นตัวแปร C++ atomic หรือ Java volatile ซึ่งทั้งคู่มีค่าเริ่มต้นเป็นเท็จ

ชุดข้อความ 1 ชุดข้อความ 2
flag1 = true
if (flag2 == false)
    critical-stuff
flag2 = true
if (flag1 == false)
    critical-stuff

ความสอดคล้องตามลำดับหมายความว่าการกำหนดค่าอย่างใดอย่างหนึ่งให้กับ flagn ต้องดำเนินการก่อน และการทดสอบในเทรดอื่นต้องเห็นการกำหนดค่านั้น ดังนั้น เราจะไม่เห็นเทรดเหล่านี้เรียกใช้ "critical-stuff" พร้อมกัน

แต่การกำหนดขอบเขตที่จำเป็นสำหรับการจัดลำดับการรับ-ปล่อยจะเพิ่มขอบเขตที่จุดเริ่มต้นและจุดสิ้นสุดของแต่ละชุดข้อความเท่านั้น ซึ่งไม่ได้ช่วยอะไรในกรณีนี้ นอกจากนี้ เรายังต้องตรวจสอบว่าหากการจัดเก็บ volatile/atomic ตามด้วยการโหลด volatile/atomic จะไม่มีการจัดลำดับทั้ง 2 รายการใหม่ โดยปกติแล้วจะบังคับใช้โดยการเพิ่มรั้วกั้นไม่เพียงแต่ก่อนที่เก็บข้อมูลที่สอดคล้องกันตามลำดับเท่านั้น แต่ยังรวมถึงหลังจากนั้นด้วย (ซึ่งก็เข้มงวดกว่าที่จำเป็นมากอีกครั้ง เนื่องจากโดยทั่วไปแล้วรั้วนี้จะจัดลำดับการเข้าถึงหน่วยความจำก่อนหน้าทั้งหมดโดยสัมพันธ์กับการเข้าถึงในภายหลังทั้งหมด)

เราอาจเชื่อมโยงรั้วเพิ่มเติมกับโหลดที่สอดคล้องกันตามลำดับแทน เนื่องจากร้านค้ามีจำนวนน้อยกว่า รูปแบบที่เราอธิบายจึงเป็นที่นิยมมากกว่าและใช้ใน Android

ดังที่เราได้เห็นในส่วนก่อนหน้านี้ เราจำเป็นต้องแทรก Store/Load Barrier ระหว่างการดำเนินการทั้งสอง โค้ดที่เรียกใช้ใน VM สำหรับการเข้าถึงแบบชั่วคราวจะมีลักษณะดังนี้

ภาระที่ผันผวน ร้านค้าที่ผันผวน
reg = A
fence for "acquire" (1)
fence for "release" (2)
A = reg
fence for later atomic load (3)

สถาปัตยกรรมเครื่องจริงมักจะมีรั้วหลายประเภท ซึ่งจะจัดลำดับการเข้าถึงประเภทต่างๆ และอาจมีค่าใช้จ่ายที่แตกต่างกัน การเลือกระหว่าง 2 ตัวเลือกนี้มีความละเอียดอ่อน และได้รับอิทธิพลจากความจำเป็นในการตรวจสอบว่ามีการทำให้ Store มองเห็นได้สำหรับ Core อื่นๆ ในลำดับที่สอดคล้องกัน และการจัดลำดับหน่วยความจำที่กำหนดโดยการรวมรั้วหลายรายการเข้าด้วยกันนั้นประกอบกันอย่างถูกต้อง ดูรายละเอียดเพิ่มเติมได้ที่หน้าของมหาวิทยาลัยเคมบริดจ์ซึ่งมีการแมปอะตอมกับโปรเซสเซอร์จริงที่รวบรวมไว้

ในสถาปัตยกรรมบางอย่าง โดยเฉพาะ x86 อุปสรรค "รับ" และ "ปล่อย" ไม่จำเป็น เนื่องจากฮาร์ดแวร์จะบังคับใช้การจัดลำดับที่เพียงพอโดยนัยเสมอ ดังนั้นใน x86 จะมีการสร้างเฉพาะ Fence สุดท้าย (3) เท่านั้น ในทำนองเดียวกัน ใน x86 การดำเนินการอ่าน-แก้ไข-เขียนแบบอะตอมมิกจะรวมรั้วที่แข็งแกร่งไว้โดยนัย ดังนั้นจึงไม่จำเป็นต้องมีรั้วกั้น ใน ARMv7 ต้องใช้ Fence ทั้งหมดที่เราพูดถึงข้างต้น

ARMv8 มีคำสั่ง LDAR และ STLR ที่บังคับใช้ข้อกำหนดของ Java Volatile หรือ C++ Sequentially Consistent Load and Store โดยตรง ซึ่งจะช่วยหลีกเลี่ยงข้อจำกัดในการจัดลำดับใหม่ที่ไม่จำเป็นตามที่กล่าวไว้ข้างต้น โค้ด Android แบบ 64 บิตใน ARM ใช้สิ่งเหล่านี้ เราเลือกที่จะมุ่งเน้นไปที่การวาง Fence ของ ARMv7 ที่นี่เนื่องจากจะช่วยให้เข้าใจข้อกำหนดที่แท้จริงได้มากขึ้น

อ่านเพิ่มเติม

หน้าเว็บและเอกสารที่ให้ข้อมูลเชิงลึกหรือครอบคลุมมากกว่า บทความที่มีประโยชน์โดยทั่วไปจะอยู่ใกล้กับด้านบนของรายการมากกว่า

Shared Memory Consistency Models: A Tutorial
เขียนขึ้นในปี 1995 โดย Adve และ Gharachorloo ซึ่งเป็นจุดเริ่มต้นที่ดีหากคุณต้องการเจาะลึกเกี่ยวกับโมเดลความสอดคล้องของหน่วยความจำ
http://www.hpl.hp.com/techreports/Compaq-DEC/WRL-95-7.pdf
Memory Barrier
บทความสั้นๆ ที่สรุปปัญหาได้ดี
https://en.wikipedia.org/wiki/Memory_barrier
ข้อมูลเบื้องต้นเกี่ยวกับ Threads
ข้อมูลเบื้องต้นเกี่ยวกับการเขียนโปรแกรมแบบ Multi-Threaded ใน C++ และ Java โดย Hans Boehm การพูดคุยเกี่ยวกับการแข่งขันข้อมูลและวิธีการซิงค์ข้อมูลพื้นฐาน
http://www.hboehm.info/c++mm/threadsintro.html
Java Concurrency In Practice
หนังสือเล่มนี้ตีพิมพ์ในปี 2006 โดยครอบคลุมหัวข้อต่างๆ มากมายอย่างละเอียด ขอแนะนำอย่างยิ่งสำหรับผู้ที่เขียนโค้ดแบบ Multi-Thread ใน Java
http://www.javaconcurrencyinpractice.com
คำถามที่พบบ่อยเกี่ยวกับ JSR-133 (Java Memory Model)
ข้อมูลเบื้องต้นเกี่ยวกับโมเดลหน่วยความจำของ Java ซึ่งรวมถึงคำอธิบายเกี่ยวกับการซิงค์ ตัวแปรแบบ Volatile และการสร้างฟิลด์ Final (ค่อนข้างล้าสมัย โดยเฉพาะเมื่อพูดถึงภาษาอื่นๆ)
http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html
ความถูกต้องของการเปลี่ยนรูปแบบโปรแกรมในโมเดลหน่วยความจำ Java
คำอธิบายทางเทคนิคเกี่ยวกับปัญหาที่เหลืออยู่ของโมเดลหน่วยความจำ Java ปัญหาเหล่านี้ไม่มีผลกับโปรแกรมที่ไม่มีการแย่งใช้ทรัพยากรข้อมูล
http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.112.1790&rep=rep1&type=pdf
ภาพรวมของแพ็กเกจ java.util.concurrent
เอกสารประกอบสำหรับแพ็กเกจ java.util.concurrent ที่ด้านล่างของหน้าจะมีส่วนที่ชื่อว่า "Memory Consistency Properties" ซึ่งอธิบายการรับประกันที่คลาสต่างๆ ทำไว้
java.util.concurrent สรุปแพ็กเกจ
Java Theory and Practice: Safe Construction Techniques in Java
บทความนี้จะตรวจสอบรายละเอียดเกี่ยวกับอันตรายของการอ้างอิงที่หลุดรอดระหว่างการสร้างออบเจ็กต์ และให้หลักเกณฑ์สำหรับคอนสตรัคเตอร์ที่ปลอดภัยต่อเธรด
http://www.ibm.com/developerworks/java/library/j-jtp0618.html
ทฤษฎีและการปฏิบัติของ Java: การจัดการความผันผวน
บทความดีๆ ที่อธิบายสิ่งที่คุณทำได้และทำไม่ได้ด้วยฟิลด์ Volatile ใน Java
http://www.ibm.com/developerworks/java/library/j-jtp06197.html
การประกาศ "Double-Checked Locking is Broken"
คำอธิบายโดยละเอียดของ Bill Pugh เกี่ยวกับวิธีต่างๆ ที่การล็อกแบบตรวจสอบซ้ำจะใช้งานไม่ได้หากไม่มี volatile หรือ atomic ซึ่งรวมถึง C/C++ และ Java
http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html
[ARM] Barrier Litmus Tests and Cookbook
การพูดคุยเกี่ยวกับปัญหา ARM SMP พร้อมข้อมูลโค้ด ARM สั้นๆ หากคุณพบว่าตัวอย่างในหน้านี้ไม่เจาะจงเกินไป หรือต้องการอ่านคำอธิบายอย่างเป็นทางการของวิธีการ DMB โปรดอ่านบทความนี้ นอกจากนี้ยังอธิบายวิธีการที่ใช้สำหรับ Memory Barrier ในโค้ดที่เรียกใช้ได้ (อาจเป็นประโยชน์หากคุณสร้างโค้ดแบบทันที) โปรดทราบว่าสถาปัตยกรรมนี้มีมาก่อน ARMv8 ซึ่งรองรับคำสั่งการจัดลำดับหน่วยความจำเพิ่มเติมและเปลี่ยนไปใช้โมเดลหน่วยความจำที่ค่อนข้างเข้มงวดกว่า (ดูรายละเอียดได้ที่ "ARM® Architecture Reference Manual ARMv8, for ARMv8-A architecture profile")
http://infocenter.arm.com/help/topic/com.arm.doc.genc007826/Barrier_Litmus_Tests_and_Cookbook_A08.pdf
Linux Kernel Memory Barriers
เอกสารประกอบสำหรับอุปสรรคด้านหน่วยความจำของเคอร์เนล Linux มีตัวอย่างที่เป็นประโยชน์และศิลปะ ASCII
http://www.kernel.org/doc/Documentation/memory-barriers.txt
ISO/IEC JTC1 SC22 WG21 (มาตรฐาน C++) 14882 (ภาษาโปรแกรม C++) ส่วนที่ 1.10 และวรรค 29 ("ไลบรารีการดำเนินการแบบอะตอม")
มาตรฐานฉบับร่างสำหรับฟีเจอร์การดำเนินการแบบอะตอมมิกของ C++ เวอร์ชันนี้ใกล้เคียงกับมาตรฐาน C++14 ซึ่งรวมถึงการเปลี่ยนแปลงเล็กน้อยในส่วนนี้จาก C++11
http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/n4527.pdf
(intro: http://www.hpl.hp.com/techreports/2008/HPL-2008-56.pdf)
ISO/IEC JTC1 SC22 WG14 (มาตรฐาน C) 9899 (ภาษาโปรแกรม C) บทที่ 7.16 ("Atomics <stdatomic.h>")
มาตรฐานฉบับร่างสำหรับฟีเจอร์การดำเนินการแบบอะตอมมิก ISO/IEC 9899-201x C โปรดดูรายละเอียดในรายงานข้อบกพร่องในภายหลังด้วย
http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1570.pdf
C/C++11 mappings to processors (University of Cambridge)
คอลเล็กชันการแปล C++ Atomics เป็นชุดคำสั่งโปรเซสเซอร์ทั่วไปต่างๆ ของ Jaroslav Sevcik และ Peter Sewell
http://www.cl.cam.ac.uk/~pes20/cpp/cpp0xmappings.html
อัลกอริทึมของ Dekker
"โซลูชันที่ถูกต้องซึ่งเป็นที่รู้จักเป็นครั้งแรกสำหรับปัญหาการแยกกันทำงานในการเขียนโปรแกรมพร้อมกัน" บทความใน Wikipedia มีอัลกอริทึมแบบเต็ม พร้อมการอภิปรายเกี่ยวกับวิธีที่ต้องอัปเดตเพื่อให้ทำงานร่วมกับคอมไพเลอร์เพิ่มประสิทธิภาพที่ทันสมัยและฮาร์ดแวร์ SMP ได้
https://en.wikipedia.org/wiki/Dekker's_algorithm
ความคิดเห็นเกี่ยวกับ ARM เทียบกับ Alpha และทรัพยากร Dependency ของที่อยู่
อีเมลในรายชื่ออีเมล arm-kernel จาก Catalin Marinas มีข้อมูลสรุปที่ดีเกี่ยวกับการขึ้นต่อกันของที่อยู่และการควบคุม
http://linux.derkeiler.com/Mailing-Lists/Kernel/2009-05/msg11811.html
What Every Programmer Should Know About Memory
บทความที่ยาวและละเอียดมากเกี่ยวกับหน่วยความจำประเภทต่างๆ โดยเฉพาะแคช CPU โดย Ulrich Drepper
http://www.akkadia.org/drepper/cpumemory.pdf
การให้เหตุผลเกี่ยวกับโมเดลหน่วยความจำที่สอดคล้องกันอย่างอ่อนของ ARM
บทความนี้เขียนโดย Chong และ Ishtiaq จาก ARM, Ltd. ซึ่งพยายามอธิบายโมเดลหน่วยความจำ ARM SMP อย่างละเอียดแต่เข้าใจง่าย คำจำกัดความของ "ความสามารถในการสังเกต" ที่ใช้ในที่นี้มาจากเอกสารนี้ ขอย้ำอีกครั้งว่านี่เป็นข้อมูลก่อน ARMv8
http://portal.acm.org/ft_gateway.cfm?id=1353528&type=pdf&coll=&dl=&CFID=96099715&CFTOKEN=57505711
The JSR-133 Cookbook for Compiler Writers
Doug Lea เขียนบทความนี้เพื่อเป็นส่วนเสริมของเอกสารประกอบ JSR-133 (Java Memory Model) ซึ่งมีชุดหลักเกณฑ์การใช้งานเบื้องต้นสำหรับโมเดลหน่วยความจำ Java ที่นักเขียนคอมไพเลอร์หลายคนใช้ และยังคงมีการอ้างอิงอย่างกว้างขวางและมีแนวโน้มที่จะให้ข้อมูลเชิงลึก น่าเสียดายที่ Fence ทั้ง 4 แบบที่กล่าวถึงที่นี่ไม่เหมาะกับสถาปัตยกรรมที่ Android รองรับ และการแมป C++11 ข้างต้นเป็นแหล่งข้อมูลที่ดีกว่าสำหรับสูตรที่แม่นยำ แม้แต่สำหรับ Java ก็ตาม
http://g.oswego.edu/dl/jmm/cookbook.html
x86-TSO: A Rigorous and Usable Programmer’s Model for x86 Multiprocessors
คำอธิบายที่แม่นยำเกี่ยวกับโมเดลหน่วยความจำ x86 น่าเสียดายที่คำอธิบายที่แม่นยำของโมเดลหน่วยความจำ ARM นั้นซับซ้อนกว่ามาก
http://www.cl.cam.ac.uk/~pes20/weakmemory/cacm.pdf