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 น่าจะเพียงพอสำหรับทำงานทั้งหมดหรืออย่างน้อยส่วนใหญ่ให้เสร็จ
2 ความคิดเห็น
ความคิดเห็นจาก Hacker News
เห็นด้วยว่าพาดหัวค่อนข้างเวอร์ไปหน่อย แต่ตัวบทความเขียนดีและสื่อประเด็นได้ดี
ผมยังมีประสบการณ์กับ Rust async ไม่มากพอจะออกความเห็นแรง ๆ แต่มีบางอย่างที่สะดุดตา
ข้อดีคือสามารถมี runtime แบบ explicit ได้ แทนที่จะทำให้ทั้งโปรเจ็กต์ปนเปื้อนเป็น async ไปหมด ก็ปล่อยให้ค่าเริ่มต้นเป็น synchronous แล้วใช้ runtime เฉพาะที่ “ขอบเขต” ของ I/O เท่านั้น
สำหรับโปรเจ็กต์ที่กำลังทำ วิธีนี้เข้ากันได้ดี และดูค่อนข้างคล้ายกับแนวทางที่ Zig ใช้กับโค้ด I/O ด้วย ในกรณีนี้ปัญหา function coloring ก็แทบหายไป และเพราะต้องแยก I/O ออกจากโค้ดที่เน้น CPU อย่างเคร่งครัดอยู่แล้ว การมี I/O runtime แบบ explicit จึงดูเป็นธรรมชาติ
ข้อเสียคือทั้ง ecosystem ดูเหมือนจะพึ่งพา tokio มากเกินไป คล้ายกับกรณีที่ Java มี GC เป็นตัวเลือก แต่ในทางปฏิบัติทุกคนกลับใช้ third-party GC runtime ตัวเดียวกัน และไม่ว่าจะดึงไลบรารีอะไรมาก็โดนบังคับให้ใช้ runtime นั้น การพึ่งพาแบบรวมศูนย์เช่นนี้ไม่ค่อยดีต่อสุขภาพของ ecosystem
ความต้องการของ async runtime บนโปรเซสเซอร์เวิร์กสเตชันกับในสภาพแวดล้อมอย่าง RP2040 ต่างกันมาก ถึงอย่างนั้นเพราะเปลี่ยน backend ได้ เวลาเขียนโค้ด async I/O สำหรับ ARM M0 microcontroller ตัวเล็ก ๆ ก็ยังใช้ runtime ที่เน้น embedded อย่าง embassy และทำให้โค้ดดูแทบไม่ต่างจากที่ใช้ในสภาพแวดล้อมอื่น
เพราะใช้ trait และ interface ชุดเดียวกัน จึงไม่ต้องสนใจรายละเอียด runtime มากนัก เมื่อเทียบกับการใช้ RTOS เล็ก ๆ หรือสร้างสภาพแวดล้อม async เองแล้วถือว่าดีมาก
สิ่งที่เรียนรู้จากการเขียนโค้ด async ด้วย embassy ก็เอาไปใช้กับด้านอื่นได้ด้วย
ถึง tokio จะไม่ใช่ส่วนหนึ่งของ standard library แต่ก็ได้รับการดูแลอย่างดี ดังนั้นสถานการณ์ตอนนี้ก็ดูโอเค กลับกันถ้าเข้าไปอยู่ใน standard library อาจทำให้ใช้ executor ตัวอื่นยากขึ้น และอาจทำให้การพอร์ต standard library ไปแพลตฟอร์มอื่นยากขึ้นด้วย
แน่นอนว่าความกังวลนี้อาจไม่มีมูลก็ได้
เรื่อง logging ตอนนี้ค่อนข้างลงตัวที่ slf4j แต่ก็ยังมีไลบรารีที่ใช้อย่างอื่น ส่วน utility ทั่วไปช่วงแรกเป็น Apache Commons แล้วตอนนี้หลายแห่งใช้ Guava
JSON ก็พอจะลงตัวที่ Jackson แต่ Gson หรือ Simple-json ก็ยังพบบ่อย และเรื่อง annotation สำหรับ nullability ก็ยังไม่เคยถูกทำให้เป็นทางการ จากเดิมเป็น distribution ไม่เป็นทางการของ JSR-305 ผ่าน checker framework แล้วช่วงหลังขยับไปทาง JSpecify
องค์ประกอบพื้นฐานแบบนี้ภาษาควรเป็นผู้จัดให้ เพื่อเลี่ยงความแตกกระจายและการมี standard library โดยพฤตินัยหลายชุด
การเขียนไลบรารีให้ไม่ผูกกับ executor ไม่ได้ยากมาก แต่ต้องระวังอย่างสม่ำเสมอ และไม่ใช่สิ่งที่คนส่วนใหญ่ในชุมชนทำกันตลอด
บทความยอดเยี่ยมมาก ผมชอบ การวิเคราะห์เชิงลึกด้าน optimization แบบนี้ และหวังว่าเป้าหมายของโปรเจ็กต์จะไปได้สวย
บางครั้งรู้สึกว่าคอมไพเลอร์ไม่ได้ทุ่มแรงมากนักกับ optimization ของกรณีที่ “เล็กน้อย”
แต่พาดหัวดราม่าเกินเนื้อหาไปหน่อย ถ้าชื่อเป็น “Async Rust Optimizations the Compiler Still Misses” ผมก็คงกดอ่านอยู่ดี
ตอนนี้ใช้ async กับ trait และ closure ได้แล้วก็จริง แต่สิ่งนั้นเป็นการอัปเดต type system ไม่ใช่การเปลี่ยนตัวกลไก async เอง ส่วน Waker ก็ใช้ง่ายขึ้นนิดหน่อย แต่ก็ใกล้เคียงกับการปรับปรุงฝั่ง std/core มากกว่า
เท่าที่เข้าใจ คนที่ทำให้ async Rust ลงจอดได้เคยหมดไฟกันไม่น้อยและค่อย ๆ หายไปจากงานนี้ และแทบไม่มีใครมารับช่วงต่อ แต่ก็ดีใจที่อย่างน้อยฝั่ง Google มี PR หนึ่งที่พยายาม optimize memory layout ของตัวแปรที่ถูก capture ไว้
ผมกับเพื่อนร่วมงานใช้ async เยอะมาก ดังนั้นอาจถึงเวลาที่เราต้องลงมือเอง หรืออย่างน้อยก็เริ่มต้นทำเสียที “ฟรี” น่าจะเป็นความหมายแบบที่ว่าฟรีเหมือนการเลี้ยงลูกหมา
เพราะงั้นก็ยอมรับว่าพาดหัวออกแนว bait นิดหน่อย แต่ก็ยังไม่คิดจะถอนคำนี้
ผู้เขียนดูเหมือนจะหมกมุ่นกับ overhead ของฟังก์ชันเล็กจิ๋วมากไปหน่อย เขาไม่พอใจกับ overhead ของสถานะ “panic” และ “returned” ซึ่งจริง ๆ ไม่ใช่ปัญหาใหญ่
async block ที่มีประโยชน์ส่วนใหญ่มักใหญ่พอจน overhead ของกรณี error ถูกกลบไป
เรื่องการ inline ที่ยังไม่ดีพออาจมีประเด็นอยู่บ้าง แต่สิ่งที่จำกัดจำนวนงานจำนวนมากจริง ๆ มักเป็น state space ที่แต่ละงานต้องใช้
โดยรวมแล้ว async ดูเหมือนเป็นไอเดียที่ยังไม่สุกพอ โค้ดทั่วไปก็เป็น async อยู่แล้วด้วยซ้ำ
ถ้าต้องรอ task async สักตัว thread ก็แค่หลับไปจนกว่าจะพร้อม และ kernel ก็ abstract เรื่องนี้ให้อยู่แล้ว แต่เพราะเราไม่ชอบจัดโค้ดในรูป logical thread เลยเพิ่มระบบ callback สำหรับ event เข้ามา ก่อนจะมารู้ทีหลังว่า callback ทำให้ reasoning ยากและการควบคุมแบบลำดับตรงไปตรงมาดีกว่า
เพราะแบบนั้นผมเลยมองว่า thread คือโมเดลการเขียนโปรแกรมที่ถูกต้อง
ตอนนี้ language runtime ชอบ “green thread” มากกว่าเพราะเรื่อง portability และ performance แต่ภาษาส่วนใหญ่กลับไม่ได้ให้สิ่งนี้มาอย่างเหมาะสม กลายเป็นว่ามีปัญหา async/non-async coloring, scheduling, priority, non-preemption ตามมา เป็นโมเดล process และ scheduling ที่แย่กว่ายุค 1970 เสียอีก
แม้แต่โค้ด async เองก็มักถูกเขียนในแบบที่ไม่ maximize concurrency ที่แสดงออกได้ เช่น แทนที่จะ “ทำ I/O ทั้ง N งานพร้อมกัน” ก็เขียนเป็น “await process(x) สำหรับแต่ละงาน X”
แต่ในโลกของ thread ปัญหาเรื่อง concurrency นี้ยิ่งหนักกว่า เพราะ thread หนักเกินไปโดยธรรมชาติ ทำให้แสดง concurrency ได้ไม่คุ้มประสิทธิภาพ และก็ไม่มีวิธี optimize ไปในทิศทางนั้นได้จริง
นี่ไม่ใช่บทเรียนใหม่ work-stealing executor เป็นที่รู้กันมานานแล้วว่ามี latency ต่ำกว่า thread แบบดั้งเดิมมาก และ P99 ก็สม่ำเสมอกว่า เหตุผลนี้เองที่ Apple สร้าง GCD ขึ้นมาตั้งแต่ต้นยุค 2000
thread ไม่ได้ให้ข้อมูลที่ละเอียดพอแก่ kernel scheduler เพื่อเข้าใจลักษณะงาน และ kernel thread ก็เป็นกลไกที่หนักเกินไปสำหรับ concurrency ระดับละเอียด โดยเฉพาะเมื่อไม่ใช่งานคำนวณล้วน ๆ แต่เป็น I/O หรือภาระงานผสม
ไม่ใช่ว่าทุกโปรแกรมต้องการ performance ระดับนี้ แต่ด้วยความพยายามเท่าเดิม การไปถึงมาตรฐานประสิทธิภาพที่สูงกว่านั้นง่ายกว่ามาก และในทางปฏิบัติก็ทำ latency กับ throughput ที่แนวทางดั้งเดิมตามได้ยากให้เกิดขึ้นได้จริง
สัญญาณว่า async มาถูกทางยังเห็นได้จาก io_uring ด้วย io_uring ซึ่งเป็นแนวทาง I/O ประสิทธิภาพสูงของ kernel นั้นแตกต่างจาก threading และ system call แบบดั้งเดิมโดยสิ้นเชิง และการจัดการ completion ก็ใกล้กับ async concurrency มากกว่า เพียงแต่ async/await อย่างเดียวมี “สี” ไม่พอที่จะอธิบายความสัมพันธ์ระหว่าง task async ได้ทั้งหมด จึงใช้งานศักยภาพนี้ได้ยากกว่าเดิม
ครั้งล่าสุดที่ผมเล่นกับโค้ด coroutine/scheduling การสร้าง thread ที่จบงานทันทีแล้ว join ใช้เวลาราว 200µs แต่การสร้าง green thread ของตัวเอง จัดตารางมัน แล้วรอผล ใช้แค่ประมาณ 400ns
ไม่ต้องรออีก 10 ปีให้ใครสักคนออกแบบ async framework ที่ซับซ้อนเกินเหตุขึ้นมาใหม่ แค่ assembly 20 บรรทัดในภาษา systems ใด ๆ ก็สร้าง green thread/coroutine แบบมี stack เองได้แล้ว
การ optimize โค้ดที่เน้น bandwidth เป็นปัญหาเรื่องการออกแบบ schedule ในโมเดล multithreading แบบดั้งเดิมเราควบคุม scheduling ได้จำกัด แต่ในโมเดล async เราควบคุมได้แทบสมบูรณ์
schedule แบบ async ที่ optimize มาดีจะเร็วกว่า architecture แบบ multithread ที่เทียบเท่ากันมากในงานที่เน้น bandwidth แบบเดียวกัน เรียกว่าเทียบกันแทบไม่ได้
ทุกวันนี้โค้ดประสิทธิภาพสูงส่วนใหญ่เป็นงานที่เน้น bandwidth และ async ก็มีอยู่เพื่อทำให้ optimize workload แบบนี้ได้ง่ายขึ้น
เวลาเทสต์การประมวลผลพร้อมกันและตรวจว่า handle race condition ได้ถูกหรือไม่ callback ง่ายกว่ามากเพราะเราควบคุม scheduling ได้ แต่ละ callback แทนหน่วยงานที่แยกจากกัน จึงมองเห็นได้ว่า event ไหน reorder ได้ และลองพิจารณาลำดับต่าง ๆ ได้ง่ายกว่า
ในทางกลับกัน thread ทำให้ง่ายที่จะมองข้ามลำดับ และไม่ค่อยคิดว่าความซับซ้อนจาก thread อื่นจะมากระทบ thread ปัจจุบันได้เมื่อไร มันไม่ใช่ว่าง่ายกว่า แต่เป็นการทำให้ดูง่ายเกินจริง
อีกอย่าง ถ้าไม่ใส่ barrier เทียมเพื่อหยุด thread หรือส่ง mock ที่มี callback สำหรับเปลี่ยน I/O ให้เป็น stub เพื่อควบคุมลำดับ ก็แทบเทสต์สถานการณ์ concurrent แบบเปลี่ยนเงื่อนไขจริง ๆ ไม่ได้เลย
ปัญหาของ callback คือ captured call stack ไม่ใช่ logical call stack ดังนั้นถ้าไม่มีไลบรารี/รันไทม์ที่พยายามทำให้ call stack มีความหมาย ก็ต้องมีการนิยาม error ที่ดีมาก
แน่นอนว่าเราก็อาจผสมสองกระบวนทัศน์นี้แล้วได้แต่ข้อเสียของทั้งคู่ก็ได้
ถ้าเป้าหมายหลักของ Rust คือความปลอดภัย ผมไม่เข้าใจว่าทำไมถึงมี panic อยู่ ควรจะพิสูจน์ได้ว่าในโค้ดไม่มีเส้นทางที่ panic ได้เลย
ผมดูเรื่องนี้มาทั้งสัปดาห์แล้ว และการสร้างโปรแกรมที่รับประกันว่าไม่ panic เลยนั้นยากมาก เท่าที่เข้าใจ panic handler มีขนาดราว 300KB และวิธีเดียวที่จะตัดมันออกได้คือตอนคอมไพล์ต้องไม่มีเส้นทางใดในโค้ดที่ panic ได้เลย การไปเช็กหลังคอมไพล์ว่า panic handler ถูกใส่มาในไบนารีหรือไม่มันให้ความรู้สึกเหมือนแฮ็ก
คุณอาจใช้ lint เพื่อห้าม unwrap และการกระทำที่ panic อื่น ๆ ได้ แต่ถ้ามี subset ของ Rust แบบ no-panic ปัญหาส่วนใหญ่ที่บทความนี้พูดถึงก็คงหายไป
ในความเป็นจริง หลายกรณีจะไม่เกิดขึ้นเลยเว้นแต่ระดับบิตพลิก แต่การต้องรับมือกับภาษาที่มี operation จำนวนมากซึ่งในทางทฤษฎี panic ได้ก็ชวนหงุดหงิด ไม่ว่าจะเป็นการพิสูจน์ว่า array ไม่ว่างหรือการรับมือกับ async
สุดท้ายจึงต้องใส่การจัดการ error จำนวนมากสำหรับกรณีที่ไม่มีทางเกิด หรือไม่ก็ใช้โครงสร้างประหลาดอย่างแพตเทิร์นรายการที่ไม่ว่างซึ่งแยก field แรกออกจากรายการที่เหลือ แล้วโครงสร้างนั้นก็เพิ่มภาระบวมของมันเองอีก
งานด้านการเพิ่ม การใช้งานแบบอิงการพิสูจน์ รวมถึงการพิสูจน์ว่า array ไม่ว่าง ก็กำลังค่อย ๆ เดินหน้าอยู่เช่นกัน
ถ้าไม่มี panic และต้องบังคับให้โปรแกรมทำงานต่อในทุกสถานการณ์ คุณก็ต้องใส่การจัดการ error จำนวนมหาศาลในทุกจุดที่ตรวจ invariant เพื่อพยายามกู้คืนจากสถานการณ์อย่าง memory corruption ที่ invariant พังไปแล้ว
ซึ่งมันก็คือปัญหาแบบเดียวกันกับที่คุณกังวลอยู่พอดี คือการต้องแบก error handling มหาศาลสำหรับเหตุการณ์ที่แทบไม่มีวันเกิด
มันน่าเหนื่อยกับท่าทีที่อยากให้เครื่องมือทำให้ทุกอย่าง fail-proof โดยที่ตัวเองไม่อยากลงมือทำอะไรเลย อยากได้ API ที่ง่าย ถ้ายังไม่ง่ายพอก็อยากได้ Kubernetes container ที่ “เขียนโปรแกรม” ด้วย YAML และถ้ายังไม่ง่ายพอก็อยากได้บริการโฮสต์แบบคลิก ๆ ของ GCP หรือ Amazon
สุดท้ายมันไม่ค่อยเหมือนอยากเขียนโปรแกรม แต่เหมือนอยากบริโภคแอปที่ไม่มีวันพังมากกว่า และวิถีแบบนั้นก็อาศัยความสัมพันธ์เชิงพึ่งพากับคนที่สร้างของเหล่านี้ให้อยู่ดี
การถกเถียงแบบ น่าเกลียดแต่จำเป็น เช่นนี้ก็มีอยู่ใน C++ มาพักใหญ่แล้ว
ตั้งแต่ Rust เอา async เข้ามา ผมก็ไม่ค่อยชอบความเป็นสิ่งที่แพร่ติดเหมือนโรคของมันนัก
ผมหวังให้ Rust ไปได้ดี และถ้ามีคนแบบนี้มากขึ้น อนาคตของ Rust ก็น่าจะสดใสขึ้น
ผมเพิ่งเริ่มทำงาน async ใน Rust ไม่นาน และปัญหาหลักที่เจอตอนนี้คือ การทำโค้ดซ้ำ
ทุกฟังก์ชันที่อยากรองรับทั้ง async API และ blocking API ต้องเขียนซ้ำทั้งคู่
maybe-asyncฟังดูน่าจะดีมากผมลองดู crate อย่าง maybe-async, bisync เพื่อหาทางเลี่ยง แต่ทั้งหมดมีปัญหาหรือข้อจำกัดค่อนข้างแรง
asyncหรือconstได้ตอนนี้ตัวเลือกที่ดีที่สุดสำหรับการเขียนโค้ดที่อยากอยู่ได้ทั้งโลก synchronous และ asynchronous คือ sans-io Thomas Eizinger จาก Fireguard เขียนบทความดี ๆ เกี่ยวกับแพตเทิร์นนี้ไว้[1]
แพตเทิร์นนี้ไม่เพียงแก้ปัญหา sync/async ได้อย่างสะอาด แต่ยังทำให้เทสต์ง่ายขึ้น และเปิดทางไปสู่เทคนิคอย่าง DST ได้ด้วย[2]
ผมเองก็เขียนบทความเกี่ยวกับหัวข้อนี้ไว้[3] โดยเน้นว่าปัญหานี้กว้างกว่าแค่ async vs sync เพราะยังรวมถึง executor คนละตัวด้วย
0: https://github.com/rust-lang/effects-initiative
1: https://www.firezone.dev/blog/sans-io
2: https://notes.eatonphil.com/2024-08-20-deterministic-simulat...
3: https://hugotunius.se/2024/03/08/on-async-rust.html
asyncfunction นั่นแหละคือmaybe-asyncอยู่แล้วความต่างระหว่าง
fn -> voidกับfn -> Futureคืออันแรกทำงานจนจบทันที ส่วนอันหลังอาจไปจบทีหลังถ้าอยากรัน async function แบบ blocking ก็ใช้ blocking executor ได้เลย
สิ่งที่ทำให้ผมชอบบทความนี้คือมันทำให้ได้เห็นแม้กระทั่ง เป้าหมาย Rust ปี 2026
ทีมของผมใช้ Rust แต่ไม่จำเป็นต้องลงลึกมากเพื่อทำงานที่ต้องการ ถึงอย่างนั้นการได้เห็นภาษาที่มี feedback จากชุมชนมากพัฒนาจากฐานรากก็ยังสนุกดี
กับ C++ ผมไม่ค่อยรู้สึกถึงกระแสแบบนี้ และในพื้นที่อื่นเขาทำกันอย่างไรก็ไม่ค่อยทราบ
เพียงแต่สิ่งที่น่าเสียดายคือดูเหมือนแต่ละเป้าหมายต้องมีการระดมทุนเฉพาะทาง ทำให้อารมณ์คล้าย Kickstarter นิด ๆ เลยสงสัยว่านี่คือโมเดลที่ดีที่สุดที่หาได้แล้วหรือยัง
project goal คือระบบที่ให้บุคคลหนึ่งคนหรือกลุ่มเล็ก ๆ แสดงว่าพวกเขาอยากทำงานบางอย่าง และขอเวลาสนับสนุนอย่างต่อเนื่องจากอาสาสมัครของโปรเจ็กต์ Rust เช่น การรีวิวโค้ดหรือช่วยตอบคำถาม
มันไม่ได้แปลว่าตัวโปรเจ็กต์ Rust เองเป็นผู้ตั้งเป้าหมายนั้น หรือสนับสนุนมันอย่างเป็นทางการเสมอไป
ดังนั้นจึงไม่ค่อยถูกนักถ้าจะมองสิ่งนี้เป็น roadmap ทางการของ Rust และจะถูกกว่าถ้ามองว่าเป็น “มีผู้มีส่วนร่วมที่อยากทำงานในพื้นที่นี้”
พอเทคโนโลยีกลายเป็นสิ่งที่มีผลประโยชน์ทางการค้า ก็น่าเสียดายที่มันมักไหลไปในทิศทางนี้ จะไปโทษสปอนเซอร์รายใหญ่ที่สนับสนุนเฉพาะส่วนที่ตัวเองสนใจก็ยาก
โชคดีที่เงินทุนจำนวนหนึ่งของ TweedeGolf มาจากรัฐบาลเนเธอร์แลนด์ตามที่ผมเข้าใจ
ฟีเจอร์ใหม่ “ขาย” ได้ การสร้างมันมีต้นทุน แต่ถ้ามันแก้ปัญหาจริง และต้นทุนของปัญหานั้นสูงกว่าค่าใช้จ่ายในการพัฒนาฟีเจอร์ บริษัทก็มักยอมจ่าย
งานบำรุงรักษายากกว่า แต่ตอนนี้ก็มีกองทุนสำหรับ maintainers แล้ว ตัวอย่างเช่นกองทุนของ RustNL: https://rustnl.org/maintainers/
กองทุนลักษณะนี้รองรับงานที่กว้างและต่อเนื่องกว่า โดยให้องค์กรหลายแห่งช่วยกันลงเงินทีละน้อย
ไม่แน่ใจว่านี่คือโมเดลที่ดีที่สุดหรือไม่ แต่อย่างน้อยก็ดูเหมือนพอใช้การได้ในระดับหนึ่ง
ถ้าอ่านเอกสารของ Rust Async และ Tokio จะเห็นว่าอธิบายไว้ค่อนข้างดีว่าทำไมไม่ควรเอาส่วนที่ใช้ CPU หนักไปวางไว้ใน async stack, วิธีใช้เครื่องมือพื้นฐานอย่าง
std::sync::Mutexให้มีประสิทธิภาพภายใน async block, และวิธีเชื่อมโค้ด synchronous กับโค้ด async เข้าด้วยกันโค้ดจำนวนมากไม่ได้สนใจหรือไม่จำเป็นต้องสนใจเรื่องประสิทธิภาพ จึงไม่เดินตามแนวทางเหล่านี้ แต่ก็มีหลายโปรเจ็กต์ที่ให้ความสำคัญกับ performance และ efficiency และพอเอาโค้ดไปรันจริงใน production ก็จะเจอกับดักเหล่านี้ ScyllaDB เป็นตัวอย่างหนึ่ง
LLM ก็ไม่ช่วย มันสร้างทุกอย่างให้เป็น async ไปจนถึง
main, ใช้เครื่องมือพื้นฐานผิดตัว และไม่ออกแบบระบบให้ถูกต้องเรื่องการพับสถานะซ้ำ ๆ หรือก็คือ แพตเทิร์นดึง
matchออกมาไว้นอกแขนงที่มี await แบบในตัวอย่างprocess_commandนั้น น่าจะเป็นวิธีที่ง่ายที่สุดที่ใครก็เอาไปใช้กับโค้ด async เดิมได้ตั้งแต่วันนี้ไม่ต้องรอให้คอมไพเลอร์ทำอะไร แค่รีแฟกเตอร์โค้ดเท่านั้น
เกี่ยวกับประเด็น “Future ไม่ถูก inline ได้ง่าย” ในภาษาโปรแกรมที่ผมสร้างเอง ผมเคยเขียน custom pass เพื่อ inline การเรียก async function ภายใน async function
โดยรวมแล้วมันทำงานได้ดีและตัด boilerplate ออกได้บางส่วน แต่ขนาดไบนารีที่ได้ใหญ่ขึ้นมาก
ในเชิงเทคนิค Rust ก็น่าจะทำแบบเดียวกันได้
ความเห็นจาก 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 แล้วด้วย เพราะงั้นอาการที่เจออาจซับซ้อนกว่าที่เห็นก็ได้