การจัดการการยกเลิกใน Rust แบบอะซิงโครนัส
(sunshowers.io)- การจัดการ การยกเลิก ในสภาพแวดล้อม Rust แบบอะซิงโครนัส นั้นสะดวก แต่หากจัดการไม่ดีอาจทำให้เกิดบั๊กที่ไม่คาดคิดและปัญหาที่ยุ่งยากได้
- ใน Rust แบบซิงโครนัส มักต้องตรวจสอบแฟล็กอย่างชัดเจนหรือจบโปรเซส แต่ใน Rust แบบอะซิงโครนัส สามารถยกเลิกได้ง่ายมากเพียงแค่ drop future
- ความปลอดภัยเมื่อยกเลิก (cancel safety) และความถูกต้องของการยกเลิก (cancel correctness) เป็นคนละแนวคิดกัน โดยการยกเลิก future หนึ่งตัวอาจก่อปัญหาต่อทั้งระบบได้
- รูปแบบปัญหาหลักที่เกี่ยวกับการยกเลิก ได้แก่ Tokio mutex, แมโคร
select,try_joinและการใช้งาน future ที่ผิดพลาด - แม้จะยังไม่มีวิธีแก้ที่สมบูรณ์ แต่สามารถ ลดปัญหาที่เกิดจากการยกเลิกได้ ด้วยการใช้ API ที่ปลอดภัยต่อการยกเลิก, การ pin future และการแยก task
บทนำ
- โพสต์นี้อ้างอิงจากเนื้อหาการบรรยาย RustConf 2025 เรื่อง การจัดการการยกเลิก (cancellation) ใน Rust แบบอะซิงโครนัส
- ในตัวอย่างโค้ดอะซิงก์ของ Rust ทั่วไป เมื่อเพิ่ม timeout เข้าไปในลูปรับหรือส่งข้อความ มักพบปัญหาข้อความสูญหายอยู่บ่อยครั้ง
- กล่าวถึงปัญหาการยกเลิกและกรณีบั๊กจริงที่เกิดขึ้นจากการใช้งาน async Rust ในระบบขนาดใหญ่จริง เช่นที่ Oxide Computer Company
- บทความแบ่งเป็น 3 ส่วน: 1) แนวคิดเรื่องการยกเลิก 2) การวิเคราะห์การยกเลิก 3) วิธีแก้เชิงปฏิบัติ
- ผู้เขียนเคยประสบทั้งข้อดีและความยากของ Rust แบบอะซิงโครนัส ผ่านงานด้าน Rust signal handling และการพัฒนา cargo-nextest
1. การยกเลิกคืออะไร?
ความหมายของการยกเลิก
- การยกเลิก (cancellation) คือสถานการณ์ที่เริ่มงานอะซิงโครนัสบางอย่างแล้วหยุดมันระหว่างทาง
- ตัวอย่าง: การดาวน์โหลดขนาดใหญ่/คำขอเครือข่าย, การอ่านไฟล์บางส่วน ซึ่งอาจถูกยกเลิกกลางคันได้
วิธีการยกเลิกใน Rust แบบซิงโครนัส
- โดยทั่วไปจะใช้ atomic flag เพื่อตรวจสอบเป็นระยะว่าควรยกเลิกหรือไม่ หรือใช้ข้อยกเว้นพิเศษอย่าง panic หรือบังคับจบทั้งโปรเซส
- บางเฟรมเวิร์ก (เช่น Salsa) ใช้ panic payload แต่ไม่สามารถทำงานได้บนทุกแพลตฟอร์มของ Rust (โดยเฉพาะสภาพแวดล้อมอย่าง Wasm)
- การบังคับหยุดเฉพาะเธรดไม่ถูกอนุญาต เนื่องจากข้อกำหนดด้านความปลอดภัยของ Rust และโครงสร้าง mutex
- สรุปคือ ใน Rust แบบซิงโครนัส ยังไม่มีโปรโตคอลการยกเลิกที่ใช้ได้ทั่วไปและปลอดภัย
ใน Rust แบบอะซิงโครนัส: Future คืออะไร?
- Future คือ state machine ที่คอมไพเลอร์ Rust สร้างขึ้น ซึ่งเป็นเพียงข้อมูลธรรมดาในหน่วยความจำ
- การสร้าง future อย่างเดียวไม่ได้ทำให้มันรัน และจะคืบหน้าก็ต่อเมื่อมีการเรียก await หรือ poll เท่านั้น
- Future ของ Rust เป็นสิ่งที่ไม่ทำงานเอง (inert) หากไม่มีการ poll/await อย่างชัดเจน มันจะไม่ประมวลผลงานใดๆ
- ต่างจาก Go/JavaScript/C# ที่เมื่อสร้าง future แล้วมักเริ่มทำงานทันที
โปรโตคอลการยกเลิกของ Rust แบบอะซิงโครนัส
- การ ยกเลิก Future ทำได้ง่ายๆ ด้วยการ drop มันทิ้ง หรือหยุดเรียก poll/await ต่อ
- เพราะมันเป็น state machine จึงสามารถทิ้ง Future ได้ทุกเมื่อ
- ใน Rust แบบอะซิงโครนัส การยกเลิกจึงทรงพลังและใช้งานได้ง่ายมาก
- แต่ก็ ง่ายเกินไป จน future อาจถูก drop อย่างเงียบๆ และทำให้ child future ถูกยกเลิกต่อเนื่องตามไปด้วยตามโมเดล ownership
- ด้วยคุณสมบัตินี้ การยกเลิกจึงเป็นปรากฏการณ์แบบ ไม่จำกัดอยู่เฉพาะจุด (non-local) และส่งผลต่อทั้งสายการเรียกได้
2. การวิเคราะห์การยกเลิก
ความปลอดภัยเมื่อยกเลิก และความถูกต้องของการยกเลิก
- ความปลอดภัยเมื่อยกเลิก (cancel safety): คุณสมบัติที่ future แต่ละตัวสามารถถูกยกเลิกได้อย่างปลอดภัยโดยไม่มีผลข้างเคียง
- ตัวอย่าง: future ของ
Tokioสำหรับ sleep มี cancel safety - ในทางกลับกัน
MPSC sendของ Tokio มีความเสี่ยงที่ข้อความจะสูญหายเมื่อถูก drop (จึงไม่มี cancel safety)
- ตัวอย่าง: future ของ
- ความถูกต้องของการยกเลิก (cancel correctness): เป็นคุณสมบัติระดับระบบโดยรวมว่าเมื่อเกิดการยกเลิกแล้ว ระบบยังคงรักษาคุณสมบัติสำคัญไว้ได้
- ถ้า future ที่ไม่ปลอดภัยต่อการยกเลิก ไม่มีอยู่ในระบบ ก็จะไม่มีปัญหาเรื่อง correctness
- ปัญหาจะเกิดก็ต่อเมื่อ future ที่ไม่ cancel-safe ถูกยกเลิกจริง
- หากการยกเลิกทำให้ข้อมูลสูญหาย, invariant ถูกละเมิด หรือ cleanup ไม่ครบ ก็ถือว่าละเมิด cancel correctness
ความยากของ Tokio mutex
- Tokio mutex ทำงานโดยล็อกข้อมูล ปรับสถานะข้อมูล แล้วจึงปล่อยล็อก
- ปัญหาคือ ภายในล็อกอาจมีการทำให้สถานะผิด invariant ชั่วคราว (เช่นเปลี่ยน
Option<T>เป็นNone) แล้วหากผ่านawaitและ future ถูกยกเลิก ข้อมูลอาจค้างอยู่ในสถานะที่ผิด - ในงานจริง (เช่นการจัดการสถานะ sled ที่ Oxide) เคยเกิดสถานะไม่เสถียรขึ้นจากการยกเลิกที่จุด
await - นี่ทำให้เห็นว่า การยกเลิกเป็นสาเหตุของข้อบกพร่องที่อันตรายมากในการจัดการสถานะของโค้ดอะซิงโครนัส
รูปแบบและตัวอย่างของการเกิดการยกเลิก
- เรียก future โดยลืม
.await: Rust จะเตือนสำหรับ future ที่ไม่ได้ใช้งาน แต่ถ้ารับค่าที่คืนมาเป็น_อาจไม่มีคำเตือน (ควรใช้ lint ล่าสุดของ Clippy) - การดำเนินการแบบ try เช่น
try_join: เมื่อ future หนึ่งล้มเหลว จะทำให้ future ที่เหลือถูกยกเลิกด้วย (และเคยกลายเป็นบั๊กในลอจิกหยุดบริการจริง) - แมโคร
select: เมื่อรันหลาย future แบบขนาน future ที่ไม่ใช่ตัวที่เสร็จก่อนจะถูกยกเลิกทั้งหมด (เสี่ยงสูงต่อข้อมูลสูญหายในลูปselect) - แม้รูปแบบเหล่านี้จะถูกกล่าวถึงในเอกสาร แต่ในทางปฏิบัติการยกเลิกแบบอะซิงก์สามารถเกิดขึ้นโดยปริยายได้ในหลายจุด
3. เราทำอะไรได้บ้าง?
- ปัจจุบันยังไม่มีวิธีแก้ปัญหา cancel correctness ที่เป็นรากฐานและสมบูรณ์
- แต่ในทางปฏิบัติ สามารถลดโอกาสเกิดข้อบกพร่องจากการยกเลิกได้ด้วยวิธีต่อไปนี้
ปรับโครงสร้างให้เป็น future ที่ปลอดภัยต่อการยกเลิก
- ตัวอย่าง
MPSC send: แยกการจอง (reserve) ออกจากการส่งจริง (send) เพื่อให้มี cancel safety บางส่วน- การจองสามารถถูกยกเลิกได้โดยไม่ทำให้ข้อความสูญหาย
- เมื่อได้ permit แล้ว ก็สามารถส่งได้โดยไม่ต้องกังวลเรื่องการยกเลิก
- สำหรับ
AsyncWriteของwrite_all: การเขียนทั้งบัฟเฟอร์ด้วยwrite_allไม่เสถียรต่อการยกเลิก แต่write_all_bufใช้ cursor ของบัฟเฟอร์เพื่อติดตามความคืบหน้าเมื่อถูกยกเลิก- ทำให้สามารถกลับมาทำงานต่อจากความคืบหน้าบางส่วนในลูปได้อย่างปลอดภัยด้วย
write_all_buf
- ทำให้สามารถกลับมาทำงานต่อจากความคืบหน้าบางส่วนในลูปได้อย่างปลอดภัยด้วย
การใช้งาน future โดยหลีกเลี่ยงการยกเลิก
- future pinning: ในลูป
selectเป็นต้น สามารถตรึง future ด้วย pin เพื่อไม่ให้ถูกยกเลิก และ poll ผ่านการอ้างอิงแทน- ตัวอย่าง: หากนำ future ของ
reserveกลับมาใช้ซ้ำ ก็จะยังคงลำดับคิวรอการจองไว้ได้
- ตัวอย่าง: หากนำ future ของ
- การใช้ task: หากรัน future เป็น task ด้วย
tokio::spawnเป็นต้น ต่อให้ drop handle ไป task เองก็ยังถูกจัดการต่อโดย runtime และจะไม่ถูกบังคับยกเลิกทันที- ตัวอย่างเช่นในเซิร์ฟเวอร์ HTTP ของ Oxide Dropshot จะรันแต่ละคำขอเป็น task แยก ทำให้แม้การเชื่อมต่อของไคลเอนต์จะหลุดไป ก็ยังรับประกันได้ว่าการประมวลผลคำขอจะเสร็จสิ้น
แนวทางแก้อย่างเป็นระบบ?
- ในระดับ safe Rust ปัจจุบันยังมีข้อจำกัด แต่มีแนวทางที่กำลังถูกพูดถึงอยู่ เช่น
- Async drop: อนุญาตให้รันโค้ด cleanup แบบอะซิงโครนัสเมื่อ future ถูกยกเลิก
- Linear types: บังคับให้มีการรันโค้ดบางอย่างเมื่อ drop หรือระบุว่า future บางตัวห้ามยกเลิก
- อย่างไรก็ตาม ทุกแนวทางเหล่านี้ยังมีความยากด้านการนำไปใช้งานจริง
บทสรุปและข้อแนะนำ
- ต้องเข้าใจให้ชัดว่าคุณสมบัติพื้นฐานของ Future คือเป็นสิ่งที่ไม่ทำงานเอง (passive)
- ควรทำความเข้าใจแนวคิด cancel safety และ cancel correctness
- ควรรู้จักกรณีบั๊กจากการยกเลิกและรูปแบบโค้ดสำคัญ เพื่อเตรียมกลยุทธ์รับมือไว้ล่วงหน้า
- ข้อแนะนำเชิงปฏิบัติบางส่วน
- หลีกเลี่ยงการใช้
Tokio mutexและพิจารณาทางเลือกอื่น - ออกแบบหรือใช้ API แบบ partially-complete หรือ API ที่ปลอดภัยต่อการยกเลิก
- สำหรับ future ที่ไม่ cancel-safe ควรใช้โครงสร้างโค้ดที่รับประกันว่ามันจะทำงานจนเสร็จ
- หลีกเลี่ยงการใช้
- นอกจากนี้ยังควรพิจารณาหัวข้อเชิงลึกอย่าง cooperative cancellation, actor model, structured concurrency, panic safety และ mutex poisoning
- ดูข้อมูลเพิ่มเติมได้ที่ sunshowers/cancelling-async-rust
ขอบคุณที่อ่าน และขอขอบคุณเพื่อนร่วมงานที่ Oxide ที่ช่วยตรวจทานการบรรยายและเอกสารอ้างอิง รวมถึงให้ข้อเสนอแนะ
1 ความคิดเห็น
ความคิดเห็น Hacker News
กรณีเหล่านี้ทั้งหมดบริบทถูก cancel จนงานไม่เสร็จ ถือเป็นพฤติกรรมที่คาดได้อยู่แล้ว ถ้างานนั้นจำเป็นต้องเสร็จจริงๆ ก็แยกไปเป็น task อิสระก็พอ ผมเลยสงสัยว่าตัวเองกำลังพลาด nuance สำคัญอะไรไปหรือเปล่า เดิมทีผมเข้าใจว่าการที่ work หายไปเพราะ cancellation นั้นเป็นเจตนาของการออกแบบ futures อยู่แล้ว อยากให้ช่วยชี้อีกทีว่าปัญหามันอยู่ตรงไหน