Async Rust ไม่เคยหลุดพ้นจากสถานะ MVP
(tweedegolf.nl)- Async Rust ช่วยให้โค้ดที่ไม่ขึ้นกับ executor สามารถรันได้ทั้งบนเซิร์ฟเวอร์และไมโครคอนโทรลเลอร์ แต่ state machine ที่คอมไพเลอร์สร้างขึ้นทำให้ขนาดไบนารีเพิ่มขึ้นอย่างชัดเจน โดยเฉพาะในงาน embedded
- แม้แต่ตัวอย่างง่าย ๆ อย่าง
bar()ที่มีจุด await 2 จุด ก็ยังสร้าง MIR 360 บรรทัด และสถานะUnresumed,Returned,Panicked,Suspend0,Suspend1ขณะที่เวอร์ชัน synchronous ต้องใช้เพียง 23 บรรทัด - หากเปลี่ยนให้ future ที่เสร็จแล้วเมื่อถูก poll ซ้ำคืนค่า
Poll::Pendingแทนpanicก็ยังคงเป็นไปตามสัญญาโดยไม่เกิด unsafe behavior และจากการทดลองพบว่า ขนาดไบนารีของ embedded firmware ลดลง 2%~5% - แม้
async { 5 }ที่ไม่มี await ปัจจุบันก็ยังสร้าง state machine ที่มี 3 สถานะพื้นฐาน แต่หากปรับให้คืนPoll::Ready(5)ทุกครั้งแทน จะทำให้ขนาดไบนารี embedded ลดลง 0.2% - Project Goal ที่เสนอขึ้นมามุ่งผลักดันงานในคอมไพเลอร์ เช่น ตัด panic หลังเสร็จสิ้นในโหมด release, ตัด state machine ของ async block ที่ไม่มี await, inline future ที่มี await เดียว และรวมสถานะที่ซ้ำกัน
ปัญหาความเทอะทะระดับคอมไพเลอร์ของ Async Rust
- Async Rust ช่วยให้โค้ดที่ไม่ขึ้นกับ executor สามารถทำงานได้พร้อมกันทั้งบนเซิร์ฟเวอร์และไมโครคอนโทรลเลอร์ แต่บนไมโครคอนโทรลเลอร์ขนาดเล็ก การเพิ่มขึ้นของขนาดไบนารีจะเห็นได้ชัดเป็นพิเศษ
- บล็อก Rust เคยแนะนำ async/await ว่าเป็น zero-cost abstraction แต่ในทางปฏิบัติ async กลับสร้างความเทอะทะ (bloat) จำนวนมาก และแม้เดสก์ท็อปกับเซิร์ฟเวอร์ก็มีปัญหาเดียวกัน เพียงแต่มีหน่วยความจำและทรัพยากรประมวลผลมากพอจนมองเห็นได้น้อยกว่า
- ต่อจาก วิธีเลี่ยง เพื่อลดความเทอะทะเวลาที่เขียนโค้ด async จึงมีการยื่น Project Goal เพื่อแก้ปัญหานี้ในคอมไพเลอร์
- ปัญหาที่ future มีขนาดใหญ่เกินจำเป็นและมีการคัดลอกมากเกินไปไม่ได้อยู่ในขอบเขตนี้
- ปัญหานี้เป็นที่รู้จักอยู่แล้ว และมี PR ที่เปิดไว้เพื่อจัดการบางส่วน: https://github.com/rust-lang/rust/pull/135527
โครงสร้างของ future ที่ถูกสร้างขึ้น
- โค้ดตัวอย่างคือ
foo()คืนค่าasync { 5 }และbar()ทำfoo().await + foo().await- ตัวอย่าง Godbolt: godbolt
barมีจุด await 2 จุด ดังนั้น state machine จึงต้องมีอย่างน้อย 2 สถานะ แต่ในความเป็นจริงกลับมีสถานะมากกว่านั้น- Rust compiler สามารถ dump MIR ได้หลาย pass และ pass
coroutine_resumeคือ MIR pass สุดท้ายที่เฉพาะกับ async- async ยังคงอยู่ใน MIR แต่ไม่เหลืออยู่ใน LLVM IR แล้ว ดังนั้นกระบวนการแปลง async เป็น state machine จึงเกิดขึ้นใน MIR pass
- ฟังก์ชัน
barสร้าง MIR 360 บรรทัด ขณะที่เวอร์ชัน synchronous ใช้เพียง 23 บรรทัด CoroutineLayoutที่คอมไพเลอร์แสดงออกมานั้นโดยพื้นฐานคือชุดสถานะแบบ enumUnresumed: สถานะเริ่มต้นReturned: สถานะที่เสร็จสิ้นแล้วPanicked: สถานะหลัง panicSuspend0: จุด await แรก และเก็บ future ของfooSuspend1: จุด await ที่สอง และเก็บผลลัพธ์แรกพร้อม future ของfooตัวที่สอง
Future::pollเป็นฟังก์ชันที่ปลอดภัย ดังนั้นแม้จะถูกเรียกอีกครั้งหลัง future เสร็จแล้ว ก็ต้องไม่ก่อให้เกิด UB- ปัจจุบัน หลัง
Suspend1จะคืนReadyและเปลี่ยน future ไปเป็นสถานะReturned - หาก poll อีกครั้งในสถานะนี้ จะเกิด panic
- ปัจจุบัน หลัง
- สถานะ
Panickedดูเหมือนมีไว้เพื่อป้องกันไม่ให้ future ถูก poll อีกครั้ง หลัง async function panic แล้วถูกจับด้วยcatch_unwind- เพราะหลัง panic แล้ว future อาจอยู่ในสถานะที่ไม่สมบูรณ์ และการ poll ซ้ำอาจนำไปสู่ UB
- กลไกนี้คล้ายกับ mutex poisoning มาก
- การตีความสถานะ
Panickedแบบนี้ยังไม่มีเอกสารยืนยันชัดเจน จึงมั่นใจประมาณ 90%
การ poll หลังเสร็จสิ้นจำเป็นต้อง panic หรือไม่
- future ในสถานะ
Returnedปัจจุบันจะ panic แต่ไม่ได้จำเป็นว่าต้องเป็นแบบนั้นเสมอไป- เงื่อนไขที่จำเป็นจริง ๆ คือห้ามก่อให้เกิด UB
- panic มีต้นทุนค่อนข้างสูง และเพิ่มเส้นทางที่มีผลข้างเคียงซึ่งยากต่อการลบออกด้วยการ optimize
- หาก future ที่เสร็จแล้วเมื่อถูก poll ซ้ำคืน
Poll::Pendingก็จะยังคงเป็นไปตามสัญญาของชนิดFutureโดยไม่เกิด unsafe behavior - เมื่อแก้คอมไพเลอร์เพื่อทดลองแนวทางนี้ พบว่าใน embedded firmware ที่ใช้ async ขนาดไบนารีลดลง 2%~5%
- มีข้อเสนอให้เปิดพฤติกรรมนี้เป็นสวิตช์คล้าย
overflow-checks = falseของ integer overflow- ใน debug build ยังคง panic ต่อไปเพื่อให้พฤติกรรมที่ผิดถูกเปิดเผยทันที
- ใน release build จะได้ future ที่มีขนาดเล็กลง
- เมื่อใช้
panic=abortอาจมีโอกาสตัดสถานะPanickedออกไปได้เลย แต่ผลกระทบยังต้องพิจารณาเพิ่มเติม
ต่อให้ไม่มี await ก็ยังสร้าง state machine เสมอ
foo()คืนค่าเพียงasync { 5 }ดังนั้นรูปแบบที่เหมาะที่สุดหากเขียนเองคือ future ที่ไม่มีสถานะ และคืนPoll::Ready(5)ทุกครั้ง- แต่ MIR ที่คอมไพเลอร์สร้างขึ้นยังคงมี 3 สถานะพื้นฐานคือ
Unresumed,Returned,Panicked- ตอน poll จะตรวจ discriminant ของสถานะปัจจุบันแล้วจึงแตกแขนง
- หาก poll อีกครั้งหลังเสร็จ จะ panic ด้วย assert
`async fn` resumed after completion
- ในกรณีนี้สามารถ optimize ได้โดยไม่สร้าง state machine และคืน
Poll::Ready(5)ทุกครั้งแทน - เมื่อลองใส่การเปลี่ยนแปลงนี้ในคอมไพเลอร์แบบทดลอง พบว่าขนาดไบนารี embedded ลดลง 0.2%
- แม้การลดลงจะไม่มาก แต่เป็น optimization ที่เรียบง่าย จึงอาจคุ้มค่าที่จะนำมาใช้
- optimization นี้เปลี่ยนพฤติกรรมเล็กน้อย แต่ผู้ที่ได้รับผลกระทบมีเพียง executor ที่ไม่ทำตามข้อกำหนดเท่านั้น
- ปัจจุบันคอมไพเลอร์จะ panic เมื่อมีการ poll ภายหลัง
- หลัง optimize แล้ว future จะคืน
Readyเสมอ
พึ่งพา LLVM อย่างเดียวไม่เพียงพอ
- แม้ผลลัพธ์ MIR จะไม่มีประสิทธิภาพ แต่บางครั้ง LLVM ก็สามารถจัดการให้ได้ทั้งหมด อย่างไรก็ดีเงื่อนไขค่อนข้างจำกัด
- future ต้องเรียบง่ายพอ
- ต้องใช้
opt-level=3
- เมื่อ future ซับซ้อนขึ้น LLVM จะลบส่วนเกินออกไม่ได้ และเพราะโค้ด async Rust ตามแบบที่ใช้กันทั่วไปมักมี future ซ้อนลึก ความซับซ้อนจึงเพิ่มขึ้นอย่างรวดเร็ว
- ในสภาพแวดล้อมที่มัก optimize เพื่อขนาดอย่าง embedded หรือ wasm, LLVM ไม่สามารถ optimize ทุกอย่างได้หมด
- ตัวอย่าง Godbolt: https://godbolt.org/z/58ahb3nne
- ใน assembly ที่สร้างขึ้น LLVM รู้ว่า
fooคืนค่า 5 แต่ยัง optimize คำตอบของbarให้เป็น 10 ไม่ได้ - การเรียก poll ของ
fooก็ยังคงอยู่ - สาเหตุคือยังมีเส้นทาง panic ที่เป็นไปได้ซึ่งคอมไพเลอร์ไม่สามารถสรุปทิ้งได้ทั้งหมด
- LLVM ไม่รู้ว่าแท้จริงแล้ว
fooถูกเรียกเพียงครั้งเดียวและไม่ panic
- ใน assembly ที่สร้างขึ้น LLVM รู้ว่า
- หากคอมเมนต์ทิ้ง branch ที่ panic ใน IR จะ optimize ได้ดีขึ้น: https://godbolt.org/z/38KqjsY8E
- แทนที่จะหวังการ optimize ภายหลังจาก LLVM คอมไพเลอร์ควรส่งอินพุตที่ดีกว่าให้ LLVM ตั้งแต่แรก
การ inline future ยังทำได้ไม่ดี
- การ inline สำคัญเพราะเปิดทางให้ optimization pass อื่น ๆ ทำงานต่อได้ แต่ future ที่ Rust สร้างขึ้นในปัจจุบันยังไม่ถูก inline ในช่วงต้น
- หลังแต่ละ future ได้ implementation แล้ว LLVM และ linker จึงค่อยมีโอกาส inline แต่จากปัญหาก่อนหน้านี้ เวลานั้นถือว่าช้าเกินไป
- โอกาส inline ที่ตรงไปตรงมาที่สุดคือรูปแบบที่
bar()ทำเพียงfoo(blah).await- เป็นแพตเทิร์นที่พบได้บ่อยเมื่อสร้าง abstraction ด้วย trait
- ปัจจุบันคอมไพเลอร์จะสร้าง state machine สำหรับ
barแล้วค่อยเรียก state machine ของfooข้างใน - ที่มีประสิทธิภาพกว่าคือให้
barกลายเป็น future ของfooเอง
- กรณีที่มี preamble และ postamble จะซับซ้อนขึ้น
- เช่น
bar(input)สร้างblahจากinput > 10จากนั้นfoo(blah).awaitแล้วนำผลลัพธ์ไป* 2 - พบได้บ่อยเวลาที่แปลง async function ไปเป็น signature อื่น โดยเฉพาะใน trait implementation
- เช่น
barในรูปแบบนี้ก็ยังไม่จำเป็นต้องมีสถานะ async ของตัวเอง- เพราะไม่มีข้อมูลใดที่ต้องถูกเก็บข้ามจุด await เดียว นอกจากค่าที่ถูกจับไว้ใน
foo - เพียงแต่
barจะกลายเป็นfooตรง ๆ ไม่ได้ และต้องพึ่งพาสถานะส่วนใหญ่จากfoo
- เพราะไม่มีข้อมูลใดที่ต้องถูกเก็บข้ามจุด await เดียว นอกจากค่าที่ถูกจับไว้ใน
- หากเขียนเอง
BarFutอาจมีสถานะUnresumed { input }และInlined { foo: FooFut }- ในการ poll ครั้งแรก จะรัน preamble เพื่อสร้าง
foo(blah)แล้วเปลี่ยนเป็นสถานะInlined - หลังจากนั้นจะนำผลลัพธ์ของ
foo.poll(cx)ไปผ่าน postamble
- ในการ poll ครั้งแรก จะรัน preamble เพื่อสร้าง
- หากสามารถรันโค้ดล่วงหน้าก่อนถึง await จุดแรกได้ ก็อาจตัดสถานะ
Unresumedออกได้ด้วย แต่มีการรับประกันว่า future จะไม่ทำอะไรเลยก่อนถูก poll จึงเปลี่ยนไม่ได้ - หากสามารถ query คุณสมบัติของ future ที่กำลังถูก poll ได้ ก็จะเปิดทางให้มี optimization การ inline เพิ่มเติม
- ตัวอย่างเช่น หากรู้ว่า future คืนค่า ready เสมอในการ poll ครั้งแรก ก็ไม่จำเป็นที่ future ฝั่งผู้เรียกจะต้องสร้างสถานะสำหรับ await จุดนั้น
- หากนำ optimization แบบนี้ไปใช้แบบ recursive ก็อาจยุบ future จำนวนมากให้กลายเป็น state machine ที่เรียบง่ายกว่ามากได้
- จากโครงสร้างของ
rustcในปัจจุบัน ดูเหมือนว่า async block แต่ละก้อนจะถูกแปลงแยกกัน และข้อมูลที่เกี่ยวข้องจะไม่ถูกเก็บต่อไว้ จึงไม่สามารถ query แบบนี้ได้ - future inline ยังไม่ได้ถูกทดลองจริง แต่คาดว่าจะช่วยได้มากทั้งกับขนาดไบนารีและประสิทธิภาพ
การรวมสถานะที่เหมือนกัน
- สำหรับแต่ละจุด await ใน async block state machine จะมีสถานะเพิ่มขึ้น
- โค้ดลักษณะต่อไปนี้เขียนได้เป็นธรรมชาติ แต่เพราะ await async function เดียวกันในสอง branch จึงเกิดสถานะที่เหมือนกัน 2 ชุด
CommandId::A => send_response(123).awaitCommandId::B => send_response(456).await
- ในกรณีนี้
CoroutineLayoutจะมี_s0,_s1ซึ่งเก็บ coroutine type เดียวกันของsend_responseและมีสถานะSuspend0,Suspend1สองสถานะ - MIR ของฟังก์ชันนี้มีความยาว 456 บรรทัด และหลาย basic block ก็แทบจะซ้ำกันทั้งหมด
- หาก refactor เองโดยคำนวณเฉพาะค่าตอบกลับก่อน แล้วค่อยเรียก
send_response(response).awaitเพียงครั้งเดียว สถานะซ้ำเหล่านี้จะหายไปCommandId::Aคือ123CommandId::Bคือ456- จากนั้น
send_response(response).await
- หลัง refactor แล้ว
CoroutineLayoutจะเหลือ future ที่เก็บไว้เพียงตัวเดียว และเหลือแค่สถานะSuspend0สถานะเดียว - ความยาว MIR ทั้งหมดลดลงเหลือ 302 บรรทัด และความซ้ำซ้อนหายไป
- ดังนั้น optimization pass ที่ค้นหาเส้นทางโค้ดและสถานะที่เหมือนกันแล้วรวมเข้าด้วยกันจึงดูมีประโยชน์
- optimization นี้มีแนวโน้มว่าจะทำงานร่วมกับ future inline pass ได้ดี
ลิงก์การทดลองและ benchmark เพิ่มเติม
- เมื่อนำการทดลองทั้งสองอย่างมาใช้ร่วมกัน จะได้ ประสิทธิภาพดีขึ้นราว 3% บน synthetic benchmark ของ x86 ที่ใช้ executor
smol - No panics in poll after ready: https://github.com/rust-lang/rust/compare/main...diondokter:rust:resume-pending
- No await, no statemachine: https://github.com/rust-lang/rust/compare/main...diondokter:rust:no-statemachine-when-no-await
ขอการสนับสนุน Project Goal
- งานนี้ถูกยื่นเป็น Project Goal เพื่อดำเนินการในคอมไพเลอร์: https://rust-lang.github.io/rust-project-goals/2026/async-statemachine-optimisation.html
- หากไม่มีเงินทุนก็ยากที่จะเดินหน้างานได้มาก จึงต้องการการสนับสนุนบางส่วนหรือทั้งหมดจากบริษัทหรือองค์กรที่จะได้รับประโยชน์จากงานนี้
- ติดต่อได้ที่
dion@tweedegolf.com - ขอบเขตงานและขนาดเงินทุนที่ต้องการยังยืดหยุ่นได้ แต่คาดว่า €30k น่าจะเพียงพอสำหรับทำงานทั้งหมดหรืออย่างน้อยส่วนใหญ่ให้เสร็จ
1 ความคิดเห็น
ความเห็นจาก Lobste.rs
พอเห็นแค่ชื่อเรื่องก็คิดไว้แบบหนึ่ง แต่บทความนี้ สร้างสรรค์กว่าที่คาดไว้มาก
หวังว่าคนที่อยากทำงานนี้จะได้รับการสนับสนุนที่จำเป็น
ดีใจที่เห็นว่าปัญหานี้กำลังถูกหยิบมาจัดการ ก่อนหน้านี้เคยเห็นหลายบทความบอกว่า rustc โยนโค้ดให้ LLVM มากเกินไป แล้วคาดหวังให้ตัว optimize จัดการทุกอย่างให้ โดยเฉพาะบทความนี้ยังขอ เงินสนับสนุน สำหรับงานดังกล่าวด้วย
โอ้โห ฉันนี่โง่เอง
ฉันคิดมาตลอดว่า async มัน “อ้วน” โดยเนื้อแท้ เพราะไม่ว่ารูปแบบไหนก็ต้องมี runtime, การติดตามงาน, และการ polling เพื่อตรวจว่าทำเสร็จหรือยัง
เพราะ overhead นั้นมันไม่เป็นศูนย์อยู่แล้ว ฉันเลยมองว่า “zero-cost abstraction” ที่พูดกันเป็นเรื่องของฟีเจอร์ภาษา และแยกจาก runtime ที่พ่วงเข้ามา
ไม่เคยนึกเลยว่าจะต้องไปดูว่า rustc ปล่อยอะไรออกมาก่อนส่งให้ LLVM
สำหรับคนที่ไม่คุ้นกับ async Rust:
อันนี้จริงมาก ต้นไม้ของการเรียก async ที่ซ้อนกัน แม้จะลึกแค่ไหน ถ้าผ่านการ optimize เต็มที่แล้วก็จะยุบกลายเป็น struct เดียว ที่มี state machine อยู่ข้างใน เป็นวิธีที่ฉลาดมากจริงๆ
ถ้าไปถึงกรณีนี้ใน release build มันจะกลายเป็น deadlock แบบหนึ่งหรือเปล่า? หรืออาจเกิดการรั่วเพราะมี task ที่รอ future ที่เป็น
Pendingตลอดเวลา?การ poll ผิดผ่าน
.awaitทำไม่ได้มีความคิดอยู่สองสามข้อ:
panic=unwindอยู่ดี นอกจาก test harness บางแบบแล้ว แทบไม่เคยเห็นข้อดีที่มากพอจะชดเชยต้นทุนเมื่อเทียบกับpanic=abortเลย แม้แต่ test harness เอง บน Linux ก็ดูเหมือนจะใช้ทางเลือกคล้ายกันได้ เช่น ใช้cloneแบบแปลกๆ แล้วwaitเฉพาะ thread ที่รัน แทนpthread_joinตรงนี้ฉันอาจเข้าใจผิดก็ได้ลิงก์นี้เพิ่งใช้ไม่ได้สำหรับคนอื่นด้วยไหม?
แก้ไข: หน้า blog post โผล่มาได้ประมาณครึ่งวินาทีแล้วก็เด้งไปหน้า 404
แก้ไข 2: เข้าไปที่รายการบทความในบล็อกแล้วลองกดไปหลายอัน พอเปิดบทความนั้นจากในรายการก็ยังไปหน้า 404 เหมือนเดิม จะทำบล็อกที่เป็น static page หรืออย่างน้อยควรจะเป็นแบบนั้น ให้พังได้ขนาดนี้ยังไงกัน?
สำหรับข้อมูลเพิ่มเติม ฉันลองทำตามขั้นตอนเดิมเหมือนกันแล้ว แต่ไม่เจอ 404 เลย ทั้งบนมือถือและเดสก์ท็อป และลองทั้งเปิดกับปิด JavaScript แล้วด้วย เพราะงั้นอาการที่เจออาจซับซ้อนกว่าที่เห็นก็ได้