- ภาษา Zig นำเสนอ โมเดลใหม่บนพื้นฐาน
Io อินเทอร์เฟซ เพื่อแก้ปัญหาความซับซ้อนของการออกแบบ I/O แบบอะซิงโครนัสเดิม
- โมเดลนี้รักษาโครงสร้างฟังก์ชันเดียวกันสำหรับโค้ดซิงโครนัสและอะซิงโครนัส โดยมี
Io.Threaded และ Io.Evented เป็นตัวดำเนินการ
Io.Threaded ทำงานแบบซิงโครนัสตามค่าเริ่มต้น ส่วน Io.Evented รันแบบอะซิงโครนัสด้วย event loop
- นักพัฒนาสามารถควบคุมการทำงานแบบขนานผ่านฟังก์ชัน
async() และ concurrent() และปรับประสิทธิภาพได้โดยไม่ต้องแก้โค้ด
- แนวทางนี้แก้ปัญหา การระบายสีฟังก์ชัน (function coloring) และคงความเรียบง่ายและการควบคุมของ Zig ไว้ ขณะเดียวกันยังคงประสิทธิภาพอะซิงโครนัสได้
การเปลี่ยนแปลงการออกแบบอะซิงโครนัสของ Zig
- Zig ค้นหาวิธีใหม่เนื่องจากการออกแบบอะซิงโครนัสเดิมไม่สอดคล้องกับปรัชญาความมินิมัลลิสม์ของภาษา
- การออกแบบเดิมมีการผสานรวมกับฟังก์ชันอื่นไม่สูง
- โมเดลใหม่สามารถจัดการ I/O แบบซิงโครนัสและอะซิงโครนัสด้วยโครงสร้างโค้ดเดียวกัน
- โมเดลใหม่ทำงานรอบ อินเทอร์เฟซ generic
Io
- ฟังก์ชัน I/O ทั้งหมดรับพารามิเตอร์เป็นอินสแตนซ์
Io
- คล้ายโครงสร้างอินเทอร์เฟซ
Allocator ทำให้ควบคุม I/O ได้แบบเดียวกับการจัดสรรหน่วยความจำ
โครงสร้างของอินเทอร์เฟซ Io
- ไลบรารีมาตรฐานมี ตัวใช้งานมาตรฐาน 2 แบบ
Io.Threaded: รันแบบซิงโครนัสเป็นค่าเริ่มต้น ใช้งานเธรดแบบขนานได้เมื่อจำเป็น
Io.Evented: รันแบบอะซิงโครนัสบน event loop (ใช้ io_uring, kqueue และอื่น ๆ)
- ผู้ใช้สามารถเขียน ตัวใช้งาน
Io ใหม่เอง เพื่อให้ควบคุมวิธีการรันได้ละเอียดขึ้น
ตัวอย่างโค้ดและการทำงาน
- ฟังก์ชันตัวอย่าง
saveFile() ทำหน้าที่สร้างไฟล์ เขียน และปิดไฟล์
- เมื่อใช้
Io.Threaded จะทำงานผ่าน system call ตามปกติ
- เมื่อใช้
Io.Evented จะทำงานผ่าน backend แบบอะซิงโครนัส
- ในทั้งสองกรณี การเรียก
writeAll() จะรับประกันว่าเสร็จสิ้นงาน
- โค้ดเดียวกันทำงานได้ทั้งในสภาพแวดล้อมซิงโครนัสและอะซิงโครนัสเหมือนกัน
- ผู้เขียนไลบรารีไม่จำเป็นต้องกังวลเรื่องวิธีการรัน
การรันแบบขนานและ async() / concurrent()
- ฟังก์ชัน
async() เป็นการร้องขอให้รันแบบอะซิงโครนัส แต่กับ Io.Threaded อาจรันได้ทันที
- ใน
Io.Evented จะรันแบบอะซิงโครนัสจริง และสามารถบันทึกไฟล์พร้อมกันได้สองไฟล์
- ฟังก์ชัน
concurrent() ใช้เมื่อจำเป็นต้องรันแบบขนานจริง
Io.Threaded ใช้ thread pool
Io.Evented จัดการเหมือนกับ async()
- การเลือกฟังก์ชันที่ผิด (
async แทน concurrent) จะถือว่าเป็นบั๊ก และไม่สามารถป้องกันได้ในระดับภาษา
รูปแบบโค้ดและการผสานรวมกับภาษา
- รักษาสไตล์โค้ด Zig ทั่วไปโดยไม่มีกฎไวยากรณ์เฉพาะสำหรับอะซิงโครนัส
- ใช้
try, defer และโครงสร้างควบคุมกระแสเดิมต่อไป
- Andrew Kelley กล่าวว่าสามารถอ่านได้เหมือน โค้ด Zig มาตรฐาน
- มีตัวอย่างการนำเสนอ การ lookup DNS แบบอะซิงโครนัส
- แตกต่างจาก
getaddrinfo() ในแง่ที่คืนผลลัพธ์ความสำเร็จแรกเท่านั้นและยกเลิกคำขอที่เหลือ
แผนงานและสถานะการพัฒนา
Io.Evented ยังอยู่ในขั้นทดลอง และยังไม่รองรับบางระบบปฏิบัติการ
- การพัฒนา
Io สำหรับ WebAssembly อยู่ระหว่างแผนงาน และยังมีความจำเป็นต้องพัฒนาฟีเจอร์ที่เกี่ยวข้อง
- มี 24 งานย่อยหลังการปรับปรุง
Io และส่วนใหญ่ยังไม่แล้วเสร็จ
- Zig ยังไม่ถึงรุ่น 1.0 การทำ I/O แบบอะซิงโครนัสและการสร้างโค้ดเนทีฟยังเป็นงานสำคัญที่เหลืออยู่
- ด้วยโมเดลใหม่นี้คาดว่าความถี่การเขียนโค้ดใหม่เพราะการเปลี่ยนอินเทอร์เฟซ I/O จะลดลง
สรุปจากการอภิปรายในชุมชน
- ความคิดเห็นหลายส่วนประเมินว่าแนวทางของ Zig ง่ายและยืดหยุ่นกว่ารูปแบบ async/await ของ Rust
- Rust มีความซับซ้อนมากขึ้นเมื่อมี executor หลายตัวทำงานร่วมกัน
- Zig ทำให้ความเป็นไปได้ของการอยู่ร่วมกันของ executor หลายตัวผ่านอินเทอร์เฟซ
Io เป็นไปได้
- บางส่วนชี้ว่าโค้ดอาจยืดยาวไปบ้าง
- อย่างไรก็ตาม API ที่ชัดเจนช่วยเพิ่มการควบคุมด้านความปลอดภัย ประสิทธิภาพ และการทดสอบ
- การอภิปรายทางเทคนิครวมถึงความต่างระหว่างการรันด้วย thread กับ async, รวมถึงการใช้งาน stackful และ stackless coroutine
Io ของ Zig ทำเป็นการขยายไลบรารีมาตรฐานโดยไม่ต้องมีการจัดการพิเศษระดับภาษา
- คาดว่าจะเพิ่มความสามารถแบบ stackless coroutine ในอนาคต
สรุป
- โมเดลอะซิงโครนัสใหม่ของ Zig มีเป้าหมายให้ความเรียบง่ายของภาษาและประสิทธิภาพ I/O สูงสุดอยู่ร่วมกัน
- การแก้ปัญหา function coloring, การผสานโค้ดซิงโครนัสและอะซิงโครนัส, และโครงสร้างควบคุมแบบชัดเจน ทำให้โมเดลนี้ถูกประเมินว่าเป็นขั้นตอนสำคัญสู่ความเสถียรของ Zig 1.0
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
โดยรวมแล้วบทความนี้ถูกต้องและค้นคว้ามาอย่างดี
แต่มี จุดแก้ไขเล็กน้อย อยู่บางประการ
ในอินสแตนซ์
Io.Threadedนั้นasync()ไม่ได้ทำงานแบบอะซิงโครนัสจริง ๆ แต่จะรันทันที อย่างไรก็ตามstd.Io.Threadedจะใช้ thread pool เป็นค่าเริ่มต้นเพื่อกระจายงานอะซิงโครนัสแต่ถ้าเริ่มต้นด้วย
init_single_threadedก็จะทำงานแบบที่อธิบายไว้ในบทความอีกอย่างหนึ่งคือ แต่ก่อนมีฟังก์ชันชื่อ
asyncConcurrent()แต่ตอนนี้เปลี่ยนชื่อเป็นconcurrent()เฉย ๆ แล้วถ้าต้องการส่งฟีดแบ็กในอนาคต สามารถส่งอีเมลไปที่ lwn@lwn.net ได้
ขอบคุณสำหรับข้อเสนอการแก้ไขและงานที่เกี่ยวข้องกับ Zig
ผมสงสัยว่าถ้าใช้
asyncConcurrent()ผิดในจุดที่ควรใช้async()จะทำให้เกิด บั๊ก แบบไหนอยากรู้ว่าขึ้นอยู่กับโมเดล IO แล้วอาจกลายเป็น UB (undefined behavior) ได้ไหม หรือเป็นแค่ข้อผิดพลาดทางตรรกะ
concurrent()คือช่วยเพิ่ม ความอ่านง่ายและพลังในการสื่อความหมาย ของโค้ด ทำให้เห็นชัดว่า “โค้ดนี้ต้องรันแบบขนานเท่านั้น”ผมคิดว่าดีไซน์นี้ค่อนข้างสมเหตุสมผล
แต่คำอธิบายของ Zig ทำให้งง
มันชอบย้ำว่าแก้ปัญหา function coloring ได้แล้ว แต่จริง ๆ แล้วก็แค่ยัด IO เข้าไปเป็น effect type เท่านั้น
มันเป็นรูปแบบที่ผู้เรียกต้องเก็บ token ไว้ ดังนั้นก็ยังเป็นการระบายสีอยู่ดี
ผมมองว่าคล้ายกับแนวทาง async ของ Go
โมเดล async-await แบบเก่าของ Zig ก็แก้ปัญหา coloring ได้อยู่แล้ว
เพราะคอมไพเลอร์จะสร้าง เวอร์ชัน sync/async ให้อัตโนมัติตามบริบทของการเรียก
Zig แก้ปัญหานี้ด้วย dependency injection ซึ่งในทางปฏิบัติก็เพียงพอแล้ว
ความซับซ้อนของการเรียก async เป็นสิ่งที่เลี่ยงไม่ได้ แต่ก็เป็นราคาที่ต้องจ่ายหากต้องการการควบคุมที่ละเอียด
คุณสามารถประกาศตัวแปร io แบบ global แล้วใช้จากที่ไหนก็ได้ (แน่นอนว่าไม่แนะนำเวลาทำไลบรารี)
ถ้าดูบทความ What color is your function? ที่สรุปเงื่อนไขห้าข้อของปัญหา function coloring จะเห็นว่าแนวทางของ Zig น่าจะไม่เข้าเงื่อนไขบางข้อ (โดยเฉพาะข้อ 4 และ 5)
แต่แนวทางแบบนี้อาจก่อปัญหาอย่าง deadlock ได้
บางส่วนของโค้ดไม่ปลอดภัยต่อการใช้งานข้ามเธรด ดังนั้นการมี coloring กลับช่วยได้
ดีไซน์นี้ดูคล้าย async ของ Scala มาก
ใน Scala จะส่ง execution context ผ่าน implicit parameter ส่วน Zig รับมาแบบ explicit
ในทางปฏิบัติ มันไม่ได้ดีกว่าการใช้ thread กับ queue ตรง ๆ มากนัก และการจัดการ execution context ก็ทำให้เกิด ความซับซ้อนและพฤติกรรมที่คาดเดาได้ยาก
ดูเหมือนทีม Zig จะมีประสบการณ์กับ Scala ไม่มากนักเลยคิดว่าแนวทางนี้ใหม่
JVM แก้เรื่องนี้ด้วย virtual thread แต่ภาษา low-level จะทำประสิทธิภาพแบบเดียวกันได้ยาก
ดังนั้นภาษาตระกูล Zig จึงต้องการวิธีแก้ปัญหาด้าน scalability แบบอื่น
ในระบบ async/await แบบเก่าของ Zig นั้นสามารถ suspend/resume ฟังก์ชันได้
ผมเคยอยากใช้ฟีเจอร์นี้ตอนพัฒนา OS เพื่อทำ การพัก/กลับมาทำงานต่อของเฟรม ที่อิงกับ device interrupt
น่าเสียดายที่ระบบ io ใหม่ดูเหมือนจะต้องทำสิ่งนี้เองโดยตรง
@asyncSuspendและ@asyncResumeIo แบบใหม่เป็น abstraction กลางสำหรับ synchronous, threaded และ event-based ดังนั้นจึงไม่ได้รวมกลไก suspend ไว้
ถ้าดู Io.Evented prototype ปัจจุบัน ก็มีความเป็นไปได้ว่าจะจัดการผ่านไลบรารีภายนอกโดยอิง stackless coroutine
ในโค้ดตัวอย่างมีการบอกว่าเมื่อ
writeAll()คืนค่ากลับมา งานก็ถือว่าเสร็จแล้วแต่เนื่องจาก implementation ของ IO อาจหลากหลาย จริง ๆ แล้วควรรับประกันว่าเสร็จสิ้นตอน เริ่ม defer
ไม่อย่างนั้นก็ต้องมี การติดตาม dependency ระหว่าง
createFileกับwriteAllซึ่งถ้าเป็นแบบนั้นก็ดูไม่ต่างจาก blocking call เท่าไร
และชื่ออินเทอร์เฟซว่า IO ก็ยังดูไม่ชัดเจนว่าทำไมถึงใช้ชื่อนี้
จริง ๆ แล้วมันใกล้เคียงกับ abstraction สำหรับ “ไปรันใน context อื่น” มากกว่า
เอกสารที่เกี่ยวข้อง: std.Io
ตัวอย่างต่อไปนี้น่าสนใจ
ใน Rust หรือ Python ถ้า coroutine ไม่ได้ถูก await มันก็จะไม่เดินหน้าต่อ
แต่ในตัวอย่างของ Zig ถ้า
io.asyncเดินหน้าเองได้ แบบนั้นมันก็คล้ายกับ การสร้าง taskนี่เป็นดีไซน์ที่ใช้ได้ แต่ไม่ใช่ทิศทางที่ภาษาอื่นส่วนใหญ่เลือก
asyncจะ รันบนเธรดที่เรียกก่อนจนกว่าจะถึงจุด yield.await(io)ก่อนจึงจะรับประกันการทำงานจะรันทันทีหรือถูกคิวลง thread pool ขึ้นอยู่กับ implementation ของ Io runtime
awaitสำหรับ evented io งานสองชิ้นอาจรันแบบ สลับกัน (interleaved) ได้ และใน threaded io ก็อาจทำงานอยู่เบื้องหลังได้
กล่าวคือไม่มี “task ที่แอบไปรันเองอยู่ที่ไหนสักแห่ง”
ในฐานะคนที่ใช้ Go ทุกวัน ผมรู้สึกว่า Io ของ Zig แก้ ข้อด้อยหลายอย่าง ของ Go ได้
แต่อยากรู้ว่า Zig มีแนวคิดเรื่อง channel หรือไม่
ใน Go มีคีย์เวิร์ด select แต่ใช้กับ socket ไม่ได้ ซึ่งผมรู้สึกติดใจเรื่องนี้มาตลอด
channel ของ Go มี overhead ระดับหลายสิบ cycle ดังนั้นกับ IO ชิ้นเล็ก ๆ จะไม่คุ้ม
แต่สำหรับ การย้ายข้อมูลก้อนใหญ่ หรือ การซิงก์แบบหลายต่อหลาย มันก็ยังมีประโยชน์
std.Io.Queueที่คล้าย channel ของ Goจะทำ select แบบคล้ายกันก็ได้ แต่ในเชิงไวยากรณ์จะ ไม่ ergonomic เท่า
ข้อดีคือมันทำงานได้บน IO runtime ที่หลากหลายโดยไม่ต้องมี GC
ผมคิดว่าแนวทาง “colorless” ของ Zig ดีกว่ามาก
Goroutine ก็เป็นแค่ green thread และ channel ก็เป็นแค่ queue ที่ thread-safe เท่านั้น และ Zig ก็มีสิ่งเหล่านี้ใน standard library อยู่แล้ว
Io เวอร์ชัน async ของ Zig ดูแทบจะเหมือนแนวทางของ Go ทุกอย่าง
แต่ใน Go เวลาเรียก C library จะมี ต้นทุนการจัดสรรสแตก สูง และการทำ syscall โดยตรง ก็มีปัญหาเรื่องความเข้ากันได้ข้ามแพลตฟอร์ม
Zig ดูเหมือนจะทำให้สิ่งเหล่านี้ ประกอบเลือกใช้ได้ เพื่อให้เลือก trade-off ได้หลายแบบโดยไม่ต้องแก้โค้ด
async IO แบบใหม่ยอดเยี่ยมมากในตัวอย่างง่าย ๆ แต่กับ IO ที่ซับซ้อนระดับเซิร์ฟเวอร์ อาจมีข้อจำกัด
ผมได้เปิด issue ที่เกี่ยวข้องไว้ใน GitHub แล้ว
ประเด็นหลักคือผู้ออกแบบภาษาและไลบรารีควรมีวิธีเชื่อม execution context ที่ต่างกัน (sync/async) เข้าหากัน
วิธีหนึ่งคือห่อ context ด้วย FSM (finite-state machine) และมี ช่องทางสื่อสาร ระหว่างทั้งสองฝั่ง
บทความที่เกี่ยวข้อง: Function colors represent different execution contexts