เหตุผลที่เลือก async/await แทนเธรด
(notgull.net)async/awaitของ Rust ไม่ใช่สิ่งทดแทนเธรดแบบง่าย ๆ แต่เป็นโมเดลการเขียนโปรแกรมที่แสดงโค้ด concurrency ที่เน้น I/O ให้เป็น state machine ที่ประกอบรวมกันได้- โค้ดอย่างเว็บเซิร์ฟเวอร์ที่ต้องจัดการหลาย connection พร้อมกันจะมีข้อจำกัดหากอาศัยการรันแบบเชิงเส้นเพียงอย่างเดียว ส่วนเธรดใช้
thread::spawnเพื่อแยกการประมวลผลไคลเอนต์ออกไป ทำให้ ประมวลผลพร้อมกัน ได้ async/awaitจะยอมสละการรัน ณ จุดawaitแล้วให้ executor รันงานอื่นต่อ ทำให้งานจำนวนมากถูกรันสลับกันภายใน runtime เดียว- ข้อกำหนดอย่าง timeout 3 วินาทีสามารถเพิ่มใน
asyncได้ด้วยการประกอบraceกับTimerแต่ในโค้ดเธรดแบบ synchronous ต้องใช้ wrapper เฉพาะสำหรับTcpStreamและตั้งค่า timeout สำหรับการอ่าน/เขียน ทำให้ ความเป็นทั่วไป ลดลง - หากอธิบาย
asyncด้วย overhead ด้านประสิทธิภาพเพียงอย่างเดียว จะเกิดตัวอย่างโต้แย้งในงานแบบ CPU bound จุดแข็งของ Rustasyncจึงอยู่ที่ พลังในการสื่อความหมายเชิง semantics และความสามารถในการประกอบกับ ecosystem
จุดเริ่มต้นของปัญหา concurrency ใน Rust
- โค้ด Rust ทั่วไปโดยพื้นฐานเป็นโครงสร้างการรันแบบเชิงเส้น
- งานหนึ่งเสร็จแล้วจึงรันงานถัดไป เช่น
foo(),bar(),baz()
- งานหนึ่งเสร็จแล้วจึงรันงานถัดไป เช่น
- ในกรณีที่ต้องจัดการหลายงานพร้อมกันอย่างเว็บเซิร์ฟเวอร์ โครงสร้างเชิงเส้นจะชนข้อจำกัดอย่างรวดเร็ว
- ในโครงสร้างที่รับไคลเอนต์ด้วย
TcpListener::accept()แล้วรันhandle_client()ไคลเอนต์รายที่สองต้องรอระหว่างที่กำลังจัดการไคลเอนต์รายแรก - หาก
handle_client()ใช้เวลาไม่กี่มิลลิวินาทีและมีไคลเอนต์พร้อมกัน 2 ราย ก็จะเกิดการรอช่วงสั้น ๆ - หากมีไคลเอนต์พร้อมกัน 2 ล้านราย ผู้ใช้ที่อยู่ท้ายคิวอาจต้องรอหลายนาที
- ในโครงสร้างที่รับไคลเอนต์ด้วย
วิธีที่เธรดแก้ปัญหา
- เธรดของระบบปฏิบัติการสามารถบันทึกค่า register และ program stack ไว้ในหน่วยความจำ รัน routine อื่น แล้วกลับมารัน routine เดิมต่อภายหลังได้
- โค้ดเว็บเซิร์ฟเวอร์มอบหมายการจัดการไคลเอนต์ให้เธรดแยกต่างหากในรูปแบบ
thread::spawn(move || handle_client(client))- เธรดหลักยังคง
accept()connection ใหม่ต่อไป - หากเธรดที่จัดการไคลเอนต์ถูกบล็อก OS จะกลับมาที่เธรดหลักเพื่อรับ connection ถัดไปได้
- ไคลเอนต์สองรายสามารถรันแบบขนานกันได้หลังจากมี latency ระดับไม่กี่ไมโครวินาที
- เธรดหลักยังคง
- หากเว็บเซิร์ฟเวอร์ระดับ production มี CPU core หลายสิบ core OS ไม่เพียงทำให้ดูเหมือนว่าเธรดต่าง ๆ รันพร้อมกัน แต่ยังสามารถรันหลายเธรดพร้อมกันจริง ๆ ได้ด้วย
async/await ทำงานอย่างไร
- concurrency ใน user space มีหลายโมเดล เช่น event-driven programming, actor, coroutine และแนวทางที่ Rust เลือกคือ
async/await - หากอธิบายแบบง่าย โปรแกรมจะถูกคอมไพล์เป็นชุดของ state machine ที่สามารถรันแยกจากกันได้
async fnไม่ใช่ฟังก์ชันแบบดั้งเดิม แต่เป็นฟังก์ชันที่คืนค่า state machineawaitรวม state machine อื่นเข้ามาเป็นขั้นตอนหนึ่งของ state machine ปัจจุบัน- เมื่อฟังก์ชันภายในยอมสละการรัน เช่น ระหว่างรอ connection ใหม่ state machine ทั้งหมดจะส่งสิทธิ์ควบคุมกลับไปยัง executor ชั้นบน
- executor อย่าง
smol::Executorจะรัน state machine อื่นที่สร้างด้วยspawnแทน state machine ปัจจุบัน- บล็อก
async move { handle_client(client).await }คือ state machine ใหม่ที่เป็นอิสระจากmain - เมื่อ
mainยอมสละการรัน งานของไคลเอนต์งานหนึ่งจะถูกรัน และเมื่องานนั้นยอมสละการรันอีก ก็จะวนไปยังงานถัดไป
- บล็อก
- โครงสร้างนี้ทำให้จัดการไคลเอนต์พร้อมกันได้หลายล้านราย แต่ก็เพิ่มความซับซ้อนจากแนวคิดอย่าง executor, task และ state machine
ความสามารถในการประกอบที่เห็นได้จากตัวอย่าง timeout
- จุดแข็งอย่างหนึ่งของ Rust คือ ความสามารถในการประกอบ
Iteratorสามารถต่อ combinator หลายตัว แล้วส่งผลลัพธ์ต่อไปยังฟังก์ชันที่รับIteratorได้- สามารถกรองและแปลงค่าแล้วเพิ่มลงใน list ได้ เช่น
recv.try_iter().filter(...).map(...)ของmpsc::channel()
async/awaitทำให้สามารถนำความสามารถในการประกอบแบบนี้ไปใช้กับฟังก์ชันแบบ I/O bound ได้ด้วย- หาก
handle_client()เป็นฟังก์ชัน asynchronous ที่awaitread_to_end,do_something_with_data,write_alltimeout 3 วินาทีสามารถทำได้โดยประกอบ Future สองตัวเข้าด้วยกัน - วิธีนี้ไม่ถูกผูกไว้กับ
TcpStreamเท่านั้น- หากเป็นสิ่งที่ implement
impl AsyncRead + AsyncWriteก็สามารถใช้ pattern เดียวกันได้ - สามารถแทนที่ด้วยเป้าหมายอย่าง GZIP stream บน stream ทั่วไป, Unix socket หรือไฟล์ได้ด้วย
- หากเป็นสิ่งที่ implement
ข้อจำกัดเมื่อ implement timeout แบบเดียวกันในโค้ดเธรด synchronous
- ในโค้ดแบบ blocking โดยทั่วไปยากที่จะหยุด system call อย่าง
readหรือwriteและวิธีอย่างการปิด file descriptor ก็ใช้ไม่ได้ใน Rust TcpStreamมีset_read_timeoutและset_write_timeout- สามารถตั้งค่า timeout แยกกันสำหรับการอ่านและการเขียนได้
- แต่หากไคลเอนต์ส่งข้อมูลทีละ 1 ไบต์ทุก 2.9 วินาที timeout แบบง่ายอาจถูกรีเซ็ตต่อไปเรื่อย ๆ
- เพื่อป้องกันกรณีนี้ ต้องสร้างชนิดอย่าง
DeadlineStreamที่ห่อTcpStreamแล้วคำนวณเวลาที่เหลือจนถึง deadline ทั้งหมดทุกครั้งเพื่อตั้งค่า timeout สำหรับการอ่าน/เขียน - แนวทางนี้ทำงานได้ แต่มีข้อจำกัดมาก
- ผูกอยู่กับ
TcpStream - Rust ไม่มี trait สำหรับ abstract การใช้
set_read_timeoutและset_write_timeout - หากจะนำไปใช้กับ writer ทั่วไปต้องทำงานเพิ่มเติมอีกมาก
- มี system call เพิ่มเติมสำหรับการตั้งค่า timeout
- ใน logic ของเว็บเซิร์ฟเวอร์จริงอาจใช้งานยุ่งยากกว่า
- ผูกอยู่กับ
ตัวอย่างจาก ecosystem ของ Rust async
- เหตุผลหนึ่งที่ ecosystem ของ HTTP รวมถึงฝั่ง client เลือก
async/awaitเป็นกลไก runtime หลัก คือความสามารถในการประกอบฟังก์ชัน- ฟังก์ชันที่สร้าง HTTP call สามารถเสียบเข้ากับช่องว่างและกรณีใช้งานหลากหลายได้
towerเป็นตัวอย่างสำคัญที่แสดงความสามารถในการประกอบของasync/await- หาก implement service เป็นฟังก์ชัน
asyncก็สามารถเพิ่ม timeout, rate limiting, load balancing, hedging และการจัดการ backpressure ได้ - ไม่ว่าจะใช้ runtime ใด หรือภายใน service ทำอะไร ก็สามารถใช้
towerเพื่อเพิ่มความทนทานได้
- หาก implement service เป็นฟังก์ชัน
macroquadเป็น game engine ขนาดเล็กสำหรับ Rust และรัน engine โดยใช้async/awaitในฟังก์ชัน mainasync/awaitเหมาะสำหรับแสดงสถานการณ์ที่ต้องหยุดฟังก์ชันเชิงเส้นเพื่อรอบางงานใน Rust- สามารถจัดโครงสร้างให้ polling connection เครือข่ายของ game server และ GUI framework พร้อมกันบนเธรดเดียวกันได้
ข้อจำกัดของการอธิบาย async ด้วยประสิทธิภาพเพียงอย่างเดียว
- Rust Async Book เปรียบเทียบว่า OS thread ทำให้แสดง concurrency ได้ง่ายโดยไม่ต้องเปลี่ยนโมเดลการเขียนโปรแกรม แต่การ synchronize ระหว่างเธรดทำได้ยาก มี overhead ด้านประสิทธิภาพสูง และแม้ใช้ thread pool ก็ยังรองรับ workload แบบ I/O bound ขนาดใหญ่ได้ไม่เพียงพอ
- ในชุมชน
asyncมีแนวโน้มที่จะตอบคำถามว่าทำไมถึงใช้asyncแทน OS thread ในทำนองว่า “overhead ต่ำกว่า และอย่างอื่นเหมือนกัน” - เหตุผลที่ผู้เขียนเว็บเซิร์ฟเวอร์ย้ายมาใช้
async/awaitคือเพื่อแก้ C10k problem แต่เหตุผลที่ผู้ใช้ทุกคนเลือกasync/awaitไม่จำเป็นต้องเป็นเรื่องประสิทธิภาพเสมอไป - ข้อได้เปรียบด้านประสิทธิภาพอาจหายไปได้ตามสถานการณ์
- ในงานแบบ CPU bound workflow ที่อิงเธรดอาจเร็วกว่า workflow
asyncที่เทียบเท่ากัน - ข้อได้เปรียบด้านประสิทธิภาพชั่วคราวของ Rust
asyncถูกเน้นมากเกินไป ขณะที่ข้อได้เปรียบเชิง semantics ถูกประเมินต่ำเกินไป
- ในงานแบบ CPU bound workflow ที่อิงเธรดอาจเร็วกว่า workflow
async/awaitไม่ใช่เครื่องมือสำหรับกรณีเฉพาะกลุ่มเล็ก ๆ แต่เป็นโมเดลการเขียนโปรแกรมที่ทรงพลังสำหรับจัดการ pattern ที่ยากจะแสดงอย่างกระชับใน Rust แบบ synchronous หากไม่มีเธรดและ channel จำนวนมาก
ยอมรับความแตกต่าง แทนที่จะทำให้เหมือน sync Rust
- ใน roadmap ของโปรเจกต์ Rust มีทิศทางว่า การเขียน
async Rustควรทำได้ง่ายเท่าการเขียนโค้ด synchronous นอกเหนือจากการใช้คีย์เวิร์ดasyncและawaitเป็นครั้งคราว - แต่ก็มีมุมมองว่า framing ให้
async Rust“เหมือนกับ sync Rust ทุกประการ” นั้นยากโดยพื้นฐาน- แม้จะทำให้คล้ายกันได้ถึง 99% ผู้ใช้ทั่วไปก็ยังต้องสังเกตเห็นความแตกต่างอยู่ดี
- ecosystem ของ
async/awaitใน Rust ควรแสดงจุดแข็งอย่าง ความสามารถในการประกอบ และ พลังในการสื่อความหมาย ให้ชัดเจนขึ้น แทนที่จะพยายามทำให้เหมือน Rust แบบ synchronous - หากต้องการให้
async/awaitกลายเป็นตัวเลือกพื้นฐานเมื่อจำเป็นต้องใช้ concurrency ควรอธิบายโมเดลนี้ด้วยเหตุผลเชิง semantics มากกว่าเหตุผลเชิงประสิทธิภาพทางเทคนิค
1 ความคิดเห็น
ความเห็นจาก Hacker News
async/await แบบเธรดเดียว เป็นโมเดลที่เรียบง่ายและเป็นที่รู้จักดี และ JavaScript ก็ใช้แนวทางนี้
เธรดช่วยให้ดึง CPU หลายตัวมาร่วมแก้ปัญหาได้ และ Rust ก็ช่วยจัดการเรื่องล็อกให้ สามารถมีเธรดที่มีลำดับความสำคัญต่างกันได้ด้วย ดังนั้นสำหรับงานที่ติดขีดจำกัดด้านการคำนวณก็อาจจำเป็น
ในทางกลับกัน async/await แบบหลายเธรด จะเลอะเทอะขึ้น หากมีช่วงที่เป็นงานคำนวณหนักอย่างจริงจัง มันแทบจะไปบล็อกเธรดที่ใช้ร่วมกับงานอื่น ทำให้โมเดลพังได้ง่าย
การคำนวณแบบหลายเธรดของ Rust ไม่ได้ทำงานดีอย่างที่คาดไว้เสมอไป ในตัวจัดสรรหน่วยความจำ หลายเธรดอาจไปกระแทกล็อกเดียวกันจนเกิด futex congestion collapse และโดยเฉพาะเมื่อขยายบัฟเฟอร์ หากมีการคัดลอกซ้ำขณะล็อกตัวจัดสรรทั้งหมดไว้ จะมีต้นทุนสูงมาก ตัวจัดสรรของไลบรารีภายใน .DLL ที่อีมูเลตไลบรารี Microsoft ของ Wine เปราะบางต่อปัญหานี้ ทำให้เวลา CPU ทั้งหมดถูกใช้ไปกับ spinlock และประสิทธิภาพตกลงเป็นสิบเท่า ขณะที่ implementation ของ Microsoft ไม่เป็นแบบนั้น
อีกทั้ง
Mutexมาตรฐานและ channel ของcrossbeam-channelอาจเกิด unfair mutex starvation ได้ เมื่อหลายเธรดล็อกทรัพยากร ทำงาน แล้วปลดล็อกซ้ำ ๆ เธรดหนึ่งอาจชนะอยู่ตลอด ส่วนที่เหลือถูกดันออกไป หากต้องการ mutex ที่ยุติธรรมก็มีparking-lotแต่จะไม่มีความปลอดภัยแบบ poisoning เมื่อเธรด panic ที่ mutex มาตรฐานให้มาถ้าไม่ใช่งานที่ติดขีดจำกัดด้าน I/O เรื่องจะซับซ้อนขึ้นมาก
https://users.rust-lang.org/t/mutex-starvation/89080
ถ้า throughput ของ I/O กลับมาเป็นขีดจำกัดอีกครั้ง coroutine เป็นล้านตัวจะมีความหมายอะไร ถ้าคุณใช้ connection pool ของ DB ขนาด 10 จนหมดทันที coroutine ก็ช่วยไม่ได้ แถมทำให้การดีบักและการหาทางเลี่ยงยากขึ้น และให้เหตุผลเกี่ยวกับระบบได้ยากขึ้นด้วย
ปัญหาที่ติดขีดจำกัดด้าน CPU ดูเหมือนจะลงเอยที่การ interrupt/resume อย่างเป็นระบบ และถ้าสามารถทำ context switch แบบอิงคิวที่ยุติธรรมและมีประสิทธิภาพสำหรับเธรดการทำงานที่กำลังดำเนินอยู่ n เธรดได้ เช่น CPU ที่มี context ที่กำลังดำเนินอยู่ n ชุด ผมก็สงสัยว่าปัญหานี้อาจกลายเป็นปัญหา การจัดสรรทรัพยากร ได้หรือไม่
แม้แต่ Go ซึ่งถือว่าเป็นภาษาที่ออกแบบอย่างมีความรับผิดชอบ ตอนแรกก็ไปทาง cooperative แต่สุดท้ายก็จำเป็นต้องเปลี่ยนเป็น preemptive นี่ไม่ได้หมายความว่า cooperative multitasking ไม่มีประโยชน์ แต่ควรมีป้ายเตือนติดไว้ และยิ่งไปกว่านั้น บางทีควรบล็อกการรันโค้ดบางประเภทแบบ statically ไปเลย
แนบบทความที่เกี่ยวข้อง “What color is your function” ไว้ด้วย
https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...
executor แบบหลายเธรดของ async/await สามารถจัดการ starvation ได้ดีพอ และ implementation ของ .NET ก็ทนโค้ดที่แย่มากซึ่งผสมการเรียกแบบ blocking กับ async ได้
https://news.ycombinator.com/item?id=39530435
https://news.ycombinator.com/item?id=39786142
https://news.ycombinator.com/item?id=39721626
https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...
หากไม่ใช่ซอฟต์แวร์ที่ต้องการความสามารถในการขยายสูงมาก การประนีประนอมของ async อาจไม่คุ้มค่า
แก่นของข้อถกเถียงระหว่าง async/await กับเธรดไม่ใช่ว่าฝั่งไหนซับซ้อนกว่า แต่คือการที่มัน แบ่ง ecosystem ออกเป็นสองส่วน และทำให้ฝั่งหนึ่งกลายเป็นพลเมืองชั้นสอง หากเลือกผิดในโปรเจกต์ก็จะเกิดแรงเสียดทาน
ถึงจะใช้ปนกันได้ แต่เมื่อต้องทำจริงมักแฮ็กและไม่มีประสิทธิภาพ ตอนนี้ ecosystem ของ Rust ถูกตัดสินไปแล้วว่า หากมี I/O เข้ามาเกี่ยวข้อง แทบทั้งหมดจะถูกผูกกับ ecosystem ของ async/await และแทบทุกสิ่งที่อยากทำใน Rust มักมี I/O รวมอยู่ด้วย ยกเว้นกรณีที่พบไม่บ่อย ดังนั้นไม่ว่าส่วนที่เหลือของแอปพลิเคชันจะต้องการ async หรือไม่ ไลบรารีที่ไม่ใช่ async ก็มักต้องถูกมองข้าม
ถ้า Rust ใช้ abstraction ที่ประกอบกันได้ดีกว่า async/await และความสามารถในการประกอบนั้นไม่บังคับให้สิ่งอื่น ๆ ต้องกลายเป็น async/await ไปด้วย ความไม่พอใจส่วนใหญ่ก็น่าจะหายไป
ที่แย่กว่านั้นคือ ecosystem ไม่ได้แค่แยกเป็นสองฝั่ง แต่แม้ในโค้ด async เองก็มักผูกกับ executor หนึ่ง ๆ อย่างแน่นหนา โดยมากคือ Tokio ถ้าขยายปัญหาเรื่องสีของฟังก์ชัน จากสีน้ำเงิน (ไม่ใช่ I/O), สีเขียว (blocking I/O), สีแดง (async I/O) ในความเป็นจริงมันกลายเป็นสีน้ำเงิน, สีเขียว, สีแดง (Tokio), สีม่วง (async-std), สีส้ม (smol)
ผมมองว่า sans-I/O pattern เป็นทางออกที่ดีที่สุดสำหรับปัญหานี้ แยกโค้ดสีน้ำเงินทั้งหมดออกมา และใช้ inversion of control กับ I/O และเวลา ก็จะทำให้ logic หลักของ protocol ไม่ต้องรู้เรื่อง I/O และห่อด้วย I/O หลายรูปแบบได้ง่าย
0: https://hugotunius.se/2024/03/08/on-async-rust.html
การใช้โค้ด blocking ในแอปพลิเคชัน async ไม่ได้ลื่นไหลอย่างที่คาด แต่ก็ไม่ได้ยาก แทนที่จะใช้
foo()ก็ใช้tokio::spawn_blocking(foo).awaitซึ่งจะรันโค้ดใหม่ในเธรดแยก และคืน future ที่เสร็จเมื่อเธรดนั้นจบงานถึง I/O จะมีตัวเลือกที่ไม่ใช่ async อยู่บ้าง แต่ถ้าใช้ตัวเลือก async แล้ว โดยพฤตินัยจะถูกบังคับให้ทุกอย่างเป็น async ไปจนถึง
Main()มีวิธีเรียกเมธอด async จากเมธอด synchronous ได้อย่างปลอดภัยเหมือนกัน แต่มันทำให้การดีบักยากขึ้นอย่างสุดขีดในบทความมีหลายส่วนที่ตกหล่นไป
async/await ทำงานในบริบทของเธรดเดียว จึงไม่ต้องใช้ lock หรือ synchronization แต่ถ้ารัน async/await บนหลายเธรดเพื่อใช้ประโยชน์จากคอร์ CPU ก็จะต้องใช้ lock และ synchronization อีกครั้ง ความซับซ้อนนี้อาจถูกซ่อนไว้ในโค้ดภายนอกได้ เช่น แทนที่จะ synchronize การเข้าถึง DB connection เดียว การเปิด DB connection หนึ่งตัวต่อ async task อาจง่ายกว่า แต่ใน SQLite หรือ PostgreSQL อาจกระทบต่อประสิทธิภาพ
ใน async/await การ ส่งต่อข้อผิดพลาด ไม่ชัดเจน โดยเฉพาะเมื่อพยายามรวม async task เข้าด้วยกัน และ Happy Eyeballs เป็นตัวอย่างแบบคลาสสิก
ถ้าพูดถึง network I/O ก็ต้องพูดถึง backpressure ด้วย การนำ async/await ไปใช้ใน CPython ขึ้นชื่อว่า backpressure ของเครือข่ายไม่เพียงพอ และมีปัญหาที่เกิดจากเรื่องนั้น
“Design Patterns” ของ Gang of Four ส่วนใหญ่เป็นตำราสูตรสำเร็จสำหรับเลี่ยงข้อบกพร่องของ C++ แต่ผู้คนกลับนำ pattern เหล่านั้นไปใช้กับภาษาที่ไม่มีข้อบกพร่องแบบนั้นด้วย
Rust ไม่ใช่ JavaScript และสามารถรันหลายเธรดได้ดี ดังนั้นจึงไม่จำเป็นต้องใช้ async/await เสมอไป และในฐานะภาษาเชิงระบบ มันสามารถลองแนวทางอื่น ๆ ได้
แต่ถ้าจะผลัก Rust ให้กับโปรแกรมเมอร์ JavaScript ก็จำเป็นต้องมี async/await without.boats เขียนไว้ว่า “ผลักดัน async/await ด้วยความกระตือรือร้นอย่างจริงใจ ภายใต้สมมติฐานว่าความอยู่รอดของ Rust ขึ้นอยู่กับฟีเจอร์นี้”
https://without.boats/blog/why-async-rust/
ดูเหมือนว่าการที่ async/await เข้ากับ Rust ทางเทคนิคได้ดีหรือไม่ไม่ใช่เรื่องสำคัญ แต่เพราะโปรแกรมเมอร์ JavaScript คุ้นกับ async/await ดังนั้น Rust ก็ต้องมี async/await ด้วย
ถ้าโค้ดบล็อกหนึ่งเคยพึ่งพาการเข้าถึงแบบ exclusive และเพราะไม่มี
awaitจึงรับประกันสิ่งนั้นได้ แต่พอเพิ่มawaitเข้าไประหว่างกลาง โค้ดก็พัง Threading อย่างน้อยยังบังคับให้ระบุในโค้ดว่าอะไรต้องการการเข้าถึงแบบ exclusiveasync ยังหมายถึงการจัดการ scheduling ของเธรดเองด้วย ถ้า I/O เยอะและโค้ดที่ CPU-bound สั้นก็ไม่เป็นไร แต่ถ้ามีโค้ด CPU-bound หรือแม้แต่นาน ๆ ครั้ง ก็จะกลายเป็นการเล่นบท scheduler
หลาย service ตายแบบ ABEND ไปเฉย ๆ และจากมุมคนที่มาจาก Java การตกหล่นแบบนี้น่าประหลาดใจมาก อธิบายวิธีแก้ให้ทีมเข้าใจก็ยาก
เพราะเรื่อง การส่งต่อข้อผิดพลาด ถ้ามีทางเลือก ผมจะไม่ใช้ async/await ถ้าเป็นโปรเจกต์ทำคนเดียวก็อาจจะได้ แต่ถ้าทำร่วมกับคนอื่น ใช้ไลบรารี และต้องทำให้ทุกคนเข้าใจตรงกัน ผมไม่เอาเด็ดขาด
ผมยังไม่ได้ใช้ structured concurrency ระดับภาษาอย่างจริงจัง แต่กำลังฝากความหวังไว้กับ Project Loom ของ Java ดูแล้วน่าจะทำให้ข้อถกเถียงนี้หมดความหมายไป
มีปัญหาในบทความ
ตัวอย่างมีแค่เว็บเซิร์ฟเวอร์เดียว และฝั่งวิธีแก้ด้วยเธรดก็แก้ผิด อีกอย่าง คำถามดูเหมือนตั้งสมมติฐานว่าผู้คนต้องการเธรดของ OS แทน async/await
สิ่งที่โปรแกรมเมอร์ต้องการคือเธรดในเชิงแนวคิดและเชิงความหมาย คือการเขียนตรรกะแบบลำดับ และไม่ต้องเขียนคำกำกับแปลก ๆ อย่าง
asyncถ้า async/await ดีขนาดนั้น ทำไมไม่ทำให้ทุกฟังก์ชันเป็น async โดยปริยาย แล้วใช้การเรียกฟังก์ชันปกติแทนawaitไปเลยล่ะ แบบนั้นก็เท่ากับเขียนโปรแกรมด้วยเธรดอยู่ดีเธรดของ OS แพงเพราะมีสแตกที่จัดสรรแบบคงที่ และสิ่งที่เราต้องการคือ เธรดราคาถูก ที่รันได้เป็นล้าน ๆ ตัวบน CPU ตัวเดียว เพียงแต่ไม่ต้องมีคำหยาบ ๆ อย่าง
async/awaitเท่านั้นwaitอาจคงไว้สำหรับการรอแบบบล็อกในความหมายคลาสสิก เช่น รออีเวนต์หรือรอเธรดอื่นจบ แต่ไม่อยากให้ใช้กับการเรียกฟังก์ชันกลับไปที่ตัวอย่างเว็บเซิร์ฟเวอร์ ถ้าใช้
driver.race(timeout).awaitเพื่อทำ timeout แล้วหลังจากraceแจ้งข้อผิดพลาด timeout ซ็อกเก็ตของไคลเอนต์จะเป็นอย่างไร มันจะไม่รั่วไหลโดยยังเปิดและเชื่อมต่อค้างอยู่หรือtimeout ในเวอร์ชันเธรดก็ทำให้เกือบเหมือน async/await ได้ เช่น
threaded_race(client_thread, timeout).waitโดยthreaded_raceจะใช้ตัวจับเวลาติดตาม timeout ควบคู่ไปกับเธรด และเมื่อถึงเวลาก็เรียกclient_thread.interrupt()แบบ JavaThread.interrupt()ถ้าเธรดไม่ได้ถูกบล็อกอยู่ก็แค่ตั้งแฟล็กไว้ และถ้าถูกบล็อกอยู่ในการเรียก I/O ก็จะโยนInterruptedExceptionเนื่องจากเป็น checked exception คอมไพเลอร์จึงบังคับให้ครอบclient.read_to_end(&mut data)ด้วย try/catch หรือประกาศ exception ไว้ในhandle_clientทำให้โปรแกรมเมอร์ไม่ลืมปิดซ็อกเก็ตของไคลเอนต์race()จะถูกDropและตัวdriverเองยังคงอยู่ถ้าใช้ชนิดข้อมูลตามนั้น Rust จะบ่นว่าไม่ได้จัดการ
Resultและถ้าซ็อกเก็ตใหม่ถูกสร้างเป็น local ภายใน future มันก็จะถูกเก็บกวาดข้อดีของ Rust future คือสามารถกำหนดพฤติกรรมรอบ ๆ มันทั้งหมดได้ ต่างจากโมเดลที่ทุกฟังก์ชันเป็นแบบบล็อก Rust สามารถระบุจุดที่จะเลื่อนการทำงานไปเป็นงานถัดไปในคิวงานได้ และมีสถานะที่เก็บไว้อย่างชัดเจน (struct
Future) เพื่อ poll งานได้เร็วตามต้องการ ดังนั้นจึงไม่ต้องsleep()เพื่อ yield เหมือนเธรด ทำให้เร็วและให้เหตุผลได้ง่ายกว่าThread.interruptของ Java สุดท้ายก็ใกล้เคียงกับลูป sleep และอาจโอเคสำหรับแอปพลิเคชันส่วนใหญ่ แต่ Rust เป็นภาษาเชิงระบบ จึงปล่อยให้ใช้วิธีแบบนั้นในระบบฝังตัวไม่ได้ และก็ไม่เหมาะกับเคอร์เนลหรือแอปพลิเคชันที่ต้องการ latency ต่ำด้วยในกรณีส่วนใหญ่ ผมไม่ค่อยสนใจนักว่ามันเป็น system call แบบบล็อกของ OS หรือ system call แบบไม่บล็อก แต่ผมอยากเข้าใจ control flow ของโปรแกรมที่กำลังอ่านอยู่ รู้ว่ามันรอตรงไหน และจะรันขนานกันได้อย่างไร
กลับกัน เวลาทำงานกับฟังก์ชันแบบบล็อก ผมอยากให้มีคู่คีย์เวิร์ด
blocking/blockด้วยซ้ำ การเรียกแบบบล็อกอาจทำให้ทั้งระบบช้าลงโดยไม่รู้ตัว และผมเห็นแอปที่หน่วงจนน่าหงุดหงิดเพราะมี system call แบบบล็อกอยู่ใน UI thread มามากเกินไปแล้วความพยายามรวม synchronous กับ asynchronous เข้าด้วยกันล้วนล้มเหลว โค้ดที่รันในเธรดเดียว โค้ดที่รันระหว่างเธรด และแม้แต่โค้ดที่รันระหว่างคอมพิวเตอร์ มี ความแตกต่างเชิงความหมาย อย่างมาก ถ้าพยายามทำ abstraction กลบมัน สุดท้ายก็จะไม่พอ ดังนั้นเรียนให้ถูกตั้งแต่แรกดีกว่า
poll()และselect()ด้วย แต่นั่นก็เป็นอีกแนวทางหนึ่งต่างหากน่าสนใจที่ได้เห็นแคมเปญแทบจะเหมือนการตลาดเพื่อกู้หน้าให้ async/await
จากประสบการณ์ของผม นี่ไม่ใช่แค่ความผิดพลาดทางเทคนิค แต่ยังทำให้ชุมชนต้องจ่ายต้นทุนสูงด้วย แทนที่จะโฟกัสกับฟีเจอร์ภาษาที่มีประโยชน์จริง ๆ ความพยายามของ Rust กลับถูกเบี่ยงไปกับความสับสนนี้
ถึงอย่างนั้นผมก็ยังคาดหวังกับภาษานี้มาก และคิดว่าในบรรดาสิ่งที่มีอยู่ตอนนี้มันดีที่สุด เพียงแต่กังวลว่าศึกนี้จะยืดเยื้อไปตลอดกาล
ป.ล. ตัวอย่าง
AsyncWrite/AsyncReadดูน่าเชื่อถือ แต่จริง ๆ ถ้าจำกัดไว้ที่ *nix ก็ทำแบบเดียวกันด้วยเธรดและ file descriptor ได้การเหมารวมแบบนั้นดูมีหลักฐานไม่พอ และเหมือนจะเอนเอียงไปตาม workload เฉพาะบางแบบ
สำหรับคนที่ไม่สนใจ async เลย แต่อยากอ่านเรื่อง Rust มันค่อนข้างแย่
poll()ผมอยากรู้ว่าเป็น use case แบบไหน ทั้งชีวิตยังไม่เคยเห็นกรณีแบบนั้นเลยความผิดพลาดคือการผลักมันไปถึงกรณีใช้งานส่วนใหญ่ที่ไม่ได้ต้องการสิ่งนั้น
โปรเจกต์ Rust สำคัญ ๆ จำนวนมากพึ่งพา async ไม่ใช่แค่เพราะประสิทธิภาพดีขึ้นมากเมื่อเทียบกับทางเลือกแบบใช้เธรด แต่ยังเพราะคุณลักษณะด้านการออกแบบด้วย ใน workload หลัก ๆ ที่ติด I/O จะเห็นข้อดีเหล่านี้ได้ง่าย การที่คนฉลาด ๆ ที่แก้ปัญหาจริงนำ async ไปใช้กันอย่างกว้างขวางใน crate หลัก ๆ เป็นสัญญาณที่แรงว่า async เป็นฟีเจอร์ภาษาที่มีประโยชน์จริง
การทะเลาะกันส่วนใหญ่อยู่บน Hacker News กับ reddit ในรูปแบบที่คนซึ่งไม่ต้องการ async โกรธเพราะ crate I/O ที่พวกเขาใช้ตอนนี้ต้องการ async แล้ว ผมเข้าใจว่ามันเป็นสถานการณ์ที่ไม่น่าสนุก และ async ก็มีปัญหาจริง ๆ อีกทั้งยังอยู่ระหว่างแก้ไขจริง ไม่ได้สมบูรณ์แบบ แต่ผมรู้สึกว่าความแตกแยกเรื่อง async ที่เห็นในฟอรัมไม่ได้กว้างหรือดราม่าเท่ากับในโปรเจกต์จริง
สิ่งที่ขาดไปอย่างมากคือ การยกเลิก
future นั้นยกเลิกได้ง่ายมาก ในทางกลับกัน การยกเลิกเธรดนั้นยุ่งเหยิงเหมือนเกมตีตัวตุ่น และการบังคับหยุดเธรดก็ไม่น่าเชื่อถือ เพราะมีความเสี่ยงที่ล็อกจะยังค้างอยู่ในสถานะถูกล็อก
ในโมเดล async ของ Rust สามารถผูก timeout จากภายนอกเข้ากับ future ทุกตัวได้ ไม่จำเป็นที่ฟังก์ชัน I/O ปลายทางทุกตัวต้องรองรับตัวเลือก timeout และไม่จำเป็นต้องส่ง timeout นั้นต่อไปตลอด call stack
หากใช้
Dropguard ซึ่งเป็นแนวปฏิบัติที่ดีของ Rust สำหรับการจัดการสถานะที่กำลังดำเนินอยู่ร่วมด้วย ก็จะยกเลิกงานขนาดใหญ่และซับซ้อนได้อย่างง่ายดายและเชื่อถือได้ตัวอย่างเช่น แม้จะยกเลิก (
drop) บางสิ่งที่ใช้spawn_blockingมันก็ยังทำงานต่อในเบื้องหลัง และผู้ใช้อาจไม่รู้ การทำงานกับไฟล์ระบบแบบ async ที่ implement ด้วย thread pool ก็เช่นกัน แม้จะยกเลิกแล้วก็ยังทำงานต่อสิ่งแบบนี้อาจนำไปสู่บั๊กที่เข้าใจยาก เช่น “มั่นใจว่าไม่มีอะไรเขียนไฟล์นั้นอยู่ แล้วทำไมบริการถึงล้มเหลวเพราะไฟล์กำลังถูกใช้งาน?”
ปัญหานี้ค่อนข้างมีรูปแบบเทียบเคียงกันได้ system call นั้นยากก็จริง แต่ไม่ว่าจะทำในเธรดหรือใน async future หากเรียก system call เดียวกัน ก็จะเจอปัญหาการยกเลิกแบบเดียวกันเป๊ะ
แค่มีสถานะอย่าง flag ที่ทุกเธรดเข้าถึงได้ แล้วให้ลูปการทำงานตรวจ flag นั้น ถ้าเป็น false ก็ return แล้ว join เธรดก็จบ
ถ้างานบางอย่างไม่มีประโยชน์อีกต่อไป ก็เพียงพอแล้วถ้าข้อมูลนั้นสุดท้ายไปปรากฏให้ฟังก์ชันที่ถูกเรียกแทนงานนั้นเห็น ถ้าไม่ใช่จังหวะก่อนทำงานที่แพงมากอย่างการเริ่ม RPC ก็ไม่จำเป็นต้องตรวจเป็นพิเศษ
คำถามที่ดีกว่าน่าจะเป็น “ทำไมถึงเป็น async/await ไม่ใช่ fiber?”
ผมรู้ว่า Rust เคยมี green thread ก่อนเวอร์ชัน 1.0 และจงใจเอาออก แต่การทำ concurrency แบบอิง fiber ก็มีได้หลายแนวทาง เช่น แบบที่ไม่ต้องฝัง runtime หนัก ๆ ไว้ในภาษา
ถ้าผมเข้าใจบทความถูก ประเด็นที่ชมเป็นหลักดูเหมือนจะเป็นเรื่องที่สามารถ drop future เมื่อไรก็ได้ ใน thread นั้นทำแบบเดียวกันไม่ได้ด้วยเหตุผลที่ชัดเจน และแม้ในทางเทคนิคจะทำได้ก็ไม่ปลอดภัยอย่างยิ่ง แต่ความสามารถนี้มีต้นทุนมหาศาลตามมา ไม่ใช่แค่ว่าไม่สามารถใช้ executor แบบ completion-based อย่าง
io-uringร่วมกับอาร์เรย์บนสแตก หรือให้ subtask ไปรันบน thread executor อื่นได้เท่านั้น แต่ยังทำให้เกิดกับดักปลีกย่อยและปัญหาความน่าเชื่อถือที่กลายเป็นเรื่องน่าประหลาดใจอย่างไม่พึงประสงค์มาก ๆ เมื่อเขียน Rust แบบ synchronoushttps://smallcultfollowing.com/babysteps/blog/2022/06/13/asy...
ผมมองว่าการยกเลิกงานโดยพื้นฐานแล้วต้องเป็น cooperative cancellation และ non-cooperative cancellation นั้นแม้ผิวเผินจะสะดวก แต่ค่อนข้างเป็นฟีเจอร์ที่ผิดพลาดซึ่งมีปัญหาลึก ๆ ซ่อนอยู่ข้างใต้
อีกอย่าง การชื่นชม composability ของ async/await ก็ดูแปลก ใน Rust ปัจจุบันที่ไม่มี proper effect system มันห่างไกลจากการ compose ได้ เพราะมีลักษณะ “แพร่เชื้อ” ลองใช้เมธอด
mapของ standard library ร่วมกับ async closure หรือใช้ trait มาตรฐานio::Read/Writeดูก็ได้ต้องเลือกว่าจะทำสแตกเป็น spaghetti stack, บังคับใช้ไลบรารี memory mapping ระดับ process, หรือจำกัดให้เป็นสแตกขนาดคงที่
ทั้งสามแบบมีปัญหาเมื่อโต้ตอบกับโค้ดภาษาที่มี ABI ต่างกัน ตัวอย่างเช่น ถ้า fiber หนึ่งเรียกโค้ด C แล้วโค้ด C นั้นพยายาม resume fiber อื่น เรื่องอาจซับซ้อนขึ้นมาก
ข้อดีอย่างหนึ่งของ async/await คือคีย์เวิร์ด
awaitเอง จุดรอที่ประกาศชัดเจนทำให้เราสามารถให้เหตุผลเกี่ยวกับปฏิสัมพันธ์ของโปรแกรม concurrent ได้จริงfiber ที่ yield ได้คล้ายกับ
gotoในโลกของ concurrency เวลาเรียกเมธอดหนึ่ง เราไม่รู้ว่าการประมวลผลจะหยุดลงเป็น side effect หรือไม่ และเมื่อกลับมาทำต่อ สถานะของโลกจะเปลี่ยนไปแล้วหรือเปล่า เมื่อแตะโลกภายนอกจึงต้องเขียนโค้ดแบบ defensive ดังนั้น fiber จึงเหมาะกับงานที่รันแบบโดดเดี่ยวและสื่อสารกันด้วย completion มากกว่าgreen thread, fiber และ coroutine ต่างก็มีปัญหาร่วมกันตรงนี้ cooperative concurrency ใน user space ไม่ได้แก้ส่วนยากของ concurrency เท่าไรนัก แต่เหมือนแค่ย้ายกองเอกสารบนโต๊ะมากกว่า Rust async/await มีความชัดเจนกว่า จึงไม่ซ่อน side effect ที่กลไกอื่นซ่อนไว้
โค้ดแบบ fiber รู้สึกตามยาก เพราะต้องติดตาม thread ที่กำลังดำเนินอยู่ในหัว อย่างน้อยสำหรับผม การติดตามค่าที่จะ complete นั้นง่ายกว่ามาก
ดูรีวิวของ Gor Nishanov สำหรับคณะกรรมการ C++ ได้ที่ http://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p136... ซึ่งถูกลิงก์จาก https://devblogs.microsoft.com/oldnewthing/20191011-00/?p=10... ด้วย สรุปก็ชัดเจน: DO NOT USE FIBERS!
https://m.youtube.com/watch?v=qKQcUDEo-ZI
ผมคิดว่ามันแสดงเหตุผลได้ค่อนข้างดีว่า async/await นั้นแพร่เชื้อและเทอะทะ ขณะที่ fiber อย่างน้อยใน implementation ของ Ruby เป็น paradigm ที่ดีกว่ามาก
optimization algorithm อาจเรียกปัญหา root finding แล้วสิ่งนั้นก็เรียก ODE integrator ซ้อนกันไป โดยแต่ละขั้นอาจรันนานมาก ต้องส่ง cancellation token ไปทั่วทุกที่ แต่ framework สำหรับ numerical computation โดยทั่วไปไม่รองรับ
เราสามารถและควรกำหนดขีดจำกัดจำนวนรอบให้ทุกอัลกอริทึมได้ แต่ในอัลกอริทึมที่ซ้อนกัน มันรับประกันได้แค่ว่าจะหยุดภายในปีนี้ ไม่ใช่รับประกันได้ยากว่าจะหยุดภายใน 5 วินาที
สำหรับปัญหาแบบนี้ ผมรับประกันได้ว่าสิ่งที่ทำมีแค่การคำนวณคณิตศาสตร์จำนวนมาก, การ allocate และ page fault ที่ตามมา, ไม่มี I/O, และเขียนสตริง log ลงในออบเจ็กต์ Queue ของ standard library เพื่อให้ Main thread ที่จะไม่ถูกยกเลิกจัดการเท่านั้น ฟีเจอร์อื่นที่จำเป็นก็สามารถส่งกลับไปให้ main thread ผ่าน Queue ได้
ผมรู้สึกว่าในศตวรรษที่ 21 ปัญหานี้ควรแก้ได้โดยไม่ต้องยัด cancellation token ไปทุกที่ และไม่ต้องบังคับให้เขียนโค้ดที่รันนานแบบ defensive เพียงเพราะมันไม่ตรวจ token
ในอีกการถกเถียงเรื่อง async/await ผู้คนไม่เข้าใจ async/await, จินตนาการไม่ออกว่าทำไมจึงต้องมีกลไก concurrency บน thread เดียว และสมมติว่าไม่มีใครต้องการมัน
การเขียนโปรแกรม UI, การสื่อสารกับ GPU, และการสื่อสารระหว่าง runtime เป็นตัวอย่างที่ดี และน่าจะมีอย่างอื่นอีก
thread ไม่ว่าจะเป็น green thread หรือไม่ ไม่เหมาะกับกรณีเหล่านี้ แต่ async/await เหมาะ
ถ้าสามารถ implement ฟีเจอร์ภาษาอื่นที่สร้างไบนารีเดียวกันแต่ทำให้ผู้ใช้ภาษาลำบากน้อยกว่าได้ ก็เป็นเรื่องที่ควรคุยกัน
ข้อดีสำคัญอย่างหนึ่งของ async/await ใน Rust คือสามารถทำงานได้แม้ในสถานการณ์ที่ ไม่มีเธรดหรือหน่วยความจำแบบไดนามิก
มันยังใช้เขียนโค้ดที่กระชับบนไมโครคอนโทรลเลอร์ได้ดีพอ เช่น รอให้อินเทอร์รัพท์อ่านข้อมูล I2C ที่เข้ามาในบัฟเฟอร์ใดบัฟเฟอร์หนึ่ง เป็น abstraction ระดับสูงกว่าที่ช่วยให้ใช้ concurrency ได้โดยไม่ต้องเปิดเผยการโต้ตอบกับ runtime ชั้นล่างมากนัก
ซอฟต์แวร์หลักทั้งหมดที่ผมเคยทำงานด้วยล้วน implement สิ่งทำนองนี้ในรูปแบบใดรูปแบบหนึ่ง แม้ในโค้ดที่ไม่มีแนวคิด coroutine แบบ C++ สมัยใหม่ ก็ยังใช้ Apple Grand Central Dispatch, Intel Threading Building Blocks ฯลฯ ไม่อย่างนั้น business logic จะไปติดค้างกับ I/O อย่างไร้ประสิทธิภาพมาก หรือมีเธรดมหาศาลจนการพัฒนาและดีบักกลายเป็นนรก หรือถูกกลบด้วยรายละเอียดการ implement ของ runtime ชั้นล่าง หรือปนกันทั้งสามอย่าง
ถ้าไม่ใช้ abstraction ที่มีอยู่แล้วในตัวภาษาเองหรือในไลบรารี สุดท้ายก็ต้องสร้างเอง ซึ่งยากและโดยรวมมีแนวโน้มจะด้อยกว่าสิ่งที่ใช้กันแพร่หลาย ผมเองก็เคยทำขึ้นเองสำหรับ C++ มาก่อน: https://github.com/goto-opensource/asyncly
ดูเหมือนผู้เขียนจะสับสนสองเรื่องเข้าด้วยกัน
เรื่องหนึ่งคือ เธรดใน user space/green thread และอีกเรื่องคือ structured concurrency
อย่างแรกเป็นข้อดีของ async/await แต่ไม่ใช่ข้อดีเฉพาะตัว มีตัวอย่างที่ทำได้โดยไม่มีปัญหาเรื่องสีของฟังก์ชัน เช่น Go หรือ Java Loom
อย่างหลังสามารถ implement ได้ทั้งด้วยเธรดของ OS และ green thread ดู JEP เรื่อง Structured Concurrency ของ Java ได้
https://openjdk.org/jeps/462