Async I/O แบบใหม่ของ Zig
(kristoff.it)- การนำอินเทอร์เฟซ Async I/O แบบใหม่ของ Zig มาใช้ ทำให้ผู้เรียกสามารถเลือกและฉีดรูปแบบการทำงานของ I/O ได้โดยตรง
- อินเทอร์เฟซ Io ที่ออกแบบใหม่รองรับทั้ง asynchrony และ parallelism พร้อมกัน และมุ่งเน้นที่การนำโค้ดกลับมาใช้ซ้ำและการทำ optimization
- มีแผนจะมี implementation ของ standard library หลายแบบ เช่น Blocking I/O, event loop, thread pool, green thread, และ stackless coroutine
- API ใหม่ ทำให้รองรับ future cancellation, การจัดการ resource, buffering และพฤติกรรม I/O แบบละเอียดได้
- แก้ปัญหา function coloring เดิม ทำให้สามารถใช้ไลบรารีเดียวให้เหมาะกับทั้ง การทำงานแบบ synchronous/asynchronous ได้
ภาพรวม
Zig เพิ่งออกแบบอินเทอร์เฟซ Async I/O แบบใหม่ โดยมุ่งไปที่ ความยืดหยุ่นของงาน I/O และการรองรับ parallelism มากขึ้น การเปลี่ยนแปลงครั้งนี้แยกตัวเองออกจากแนวทาง async/await แบบเดิม เพื่อให้ผู้เขียนโปรแกรมสามารถเลือกใช้กลยุทธ์ I/O ได้หลากหลายยิ่งขึ้น
อินเทอร์เฟซ I/O แบบใหม่
เดิมทีออบเจ็กต์ที่เกี่ยวกับ I/O จะถูกสร้างและใช้งานโดยตรงในโค้ด แต่ตอนนี้เปลี่ยนเป็นให้ ผู้เรียก inject อินเทอร์เฟซ Io เข้ามาแทน
- วิธีนี้คล้ายกับแพตเทิร์น Allocator โดยฝั่งผู้เรียกจะเป็นผู้เลือกและ inject implementation ของ I/O
- ทำให้สามารถใช้กลยุทธ์ I/O แบบเดียวกันกับโค้ดจากแพ็กเกจภายนอกได้อย่างสม่ำเสมอ
การเปลี่ยนแปลงสำคัญ
- ตอนนี้อินเทอร์เฟซ Io รับผิดชอบ operation ด้าน concurrency ด้วย
- หากโค้ดแสดง concurrency ได้อย่างถูกต้อง implementation ของ Io ก็สามารถมอบ parallelism ได้ตามความสามารถของมัน
ตัวอย่างโค้ด
- เปรียบเทียบโค้ด 2 แบบ: แบบไม่มี concurrency (serial) และแบบที่แสดงความเป็นไปได้ของ parallelism ด้วย
io.asyncและawait- โค้ดแบบ serial: บันทึกลงสองไฟล์ตามลำดับ จึงใช้ประโยชน์จากโอกาสในการทำงานขนานไม่ได้
- โค้ดแบบขนาน: ใช้ futures เพื่อบันทึกไฟล์ ทำงานได้มีประสิทธิภาพกว่าบน async event loop
การใช้ await ร่วมกับ try
- เมื่อใช้
awaitร่วมกับtryจะมีปัญหาว่าหาก future หนึ่งเกิด error อาจไม่สามารถคืน resource ของ future อื่นได้ - สามารถใช้
deferและfuture.cancelเพื่อทำให้การยกเลิกและการ cleanup ชัดเจนและถูกต้อง
API Future.cancel
Future.cancel()และFuture.await()เป็น idempotent (เรียกหลายครั้งก็ไม่มีผลข้างเคียง)- หากเรียก
cancelกับ future ที่เสร็จแล้ว จะเป็นเพียงการคืนทรัพยากร ส่วนงานที่ยังไม่เสร็จจะคืนค่าerror.Canceled
implementation I/O ใน standard library
อินเทอร์เฟซ Io เป็นอินเทอร์เฟซแบบ runtime polymorphism ซึ่งสามารถ implement เองหรือใช้ implementation จากแพ็กเกจ third-party ก็ได้ โดย standard library ของ Zig มีแผนจะรองรับ implementation I/O หลายรูปแบบ
- Blocking I/O: ใช้ blocking I/O แบบ C เดิมโดยตรง ไม่มี overhead เพิ่ม
- Thread pool: กระจาย Blocking I/O ไปยัง OS thread pool เพื่อเพิ่ม parallelism บางส่วน แต่กรณีอย่าง network client ยังต้องมีการ optimize เพิ่ม
- Green thread: ใช้ asynchronous system call อย่าง
io_uringบน Linux เพื่อให้ OS thread หนึ่งจัดการ green thread แบบเบาได้หลายตัว ต้องอาศัยการรองรับของแพลตฟอร์ม (เริ่มที่ x86_64 Linux) - Stackless coroutine: coroutine แบบ state machine ที่ไม่ต้องใช้ stack แบบ explicit เพื่อรองรับบางแพลตฟอร์มอย่าง WASM และต้องมีการนำ propertyive convention ของคอมไพเลอร์ Zig กลับมาอีกครั้ง
เป้าหมายของการออกแบบ
การนำโค้ดกลับมาใช้ซ้ำ
ปัญหาใหญ่ที่สุดของ async I/O คือเรื่องการ reuse โค้ด เพราะในภาษาอื่นมักต้องแยก blocking function และ async function ออกจากกัน ทำให้โค้ดแตกเป็นหลายส่วน แต่วิธีของ Zig คือ
- ไลบรารีเดียวสามารถรองรับทั้งโหมด synchronous และ asynchronous ได้อย่างมีประสิทธิภาพ
async/awaitช่วยลบปัญหา “function coloring” และระบบ Io ก็ไม่ผูกติดกับ execution model ใดแบบตายตัวแม้อยู่ใน runtime
สรุปคือแก้ปัญหา function coloring ได้อย่างสมบูรณ์
การทำ optimization
- อินเทอร์เฟซ Io ใหม่ถูก implement แบบ non-generic ด้วยการเรียกเสมือนผ่าน vtable
- การเรียกแบบ virtual ช่วยลด code bloat แต่มี runtime overhead เล็กน้อย อย่างไรก็ตามใน optimized build หากมี implementation ของ Io เพียงแบบเดียว ก็สามารถทำ de-virtualization ได้
- หากใช้ implementation ของ Io หลายแบบ ก็จะยังคง virtual call ไว้เพื่อหลีกเลี่ยงการทำโค้ดซ้ำ
กลยุทธ์การทำ buffering
- เดิมทีแต่ละ implementation (
reader/writer) จะรับผิดชอบ buffering เอง แต่ตอนนี้ย้ายมาทำ buffering ที่ระดับอินเทอร์เฟซ Reader และ Writer - นอกจากการ flush buffer แล้ว จะไม่ต้องผ่านเส้นทาง virtual call จึง optimize ได้ง่ายกว่า
operation I/O เชิงความหมาย
อินเทอร์เฟซ Writer มี primitive ใหม่ 2 ตัวเพื่อรองรับ operation ที่ optimize ได้เฉพาะทาง
- sendFile: ได้แรงบันดาลใจจาก POSIX
sendfileย้ายข้อมูลระหว่าง file descriptor ภายใน kernel โดยตรง ช่วยลดการคัดลอกหน่วยความจำ - drain: รองรับ vectorized write + splatting ส่งหลาย data segment ได้ในครั้งเดียว และสามารถแปลงเป็น system call
writevได้ โดยพารามิเตอร์splatใช้สำหรับทำซ้ำ element สุดท้ายได้ ซึ่งมีประโยชน์กับสตรีมอย่างงานบีบอัด
โรดแมป
การเปลี่ยนแปลงส่วนหนึ่งจะเริ่มเข้าใน Zig 0.15.0 แต่เนื่องจากต้องมีการปรับโครงสร้างไลบรารีครั้งใหญ่ การนำมาใช้ทั้งหมดจะต้องรอรีลีสถัดไป โมดูลสำคัญอย่าง SSL/TLS, HTTP server/client ก็มีแผนจะออกแบบใหม่บนระบบ Io ใหม่นี้ด้วย
FAQ
Q: Zig เป็นภาษา low-level แล้วทำไม async ถึงสำคัญ?
- Zig มุ่งเน้นความ robust, การ optimize และการนำกลับมาใช้ซ้ำ
- การทำ non-blocking I/O ให้เป็นมาตรฐาน จะช่วยให้ไลบรารีอื่นและโค้ด third-party ปรับเข้ากับกลยุทธ์ I/O ทั้งระบบและนำกลับมาใช้ซ้ำได้
Q: ต่อไปนี้ผู้เขียนแพ็กเกจต้องใช้ async กับทุกโค้ดหรือไม่?
- ไม่จำเป็น ทุกโค้ดไม่ได้ต้องแสดง concurrency เสมอไป
- โค้ดแบบลำดับทั่วไปก็ยังทำงานตามกลยุทธ์ I/O ที่ผู้ใช้เลือกได้
Q: แค่เสียบปลั๊กอิน execution model แบบไหนก็ทำงานได้ถูกต้องเสมอหรือไม่?
- ส่วนใหญ่ใช่
- แต่หากมีข้อผิดพลาดเชิงโปรแกรมในโค้ด (เช่น ไม่ตรงตามข้อกำหนดของงานที่ต้องทำพร้อมกัน) ก็จะไม่สามารถทำงานได้ถูกต้อง
มีการยกตัวอย่างการทำงานจริงประกอบ พร้อมอธิบายความต่างระหว่าง asynchrony กับ parallelism และความจำเป็นของการออกแบบ flow การทำงานให้ถูกต้อง
บทสรุป
Zig เพิ่ม ความยืดหยุ่นในการเลือกกลยุทธ์ I/O, การนำโค้ดกลับมาใช้ซ้ำ, และ ศักยภาพในการทำ optimization อย่างมากผ่านอินเทอร์เฟซ Io แบบใหม่ ทำให้นักพัฒนาสามารถแสดงโครงสร้าง concurrency และ parallelism ได้ชัดเจนขึ้น โดยไม่ถูกจำกัดด้วยการแยกเขียนฟังก์ชันแบบ async/sync และยังรองรับแพลตฟอร์มกับ execution model ได้อย่างมีประสิทธิภาพ
1 ความคิดเห็น
ความคิดเห็นใน Hacker News
ผมอยากชี้ประเด็นนี้อีกครั้ง ในบทความถึงกับบอกว่า Zig แก้ปัญหา function coloring ได้อย่างสมบูรณ์ แต่ผมไม่เห็นด้วย ถ้าย้อนกลับไปคิดถึงกฎ 5 ข้อในบทความดัง "What color is your function?" ถึงใน Zig จะไม่ได้แบ่งสีแบบ async/sync/red/blue ชัดเจน สุดท้ายก็ยังมีอยู่แค่ 2 กรณีคือฟังก์ชัน IO กับฟังก์ชันที่ไม่ใช่ IO แม้จะแก้ปัญหาทางเทคนิคเรื่องวิธีเรียกฟังก์ชันที่ต่างกันตามสีได้ แต่ฟังก์ชันที่ต้องใช้ IO ก็ยังต้องรับ IO ผ่านพารามิเตอร์ ส่วนฟังก์ชันที่ไม่ต้องใช้ก็ไม่รับอยู่ดี เลยรู้สึกว่าแก่นแท้ไม่ได้เปลี่ยนไป สุดท้ายฟังก์ชัน IO ก็ยังเรียกได้จากฟังก์ชัน IO เท่านั้น ซึ่งก็ยังหนีปัญหา coloring ไม่พ้น แน่นอนว่าจะส่ง executor ตัวใหม่เข้าไปก็ได้ แต่ก็ไม่แน่ใจว่านั่นคือสิ่งที่ต้องการจริงหรือไม่ Rust ก็ทำคล้ายกันได้เหมือนกัน เรื่องที่ function call แบบมีสีนั้นใช้งานลำบากก็ยังเหมือนเดิม ส่วนประเด็นที่ฟังก์ชันในไลบรารีหลักบางตัวถูกทำให้เป็น colored นั้นก็ไม่ได้ต่างกันทั้ง Zig และ Rust แก่นของปัญหา coloring คือฟังก์ชันที่ต้องอาศัย context (เช่น async executor, auth, allocator ฯลฯ) จำเป็นต้องได้รับ context นั้นตอนถูกเรียก จึงยังยากจะบอกว่า Zig แก้ปัญหาส่วนนี้ได้จริง เพียงแต่ว่า abstraction ของ Zig ทำได้ดีมาก และ Rust ยังขาดอยู่ในจุดนี้ แต่ตัวปัญหา function coloring เองยังคงอยู่
ความต่างสำคัญจาก async function coloring แบบทั่วไปคือ 'Io' ของ Zig ไม่ได้เป็นค่าเฉพาะสำหรับงานอะซิงก์เท่านั้น แต่เป็นค่าที่จำเป็นโดยธรรมชาติสำหรับ IO ทุกชนิด ไม่ว่าจะอ่านไฟล์ sleep หรือเอาเวลา current มาก็ตาม 'Io' ไม่ใช่คุณสมบัติของฟังก์ชัน แต่เป็นค่าทั่วไปที่วางไว้ตรงไหนก็ได้ ในทางปฏิบัติ ด้วยคุณสมบัตินี้เลยทำให้ดูเหมือนปัญหา coloring ถูกแก้ไปแล้ว เพราะในโค้ดเบสส่วนใหญ่ IO มักอยู่ในสโคปอยู่แล้ว มีเพียงฟังก์ชันคำนวณล้วน ๆ เท่านั้นที่ไม่ต้องใช้ IO ถ้าฟังก์ชันไหนจู่ ๆ ต้องใช้ IO ขึ้นมา ส่วนมากก็หยิบจาก 'my_thing.io' มาใช้ได้เลย ไม่ต้องส่ง Allocator เข้าไปทุกฟังก์ชันแบบ Rust จึงไม่ค่อยน่ารำคาญ กล่าวคือถ้าเส้นทางของโค้ดเปลี่ยนจนต้องทำ IO ก็ไม่จำเป็นต้องกระจายการแก้ไปทุกฟังก์ชัน สามารถใช้ได้ทันที ในเชิงหลักการผมเห็นด้วยว่ายังมี function coloring อยู่ แต่ในทางปฏิบัติเหมือนทุกฟังก์ชันกลายเป็น async-colored ไปแล้ว เลยแทบไม่เป็นปัญหาจริง นักพัฒนา Zig เองก็มองว่าการส่ง Allocator แบบ explicit ไม่ได้สร้างความน่ารำคาญแบบ function coloring ดังนั้น 'Io' ก็น่าจะไม่ใช่ปัญหาใหญ่เช่นกัน
ดูเหมือนจะยังไม่ได้พูดถึงแก่นสำคัญ เวลาจะใช้ไลบรารี Rust คุณจำเป็นต้องให้ตรงกับเงื่อนไขอย่าง async/await, tokio, send+sync และถ้าเป็น sync API ก็แทบใช้ไม่ได้เลยในแอป async ตรงนี้เป็นสภาพจริง ตรงกันข้าม วิธีส่ง IO ของ Zig แก้ปัญหานี้จากราก ทำให้ไม่ต้องฝืนทำ procedural macro หรือยัดหลายเวอร์ชันอย่างยากลำบาก ซึ่งในความเป็นจริงแนวทางนั้นก็ไม่ได้แก้ปัญหาไลบรารีหลายเวอร์ชันได้ดีนัก มีการพูดคุยหลายอย่างเกี่ยวกับปัญหาการผสม async/sync ใน Rust และลิงก์นี้ก็อธิบายไว้ https://nullderef.com/blog/rust-async-sync/ หวังว่า Zig จะไปได้ไกลถึง cooperative scheduling, async ประสิทธิภาพสูง และ async แบบ thread-per-core ด้วย
ผมไม่ใช่ผู้เชี่ยวชาญด้าน category theory แต่สุดท้ายแล้วถ้าเดินมาถึงเส้นทางการจัดการ context แบบนี้ ก็มักจะไปจบที่ IO monad context นี้อาจมีอยู่แบบ implicit ได้ แต่ถ้าอยากได้ความช่วยเหลือจากคอมไพเลอร์อย่างแท้จริง มันก็ต้องถูกทำให้ปรากฏเป็นตัวตนในระบบ และถึงแม้ความทะเยอทะยานของภาษา system programming หลายตัวจะถูกฝังไว้ในสุสานของ Async หรือ coroutine กันมามาก แต่การที่ Andrew เหมือนค้นพบ IO monad ขึ้นมาใหม่และนำไปทำจริงได้อย่างถูกต้อง ถือเป็นความหวังของคนรุ่นนี้ ฟังก์ชันในโลกจริงมีสีอยู่แล้ว คุณจะต้องมีข้อกำหนดการเคลื่อนย้ายที่ชัดเจน ไม่เช่นนั้นก็จะไหลไปสู่ความซับซ้อนแบบ C++
co_awaitหรือ tokio มากขึ้นเรื่อย ๆ ผมคิดว่านี่แหละคือ ‘The Way’มีทริกง่าย ๆ ที่ทำให้ทุกฟังก์ชันเป็นสีแดง (หรือสีน้ำเงิน) ได้
ถ้าใช้
ioเป็นตัวแปร global ก็ไม่ต้องกังวลเรื่อง coloring แล้ว ล้อเล่นนะ แต่แน่นอนว่าการต้องใช้อินเทอร์เฟซ 'Io' ก็มีแรงเสียดทานอยู่บ้าง เพียงแต่มันต่างโดยเนื้อแท้จาก friction ที่เกิดจากการใช้ async/await จริง ๆ สำหรับผม แก่นของปัญหา function coloring คือการที่คีย์เวิร์ด async ไปกำหนดสีแบบ static จนทำให้โค้ด reuse กันไม่ได้ ใน Zig ไม่ว่าคุณจะทำฟังก์ชันให้เป็น async หรือไม่ ทุกอย่างก็รับ IO เป็นอาร์กิวเมนต์เหมือนกัน ดังนั้นในมุมนี้ coloring เองจึงไม่มีความหมาย ประเด็นที่สองคือ เมื่อใช้ async/await คุณจะถูกบังคับให้ใช้ stackless coroutine (หรือก็คือการสลับสแตกที่คอมไพเลอร์ควบคุม) แต่ระบบ IO ใหม่ของ Zig แม้ข้างในจะใช้ async ก็ยังทำงานเป็น Blocking IO ได้ นี่แหละที่ผมคิดว่าเป็นปัญหา function coloring ที่แท้จริงGo เองก็มีปัญหา “coloring แบบละเอียดอ่อน” เช่นกัน เมื่อใช้ goroutine คุณแทบต้องส่งอาร์กิวเมนต์
contextตลอดเวลาเพื่อรองรับการยกเลิก และฟังก์ชันในไลบรารีจำนวนมากก็ต้องการcontextทำให้โค้ดทั้งชุดปนเปื้อน ในทางเทคนิคจะไม่ใช้contextก็ได้ แต่การสุ่มส่งcontext.Backgroundเข้าไปนั้นไม่ใช่วิธีที่แนะนำแนวคิด sans-io เคยถูกพูดถึงใน Rust และที่อื่น ๆ แล้ว โดยดูได้จาก https://www.firezone.dev/blog/sans-io, https://sans-io.readthedocs.io/, https://news.ycombinator.com/item?id=40872020
ผมคิดว่าปัญหาของ function coloring คือไม่ว่าคุณจะจัดการบนสแตกหรือ unwind สแตก อย่างน้อยก็ต้องเหลืออย่างใดอย่างหนึ่งอยู่ดี Zig อ้างว่าแก้ปัญหา coloring ได้ แต่ในวิธี implement IO ก็ยังเปิดให้ใช้ blocking/thread pool/green thread ได้อยู่ ซึ่ง Blocking IO แบบนี้แต่เดิมก็ไม่ใช่ปัญหาอยู่แล้ว ถ้ารักษาธรรมเนียมไม่ใช้ global state ภาษาส่วนใหญ่ก็ทำระดับนี้ได้เกือบหมด ส่วน stackless coroutine ก็ยังไม่ได้ implement เลย ให้ความรู้สึกประมาณว่า ‘เหลือแค่วาดชิ้นส่วนที่เหลือก็เสร็จแล้ว’ ถ้าต้องการ function call ที่เป็นสากลจริง ๆ ผมคิดว่ามีสองทาง
ทำทุกฟังก์ชันให้เป็น async ไปเลย แล้วใส่พารามิเตอร์หนึ่งตัวเพื่อบอกว่าจะรันแบบ synchronous หรือไม่ (มี cost ด้านประสิทธิภาพ)
คอมไพล์แต่ละฟังก์ชันสองครั้ง แล้วเลือกเรียกให้เหมาะกับสถานการณ์ (แลกกับขนาดโค้ดที่เพิ่มขึ้นและความยากในการจัดการ function pointer)
ผมไม่ได้อยู่ในทีมหลัก แต่ได้ยินมาว่าเมื่อผู้ใช้และผู้ใช้จริงได้ลองใช้ implementation แบบ semiblocking มากพอ และ API ถูกทำให้เสถียรแล้ว แผนก็คือจะใช้วิธีนั้นเลย คือใส่ coroutine จริงที่อิงการกระโดดสแตกเข้าไป ตอนนี้ตัวคอมไพเลอร์ coroutine state machine ของ LLVM ยังมีปัญหาที่ต้องพึ่ง libc หรือ malloc อยู่ อินเทอร์เฟซ io ใหม่ของ Zig รองรับ userland async/await อยู่แล้ว ดังนั้นต่อให้ภายหลังมี frame jumping solution ที่เหมาะสมเข้ามา การย้ายก็น่าจะง่ายและ debug ก็สะดวก ถ้า coroutine ยังคงยากอยู่ io API ก็ถูกออกแบบให้ทนได้ด้วยการปรับเพียงเล็กน้อย จึงยังไม่คิดจะรีบเร่งไปทาง stackless coroutine มากเกินไป
ValueTask<T>ของ C#/.NET ก็ทำหน้าที่คล้ายกัน ถ้าจบแบบ synchronous ก็ไม่มี overhead และจะใช้เป็นTask<T>ก็ต่อเมื่อจำเป็น ปกติโค้ดก็แค่awaitไว้ และตอนรัน runtime หรือคอมไพเลอร์จะเลือก synchronous/async ให้เองผมชอบ Zig แต่ก็รู้สึกเสียดายนิดหน่อยที่ดูเหมือนจะไปโฟกัสที่ green thread (fiber, stackful coroutine) Rust เองก็เคยมี Runtime trait คล้ายกันก่อน 1.0 และตัดออกไปเพราะปัญหาด้านประสิทธิภาพ ความจริงแล้ว OS ภาษา และไลบรารีต่าง ๆ ได้เรียนรู้ผลเสียของแนวทางนี้มาหลายครั้งแล้ว และก็มีเอกสารที่เกี่ยวข้อง https://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p1364r0.pdf. fiber เคยโดดเด่นเรื่อง concurrency ที่สเกลได้ในยุค 90 แต่ปัจจุบันด้วย stackless coroutine และความก้าวหน้าของ OS/ฮาร์ดแวร์ มันไม่ใช่สิ่งที่แนะนำอีกแล้ว ถ้ายังไปทางนี้ต่อ Zig ก็น่าจะชนเพดานด้านประสิทธิภาพคล้าย Go และยากจะเป็นคู่แข่งด้าน performance อย่างแท้จริง หวังว่า
std.fsจะยังคงอยู่ในเคสที่ต้องการประสิทธิภาพมากความรู้สึกที่ว่าเรากำลัง “ทุ่มสุดตัว” ไปกับ green thread (fiber) นั้นเป็นความเข้าใจผิด ในบทความที่ OP อ้างถึงก็ระบุไว้อย่างชัดเจนว่าคาดหวัง implementation ที่อิง stackless coroutine และก็มีข้อเสนออยู่แล้ว https://github.com/ziglang/zig/issues/23446 ประสิทธิภาพเป็นเรื่องสำคัญ และถ้า fiber ทำผลงานด้านประสิทธิภาพได้ต่ำกว่าที่หวัง มันก็คงไม่ถูกใช้อย่างแพร่หลาย สิ่งที่คุยกันในบทความนี้ไม่ได้ขัดขวางไม่ให้ stackless coroutine กลายเป็น implementation
Ioแบบพื้นฐานผมสงสัยกับคำกล่าวว่า green thread มีประสิทธิภาพแย่ เพราะแพลตฟอร์มเซิร์ฟเวอร์ด้าน concurrency ระดับบนหลายตัว (Go, Erlang, Java) ต่างก็ใช้หรือพยายามใช้ green thread ปัญหาของ green thread อาจอยู่ที่ความเข้ากันได้กับ C FFI จึงอาจไม่เหมาะกับภาษาระดับต่ำกว่าอย่าง Rust แต่จะบอกว่ามีปัญหาด้านประสิทธิภาพเสมอไปก็คงไม่ถูกนัก
เพราะมันเป็นเพียงหนึ่งในหลายตัวเลือก ผมเลยไม่คิดว่าจะเรียกว่า ‘all-in’ ได้ จะเลือก implementation ไหนนั้นถูกตัดสินในตัว executable ไม่ใช่ในโค้ดไลบรารี
Zig กำลังมุ่งไปสู่ผลลัพธ์คล้ายกับตอนที่ Rust เอา green thread ออกแล้วแทนที่ด้วย async runtime แก่นสำคัญคือการทำให้ชัดว่า ‘async=IO, IO=async’ Rust มี async runtime แบบ pluggable อย่าง tokio ส่วน Zig จะมี IO runtime แบบ pluggable สุดท้ายทิศทางคือดึง runtime ออกจากภาษา แล้วเปิดให้เสียบจาก user space ได้ โดยทุกฝ่ายใช้ interface ร่วมกัน
เอกสารอ้างอิง (P1364R0) เป็นเอกสารที่มีข้อถกเถียง และผมคิดว่ามันมีแรงจูงใจเพื่อผลักแนวทางบางอย่างออกไปด้วย สำหรับการอภิปรายอาจดูเพิ่มได้ที่ https://old.reddit.com/r/cpp/comments/1jwlur9/stackful_coroutines_faster_than_stackless/, https://old.reddit.com/r/programming/comments/dgfxde/fibers_arent_useful_for_much_any_more/f3bmpww/ เป็นต้น
ผมรู้สึกว่าการบังคับให้มี runtime polymorphism แม้กระทั่งกับงาน IO มาตรฐานที่เจอบ่อยในภาษาระบบอย่าง Zig ก็ดูแปลกอยู่พอสมควร ในงานจริงส่วนใหญ่ implementation ของ IO มักกำหนดแบบ static ได้อยู่แล้ว เลยสงสัยว่าทำไมต้องบังคับให้จ่าย runtime overhead ด้วย
ผมคิดว่า overhead ของ dynamic dispatch ในงาน IO นั้นแทบเล็กน้อยมากในทางปฏิบัติ จะต่างกันไปตามปลายทางของ IO ก็จริง แต่สุดท้ายกรณีที่ IO กลายเป็นคอขวดของ CPU มีน้อยกว่ามาก จึงถึงเรียกว่า IO-bound
สำหรับคำถามว่า “ทำไมต้องบังคับ runtime overhead กับทุกคน?” ดูเหมือนเป้าหมายคือในระบบที่ใช้ io แค่ชนิดเดียว คอมไพเลอร์น่าจะ optimize เอาต้นทุนของ double indirection ออกไปได้อยู่แล้ว และตัว IO เองก็มักมี bottleneck อื่นอยู่ก่อน การเพิ่ม indirection อีกหนึ่งชั้นจึงแทบไม่เป็นภาระ
ตามปรัชญาของ Zig เขาค่อนข้างใส่ใจกับขนาดไบนารีมากกว่า Allocator ก็มี trade-off แบบเดียวกัน เช่น
ArrayListUnmanagedไม่ได้เป็น generic ต่อ allocator จึงเกิด dynamic dispatch ทุกครั้งที่มีการจัดสรรหน่วยความจำ แต่ในความเป็นจริงต้นทุนของการจัดสรรไฟล์หรือการเขียนข้อมูลนั้นมากกว่า overhead ของ indirect call อยู่แล้ว ความหมกมุ่นกับขนาดไบนารีแบบนี้เป็นสไตล์ของ Zig อ้อ และdevirtualization(optimization ที่เปลี่ยน dynamic call ให้เป็น static) เป็นเรื่องงมงายruntime polymorphism เองไม่ได้แย่โดยเนื้อแท้ ถ้าไม่ใช่กรณีอย่างเกิด branch ใน tight loop หรือทำให้คอมไพเลอร์ inline ไม่ได้ ก็ไม่ใช่สถานการณ์ที่เป็นปัญหา
แม้ผมจะไม่ได้ชอบที่พารามิเตอร์ io ใหม่โผล่มาให้เห็นไปทั่วนัก แต่ก็ชอบมากที่มันทำให้ใช้ implementation ได้หลายแบบง่ายขึ้น (แบบ thread, แบบ fiber ฯลฯ) และไม่ได้บังคับ implementation ใดกับผู้ใช้โดยเฉพาะ (คล้ายอินเทอร์เฟซ Allocator) โดยรวมถือว่าดีขึ้นมาก และถ้าในบรรดา implementation ของ stdlib มี synchronous/blocking io ที่ไม่มี overhead พิเศษให้ใช้ด้วย ก็จะสอดคล้องกับปรัชญา “ไม่ใช้ก็ไม่ต้องจ่าย” ของ Zig อย่างแท้จริง
ใน Zig
io.asyncใช้เพื่อแสดงความเป็น asynchronous (คือลำดับการทำงานอาจไม่รับประกัน แต่ผลลัพธ์ยังถูกต้อง) เท่านั้น ไม่ได้ใช้แทน concurrency กล่าวคือประเด็นสำคัญคือมันแยกความหมายของ async ออกจากความหมายของการเรียก io ผมคิดว่าการออกแบบนี้ฉลาดมากผมชอบที่อินเทอร์เฟซ IO นี้เปิดทางให้สร้าง vfs (Virtual File System) ในระดับภาษาได้
ผมลองทำเซิร์ฟเวอร์ ssh ง่าย ๆ เพื่อเรียนรู้ Zig และพบว่าโครงสร้าง IO/event loop ชุดนี้ช่วยให้เข้าใจการไหลของโค้ดได้ง่ายขึ้นมาก ขอบคุณ Andy
บทความเขียนดีมากและน่าสนใจมาก โดยเฉพาะนัยต่อ WebAssembly ที่น่าตื่นเต้น ทั้งการใช้ WASI จาก userspace และโครงสร้างแบบ Bring Your Own IO ก็ดูน่าสนุกมากจริง ๆ