Futurelock: ความเสี่ยงของภาวะชะงักงันแบบละเอียดอ่อนใน Rust แบบอะซิงโครนัส
(rfd.shared.oxide.computer)- Futurelock คือภาวะ deadlock ที่เกิดขึ้นเมื่อทาสก์หนึ่งจัดการ Future หลายตัวพร้อมกัน แล้วหนึ่งในนั้นต้องการทรัพยากรของ Future อื่น แต่ไม่ได้ถูก poll อีกต่อไป
- เกิดขึ้นได้ง่ายเมื่อใช้ Future ที่อ้างอิงอยู่ (
&mut future) ร่วมกับ สาขาที่มีawaitในคำสั่งtokio::select! - ปัญหานี้เกิดจาก การแยกความรับผิดชอบระหว่างทาสก์กับ Future ที่ล้มเหลว โดยทาสก์เดียวกันรอทั้งสอง Future แต่ poll แค่ฝั่งเดียว จนเข้าสู่สภาวะค้าง
- สามารถเกิดในรูปแบบคล้ายกันได้กับ FuturesUnordered, bounded channel, Stream เป็นต้น
- หัวใจสำคัญของการออกแบบอะซิงโครนัสที่ปลอดภัยคือ แยก Future ออกเป็นคนละทาสก์ด้วย
tokio::spawnหรือหลีกเลี่ยงการใช้ await ภายในselect
แนวคิดและตัวอย่างของ Futurelock
- Futurelock เกิดขึ้นเมื่อ Future A ที่ถือครองทรัพยากร เป็นสิ่งที่ Future B ต้องใช้ แต่ ทาสก์ที่ดูแล Future ทั้งสองไม่ poll A อีกต่อไป
- ในโค้ดตัวอย่าง มีการรอ
&mut future1และsleepพร้อมกันภายในtokio::select!และเมื่อsleepเสร็จก่อนfuture1ก็ยังคงอยู่ในสถานะรอล็อก - หลังจากนั้น
future3ขอใช้ล็อกเดียวกัน แต่ล็อกถูกจัดสรรให้future1แล้ว และfuture1ไม่ถูก poll จึงทำให้โปรแกรมหยุดค้างถาวร
ปฏิสัมพันธ์ระหว่าง tokio::select! กับ Mutex
tokio::sync::Mutexเป็นล็อกแบบ fair ที่มอบล็อกตามลำดับการรอ- ล็อกถูกส่งต่อให้
future1แต่ทาสก์กำลัง poll แค่future3อยู่แล้ว ทำให้future1ไม่ได้รัน - Mutex มีหน้าที่เพียงปลุกทาสก์ที่รออยู่ถัดไปเท่านั้น และไม่สามารถรู้ได้ว่า Future ใดถูก poll จริง
สาเหตุทั่วไปของ Futurelock
- โครงสร้างการพึ่งพาแบบวนรอบที่ทาสก์ T รอ Future F1, F1 พึ่งพา F2, และ F2 ก็ต้องอาศัยการ poll จาก T อีกครั้ง
- มักเกิดในสถานการณ์ต่อไปนี้
- ใช้
&mut futureในtokio::select!แล้วไปทำawaitในสาขาอื่น - ทำงานอะซิงโครนัสอื่นต่อหลังจาก Future บางตัวเสร็จใน
FuturesOrderedหรือFuturesUnordered - พฤติกรรมคล้ายกันใน Future ที่ implement ขึ้นเองแบบ manual
- ใช้
กรณีที่เกิดใน Streams และโครงสร้างอื่น
- ใน
FuturesOrderedหรือFuturesUnorderedหากดึง Future ออกมาแล้วไปรอ Future อื่นที่ใช้ทรัพยากรเกี่ยวข้องกับมัน ก็อาจเกิด Futurelock ได้ join_allจะ poll Future ทุกตัวต่อเนื่อง จึงไม่เกิด Futurelock
กรณีจริงและการดีบัก
- ในกรณี Omicron#9259 Future สำหรับการเข้าถึงฐานข้อมูลทั้งหมดติด Futurelock ทำให้คำขอ HTTP รอไม่สิ้นสุด
- การส่งผ่านช่องสัญญาณ
mpscถูกบล็อก แต่เมื่อดูฝั่งรับกลับพบว่าว่างอยู่ จึงทำให้หาสาเหตุได้ยาก - ระหว่างการดีบัก เครื่องมืออย่าง
tokio-consoleอาจช่วยได้ แต่ในกรณีส่วนใหญ่ การตามหาต้นตอทำได้ยากมาก
แนวทางป้องกัน Futurelock
- เมื่อทาสก์หนึ่ง poll Future หลายตัว ต้องระวัง อย่าหยุด poll Future ที่เริ่มทำงานไปแล้ว
- ถ้าเป็นไปได้ ให้ spawn Future เป็นทาสก์ใหม่ เพื่อให้ทำงานแยกอิสระ
- หากส่ง
JoinHandleเข้าtokio::select!จะกำจัดความเสี่ยงของ Futurelock ได้
- หากส่ง
- ข้อควรระวังเมื่อใช้
tokio::select!- อย่าใช้
&mut futureพร้อมกับawaitในเวลาเดียวกัน - หากมีทั้งสองเงื่อนไขพร้อมกัน ความเสี่ยงของ Futurelock จะสูงมาก
- อย่าใช้
- เมื่อใช้
Streamให้ใช้JoinSetเพื่อรันแต่ละ Future เป็นคนละทาสก์ - การเพิ่มความจุของ
bounded channelไม่ใช่วิธีแก้ที่ต้นเหตุ- แต่สามารถหลีกเลี่ยงการบล็อกได้ด้วยการใช้
try_send()
- แต่สามารถหลีกเลี่ยงการบล็อกได้ด้วยการใช้
รูปแบบการหลีกเลี่ยงที่ผิด
- การเพิ่มความจุของช่องสัญญาณแบบไม่จำกัด เป็นวิธีที่ไม่สมจริง และก่อผลข้างเคียง เช่น ความหน่วงและการใช้หน่วยความจำเพิ่มขึ้น
- ความพยายามตัดการพึ่งพาระหว่าง Future เป็นแนวทางที่เปราะบาง เพราะอาจมีการพึ่งพาใหม่เกิดขึ้นระหว่างการบำรุงรักษา
- วิธีที่ปลอดภัยจริงมีเพียง การแยกทาสก์ด้วย
tokio::spawn
การปรับปรุงในอนาคตและประเด็นด้านความปลอดภัย
- มีการเสนอความเป็นไปได้ในการใช้ Clippy lint เพื่อเตือนเมื่อมีการใช้
&mut futureหรือมีawaitอยู่ภายในtokio::select! - Futurelock อาจถูกนำไปใช้ในลักษณะ การปฏิเสธการให้บริการ (DoS) ได้ แต่โดยเนื้อแท้แล้วมันคือ การทำงานผิดปกติ จึงควรป้องกันไว้ล่วงหน้า
1 ความคิดเห็น
ความเห็นจาก Hacker News
ลองไล่อ่านเอกสารคร่าว ๆ แล้วรู้สึกว่าเป็นรายงานที่โปร่งใสและละเอียดมาก
โดยเฉพาะส่วนเชิงอรรถที่น่าสนใจมาก
น่าประทับใจที่มีคนจำนวนมากไม่รู้ปัญหาเรื่อง cancellation safety ของ Rust และมีความเป็นไปได้สูงว่าปัญหาแบบนี้จะแพร่กระจายอยู่ทั่ว Omicron
มันให้ความรู้สึกย้อนแย้งตรงที่เลือกใช้ Rust เพื่อหลีกเลี่ยงปัญหาเรื่องmemory safetyของ C แต่กลับเจอบั๊ก cancellation ที่ตรวจจับได้ยากใน runtime แทน
สิ่งที่น่าอึดอัดเป็นพิเศษคือคุณสมบัติแบบไดนามิกที่คอมไพเลอร์ช่วยไม่ได้ กลับต้องให้โปรแกรมเมอร์เป็นคนรับประกันเอง
ดูเหมือนว่าแม้แต่ในโมเดล concurrency ของ Rust ก็ยังมีความเป็นไปได้ของ deadlockอยู่
ตอนแรกคิดว่าการจัดการทรัพยากรแบบ RAII น่าจะช่วยป้องกันปัญหาแบบนี้ได้ แต่ความจริงกลับไม่เป็นเช่นนั้น ซึ่งทำให้งงอยู่เหมือนกัน
เลยสงสัยว่านี่เป็นเพียงความบังเอิญจากการ implement หรือเป็นข้อจำกัดเชิงโครงสร้างของโมเดล Rust/Tokio กันแน่
นี่ดูเหมือนเป็น deadlock แบบแปรผันที่ละเอียดอ่อนจากบทความ FuturesUnordered ของ withoutboats
เวลาใช้ concurrency แบบ “intra-task” ต้องระวังไม่ให้ future ใด ๆ ตกอยู่ในภาวะ starvation
โดยพื้นฐานแล้วการ spawn task จะปลอดภัยกว่า และถ้าจะจัดการ timeout ด้วย
tokio::select!ก็ควรจัดการ future ที่ยัง pending ทั้งหมดไว้ในนั้นไม่ค่อยแนะนำ
FuturesUnorderedเว้นแต่จะทดสอบ edge case ทุกแบบจริง ๆฟังดูคล้ายปัญหาpriority inversion
ใน OS ถ้าเธรด priority ต่ำถือ lock อยู่ แล้วเธรด priority สูงต้องรอ เธรดฝั่งต่ำจะได้รับการสืบทอด priorityเพื่อให้ได้รัน
เลยสงสัยว่าแนวคิดคล้ายกันจะเอามาใช้กับ Tokio ได้ไหม — เช่น ถ้ามี future ที่รันไม่ได้แต่กำลังถือ Mutex อยู่ ก็ไป poll future นั้นแทน
แต่การตรวจจับสถานะว่า “รันไม่ได้” ก็น่าจะมี overhead ค่อนข้างมาก
แนวทางแบบนี้อาจทำได้ใน Tokio ระดับtask
แต่ใช้กับfuture ภายใน taskไม่ได้
เพราะการออกแบบพื้นฐานของ async Rust คือ “futures are inert” — future เป็นแค่ struct ธรรมดา และ runtime ไม่รู้ว่าในนั้นมีอะไร
สิ่งที่ runtime รู้มีแค่ระดับ task เท่านั้น จึงไม่ติดตามสถานะของ future ภายในเลย
async ของ Rust ใช้โมเดลstackless coroutine จึงไม่ปลอดภัยที่จะไปต่อการทำงานของ async function ที่กำลังรันอยู่แบบตามอำเภอใจ
โมเดล stackless เก็บ local state ไว้ใน shared stack จึงรันได้อย่างปลอดภัยเฉพาะตามลำดับ LIFO เท่านั้น
เพราะอย่างนั้นจึงต้องมี coloring และไม่สามารถ yield ได้อย่างอิสระแบบ stackful coroutine
โค้ดดูซับซ้อนเกินไปมาก
ดูเยิ่นเย้อกว่าเวลาจะเขียนด้วย Erlang, Elixir, Go หรือแม้แต่ C เสียอีก
ผมคิดว่านี่คล้ายกับ2-lock deadlock แบบพื้นฐาน
คิวรอของ Mutex ใน Tokio กับ task scheduling เข้าไปพันกันจนเกิดโครงสร้าง deadlock
ถ้าเป็น OS Mutex ก็น่าจะปลุก waiting thread ตัวอื่นขึ้นมาแล้วแก้ได้ แต่ใน async Rust มันยากเพราะโครงสร้าง state machine ของ future
จะคลายปัญหาด้วยการ poll future ในคิวรอทีละตัวก็อาจได้ แต่แบบนั้นก็อาจก่อให้เกิดผลข้างเคียงที่ไม่คาดคิดอีก
เคยมีประสบการณ์รับมือปัญหาแบบนี้ร่วมกันใน ecosystem ของ async Rust
ถ้าทำให้
select!ใช้ reference ไม่ได้ ก็จะหลีกเลี่ยงปัญหานี้ได้ แต่ก็จะทำให้ pattern แบบวนselect!ซ้ำโดยไม่เสียตำแหน่งในคิวเป็นไปไม่ได้เมื่อรวมกับปัญหา cancellation แล้ว เรื่องแบบนี้อาจกลายเป็นกับดักที่คาดไม่ถึงแม้แต่สำหรับผู้เชี่ยวชาญ Rust
ถึงอย่างนั้นมันก็ยังมีเรื่องน่าตกใจน้อยกว่าโค้ดแบบ callback-based มาก
ใช่เลย หลังจากทีมเราวิเคราะห์ deadlock นี้แล้ว ก็ได้คุยกันว่า “จะป้องกันเรื่องนี้ได้อย่างไร?” แต่สุดท้ายก็ได้ข้อสรุปว่าไม่ใช่ความผิดของใครเลย
primitive ทุกตัวของ Tokio ทำงานตามที่ออกแบบไว้ และโค้ดก็เขียนถูกต้อง แต่ปฏิสัมพันธ์ระหว่างกันกลับสร้าง deadlock ที่ไม่คาดคิดขึ้นมา
ถ้าห้าม
&mut futureในselect!ก็อาจป้องกันได้ แต่ก็จะไปขัดขวางโค้ดปกติที่ถูกต้องจำนวนมากด้วยสุดท้ายจึงได้ข้อสรุปแบบขมขื่นว่า “มันเป็นจุดที่ต้องระวังกันเอง”
การพูดคุยที่เกี่ยวข้องยังต่อเนื่องอยู่ในคอมเมนต์นี้
ถ้า
select!คืน future ที่ไม่ได้ถูกเลือกกลับมาโดยไม่dropมัน ก็อาจรักษาสถานะไว้ได้โดยไม่สูญหายแต่ถึงอย่างนั้นก็ยังใช้งานลำบาก และไม่ใช่วิธีแก้ที่ต้นเหตุ
สาเหตุจริงอยู่ที่ความไม่สมบูรณ์ของการจัดการ cancellation ตามที่อธิบายไว้ในเธรดนี้
คำถามใน FAQ ที่ว่า “future1 ไม่ถูกยกเลิกเหรอ?” น่าสนใจดี
cancellation มีสองขั้น — หยุด poll กับ drop
ในตัวอย่างนี้ drop ถูกหน่วงออกไป ทำให้ยังคงถือ guard อยู่และเกิดผลข้างเคียงตามมา
เลยคิดอยู่ว่าจะรับประกันได้ไหมให้สองการกระทำนี้เกิดพร้อมกันเสมอ
อยากถามคนออกแบบ Rust ว่า ทำไมถึงเลือกแพตเทิร์น async แทนactor model
พอใช้ Erlang แล้ว actor model รู้สึกสะอาดและปลอดภัยกว่ามาก
JS นั้นโครงสร้างภาษาบังคับให้ต้องใช้ async แต่ Rust เป็นภาษาใหม่ เลยสงสัยว่าทำไมถึงเลือกเดินเส้นทางนี้
การออกแบบ async ของ Rust มีเหตุผลใหญ่อย่างหนึ่งคือการรองรับสภาพแวดล้อมแบบ embedded
เพราะมันต้องทำงานได้โดยไม่ใช้ malloc หรือ thread จึงใช้ actor model ไม่ได้
จะเขียนโค้ดสไตล์ actor ด้วย Tokio ก็ได้ แต่ไม่ได้เป็นธรรมชาติเท่าไร
อีกเหตุผลหนึ่งคือประสิทธิภาพ
actor model มีต้นทุนจากการคัดลอก message สูง และ Rust เป็นภาษาระบบที่ให้ความสำคัญกับประสิทธิภาพ จึงมุ่งไปที่ zero-cost abstraction ผ่าน async state machine
Erlang กับ Go เป็นภาษาที่เลือก trade-off คนละแบบ
เพราะ Rust ไม่ต้องการยอมรับ overhead ตอนเรียก C FFI จึงตัดโมเดลแบบgreen threadทิ้งไป
async/await จะถูกคอมไพล์เป็น state machine จึงมี overhead ต่ำ
Go เองในช่วงแรกก็ไม่มี preemption และเคยมีปัญหาstarvationคล้ายกัน ก่อนที่ scheduler จะเข้ามาแก้
สุดท้ายแล้วแต่ละภาษาก็มีเป้าหมายและข้อจำกัดต่างกัน
ผมเองก็แปลกใจเหมือนกันที่ Oxide เลือกใช้ async
ในฝั่ง embedded หรือ HTTP server มันคุ้นเคยดี แต่ไม่คิดว่าบริษัทสายระบบอย่าง Oxide จะใช้ลึกขนาดนี้
ตอนอ่านเอกสารแล้วส่วนที่ไม่เข้าใจคือ ทำไมถึงเป็นเธรดหลัก ไม่ใช่ future ที่ถือ lock อยู่ที่ถูกปลุกขึ้นมา
ถ้าเป็น lock แบบยุติธรรม ก็น่าจะต้องปลุก future1 สิ Runtime ถึงเลือกอีกเธรดหนึ่งขึ้นมาทำไมจึงยังสงสัยอยู่
บทความน่าสนใจมากจริง ๆ
โค้ดตัวอย่างก็ชัดเจน และแม้การหาบั๊กแบบนี้จะเหมือนฝันร้าย แต่พอหาเจอแล้วก็มีความสะใจแบบจิ๊กซอว์ต่อครบ
เป็นภาพที่น่าประทับใจมากตอน Eliza, Sean, John และ Dave ช่วยกัน brainstorm จนเจอสาเหตุ
เราจะปล่อยตอนพอดแคสต์ที่พูดถึงเรื่องนี้ในวันจันทร์
ดูวิดีโอที่เกี่ยวข้องได้ที่ RFD 537 และลิงก์อีเวนต์นี้
มันดูเข้าใจยากและเป็นการออกแบบที่ผลิตบั๊กที่ Rust ไม่ได้ทำให้ทุก active task เดินหน้าไปพร้อมกัน
ถ้ามี structured concurrency แบบของ Trio ใน Python ก็น่าจะตรงไปตรงมามากกว่า
เลยสงสัยว่า Rust จะนำโมเดลแบบนั้นมาใช้ได้ไหม
ใน Rust ก็ทำ structured concurrency ได้ แต่ใช้ได้เฉพาะในระดับtask
future เป็นเพียง struct ที่จะคืบหน้าก็ต่อเมื่อถูก poll เท่านั้น จึงไม่มีแนวคิดเรื่อง “active future”
ถ้า spawn ทุกอย่างเป็น task ก็ดูเหมือนจะแก้ได้ แต่แบบนั้นก็ไปห้าม pattern ที่มีประโยชน์บางอย่างเช่นกัน
ความต่างระหว่าง task กับ futureสำคัญมาก
future ถ้าไม่ถูก poll ก็จะไม่ทำอะไรเลย
ถ้านิยาม cancellation ว่าเป็น “สถานะที่ไม่ถูก poll จนกว่าจะถูก drop” ก็จะเกิดกรณีแบบปัญหานี้ได้ คือมีfuture ที่ค้างอยู่พร้อมถือ lock
ตามปรัชญา RAII ของ Rust เราคาดหวังให้มีการ cleanup ตอน drop แต่ถ้าอยู่ในสถานะหยุด poll แม้แต่นั้นก็ยังไม่เกิดขึ้น
ช่วงนี้ยิ่งดูยิ่งรู้สึกว่า async ของ Rust เหมือนถูกปล่อยออกมาเร็วเกินไปหรือเปล่า
บางอย่างอย่าง Pin หรือ syntax บางส่วนอาจขัดเกลาได้ แต่โครงสร้างหลักไม่ได้จำเป็นต้องเปลี่ยน
มันยังเป็นแค่ช่วง “งานฐานรากที่ยังสร้างบ้านไม่เสร็จ” มากกว่า ไม่ใช่ผลจากการรีบปล่อย
เพียงแต่คิดว่ายังต้องมีชั้นล่างอย่าง coroutine แบบ generalized เพิ่มเติม