2 คะแนน โดย GN⁺ 2023-09-09 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • async/await ของ Rust มุ่งเป้าไปที่ concurrency ขนาดใหญ่ ที่ต้องรองรับการเชื่อมต่อนับหมื่น แต่กลับขัดกับเป้าหมายของ Rust เองเรื่องการควบคุมระดับต่ำและการตรวจสอบ lifetime แบบสถิต ทำให้ประสบการณ์พัฒนาแตกต่างจาก Rust แบบทั่วไป
  • thread และ channel เพียงพอสำหรับซอฟต์แวร์จำนวนมาก แต่เมื่อถึงสเกลระดับ C10K ภาระของโมเดลหนึ่งการเชื่อมต่อหนึ่ง thread จะสูงเกินไป จึงต้องใช้ task ใน user space และการจัดตารางโดย runtime
  • ใน async Rust ข้อมูลต้องถูกย้ายแบบ Send หรือถูกจัดการผ่าน reference แบบ 'static และด้วยความที่ async มีลักษณะ แพร่กระจาย ข้อจำกัดเหล่านี้จึงเกิดซ้ำไปทั่วทั้งโค้ด
  • แม้ Arc จะช่วยแก้ปัญหาการคอมไพล์ได้ แต่ก็ทำให้ lifetime ของอ็อบเจ็กต์และ resource ไม่ชัดเจน อีกทั้งยังมี กับดัก ต่อเนื่องอย่าง recursive async, ความต่างระหว่าง 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.js
    • async 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

  • async Rust ให้ความรู้สึกต่างจาก 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 ความคิดเห็น

 
GN⁺ 2023-09-09
ความคิดเห็นบน 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 ปี

    • ในช่วง 3 ปีที่ผ่านมาได้ทำ ตัวจำลองหุ่นยนต์ด้วย Rust และประสบการณ์แทบเหมือนกัน ตลอด 3 ปีมี runtime bug จริง ๆ ราว 5 จุด แม้ Rust กับ async จะมีปัญหาอยู่บ้าง แต่โดยรวมข้อดีมากกว่ามาก
    • การตามลำดับการ lock เพื่อหา deadlock ที่อาจเกิดขึ้นดูเป็นไอเดียที่ดี
      คล้ายกับ lockdep ของ Linux คือวิเคราะห์ว่าขณะถือ lock หนึ่งอยู่ มีการไปจับ lock อื่นหรือไม่ และแจ้งเตือน combination ที่เสี่ยงได้แม้ก่อนจะค้างจริง สำหรับ lock ที่ซับซ้อนอาจต้องมี annotation เช่น “lock class นี้ต้องจับตามลำดับ address เสมอ” แต่ดูแล้วน่าจะทำได้
    • ผมทำงานแทบแบบเดียวกันใน MMO ด้วย Java อยู่ และ JDK ทำให้ทุกอย่างง่ายมาก แค่สร้างโมเดลจาก network แล้วโยกอ็อบเจกต์ไปยัง UI thread ผ่าน concurrent queue ก็พอ มันเรียบง่ายจนค่อนข้างน่าเบื่อ แต่ก็เร็ว
    • Rust ไม่ใช่ว่าไม่มี race condition แต่คือไม่มี data race
      นอกเหนือจากการเข้าถึงข้อมูลแล้ว 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 สูง
  • เรื่อง async Rust นั้นอยู่ในจุดที่ค่อนข้างประหลาด
    ถ้าใช้ 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 อย่างไร ถ้าคุณกำลัง implement async runtime เองก็คงจำเป็น แต่ผมไม่ค่อยเข้าใจว่าทำไมผู้ใช้ไลบรารีทั่วไปต้องโฟกัสกับเรื่องนี้มากขนาดนั้น

    • คำวิจารณ์นี้รู้สึกแปลกอยู่บ้าง async ไม่ได้แปลว่า multi-threading โดยอัตโนมัติ และถ้าเป็น async ในเธรดเดียวกันก็ไม่มีการแชร์ จึงไม่จำเป็นต้องติด keyword วิเศษสารพัดให้สิ่งที่แชร์
      เวลาข้ามระหว่างเธรด ก็ส่งสัญญาณผ่าน channel แทนที่จะมี shared state เต็มไปหมด ถ้ามี global state ที่จำเป็นจริง ๆ ก็สร้าง struct เล็ก ๆ ที่ห่อกลไก exclusive access อย่าง Arc/RwLock ไว้ และในมุมของ caller ให้ดูเหมือนเป็นการเรียกฟังก์ชันธรรมดา
      ความกังวลเรื่อง Send+Sync ก็ไม่ค่อยเข้าใจ จากประสบการณ์ ส่วนใหญ่เป็น Send+Sync ได้ง่าย ๆ และสิ่งที่ไม่ใช่ก็มักเป็นสิ่งที่ไม่ควรหรือไม่สามารถเป็นแบบนั้นได้อยู่แล้ว บางครั้งก็อยากเขียนโค้ดโดยไม่ต้องคิดรายละเอียด แต่ถ้าต้องการ concurrency และ parallelism ที่มีประสิทธิภาพ microsecond กับ throughput จะสำคัญขึ้นมา และตอนนั้นก็ต้องเขียนโค้ดสำหรับคอมพิวเตอร์จริง ๆ ให้ถูกต้อง
    • พาราไดม์ message passing นั้นดีมากจริง ๆ และภาษาอย่าง Erlang ก็แสดงให้เห็นว่าเป็นตัวเลือกที่ยอดเยี่ยมสำหรับระบบกระจายศูนย์
      แต่การเขียนโค้ดแบบนี้ต่างจาก async JavaScript ที่ให้ความรู้สึกเหมือนเอา green thread มาครอบ synchronous code อยู่มาก ผู้คนพยายามเขียนโค้ดในแบบที่คุ้นเคย จึงดูเหมือนจะหลงไปทาง Arc กับ RwLock ใน Rust
    • ความฝันของ Smalltalk และ OOP แท้ ๆ ยังมีชีวิตอยู่
    • ตอนมหาวิทยาลัย ผมได้คำแนะนำแบบนี้จากอาจารย์ และมันช่วยได้มากจริง ๆ
      การจัดโครงสร้างปัญหาให้เป็นข้อมูลที่ไหลระหว่างงานต่าง ๆ เชื่อมกันด้วย 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

    • ประเด็นหลักคือ Rust นำ coroutine มาใช้งานผิดทาง
      การเลือก coroutine แบบไม่มี stack ทำให้เกิดปัญหา async/await และปัญหา colored functions จนเกิดแรงเสียดทานอย่างที่บทความกล่าวถึง ส่วน Go ใช้ coroutine แบบมี stack จึงไม่มีปัญหาเหล่านี้
      ตอนแรก Rust ก็เคยพิจารณา coroutine แบบมี stack แต่เห็นว่าต้องมี runtime สำหรับ preemption ของ coroutine และมีต้นทุนสูง จึงไปใช้โมเดลแบบไม่มี stack แทน แต่คนส่วนใหญ่ไม่ได้ใช้ async Rust แบบไม่มี runtime และใช้ Tokio กัน โดย Tokio ก็ทำแทบทุกอย่างที่ runtime ซึ่งเดิมทีพยายามหลีกเลี่ยงต้องทำอยู่ดี
      ดังนั้นผู้ใช้ async Rust จำนวนมากจึงได้ข้อเสียของทั้งสองฝั่ง ในฝั่ง embedded ก็มีการใช้ async Rust กับ runtime ที่บางมากอยู่บ้าง แต่จำนวนก็ยังน้อย และแม้แต่พวกเขาเองก็ยังไม่ได้ถูกโน้มน้าวอย่างสมบูรณ์
    • เห็นว่า Tokio ถูกดึงเข้ามาเป็น dependency ในโปรแกรมของผมอีกแล้ว ทั้งที่ไม่ได้ใช้โดยตรง ฟังก์ชันที่ผมไม่ได้ใช้ใน crate บางตัวดึง reqwest เข้ามา แล้วมันดึง h2 เข้ามา จากนั้นก็ดึง tokio เข้ามาอีกที
    • ถ้าแพลตฟอร์มรองรับ virtual threads ก็สงสัยว่ายังมีเหตุผลให้ใช้ async อยู่ไหม
      ในฐานะคนใช้ Java ผมกำลังจะทิ้ง paradigm แบบอะซิงโครนัสทั้งหมด แล้วเขียนโค้ดใหม่เป็นโมเดล blocking บน virtual threads ที่ blocking ได้โดยไม่เป็นไร
  • async แพร่ไปยัง crate มากเกินไป จนทั้งโปรแกรมต้องกลายเป็น async หรืออย่างน้อยก็ต้องพึ่งพา Tokio กับงานจำนวนมาก
    ถ้าต้องการเว็บเซิร์ฟเวอร์ก็เหมือนถูกบอกว่า async + tokio หรือไม่ก็ไสหัวไป ส่วน SQL connector ก็ให้บรรยากาศว่าถ้าไม่ต้องการแบบอะซิงโครนัสก็ต้องเขียนเอง แต่ละคนแก้ปัญหาที่ async นำมาด้วยวิธีต่างกัน และของอย่าง async closure ก็รู้สึกเหมือนเปิดประตูนรกให้ compiler
    เป็นเรื่องดีที่ Rust เองและ compiler ช่วยแก้ปัญหาได้ แต่ ecosystem ที่ใกล้เคียงกับ “ถ้าไม่ใช้ async ก็ทำเอง” นั้นยังไม่เพียงพอ

    • ถ้ามี primitive ด้านอะซิงโครนัส ที่ดีกว่านี้ใน standard library หรือ crate futures ก็คงลดความเจ็บปวดได้มาก
      จำเป็นต้องมีสิ่งอย่าง trait ที่ executor ต้อง implement หรือ executor แบบ blocking พื้นฐานสำหรับรันโค้ดอะซิงโครนัสจากโค้ด synchronous ตอนนี้แค่การทำไลบรารีให้รองรับ async runtime หลายตัวก็ลำบากแล้ว สุดท้ายจึงมักรองรับแค่ Tokio หรืออย่างมากก็เพิ่ม async-std เข้ามา
  • ผมไม่ใช่ผู้เชี่ยวชาญ async Rust แต่เดือนนี้ได้เขียน 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 ยังไม่สวยก็คล้ายกับการวิจารณ์ร่างแรก ๆ ของหนังสือที่จะเสร็จสมบูรณ์ในที่สุด

    • ผมสงสัยว่า “การออกแบบ API แบบอะซิงโครนัสที่ดี” คืออะไร ถ้าจะออกแบบเซิร์ฟเวอร์ที่เน้นอะซิงโครนัสเต็มตัว ขยายได้ บำรุงรักษาได้ และเข้าใจง่าย มันควรมีหน้าตาอย่างไร
      อีกเรื่องที่คิดอยู่คือจะป้องกันไม่ให้อะซิงโครนัสแพร่ไปทั่วทั้ง 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
    • ถ้ายังไม่เสถียร ก็ไม่ควรใช้ใน production
    • คอมเมนต์ที่ทึกทักว่าเป็นมือใหม่ Rust นี่ตลกดี จริง ๆ แล้วผู้เขียนบทความอาจมีประสบการณ์มากกว่าพวกเขาก็ได้
  • อายุการใช้งานของ Arc ไม่ได้เป็นสิ่งที่ไม่รู้ แต่ถูกกำหนดจากว่าถูกถือไว้ที่ไหนและอย่างไร
    ความไม่ลงรอยในบทความนี้น่าจะมาจากการที่ผู้เขียนพยายามยัดโมเดลความคิดเดิม ๆ อย่าง garbage collection เข้ากับ Rust มากกว่าจะเรียน Rust แล้วปรับวิธีทำงานให้เข้ากับภาษา นี่เป็นกับดักที่พบบ่อยเวลาเรียนภาษาใหม่ แต่ Rust ทำให้สะดุดได้บ่อยเป็นพิเศษ

    • ถ้าหมายความแบบนั้น อายุการใช้งานของอ็อบเจ็กต์ในระบบ garbage collection ก็มีขอบล่างว่า “ตราบเท่าที่ยังถูกอ้างอิงอยู่” เช่นกัน
      แต่นั่นแทบจะตรงข้ามกับเป้าหมายของ 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
    • reference counting ก็เป็น garbage collection ชนิดหนึ่ง https://en.wikipedia.org/wiki/Garbage_collection_(computer_s...
      ผู้เขียนน่าจะรู้ว่า Arc คืออะไรและทำงานอย่างไร และใจความน่าจะใกล้กับประเด็นที่ว่าใน Rust async คุณมักต้องใช้ 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 ของ async channel เพียงตัวเดียวอาจจะถูกต้องก็ได้
    ตอนนี้ผมกำลังเดินสาย 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 ได้
    • ผู้พัฒนาไลบรารีมีความพร้อมจะรับมือกับความซับซ้อนได้มากกว่าผู้ใช้ การมอบงานแบบนั้นให้ developer ที่มีประสบการณ์ซึ่งสร้าง infrastructure พื้นฐานเป็นทิศทางที่ถูกแล้ว
    • ผมเคยเห็น wasm VM สำหรับ Rust บางตัวที่ให้สิ่งที่ดูเหมือน transparent M:N scheduling และถ้าใช้แนวทางนั้นก็น่าจะช่วยแก้ความยากส่วนใหญ่ของ 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 นั้นสนุกก็จริง แต่ถ้าย้อนดูการบรรยายแรกที่ Ryan Dahl แนะนำ Node.js ก็ยังน่าสนใจ: https://www.youtube.com/watch?v=EeYvFl7li9E
      เขาค่อนข้างสองจิตสองใจกับตัว 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 แบบเธรดเดียว แต่ยิ่งภาษามีระดับต่ำลง มันก็ยิ่งแย่ลงในฐานะนามธรรม ดังนั้นคำวิจารณ์ async Rust ที่มาจากจุดนี้ส่วนใหญ่จึงสมเหตุสมผล
  • บทความอธิบายความซับซ้อนและความยากของ async Rust ได้ดี แต่สิ่งสำคัญอีกอย่างคือหนึ่งในปรัชญาหลักของ 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 ยุคใหม่ และผมก็ใช้มันอยู่บ้าง แต่ในกรณีที่แค่ต้องรันงานไม่กี่อย่างแบบขนานแล้วรอให้เสร็จ ผมมักไม่ค่อยใช้

    • จุดแยกสำคัญคือ ไม่ใช่ว่า Tokio future เองเป็น 'static แต่มีเพียง future ที่สามารถ spawn เพื่อใช้ concurrency ของ runtime ได้เท่านั้นที่ต้องเป็น 'static
      future ต้องถูก Pin เพื่อให้ถูก poll() ได้ และ T: !Unpin ที่ถูก Pin แล้ว สุดท้ายต้องเรียก Drop: https://doc.rust-lang.org/std/pin/#drop-guarantee
      future ที่สร้างด้วยฟีเจอร์ async ของคอมไพเลอร์มีคุณสมบัตินี้ และสำหรับ future ที่เขียนเองก็ใส่ PhantomPinned ได้ ด้วยเหตุนี้ หลังจากถูก poll() แล้ว เราจึงถือได้ว่าการเล่นแผลง ๆ ด้วย mem::forget เป็น undefined behavior และยังทำให้ไลบรารี future แบบ intrusive และ self-referential เป็นไปได้ด้วย: https://docs.rs/futures-intrusive/latest/futures_intrusive/
      future อาจยังมีชีวิตอยู่ต่อและรั่วได้เพราะ Arc/Rc แต่จากมุมของผู้พัฒนาไลบรารี มันแยกจากการใช้งานปกติได้ไม่สมเหตุสมผลนัก หรือไม่จำเป็นต้องใส่ใจมาก
    • ถ้ามองว่าการที่ memory leak ถือว่าปลอดภัยเป็นความผิดพลาดด้านการออกแบบ ผมก็สงสัยว่าคุณจะชอบทางเลือกไหนมากกว่า: ตัด interior mutability ทิ้ง, ตัด Rc ทิ้ง, หรือใส่ขอบเขต trait แบบ unsafe ที่มีลักษณะแพร่กระจาย