async/awaitของ Rust มุ่งเป้าไปที่ concurrency ขนาดใหญ่ ที่ต้องรองรับการเชื่อมต่อนับหมื่น แต่กลับขัดกับเป้าหมายของ Rust เองเรื่องการควบคุมระดับต่ำและการตรวจสอบ lifetime แบบสถิต ทำให้ประสบการณ์พัฒนาแตกต่างจาก Rust แบบทั่วไป- thread และ channel เพียงพอสำหรับซอฟต์แวร์จำนวนมาก แต่เมื่อถึงสเกลระดับ C10K ภาระของโมเดลหนึ่งการเชื่อมต่อหนึ่ง thread จะสูงเกินไป จึงต้องใช้ task ใน user space และการจัดตารางโดย runtime
- ใน
asyncRust ข้อมูลต้องถูกย้ายแบบSendหรือถูกจัดการผ่าน reference แบบ'staticและด้วยความที่asyncมีลักษณะ แพร่กระจาย ข้อจำกัดเหล่านี้จึงเกิดซ้ำไปทั่วทั้งโค้ด - แม้
Arcจะช่วยแก้ปัญหาการคอมไพล์ได้ แต่ก็ทำให้ lifetime ของอ็อบเจ็กต์และ resource ไม่ชัดเจน อีกทั้งยังมี กับดัก ต่อเนื่องอย่าง recursiveasync, ความต่างระหว่าง future กับ task, และการเรียก blocking ที่ไปขวาง thread ของ runtime - ใน Haskell หรือ Go “async code” ทำงานเหมือนโค้ดปกติ และ runtime กับ GC จะซ่อนความแตกต่างเหล่านี้ไว้ ดังนั้นสำหรับงานลักษณะนี้ การควบคุมแบบชัดเจนของ Rust อาจไม่ได้เป็นข้อดีล้วน ๆ
ทำไมจึงต้องมี concurrency และ parallelism
- โปรแกรมที่เร็วมีข้อกำหนดอยู่สองอย่างพร้อมกัน
- ต้องใช้ CPU หลายคอร์เพื่อใช้ประโยชน์จากทั้งเครื่องให้เต็มที่
- ต้องทำงานอื่นต่อได้ระหว่างรองานช้า เช่น การส่งข้อความผ่านอินเทอร์เน็ตหรือการเปิดไฟล์
- Parallelism คือปัญหาของการรันโค้ดพร้อมกันบน CPU หลายตัว
- Concurrency คือวิธีแบ่งปัญหาออกเป็นส่วนที่เป็นอิสระต่อกัน
- ทั้งสองอย่างไม่เหมือนกัน แต่ถ้าแบ่งโปรแกรมออกเป็นชิ้นที่ทำงานพร้อมกันได้ ชิ้นเหล่านั้นก็สามารถถูกรันแบบขนานและทำให้คอร์ต่าง ๆ ไม่ว่างงาน
Process, thread, channel
- วิธีง่าย ๆ ในการสร้างระบบที่มี concurrency คือแบ่งโค้ดออกเป็นหลาย process
- scheduler ของระบบปฏิบัติการจะนำ time slice ของ process ที่พร้อมรันไปกระจายลงบน CPU core ที่มีอยู่
- กรณีเชื่อมคำสั่ง shell เข้าด้วยกันผ่าน pipe ก็ใช้โมเดลนี้เช่นกัน
- วิธีแบบ process มีต้นทุนการสื่อสารระหว่าง process สูง
- หลาย implementation ต้องคัดลอกข้อมูลเข้าไปในหน่วยความจำของ OS แล้วค่อยดึงกลับออกมาอีกครั้ง
- แม้จะลดต้นทุนได้ด้วย shared memory แต่ข้อดีเรื่องการแยก process ออกจากกันโดย OS ก็จะลดลง
- Thread แชร์หน่วยความจำเดียวกันจึงเลี่ยง overhead นี้ได้ แต่ถ้าใช้เครื่องมือ synchronization อย่าง mutex, condition variable, semaphore ผิด ก็อาจเกิด data race และ deadlock ได้
- โมเดล Communicating Sequential Processes ของ Tony Hoare เชื่อม thread เข้าด้วยกันผ่าน queue หรือ channel
- thread จะไม่แชร์หน่วยความจำกัน จึงได้การแยกตัวคล้าย process
- input และ output ของแต่ละ thread ถูกเปิดเผยผ่าน channel ทำให้ reasoning และ debugging ง่ายขึ้น
- ตัว channel เองทำหน้าที่เป็น synchronization ด้วย โดยถ้าว่าง receiver จะรอ และถ้าเต็ม sender จะรอ
- Rust standard library มี std::sync::mpsc::sync_channel
- ซอฟต์แวร์จำนวนมากใช้งานเพียง thread กับ channel รวมถึงเครื่องมืออย่าง Rayon สำหรับทำให้ลูปที่ใช้ CPU หนัก ๆ ทำงานแบบขนาน ก็เพียงพอแล้ว
Concurrency ใน user space และ Rust async
- ในปัญหาแบบ C10K เช่นเว็บเซิร์ฟเวอร์ที่มีผู้ใช้พร้อมกันนับหมื่น การจับหนึ่ง thread ต่อหนึ่งการเชื่อมต่อจะเริ่มชนข้อจำกัด
- บน Linux แต่ละ thread มี control block ขนาด 4kB และการสลับ thread ต้องทำ context switch ผ่าน scheduler ของระบบปฏิบัติการ
- เพื่อรองรับ concurrency ขนาดใหญ่ บางภาษาเลือกสร้างและจัดการ task ใน user space
- runtime จะจัดตาราง task ลงบน pool ของ OS thread
- โดยทั่วไปจะตั้งค่า pool ให้มีประมาณหนึ่ง thread ต่อหนึ่ง CPU core เพื่อดึง parallelism ออกมาให้มากที่สุด
- แนวทางนี้มีชื่อเรียกได้หลายแบบ เช่น green thread, lightweight thread, lightweight process, fiber, coroutine
- Rust ใช้โมเดล
async/awaitแบบที่พบใน C# หรือ Node.jsasync fnจะไม่คืนค่าโดยตรง แต่คืน future หรือ promise ที่ต้องใช้.awaitเพื่อรับผลลัพธ์
- future ของ Rust มีขนาดเล็กและเร็วมากด้วยการออกแบบแบบ cooperative scheduling และ stackless
- Rust พยายามมอบ abstraction ของ future พร้อมกับยังคงสัญญาการควบคุมระดับต่ำแก่โปรแกรมเมอร์
- พยายามตรวจสอบ lifetime ของทุกอ็อบเจ็กต์และ reference แบบสถิตตั้งแต่คอมไพล์ไทม์
- future แบ่งโค้ดและข้อมูลที่โค้ดนั้นอ้างถึงออกเป็นชิ้นเล็ก ๆ นับพันชิ้น และทำให้มันสามารถไปรันบน thread ไหน เมื่อไรก็ได้ ตามเงื่อนไขที่รู้ได้หลังเริ่มรันเท่านั้น
- future ที่อ่านข้อมูลจาก client ควรถูกรันเฉพาะตอนที่ socket นั้นมีข้อมูลให้อ่าน แต่ lifetime annotation ไม่ได้บอกเวลานั้น
- Rust ไม่ได้ฝัง future runtime ไว้ในตัวภาษา แต่ปล่อยให้เป็นหน้าที่ของไลบรารีอย่าง Tokio
- ผู้ใช้จึงมีอิสระเลือกทางเลือกที่เหมาะกับสภาพแวดล้อมของตน
- แต่ถึงจะจินตนาการว่า Tokio ถูกฝังมาในภาษา กฎเดียวกันก็ยังใช้เหมือนเดิม ดังนั้นในประเด็นนี้มันเป็นเพียงรายละเอียดรอง
แรงกดดันจาก Send, 'static, Arc
- หากต้องการให้คอมไพเลอร์ยอมรับ ข้อมูลต้องถูกระบุว่าเคลื่อนย้ายได้ด้วย
Sendหรือส่งผ่านด้วย reference ที่มี lifetime แบบ'static - ในโค้ด
asyncเป็นเรื่องปกติที่หลาย task จะแชร์ state ร่วมกัน ดังนั้นการย้ายข้อมูลโดยไม่ทำสำเนาจึงมักไม่ใช่ทางเลือกที่เหมาะ - การใช้ reference ก็ยากเช่นกัน และไม่มีสิ่งเทียบเท่า
thread::scopeที่ช่วยจำกัด lifetime ของ future ให้สั้นกว่า “ตลอดไป” asyncมีลักษณะ แพร่กระจาย ทำให้ฟังก์ชันที่เรียกฟังก์ชันasyncต้องกลายเป็นasyncไปด้วย- นั่นหมายความว่าปัญหาเรื่อง lifetime และการเคลื่อนย้ายข้อมูลนี้ไม่ได้แก้แค่บางฟังก์ชัน แต่ต้องแก้ซ้ำอย่างต่อเนื่อง
- แม้จะตัด chain ได้ด้วยการรอให้ future เสร็จที่ runtime ผ่าน
block_onแต่วิธีนี้นำไปประกอบต่อได้ไม่ดี และถ้าซ้อนกัน runtime อาจ panic ได้
- Arc เป็นเครื่องมือสำหรับจัดการ lifetime แบบไดนามิกข้ามหลาย thread และช่วยให้ผ่าน borrow check จนโค้ดคอมไพล์ได้
- แต่ถ้าใช้
Arcอย่างกว้างขวาง ก็จะทำให้ lifetime ของอ็อบเจ็กต์และ resource ไม่ชัดเจน- ไม่แน่ชัดว่า resource อย่างหน่วยความจำ ไฟล์ หรือ socket จะถูกปล่อยเมื่อใด
- ต้องรับข้อเสียคล้าย GC โดยไม่ได้ข้อดีแบบ allocation throughput, fragmentation ต่ำ หรือการหลีกเลี่ยง cycle leak แบบที่ GC จริงให้มา
กับดักเพิ่มเติมของ async Rust
- coroutine ของ Rust เป็นแบบ stackless ดังนั้นคอมไพเลอร์จะเปลี่ยนแต่ละ coroutine ให้เป็น state machine ที่เดินหน้าไปถึงจุด
.await- ฟังก์ชัน
asyncแบบ recursive จึงกลายเป็น type ที่นิยามตัวเองแบบ recursive - ผู้ใช้ที่ต้องการเรียกตัวเองแบบง่าย ๆ จึงต้องทำ boxing เองหรือใช้ crate อย่าง async-recursion
- ฟังก์ชัน
- Future จะยังไม่ทำอะไรเลยจนกว่าจะถูก await
- Task จะเริ่มทำงานบน thread pool ของ runtime และคืน future ที่ใช้บอกการเสร็จสิ้น
- ไม่มีอะไรมาห้ามไม่ให้เรียกโค้ด blocking จากภายใน future
- และก็ไม่มีอะไรมาห้ามไม่ให้การเรียกนั้นไปขวาง runtime thread ที่มันกำลังรันอยู่
- ซึ่งขัดกับจุดประสงค์หลักของการใช้
async
ความต่างจาก Rust ปกติ, Haskell, Go
asyncRust ให้ความรู้สึกต่างจาก Rust “ปกติ” อย่างมาก- มีกับดักมากกว่า
- เข้าใจและสอนได้ยากกว่า
- ผู้ใช้จึงเหมือนถูกบีบให้เลือกอยู่ระหว่างสองทาง
- ทำความเข้าใจเชิงลึกว่าตัว abstraction ทำงานอย่างไรจริง ๆ แล้วเขียนโค้ดที่ซับซ้อน
- หรือโปรย
Arc,Pin,'staticและองค์ประกอบคล้ายกันไปทั่วโค้ดแล้วหวังว่ามันจะเวิร์ก
- แม้แต่ทีมพัฒนาที่มีประสบการณ์ก็อาจพยายามใช้ Rust ในโปรเจกต์ใหม่ แล้วติดอยู่กับรายละเอียดเหล่านี้
- ใน Haskell หรือ Go “async code” ก็คือโค้ดปกติ
- ทั้งสองภาษาซ่อนความต่างระหว่างโค้ด blocking กับ non-blocking ไว้หลัง runtime ที่หนา
- ปัญหาเรื่อง lifetime ถูกโยนให้ garbage collection จัดการ
- สำหรับซอฟต์แวร์แบบ user-space ที่มีผู้ใช้พร้อมกันจำนวนมากลักษณะนี้ แนวทางที่ให้ runtime กับ GC ซ่อนความแตกต่างไว้กลับเป็นข้อดีล้วน ๆ
- Rust อาจไม่ใช่เครื่องมือที่ดีสำหรับซอฟต์แวร์แบบ user-space ที่มี concurrency ขนาดใหญ่ และอาจเหมาะกว่าที่จะใช้กับโปรเจกต์ที่ไม่มีความต้องการแบบนั้น
1 ความคิดเห็น
ความคิดเห็นบน Hacker News
กำลังเขียน ไคลเอนต์เมตาเวิร์ส ประสิทธิภาพสูงด้วย Rust และตอนนี้มีขนาดประมาณ 40,000 บรรทัด
วิดีโอเดโมอยู่ที่ https://video.hardlimit.com/w/tp9mLAQoHaFR32YAVKVDrz
ถ้าเป็นเมตาเวิร์สที่จริงจัง ก็ต้องประมวลผลคอนเทนต์ที่ผู้ใช้สร้างขึ้นแทบจะเรียลไทม์ จึงต้องใช้ VRAM มากกว่าเกมลักษณะคล้ายกัน 2–3 เท่า และถ้าจะโหลด asset จากเซิร์ฟเวอร์ก็ต้องใช้แบนด์วิดท์ระดับหลายร้อย Mbps, CPU หลายตัว และ Vulkan สำหรับทำ rendering ควบคู่กับการอัปโหลดไปยัง GPU
นี่ไม่ใช่โครงสร้างแบบ concurrency “ระดับเว็บ” ที่มีเซิร์ฟเวอร์เล็ก ๆ แยกกันรันอยู่ใน address space เดียวกัน แต่เป็นโครงสร้างที่มีเธรดหลายตัวทำงานร่วมกัน เช่น เธรด render ลำดับความสำคัญสูง, เธรดอัปเดตอีเวนต์เครือข่าย, เธรดโหลดและแตกไฟล์ asset, รวมถึงเธรดที่ดูแลวัตถุเคลื่อนที่, LOD, การล้างแคช ฯลฯ
ใน Rust เราใช้ lock ค่อนข้างมากโดยไม่มี global state นอกจากค่าคงที่ ใช้ channel ในจุดที่เหมาะสม และ tree ของอ็อบเจกต์หลักถูกจัดการด้วย ownership แบบเดี่ยว โดยเธรดอัปเดตเป็นผู้ดูแลหลัก การเชื่อมโยงอ็อบเจกต์กราฟิกจัดการด้วย reference count ของ
Arcและอัปโหลด mesh กับ texture ไปยัง GPU ผ่าน Rend3/WGPU/Vulkanถ้าทำด้วย C++ คงต้องต่อสู้กับ crash ไม่หยุด แต่ใน Rust crash ที่เกี่ยวกับหน่วยความจำเกิดขึ้นประมาณปีละครั้ง และส่วนใหญ่ก็มาจากโค้ด
unsafeของคนอื่น โค้ดของผมห้ามใช้unsafeไว้ แม้จะคอมไพล์ยาก แต่พอคอมไพล์ผ่านแล้วก็มักจะ “ใช้งานได้เลย” ซึ่งผมมองว่าดีกว่าการดีบัก concurrency มากก็มีเรื่องไม่พอใจอยู่เหมือนกัน Rust แข็งแกร่งเรื่อง data race แต่ป้องกัน deadlock ไม่ได้ จึงอยากได้ static analyzer ที่ตามลำดับการ lock ไปตาม call path ได้
asyncไม่เหมาะกับงานเน้นคำนวณและเธรดหลายระดับความสำคัญ แต่กลับค่อย ๆ แทรกเข้ามาเป็น dependency อยู่เรื่อย ๆ โครงสร้างที่พบบ่อยอย่าง ownership แบบเดี่ยวที่มี back-reference นั้นยากเกินไปถ้าไม่มีRcกับWeakและ trait system ก็ซับซ้อนจนส่วนการจัดการ asset ที่เหมาะกับ OOP ตามธรรมชาติเกิดโค้ดซ้ำcrate หลักด้านกราฟิกก็ยังไม่สมบูรณ์พอ คำพูดที่ว่า “Rust มีเกม 5 เกมกับ game engine 50 ตัว” ไม่ใช่ปัญหาของภาษา แต่เป็นปัญหาของ ecosystem และเมื่อเทียบกับ https://gamedev.rs/ แล้วก็ดูเหมือนว่าการพัฒนาเกมจริงจังด้วย Rust ยังมีไม่มากพอ ถ้าเป็นการพัฒนาเกมมืออาชีพที่มีกำหนดส่ง ผมมองว่า ecosystem เกมของ Rust ยังไม่พร้อม และน่าจะต้องใช้คนประมาณ 5 คนทำงานต่ออีกสัก 1 ปี
asyncจะมีปัญหาอยู่บ้าง แต่โดยรวมข้อดีมากกว่ามากคล้ายกับ
lockdepของ Linux คือวิเคราะห์ว่าขณะถือ lock หนึ่งอยู่ มีการไปจับ lock อื่นหรือไม่ และแจ้งเตือน combination ที่เสี่ยงได้แม้ก่อนจะค้างจริง สำหรับ lock ที่ซับซ้อนอาจต้องมี annotation เช่น “lock class นี้ต้องจับตามลำดับ address เสมอ” แต่ดูแล้วน่าจะทำได้นอกเหนือจากการเข้าถึงข้อมูลแล้ว race condition ก็ยังเกิดขึ้นได้: https://news.ycombinator.com/item?id=23599598
อาจสร้าง thread pool หลายชุดแล้ว route future ให้เหมาะสม หรือเขียน event loop เองเพื่อดึงงานจาก event queue หลายชุดที่มี priority ต่างกันก็ได้ วิธีที่สอง ถ้าเวลารันของงานถูกจำกัดไว้ ก็ยังทำให้งาน priority ต่ำคืบหน้าได้แม้ CPU ใช้ 100% พร้อมให้ การรับประกันแบบ soft real-time กับงาน priority สูง
เรื่อง
asyncRust นั้นอยู่ในจุดที่ค่อนข้างประหลาดถ้าใช้
Arc,RwLockและ shared state เต็มไปหมด โค้ดจะเละเทะ และคำวิจารณ์ที่ว่าโดยเฉพาะเมื่อ'staticเริ่มแพร่ไปทั่ว มันเหมือน colored function ที่ทำให้ทุกอย่างติดไปหมดนั้นก็ถูกต้อง เมื่อก่อนผมเคยพยายามใส่Arcแล้วจัดการ lifetime borrowing ให้ฉลาด ๆ สุดท้ายก็กลายเป็นความยุ่งเหยิงแต่ Rust ก็มี channel ด้วย โค้ดที่ผมเขียนอยู่ตอนนี้ส่วนใหญ่เป็นโครงสร้างที่มี task ไม่กี่ตัวคอย service channel ดูข้อความที่เข้ามา และถ้าจำเป็นก็ใส่ข้อความที่จะส่งต่อไปยัง task อื่นลงใน channel ที่เหมาะสม ไม่แชร์อ็อบเจกต์กัน ถ้าหลาย task ต้องใช้อ็อบเจกต์ขนาดใหญ่ ก็จะเก็บไว้ใน task ที่ส่งผลลัพธ์ของ query ที่เกี่ยวข้องกลับมาเป็น message หรือให้แต่ละ task สร้างสำเนาของตัวเองจาก message flow
ถึงอย่างนั้นก็ยังมีบทความจำนวนมากที่พูดเรื่องจะใช้
Arcอย่างไรและจัดการ lifetime อย่างไร ถ้าคุณกำลัง implementasyncruntime เองก็คงจำเป็น แต่ผมไม่ค่อยเข้าใจว่าทำไมผู้ใช้ไลบรารีทั่วไปต้องโฟกัสกับเรื่องนี้มากขนาดนั้นasyncไม่ได้แปลว่า multi-threading โดยอัตโนมัติ และถ้าเป็นasyncในเธรดเดียวกันก็ไม่มีการแชร์ จึงไม่จำเป็นต้องติด keyword วิเศษสารพัดให้สิ่งที่แชร์เวลาข้ามระหว่างเธรด ก็ส่งสัญญาณผ่าน channel แทนที่จะมี shared state เต็มไปหมด ถ้ามี global state ที่จำเป็นจริง ๆ ก็สร้าง struct เล็ก ๆ ที่ห่อกลไก exclusive access อย่าง
Arc/RwLockไว้ และในมุมของ caller ให้ดูเหมือนเป็นการเรียกฟังก์ชันธรรมดาความกังวลเรื่อง
Send+Syncก็ไม่ค่อยเข้าใจ จากประสบการณ์ ส่วนใหญ่เป็นSend+Syncได้ง่าย ๆ และสิ่งที่ไม่ใช่ก็มักเป็นสิ่งที่ไม่ควรหรือไม่สามารถเป็นแบบนั้นได้อยู่แล้ว บางครั้งก็อยากเขียนโค้ดโดยไม่ต้องคิดรายละเอียด แต่ถ้าต้องการ concurrency และ parallelism ที่มีประสิทธิภาพ microsecond กับ throughput จะสำคัญขึ้นมา และตอนนั้นก็ต้องเขียนโค้ดสำหรับคอมพิวเตอร์จริง ๆ ให้ถูกต้องแต่การเขียนโค้ดแบบนี้ต่างจาก
asyncJavaScript ที่ให้ความรู้สึกเหมือนเอา green thread มาครอบ synchronous code อยู่มาก ผู้คนพยายามเขียนโค้ดในแบบที่คุ้นเคย จึงดูเหมือนจะหลงไปทางArcกับRwLockใน Rustการจัดโครงสร้างปัญหาให้เป็นข้อมูลที่ไหลระหว่างงานต่าง ๆ เชื่อมกันด้วย queue และหลีกเลี่ยง shared state เป็นวิธีที่ดีกว่าในการจัดการ multi-threading ไม่ว่าจะใช้ภาษาใดก็ตาม
asyncแท้จริงแล้วคือ Rust ที่ยากขึ้นมาก และโปรเจกต์ที่จำเป็นต้องใช้จริง ๆ น่าจะมีแค่ราว 1% แต่น่าเสียดายที่มันเหมือนถูกบังคับใช้กับทุกคนแต่ใน 1% นั้นมันยอดเยี่ยมจริง ๆ สำหรับบริการที่มีแกนหลักคือการจัดการการเรียกเครือข่ายจำนวนมากอย่าง linkerd หรือ nginx, กรณีที่ต้องรันงานเบาจำนวนมหาศาลในเกม, หรือกรณีที่ต้องการ concurrency แบบร่วมมือกันในระบบ embedded นั้น async Rust กลายเป็นอาวุธที่ทรงพลัง
โค้ดระดับระบบและแอปพลิเคชันส่วนใหญ่ไม่จำเป็นต้องใช้ I/O แบบอะซิงโครนัส แอป REST ใช้ thread pool ก็เพียงพอแล้ว และแม้ต้องใช้
asyncก็มักจะเหมาะกับ โมเดลผสม ที่จำกัดไว้เฉพาะส่วนเล็ก ๆ อย่างเครือข่าย แล้วเชื่อมส่วนที่เหลือด้วย thread และ channelชุมชน Rust ใช้
asyncอย่างพร่ำเพรื่อเกินไปในทุกที่ จน Rust แบบ blocking I/O ซึ่งมีประสบการณ์ผู้ใช้ดีกว่ากลายเป็นพลเมืองชั้นสองใน ecosystem ไปแล้ว แม้แต่เว็บเฟรมเวิร์กก็มีเฟรมเวิร์กแบบอะซิงโครนัสที่ออกแบบดีอยู่หลายตัวอย่าง Axum, Warp แต่ฝั่ง blocking มีตัวเลือกจำกัดกว่ามาก เช่นtiny_http,rouille,astraการเลือก coroutine แบบไม่มี stack ทำให้เกิดปัญหา
async/awaitและปัญหา colored functions จนเกิดแรงเสียดทานอย่างที่บทความกล่าวถึง ส่วน Go ใช้ coroutine แบบมี stack จึงไม่มีปัญหาเหล่านี้ตอนแรก Rust ก็เคยพิจารณา coroutine แบบมี stack แต่เห็นว่าต้องมี runtime สำหรับ preemption ของ coroutine และมีต้นทุนสูง จึงไปใช้โมเดลแบบไม่มี stack แทน แต่คนส่วนใหญ่ไม่ได้ใช้
asyncRust แบบไม่มี runtime และใช้ Tokio กัน โดย Tokio ก็ทำแทบทุกอย่างที่ runtime ซึ่งเดิมทีพยายามหลีกเลี่ยงต้องทำอยู่ดีดังนั้นผู้ใช้
asyncRust จำนวนมากจึงได้ข้อเสียของทั้งสองฝั่ง ในฝั่ง embedded ก็มีการใช้asyncRust กับ runtime ที่บางมากอยู่บ้าง แต่จำนวนก็ยังน้อย และแม้แต่พวกเขาเองก็ยังไม่ได้ถูกโน้มน้าวอย่างสมบูรณ์reqwestเข้ามา แล้วมันดึงh2เข้ามา จากนั้นก็ดึงtokioเข้ามาอีกทีasyncอยู่ไหมในฐานะคนใช้ Java ผมกำลังจะทิ้ง paradigm แบบอะซิงโครนัสทั้งหมด แล้วเขียนโค้ดใหม่เป็นโมเดล blocking บน virtual threads ที่ blocking ได้โดยไม่เป็นไร
asyncแพร่ไปยัง crate มากเกินไป จนทั้งโปรแกรมต้องกลายเป็นasyncหรืออย่างน้อยก็ต้องพึ่งพา Tokio กับงานจำนวนมากถ้าต้องการเว็บเซิร์ฟเวอร์ก็เหมือนถูกบอกว่า
async + tokioหรือไม่ก็ไสหัวไป ส่วน SQL connector ก็ให้บรรยากาศว่าถ้าไม่ต้องการแบบอะซิงโครนัสก็ต้องเขียนเอง แต่ละคนแก้ปัญหาที่asyncนำมาด้วยวิธีต่างกัน และของอย่างasyncclosure ก็รู้สึกเหมือนเปิดประตูนรกให้ compilerเป็นเรื่องดีที่ Rust เองและ compiler ช่วยแก้ปัญหาได้ แต่ ecosystem ที่ใกล้เคียงกับ “ถ้าไม่ใช้
asyncก็ทำเอง” นั้นยังไม่เพียงพอfuturesก็คงลดความเจ็บปวดได้มากจำเป็นต้องมีสิ่งอย่าง trait ที่ executor ต้อง implement หรือ executor แบบ blocking พื้นฐานสำหรับรันโค้ดอะซิงโครนัสจากโค้ด synchronous ตอนนี้แค่การทำไลบรารีให้รองรับ async runtime หลายตัวก็ลำบากแล้ว สุดท้ายจึงมักรองรับแค่ Tokio หรืออย่างมากก็เพิ่ม
async-stdเข้ามาผมไม่ใช่ผู้เชี่ยวชาญ
asyncRust แต่เดือนนี้ได้เขียน Rust แบบ synchronous ไปหลายพันบรรทัด และสิ่งที่รู้สึกคือ เมื่อrustcทำให้แนวทางบางอย่างยาก มักจะมีเหตุผลที่สมควร และมักมีทางที่ได้ผลลัพธ์คล้ายกันด้วยวิธีที่ดีกว่าถ้ากำลังเรียนภาษาอยู่ แนะนำให้คุ้นเคยกับ โค้ด synchronous ธรรมดา ลูป เงื่อนไข และกฎการยืมก่อน
asyncยังพัฒนาอยู่มาก ไม่ใช่แค่ในแง่ implementation แต่รวมถึงระดับปรัชญาว่า “อะซิงโครนัสคืออะไร และควรปรากฏต่อผู้ใช้อย่างไร”compiler พึ่งพา trait อย่างมาก แต่ความสามารถของ trait ในการจัดการ
asyncยังไม่เสถียร ตัวอย่างเช่นมีงานอย่าง https://blog.rust-lang.org/inside-rust/2022/11/17/async-fn-i...ถ้าฟีเจอร์อะซิงโครนัสของ trait ยังไม่เสถียร การโจมตีว่าโค้ดอะซิงโครนัสของ Rust ยังไม่สวยก็คล้ายกับการวิจารณ์ร่างแรก ๆ ของหนังสือที่จะเสร็จสมบูรณ์ในที่สุด
อีกเรื่องที่คิดอยู่คือจะป้องกันไม่ให้อะซิงโครนัสแพร่ไปทั่วทั้ง codebase ได้อย่างไร
แนวคิดปัจจุบันคือให้ thread สำหรับ I/O แยกอีเวนต์ระบบของ
liburingหรือepollออกเป็นสองขั้นคือ “submit” และ “handle” แล้วส่งไปยัง component อื่น เช่น ถ้าสร้างtcp-connectionก็สามารถ subscribe อีเวนต์อะซิงโครนัสอย่าง “พร้อมเขียน”, “พร้อมอ่าน” ได้ และอีเวนต์พร้อมเขียนจะดึงข้อมูลจาก buffer ที่เติมด้วย mutex ปกติ แล้วส่งด้วยEPOLLOUT/io_uring_prep_writevการส่งอีเวนต์ข้าม thread สามารถใช้ ring buffer แบบ multi-producer/multi-consumer ตามแพตเทิร์น LMAX Disruptor ได้ thread ของแอปพลิเคชันหรือ thread pool แต่ละตัวจะมี event loop ของตัวเองและประมวลผล ring buffer นี้
กำลังทำ syntax สำหรับแสดงลำดับการปล่อยอีเวนต์อะซิงโครนัสด้วย หน้าตาคล้าย pipeline ของ Bash และเรียกว่า
statelines:initialstate1 initialstate2 = state1 | {state1a state1b state1c} {state2a state2b state2d} | state3อายุการใช้งานของ
Arcไม่ได้เป็นสิ่งที่ไม่รู้ แต่ถูกกำหนดจากว่าถูกถือไว้ที่ไหนและอย่างไรความไม่ลงรอยในบทความนี้น่าจะมาจากการที่ผู้เขียนพยายามยัดโมเดลความคิดเดิม ๆ อย่าง garbage collection เข้ากับ Rust มากกว่าจะเรียน Rust แล้วปรับวิธีทำงานให้เข้ากับภาษา นี่เป็นกับดักที่พบบ่อยเวลาเรียนภาษาใหม่ แต่ Rust ทำให้สะดุดได้บ่อยเป็นพิเศษ
แต่นั่นแทบจะตรงข้ามกับเป้าหมายของ borrow checker ที่พยายามจำกัดอายุการใช้งานของอ็อบเจ็กต์แบบสแตติกในเวลา compile
สำหรับผมจริง ๆ แล้วเกือบจะตรงกันข้ามเลย ผมทำ system programming ด้วย C, C++, Rust มาประมาณ 10 ปี แล้วปัจจุบันได้ใช้ Haskell เยอะในที่ทำงาน และค่อนข้างเปิดหูเปิดตาว่า runtime ของภาษาขนาดใหญ่กับ garbage collection ไม่ได้เป็นสัตว์ประหลาดในบางโดเมนของปัญหา
asyncไปขัดขวาง optimization ที่ compiler ทำได้ในโค้ดที่ไม่ใช่ async อย่างไรส่วนที่พูดว่าต้องต่อสู้กับ
Weakดูเหมือนกำลังพยายามสร้างโครงสร้าง ownership ที่ซับซ้อน ซึ่งเป็นเรื่องไม่ง่ายใน Rust โดยรวม ผมแทบไม่ค่อยใช้ weak smart pointer เลยchannel แทบไม่ถูกพูดถึง ทั้งที่เป็นเครื่องมือหลักสำหรับให้ส่วนต่าง ๆ ของโปรแกรมสื่อสารกันในโค้ด async หรือเมื่อต้องเชื่อมโค้ด async กับ sync นอกจากนี้ยังมี abstraction สำหรับสัญญาณอย่าง
Notifyและ semaphore ด้วยmutex ช้าและมักกลายเป็นคอขวด ส่วน shared state ก็ซับซ้อนขึ้นอย่างรวดเร็ว เรื่องนี้เป็นที่รู้กันมานานแล้ว ปัญหาอาจอยู่ที่โครงสร้างอย่าง
BIG_GLOBAL_STATIC_REF_OR_SIMILAR_HORRORตั้งแต่แรกข้อสังเกตที่ว่า Rust ไม่ได้กันไม่ให้เรียกโค้ด blocking ใน context แบบ async นั้นถูกต้อง แต่ถ้าจำเป็นก็จัดการได้ค่อนข้างดีด้วยของอย่าง
tokio::spawn_blockingผู้เขียนน่าจะรู้ว่า
Arcคืออะไรและทำงานอย่างไร และใจความน่าจะใกล้กับประเด็นที่ว่าใน Rustasyncคุณมักต้องใช้Arcบ่อยกว่า RAII ปกติอย่างมากเมื่อเทียบกับโค้ด synchronousถ้าอ็อบเจ็กต์ 90% ในโปรแกรมถูก reference-counted การใช้ tracing garbage collection อาจดีกว่าการต้องจ่ายต้นทุนจากการ allocate/deallocate บน heap ชิ้นเล็ก ๆ จำนวนมากและ atomic operation ตัวอย่างใน Tokio tutorial ก็ชี้ไปในทิศทางคล้ายกัน: https://tokio.rs/tokio/tutorial/shared-state
ผมสงสัยว่า tracing garbage collection จริง ๆ ใน Rust จะทำให้แอป async ทั่วไปอย่าง HTTP server เร็วขึ้นอย่างมีนัยสำคัญได้ไหม: https://manishearth.github.io/blog/2015/09/01/designing-a-gc...
Arcไม่ได้สุ่ม แต่เป็นสิ่งที่ ไม่สามารถรู้ได้แบบสแตติกArcของ Rust สามารถ move หรือ borrow ได้ และยังใช้งานได้โดยไม่ต้องแตะ reference count ด้วยในหลายกรณีมันถูกกว่าอ็อบเจ็กต์ในภาษาที่มี reference counting แบบ implicit มาก
ผมชอบ Rust แต่
asyncเป็นความวุ่นวาย และคุณไม่สามารถเขียนโค้ด async เหมือนเขียนโค้ด sync ได้ผมยิ่งมั่นใจขึ้นเรื่อย ๆ ว่าการผสมสองอย่างนี้เป็นความคิดที่ไม่ดี และแนวทางแบบ Go ที่ปล่อยทุกอย่างเป็น synchronous แล้วให้ primitive ของ
asyncchannel เพียงตัวเดียวอาจจะถูกต้องก็ได้ตอนนี้ผมกำลังเดินสาย logic ให้เรียก sync method จาก struct ที่ implement
Futureอยู่ ซึ่งเป็นความท้าทายที่น่าสนใจทีเดียว abstraction แบบ async ที่ไร้ overhead อาจทำให้ผู้ใช้งานใช้ได้ง่ายขึ้นในระดับหนึ่ง แต่ความเจ็บปวดจะตกไปอยู่กับ ผู้พัฒนาไลบรารีasyncก็เจ็บปวดสำหรับ end user อย่างชัดเจนเช่นกัน มันให้ความรู้สึกเหมือนใช้ภาษาแยกต่างหากที่ขาดฟีเจอร์หลักของ Rust อย่าง lifetime และ explicit type แล้วโรยPinไว้เต็มไปหมดเพราะรัน fiber ที่มี scope ไม่ได้ สุดท้ายก็ต้องแปะ
Arcเต็มไปหมด,Pinใช้ยากถ้าไม่มีunsafe, และการเปลี่ยนแปลงเล็กมาก ๆ ในฟังก์ชัน async ก็อาจทำให้ future ทั่วทั้ง codebase กลายเป็น!Sendได้asyncได้ ต้องรอดูว่าจะพัฒนาไปอย่างไรAsync Everything เป็นแนวทางของภาษาที่แย่
async/awaitเป็นไอเดียแย่ ๆ ที่พยายามแก้ปัญหาว่า JavaScript ไม่มีเธรดแบบบล็อกกิงที่ใช้งานได้จริง และตอนนี้มันกำลังถูกแปะเพิ่มเข้าไปในทุกภาษา มันจะแบ่งภาษาและระบบนิเวศของไลบรารีออกเป็นสองฝั่ง และสร้างความเจ็บปวดไปอีกนานใครก็ตามที่เคยทำมัลติเธรดนอกโลกของ JavaScript จะรู้ว่า actor หรือ communicating sequential processes เป็นวิธีที่ดีที่สุดสำหรับมัลติเธรด
ในวิทยานิพนธ์ของ Joe Armstrong ก็อธิบายว่า วิธีเดียวที่จะทำความเข้าใจโปรแกรมมัลติเธรดได้ คือการเขียนโค้ดที่เป็นลำดับอย่างเคร่งครัดในแต่ละเธรด และไม่เอาโค้ดของหลายเธรดมาปะปนกันในที่เดียว ช่องว่างเชิงแนวคิดจะน้อยที่สุดเมื่อกิจกรรมพร้อมกันจริงหนึ่งอย่างของปัญหา ตรงกับโปรเซสพร้อมกันหนึ่งตัวในภาษาโปรแกรมพอดี: https://erlang.org/download/armstrong_thesis_2003.pdf
คำวิจารณ์
async/awaitของ Ron Pressler ผู้ทำ Java Project Loom ก็ดีเช่นกัน: https://www.youtube.com/watch?v=oNnITaBseYQเขาค่อนข้างสองจิตสองใจกับตัว JavaScript เอง และเป้าหมายหลักคือการหานามธรรมสำหรับจัดการ
epoll()I/O event loop โดยไม่ทำให้รู้สึกอยากจิ้มตาตัวเอง ก่อนหน้านั้นเขาลองวิธีอื่นมามากแล้วasync/awaitจริง ๆ แล้วไม่ได้เริ่มจาก JavaScript แต่เริ่มจาก C#Anders Hejlsberg แห่ง C# ยังเป็นผู้สร้าง TypeScript ด้วย และฟีเจอร์อย่าง class, arrow function,
async/awaitของ TypeScript ก็สุดท้ายถูกนำเข้า ES6+ผมคิดว่ามันเป็นทางออกที่ยอดเยี่ยมสำหรับ JS/TS ซึ่งเป็น event loop แบบเธรดเดียว แต่ยิ่งภาษามีระดับต่ำลง มันก็ยิ่งแย่ลงในฐานะนามธรรม ดังนั้นคำวิจารณ์
asyncRust ที่มาจากจุดนี้ส่วนใหญ่จึงสมเหตุสมผลบทความอธิบายความซับซ้อนและความยากของ
asyncRust ได้ดี แต่สิ่งสำคัญอีกอย่างคือหนึ่งในปรัชญาหลักของ Rust คือ ความปลอดภัยของหน่วยความจำโดยไม่แลกกับประสิทธิภาพรูปแบบ asynchronous ของ Rust โดยเฉพาะวิธีที่ทำให้คอมไพเลอร์รับประกันความปลอดภัยของข้อมูล แสดงให้เห็นปรัชญานี้ได้ดี แม้จะมีความซับซ้อน แต่ก็มีคุณค่าในฐานะโมเดล concurrency ที่ปลอดภัยกว่า ซึ่งบังคับให้นักพัฒนาคิดลึกขึ้นเกี่ยวกับข้อมูลและโฟลว์การทำงาน
Rust อาจไม่ใช่คำตอบสำหรับแอปพลิเคชัน user space ขนาดใหญ่ที่มี concurrency สูงทุกประเภท แต่ในระบบที่ให้ความสำคัญกับความทนทานและความปลอดภัยเป็นอันดับแรก การแลกเปลี่ยนนี้อาจสมเหตุสมผล เมื่อระบบนิเวศพัฒนาขึ้น ก็น่าจะมีนามธรรมและไลบรารีที่ช่วยลดความเจ็บปวดเหล่านี้ออกมามากขึ้น
ผมเขียน Rust แบบ lock-free ที่อิง
asyncอยู่เยอะ ปัญหาหลักคือ Tokio future เป็น'staticและสิ่งนี้มาจากความผิดพลาดด้านการออกแบบที่ฝังลึกในระบบนิเวศ Rust นั่นคือการตัดสินใจว่า memory leak นั้นปลอดภัยด้วยเหตุนี้จึงไม่สามารถรับประกันแบบสแตติกได้ว่า future จะถูกเก็บกวาดอย่างถูกต้อง เมื่อสร้างงาน asynchronous ขึ้นมา หากมีใครใช้
std::mem::forgetลืม future นั้นไป borrow checker ก็ไม่อาจรู้ได้ว่า reference ที่ future นั้นส่งต่อแบบ transitively ยังมีชีวิตอยู่แทนที่จะโปรย
Arcไปทุกที่ ผมใช้ crate แบบunsafeนี้: https://docs.rs/async-scoped/latest/async_scoped/แค่นี้ก็จับบั๊กที่ผมน่าจะสร้างใน C++ ได้ 99% แล้ว จึงเป็นการแลกเปลี่ยนที่สมเหตุสมผล งานทำให้ future ที่ไม่ใช่
'staticใช้งานได้อย่างปลอดภัยก็กำลังดำเนินอยู่ หวังว่าจะสำเร็จอีกปัญหาใหญ่คือ
async traitตอนนี้ต้องใช้ boxed future ทำให้มีmalloc/freeเพิ่มทุกขอบเขตการเรียกฟังก์ชัน ซึ่งอยู่ในโรดแมปที่จะแก้ในปีนี้คำแนะนำว่า “ก็ใช้ channel สิ” ก็ทำให้โฟลว์ควบคุมกระจัดกระจายไปทั่วใน codebase ขนาดใหญ่ channel ให้ความรู้สึกเหมือน
GOTOยุคใหม่ และผมก็ใช้มันอยู่บ้าง แต่ในกรณีที่แค่ต้องรันงานไม่กี่อย่างแบบขนานแล้วรอให้เสร็จ ผมมักไม่ค่อยใช้'staticแต่มีเพียง future ที่สามารถspawnเพื่อใช้ concurrency ของ runtime ได้เท่านั้นที่ต้องเป็น'staticfuture ต้องถูก
Pinเพื่อให้ถูกpoll()ได้ และT: !Unpinที่ถูกPinแล้ว สุดท้ายต้องเรียกDrop: https://doc.rust-lang.org/std/pin/#drop-guaranteefuture ที่สร้างด้วยฟีเจอร์
asyncของคอมไพเลอร์มีคุณสมบัตินี้ และสำหรับ future ที่เขียนเองก็ใส่PhantomPinnedได้ ด้วยเหตุนี้ หลังจากถูกpoll()แล้ว เราจึงถือได้ว่าการเล่นแผลง ๆ ด้วยmem::forgetเป็น undefined behavior และยังทำให้ไลบรารี future แบบ intrusive และ self-referential เป็นไปได้ด้วย: https://docs.rs/futures-intrusive/latest/futures_intrusive/future อาจยังมีชีวิตอยู่ต่อและรั่วได้เพราะ
Arc/Rcแต่จากมุมของผู้พัฒนาไลบรารี มันแยกจากการใช้งานปกติได้ไม่สมเหตุสมผลนัก หรือไม่จำเป็นต้องใส่ใจมากRcทิ้ง, หรือใส่ขอบเขต trait แบบunsafeที่มีลักษณะแพร่กระจาย