วิธีทดสอบโครงสร้างข้อมูลแบบ concurrent อย่างถูกต้อง
(matklad.github.io)- ใช้ตัวอย่าง counter แบบ concurrent ใน Rust ที่มีข้อบกพร่อง เพื่อเผยปัญหาที่การทดสอบโหลดด้วย thread ทั่วไปมองข้าม ด้วย การควบคุมลำดับการรันที่ทำซ้ำได้และย่อให้เล็กที่สุดได้
- wrapper
AtomicU32สำหรับทดสอบจะแทรกpause()และ managed thread จะหยุดก่อนและหลังการดำเนินการแบบ atomic แล้วจึงรันต่อในลำดับที่การทดสอบเลือก - การทดสอบแบบง่ายอาจสร้างความล้มเหลวได้ เช่นให้ thread
100ตัวเพิ่มค่าคนละ100ครั้ง แต่ได้9598แทนค่าที่คาดไว้10000ทว่าขึ้นกับ timing จึงทำซ้ำ ดีบัก และย่อกรณีได้ยาก - การทดสอบเชิงคุณสมบัติที่ใช้
arbtestสามารถทำซ้ำ interleaving เดิมด้วย seed เดิม และ ย่อ กรณีล้มเหลวลงเหลือ0: increment,1: increment,0: unpause,1: unpause - เมื่อขยายโครงสร้างเดียวกันด้วย
exhaustigenจะไล่แจง interleaving ทั้งหมดได้จนถึงการเพิ่มค่าสูงสุด5ครั้ง และหลังแก้เป็นfetch_addแล้ว interleaving81133แบบผ่านทั้งหมด
Counter แบบ concurrent ที่ไม่เป็น atomic
- ตัวอย่างใช้
AtomicU32ของ Rust แต่increment()ทำloadแล้วตามด้วยstore(value + 1)ดังนั้น การเพิ่มค่าเองไม่ได้เป็น atomic - โครงสร้าง
Counterเรียบง่ายvalue: AtomicU32increment()อ่านค่าด้วยSeqCstบวก1กับค่าที่อ่านได้ แล้วเขียนกลับไปget()อ่านค่าปัจจุบันด้วยSeqCst
- หากสอง thread อ่านค่าเดียวกันแล้วเขียนผลลัพธ์หลังเพิ่มค่าเดียวกันกลับไป การอัปเดตหนึ่งครั้งอาจหายไป
ทำไมการทดสอบด้วย thread ทั่วไปจึงไม่พอ
- วิธีตรวจสอบที่ง่ายที่สุดคือให้หลาย thread เพิ่มค่า counter เดียวกันซ้ำ ๆ แล้วตรวจค่าท้ายสุด
thread_count = 100increment_count = 100- ค่าที่คาดไว้คือ
10000
- ตัวอย่างการรันล้มเหลวด้วย
left: 9598,right: 10000 - วิธีนี้พึ่งพา timing ของ scheduling อย่างมาก
- ทำซ้ำความล้มเหลวเดิมแบบแน่นอนได้ยาก
- ดีบักได้ยาก
- หากลดจำนวน thread หรือจำนวนครั้งที่เพิ่มค่า อาจผ่านได้เพราะโชคดี จึงย่อกรณีล้มเหลวให้เล็กที่สุดได้ยาก
จัดการ interleaving ด้วยการทดสอบเชิงคุณสมบัติ
- การทดสอบเชิงคุณสมบัติ (PBT) เข้ากันได้ดีกับการทดสอบ state machine
- สร้างอินพุตแบบสุ่มได้ง่าย
- ตั้งคุณสมบัติได้ว่าผลลัพธ์ของการรันแบบ concurrent ต้องเหมือนกับโมเดลที่รันแบบลำดับ
- สอดคล้องกับความต้องการย่ออินพุตที่ล้มเหลวให้เล็กที่สุด
- จุดยากคือการสั่งให้ OS thread จริงเดินหน้าไปทีละขั้น ณ จุดที่ต้องการทำได้ยาก
- ทางแก้คือโครงสร้างที่ในแต่ละรอบเลือก thread แบบสุ่มมาหนึ่งตัวแล้วให้เดินหน้าไปหนึ่งขั้น
- ต้องสามารถแทรก thread อื่นเข้ามาระหว่าง
loadกับstoreของ thread หนึ่งได้ - เพื่อสิ่งนี้ จึงสร้าง API managed thread ที่ควบคุม thread ได้โดยตรง
- ต้องสามารถแทรก thread อื่นเข้ามาระหว่าง
AtomicU32 สำหรับทดสอบและการแทรก pause
- ใน build สำหรับทดสอบ จะใช้
managed_thread::AtomicU32ที่ทำเอง แทนstd::sync::atomic::AtomicU32#[cfg(test)] use managed_thread::AtomicU32#[cfg(not(test))] use std::sync::atomic::AtomicU32
- wrapper
AtomicU32เรียกpause()ก่อนและหลังload()กับstore()load:pause()→loadจริง →pause()store:pause()→storeจริง →pause()
- จุดที่แทรกนี้ทำให้การทดสอบหยุดและปล่อย thread ให้รันต่อรอบ ๆ การดำเนินการแบบ atomic ได้ พร้อมควบคุม ลำดับการรัน
รูปแบบของ managed thread API
- การทดสอบสร้าง managed thread สองตัวภายใน
std::thread::scope- เพราะใช้ scoped thread จึงยืมข้อมูลใน stack frame ได้
- ส่ง reference ของ counter เป็นสถานะ เช่น
spawn(scope, &counter)
- managed thread ไม่ได้เริ่มด้วยการรันฟังก์ชัน
mainเฉพาะตั้งแต่ต้น แต่รัน closure ที่ thread ควบคุมส่งมาด้วยsubmit()t.submit(|c| c.increment())- thread จะรัน closure กับสถานะ
Tของตัวเอง
- loop ของการทดสอบจะสุ่มการกระทำกับแต่ละ thread ขณะยังมี entropy เหลืออยู่
- ถ้า thread หยุดอยู่ ให้
unpause() - ถ้าไม่ได้หยุดอยู่ ให้ส่ง
increment()ด้วยsubmit() - โมเดลแบบลำดับ
counter_modelก็เพิ่มค่าด้วยจำนวนครั้งเท่ากัน
- ถ้า thread หยุดอยู่ ให้
- ท้ายสุดจะ
join()thread ทั้งหมด และเปรียบเทียบcounter_modelกับcounter.get()จริง
การทำงานของ pause และ unpause
pause()ใช้thread_local!เพื่อค้นหา context ของ managed thread ปัจจุบัน โดยไม่ต้องเปลี่ยน API ของCounterที่ถูกทดสอบ- context ถูกแชร์ด้วย
Arc<SharedContext> SharedContextมีMutex<State>และCondvar
- context ถูกแชร์ด้วย
- สถานะแบ่งเป็น
Ready,Running,PausedReady: สถานะที่รอ closure ถัดไปRunning: สถานะที่ managed thread กำลังรันPaused: สถานะที่หยุดอยู่ ณ จุดpause()
- เมื่อ managed thread มาถึง
pause()จะเปลี่ยนสถานะจากRunningเป็นPausedและแจ้ง thread ควบคุมด้วย condition variable unpause()เปลี่ยนสถานะจากPausedเป็นRunningและปลุก managed thread จากนั้นรอจนกว่าสถานะจะไม่ใช่Runningอีกครั้ง- ป้องกันสถานการณ์ที่ thread ควบคุมกับ managed thread รันต่อพร้อมกัน
- ทำให้มีเพียงฝ่ายใดฝ่ายหนึ่งเท่านั้นที่รันในแต่ละขณะ เพื่อลด ความไม่แน่นอน
การทำซ้ำและย่อกรณีล้มเหลว
- การรัน
arbtestพบความล้มเหลวใน counter ที่เสีย- ตัวอย่างความล้มเหลวคือค่าโมเดล
4แต่ค่าจริง3 - seed ที่ล้มเหลวคือ
0x4fd7ddff00000020
- ตัวอย่างความล้มเหลวคือค่าโมเดล
- หากระบุ seed เดิม จะได้ interleaving เดิมอีกครั้ง ทำให้ทำซ้ำความล้มเหลวได้ง่าย
- เมื่อใช้
.minimize()กรณีล้มเหลวจะถูกลดให้เหลือการรันที่สั้นลง- seed ของกรณีขั้นต่ำสุดท้ายคือ
0x9c2a13a600000001 - trace ขั้นต่ำมีสี่ขั้น
0: increment1: increment0: unpause1: unpause
- seed ของกรณีขั้นต่ำสุดท้ายคือ
- ในกรณีขั้นต่ำนี้ ค่าที่คาดไว้คือ
2แต่ค่าจริงเป็น1เผยให้เห็นข้อบกพร่องของการเพิ่มค่าที่อิงload/store
ขยายเป็นการไล่แจง interleaving ทั้งหมด
- โครงสร้างเดียวกันสามารถเปลี่ยนจาก interleaving แบบสุ่มเป็นแบบไล่แจงได้
- ใช้
exhaustigenเขียนการทดสอบที่สำรวจ interleaving ทั้งหมดจนถึงการเพิ่มค่าสูงสุด5ครั้ง- การทดสอบหลีกเลี่ยง loop dummy และจัดให้ thread ถูก
unpauseหรือส่งincrementเสมอ
- การทดสอบหลีกเลี่ยง loop dummy และจัดให้ thread ถูก
- implementation ที่เสียพบ bug เดิม
- ตัวอย่างความล้มเหลวคือ
left: 2,right: 1
- ตัวอย่างความล้มเหลวคือ
- เมื่อแก้
Counter::increment()เป็นfetch_add(1, SeqCst)การทดสอบก็ผ่าน- เพิ่ม
pause()ก่อนและหลังfetch_add()ใน wrapperAtomicU32ด้วย - ผลการรันคือ
all 81133 interleavings are fine! - เวลารันคือ
real 8.65s, CPU8.16s, RSS63.91mb
- เพิ่ม
การขยายไปสู่ weak memory model และ model checking
AtomicU32ใน implementation ของเล่นตอนนี้ delegate ไปยัง atomic จริง- แนวคิดสำหรับขยายคือให้แต่ละ atomic เก็บเซตของค่าที่ถูกเขียนไว้ และเมื่ออ่านให้คืนค่าใดค่าหนึ่งที่สอดคล้องกับ weak memory model
- การสำรวจ interleaving ก็สามารถทำให้ฉลาดกว่าการสุ่มได้
- ใช้แนวทาง model checking เพื่อตรวจว่าได้พิจารณา interleaving ที่แตกต่างกันอย่างมีนัยสำคัญครบแล้วหรือไม่
- ไล่แจง interleaving ทั้งหมดในขอบเขตเล็ก ๆ ได้ เช่นเดียวกับวิธีใน Generate All The Things
ทำไมจึงย่อให้เล็กได้โดยไม่ต้องมี shrinking
arbtestที่ใช้ดูเหมือนอินเทอร์เฟซ PRNG ที่คุ้นเคย แต่ใช้ PRNG แบบมีขอบเขตจำกัด- หากขอค่าสุ่มต่อไปเรื่อย ๆ ถึงจุดหนึ่งจะคืน
Err(OutOfEntropy) - ดังนั้นในโค้ดทดสอบจึงมี
?และwhile !rng.is_empty()
- หากขอค่าสุ่มต่อไปเรื่อย ๆ ถึงจุดหนึ่งจะคืน
- เมื่อการทดสอบใช้ entropy หมด ก็จะจบสั้น ๆ ดังนั้นถ้าลด entropy ที่ใช้ได้ การรันทดสอบก็จะสั้นลงด้วย
- implementation ภายในในเชิงแนวคิดใกล้เคียงกับ
&mut &[u8]- ทุกครั้งที่ขอตัวเลขสุ่ม byte slice จะสั้นลง
- slice เริ่มต้นยิ่งสั้น การทดสอบก็ยิ่งเรียบง่าย
- ด้วยวิธีนี้ กรณีล้มเหลวจึงสั้นลงได้โดยไม่ต้อง implement logic สำหรับ shrinking แยกเอง
- โค้ดต้นฉบับของตัวอย่างอยู่ที่ properly-concurrent
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
ใน Rust กำลังทำไลบรารีชื่อ Temper ด้วยแนวทางคล้ายกัน: https://github.com/reitzensteinm/temper/tree/main
อย่างไรก็ตาม หากต้องการโมเดลนัยแปลก ๆ ที่เกิดจาก memory model ทั้งหมดของ Rust ต้องไปไกลกว่านี้มาก จึงจำเป็นต้องมีบัญชีติดตามว่าแต่ละเธรดรับรู้การเขียนใดไปแล้วบ้าง ตาม atomic memory ordering, read/write fence ฯลฯ อาจเกิดการรับประกันในทำนองว่า ถ้ารับรู้การเขียน X แล้ว ก็ต้องรับรู้การเขียน Y ด้วยเสมอ
คิดว่าน่าจะเป็นชุดกรณีทดสอบ memory model ของ C++/Rust ที่รวบรวมไว้มากที่สุดแห่งหนึ่ง และแทบจะรวบรวมทุกอย่างที่หาได้จากหนังสือ มาตรฐาน C++, Stack Overflow, บล็อก ฯลฯ ไว้แล้ว ตัวอย่างเช่น ไฟล์สำหรับหนังสือ Rust Atomics and Locks ของ Mara Bos อยู่ที่นี่: https://github.com/reitzensteinm/temper/blob/main/memlog/tes...
Loom ที่กล่าวถึงในบทความเป็นไลบรารีที่คล้ายกันแต่สมบูรณ์กว่ามาก ช่วยให้ทดสอบคอมโพเนนต์ระดับสูงกว่าอย่าง mutex หรือ queue ได้อย่างละเอียด: https://github.com/tokio-rs/loom อย่างไรก็ตาม ตัว memory model เองไม่ได้ถูกโมเดลอย่างถี่ยิบเท่า Temper และก็เคยคิดจะย้ายกรณีทดสอบไปใช้ Loom อยู่
ได้แรงบันดาลใจจากงานนำเสนอการทดสอบ FoundationDB ของ Will Wilson และตอนนี้เขากำลังทำโซลูชันบนไฮเปอร์ไวเซอร์ที่ Antithesis เพื่อทดสอบคอนเทนเนอร์ Docker ใด ๆ ด้วยวิธีแบบนี้: https://www.youtube.com/watch?v=4fFDFbi3toc, https://antithesis.com/
เชื่ออย่างหนักแน่นว่าอีก 10 ปีข้างหน้า พื้นที่นี้จะเติบโตขึ้นมาก WebAssembly อยู่ในจุดลงตัว คือสมบูรณ์พอที่จะคอมไพล์ซอฟต์แวร์ใด ๆ ได้ แต่ก็เรียบง่ายพอที่การสร้างสิ่งแบบ Antithesis จะไม่กลายเป็นโปรเจกต์ 5 ปีของทีมระดับหัวกะทิที่เคยออกฐานข้อมูลมาแล้ว
เคย implement atomic snapshot ของ shared memory ด้วย Rust และให้ความสำคัญกับการทดสอบอัตโนมัติอย่างจริงจังที่สุดเท่าที่ทำได้: https://github.com/kaymanb/todc/tree/main/todc-mem
ตอนแรกใช้ Loom ที่อยู่ในบทความ แต่ภายหลังเปลี่ยนเป็น shuttle: https://github.com/tokio-rs/loom, https://github.com/awslabs/shuttle
shuttle ใช้แนวทางแบบสุ่มแทนการสำรวจครบถ้วนเหมือน Loom แต่ตัว scheduler ก็ยังให้การรับประกันเชิงความน่าจะเป็นในการค้นพบบั๊ก จากที่ลองใช้ shuttle เร็วกว่าและขยายไปยังสถานการณ์ทดสอบที่ซับซ้อนกว่าได้
คล้ายกับวิธีในบทความ เมื่อ schedule ใดทำให้การทดสอบล้มเหลว ก็สามารถบันทึก random seed ไว้ได้ ความสามารถในการทำซ้ำการทดสอบที่ล้มเหลวได้อย่างรวดเร็วนั้นสำคัญมาก และทำให้เขียน test case แบบชัดเจนสำหรับบั๊กที่เคยจับได้และแก้ไขไปแล้วได้: https://github.com/kaymanb/todc/blob/0e2874a70ec8beed8fae773...
ฝั่ง Kotlin/Java มี Lincheck ของ JetBrains เป็นไลบรารีที่ดีสำหรับงานแบบนี้: https://github.com/JetBrains/lincheck
โดยเฉพาะชอบที่เป็นแบบ declarative และวิธีที่มันแสดงผลลัพธ์เรื่อง linearizability
สงสัยว่าใน C++ มีไลบรารีแบบ Loom ไหม มี โครงสร้างข้อมูลแบบ lock-free ที่อยากทดสอบอยู่
เป็นเครื่องมือที่ค่อนข้างเก่าและใช้งานง่าย สร้างโดย Dmitry Vyukov ผู้เชี่ยวชาญด้าน concurrency
https://github.com/facebook/folly/blob/main/folly/test/Deter...
หากเข้าใจถูก แนวทางนี้มีข้อจำกัดเกี่ยวกับ การรับประกันความคืบหน้าแบบอ่อน
การคำนวณในเนื้อหาหลักไม่ได้เล็กน้อยมากนัก แต่ในฮาร์ดแวร์จริงและสเกดจูลเลอร์จริง เราอาจนึกถึงลูป
cmpxchgที่มีโอกาสถูกหยุดบน CPU ใด CPU หนึ่งต่ำมาก หากจำนวน CPU คือnในกรณีเลวร้ายที่สุด ความน่าจะเป็นที่จะคืบหน้าคือ1/nแต่ในวิธีทดสอบนี้จะกลายเป็น1/t^pโดยที่tคือจำนวนงาน ซึ่งอาจมากกว่าจำนวน CPU มาก และpคือจำนวนครั้งที่หยุดชั่วคราวในตัวลูปนั้น ซึ่งกลายเป็น 3 หรือมากกว่าได้ง่าย ๆ ระดับนี้เพียงพอที่จะทำให้อัลกอริทึมที่ใช้งานได้จริงดูเหมือนพังในทางกลับกัน แม้ในกรณีที่ต้องการจับความคืบหน้าแบบอ่อนว่าเป็นบั๊ก จึงต้องการความคืบหน้าแบบแข็ง วิธีนี้ก็ดูไม่ได้ให้เครื่องมือที่มีประโยชน์นัก
ถึงอย่างนั้น มันก็มีประโยชน์ชัดเจนกับปัญหา concurrency จำนวนมาก
1/t^pดูเหมือนจะไม่ถูก และน่าจะมองว่าเป็นแค่1/tมากกว่า สุดท้ายเมื่อเวลาผ่านไปtหนึ่ง งานใดงานหนึ่งย่อมต้องคืบหน้า และถ้ามีงานtงาน ความน่าจะเป็นที่งานที่คืบหน้าจะเป็นงานของฉันก็คือ1/tจุดสับสนหลักน่าจะอยู่ที่การถูกหยุดไม่ได้หมายความว่าจะต้องแพ้ที่ CAS เสมอไป
สำหรับส่วนที่ว่า “พูดตรง ๆ คือมีความรู้พื้นฐานบางอย่างอยู่ในนี้ เว้นแต่จะทำเรื่องต้องสาปมาก ๆ ด้วย inline assembly ดูเหมือนว่าเราจะหลีกเลี่ยงการสร้างเธรดจริงไม่ได้ หากมีอะไรบางอย่างเรียกฟังก์ชัน
pause()และเราต้องการทำให้มันหยุดอยู่จนกว่าจะมีคำสั่งในภายหลัง งานนั้นจะต้องเกิดขึ้นภายในเธรดที่มีสแตกแยกจากสแตกของการทดสอบ” ผมสงสัยว่าใช้ async runtime บางชนิดไม่ได้หรือดูเหมือนเป็นการทำ cooperative multitasking ด้วยการ instrument atomic operation อาจต้องดื่มกาแฟเพิ่มอีกหน่อย แต่การทำโดยไม่มีเธรดดูจะง่ายกว่า
ข้อเสียอย่างหนึ่งของแนวทางนี้คือ ต้อง แก้ไข โค้ดที่ถูกทดสอบเองให้เข้ากับโค้ดทดสอบ
น่าจะทำสิ่งเดียวกันได้ด้วยการเปิดสองเธรด แล้วใช้
ptraceทำ single-step พร้อมแทรกการรันคำสั่งแบบ “สุ่ม” คล้ายกับ chaos mode ของ rrอย่างไรก็ตาม บางคำสั่งอาจไม่เป็น atomic ดังนั้นถ้าเป็นไปได้โดยไม่ต้อง emulate ก็คงต้องมีวิธี single-step ในหน่วย “ไมโครโค้ดแบบ atomic”
หากจะใช้ Loom ดูเหมือนต้องใช้ conditional compilation และแม้จะโอเคเมื่อทดสอบไลบรารีหนึ่งตัว แต่ก็ค่อนข้าง intrusive
#[cfg(loom)]pub(crate) use loom::sync::atomic::AtomicUsize;#[cfg(not(loom))]pub(crate) use std::sync::atomic::AtomicUsize;สงสัยว่ามีภาษาที่ช่วยให้ใช้สเกดจูลเลอร์ของตัวเองได้ดีกว่านี้หรือไม่
ถ้าจะทำให้ละเอียดจริง ๆ น่าจะรันการทดสอบด้วย
ptraceแล้วให้เธรดเดินแบบ single-step เพื่อสร้าง interleaving ที่ต่างกันในระดับคำสั่งได้ สงสัยว่าเคยเห็นวิธีแบบนั้นจริง ๆ ไหมในกรณีที่ไม่สามารถ instrument โค้ดได้แบบนี้ มีทางเลือกสำหรับการทดสอบแบบ black-box หรือเปล่า?
nคำสั่ง ก็ต้องรันเพียงnครั้ง โดยรันตั้งแต่ 0 ถึงnคำสั่งก่อนแทรกสัญญาณ จากนั้น signal handler รันจนจบ แล้วเธรดหลักก็รันจนจบ เวลารวมคือO(n^2)แต่ถ้ามีเธรด
tตัวที่แต่ละตัวรันคำสั่งnคำสั่ง และสามารถหยุดกันและกันได้ที่ทุกขอบเขต สำหรับค่าnที่สมจริงแล้วคงเข้าถึงได้ยาก น่าจะต้องลดลงด้วยการเลือกเฉพาะ operation ที่มีพฤติกรรมน่าสนใจแล้วจำลองมันดูค่อนข้างเจ๋ง น่าจะต้องลองใช้ดูสักครั้ง แต่คงจับข้อผิดพลาดไม่ได้ทุกชนิด การเรียก
pause()ทุกครั้งจะสร้าง synchronization ระหว่างเธรด จนอาจบดบังปัญหา data race บางส่วนหรือเปล่า? ใน Rust อาจไม่ใช่ปัญหา