- Ngôn ngữ Zig giới thiệu mô hình mới dựa trên giao diện
Io để giảm độ phức tạp của thiết kế I/O bất đồng bộ hiện có
- Mô hình này giữ nguyên cùng một cấu trúc hàm mà không phân biệt mã đồng bộ hay bất đồng bộ, đồng thời cung cấp hai cách triển khai là
Io.Threaded và Io.Evented
Io.Threaded về cơ bản thực hiện chạy đồng bộ, còn Io.Evented thực hiện chạy bất đồng bộ dựa trên vòng lặp sự kiện
- Nhà phát triển có thể kiểm soát thực thi song song thông qua các hàm
async() và concurrent(), đồng thời tối ưu hiệu năng mà không cần sửa mã
- Cách tiếp cận này giải quyết vấn đề function coloring, đồng thời đảm bảo hiệu năng bất đồng bộ trong khi vẫn giữ được sự đơn giản và khả năng kiểm soát của Zig
Thay đổi trong thiết kế bất đồng bộ của Zig
- Zig tìm kiếm một hướng tiếp cận mới vì thiết kế bất đồng bộ hiện tại không thực sự phù hợp với triết lý tối giản của ngôn ngữ
- Thiết kế cũ có mức độ tích hợp thấp với các tính năng khác
- Mô hình mới cho phép xử lý I/O đồng bộ và bất đồng bộ bằng cùng một cấu trúc mã
- Thiết kế mới vận hành xoay quanh giao diện generic
Io
- Mọi hàm I/O đều nhận một instance
Io làm tham số để thực thi
- Có cấu trúc tương tự giao diện
Allocator, cho phép kiểm soát I/O theo cách giống như cấp phát bộ nhớ
Cấu trúc của giao diện Io
- Thư viện chuẩn bao gồm hai cách triển khai cơ bản
Io.Threaded : mặc định chạy đồng bộ, có thể xử lý song song bằng thread khi cần
Io.Evented : chạy bất đồng bộ dựa trên vòng lặp sự kiện (sử dụng io_uring, kqueue v.v.)
- Người dùng cũng có thể tự viết cách triển khai
Io mới, từ đó có thể kiểm soát chi tiết hơn phương thức thực thi
Ví dụ mã và cách hoạt động
- Hàm ví dụ
saveFile() thực hiện tạo file, ghi và đóng file
- Khi dùng
Io.Threaded, nó hoạt động bằng các system call thông thường
- Khi dùng
Io.Evented, nó chạy bằng backend bất đồng bộ
- Trong cả hai trường hợp, công việc đều được đảm bảo hoàn tất tại thời điểm gọi
writeAll()
- Cùng một đoạn mã hoạt động giống nhau trong cả môi trường đồng bộ lẫn bất đồng bộ
- Tác giả thư viện không cần quan tâm đến phương thức thực thi
Thực thi song song và async() / concurrent()
- Hàm
async() dùng để yêu cầu thực thi bất đồng bộ, nhưng trong Io.Threaded nó có thể vẫn chạy ngay lập tức
- Trong
Io.Evented, nó sẽ thực sự chạy bất đồng bộ để có thể lưu hai file cùng lúc
- Hàm
concurrent() được dùng khi cần thực thi song song thực sự
Io.Threaded tận dụng thread pool
Io.Evented xử lý giống như async()
- Việc chọn sai hàm (
async thay vì concurrent) được xem là lỗi, và không thể ngăn chặn ở cấp độ ngôn ngữ
Phong cách mã và tích hợp ngôn ngữ
- Giữ nguyên phong cách mã Zig thông thường mà không cần cú pháp dành riêng cho bất đồng bộ
- Các cú pháp điều khiển luồng hiện có như
try, defer vẫn được dùng nguyên vẹn
- Andrew Kelley nhận xét rằng nó “đọc giống như mã Zig tiêu chuẩn”
- Bài viết cũng đưa ra ví dụ triển khai tra cứu DNS bất đồng bộ
- Khác với
getaddrinfo(), nó chỉ trả về phản hồi thành công đầu tiên và hủy các yêu cầu còn lại
Kế hoạch sắp tới và tình hình phát triển
Io.Evented hiện vẫn ở giai đoạn thử nghiệm, và chưa hỗ trợ một số OS
- Đang có kế hoạch cho triển khai
Io tương thích WebAssembly, đồng thời cần phát triển thêm các tính năng liên quan
- Có 24 hạng mục công việc tiếp theo liên quan đến
Io, và phần lớn vẫn chưa hoàn thành
- Zig hiện vẫn chưa đạt bản 1.0, trong đó I/O bất đồng bộ và sinh mã native là những nhiệm vụ lớn còn lại
- Với thiết kế lần này, kỳ vọng sẽ giảm tần suất phải viết lại mã do thay đổi giao diện I/O
Tóm tắt thảo luận cộng đồng
- Nhiều bình luận đánh giá cách tiếp cận của Zig đơn giản và linh hoạt hơn mô hình async/await của Rust
- Rust trở nên phức tạp hơn khi trộn nhiều executor
- Zig dùng giao diện
Io để mở ra khả năng nhiều executor cùng tồn tại
- Một số ý kiến chỉ ra rằng mã có thể trở nên hơi dài dòng
- Tuy nhiên, thiết kế API tường minh giúp cải thiện khả năng kiểm soát về bảo mật, hiệu năng và kiểm thử
- Cũng có các thảo luận kỹ thuật tiếp nối về sự khác biệt giữa thực thi bất đồng bộ và thực thi bằng thread, cũng như cách triển khai stackful vs stackless coroutine
Io của Zig được triển khai dưới dạng mở rộng thư viện chuẩn mà không cần xử lý đặc biệt ở cấp độ ngôn ngữ
- Trong tương lai, tính năng stackless coroutine sẽ được bổ sung
Kết luận
- Mô hình bất đồng bộ mới của Zig hướng tới kết hợp giữa sự đơn giản của ngôn ngữ và I/O hiệu năng cao
- Thông qua việc giải quyết vấn đề function coloring, hợp nhất mã đồng bộ và bất đồng bộ, cùng cấu trúc điều khiển tường minh, đây được xem là bước then chốt để ổn định Zig 1.0
1 bình luận
Bình luận trên Hacker News
Nhìn chung bài viết này chính xác và được nghiên cứu kỹ.
Tuy vậy vẫn có một vài điểm chỉnh sửa nhỏ.
Trong một instance
Io.Threaded,async()thực ra không chạy bất đồng bộ mà được thực thi ngay lập tức. Nhưngstd.Io.Threadedmặc định dùng thread pool để phân phối công việc bất đồng bộ.Tuy nhiên, nếu khởi tạo bằng
init_single_threadedthì nó sẽ hoạt động như bài báo mô tả.Ngoài ra, trước đây từng có hàm tên là
asyncConcurrent(), nhưng giờ chỉ đổi tên thànhconcurrent()Nếu muốn gửi góp ý trong tương lai, bạn có thể gửi email tới lwn@lwn.net.
Cảm ơn vì đề xuất chỉnh sửa và vì công việc liên quan đến Zig
Nếu lỡ dùng
asyncConcurrent()ở chỗ đáng ra phải dùngasync(), tôi muốn biết sẽ phát sinh bug gì.Tùy mô hình IO, liệu điều đó có thể trở thành UB (hành vi không xác định) hay chỉ đơn thuần là lỗi logic?
concurrent()là nó tăng tính dễ đọc và sức biểu đạt của mã, giúp thể hiện rõ rằng “đoạn mã này nhất định phải chạy song song”Tôi thấy thiết kế này khá hợp lý.
Nhưng phần giải thích của Zig lại gây bối rối.
Họ nhấn mạnh rằng đã giải quyết được vấn đề function coloring, nhưng thực chất chỉ là đẩy IO vào một effect type.
Điều này buộc phía gọi phải giữ token, nên vẫn là một dạng coloring.
Tôi thấy nó khá giống cách Go xử lý bất đồng bộ
Mô hình async-await cũ của Zig thực ra cũng đã giải quyết vấn đề coloring rồi.
Vì compiler tự động sinh ra phiên bản đồng bộ/bất đồng bộ tùy theo ngữ cảnh gọi
Zig giải quyết điều này bằng dependency injection, và trên thực tế như vậy là đủ dùng.
Độ phức tạp của lời gọi async là thứ không thể tránh khỏi, nhưng đó là cái giá phải trả để có được khả năng kiểm soát chi tiết
Bạn có thể khai báo một biến io toàn cục và dùng nó ở mọi nơi (dù tất nhiên điều này không được khuyến khích khi viết thư viện).
Nếu xem bài What color is your function? tổng hợp năm điều kiện của function coloring, thì cách tiếp cận của Zig nhiều khả năng không thỏa một số điều kiện đó (đặc biệt là 4 và 5)
Nhưng cách tiếp cận này có thể gây ra vấn đề như deadlock.
Một số đoạn mã không thread-safe, nên trong trường hợp đó coloring lại có ích
Thiết kế này trông rất giống async của Scala.
Trong Scala, execution context được truyền dưới dạng tham số ngầm định, còn Zig thì nhận một cách tường minh.
Trên thực tế, cách này không tốt hơn bao nhiêu so với việc dùng thread và queue trực tiếp, còn việc quản lý execution context lại gây ra độ phức tạp và hành vi khó đoán.
Có vẻ nhóm Zig ít kinh nghiệm với Scala nên nghĩ rằng cách tiếp cận này là mới
JVM giải quyết việc này bằng virtual thread, nhưng ngôn ngữ cấp thấp thì khó đạt được hiệu quả tương tự.
Vì vậy những ngôn ngữ như Zig cần một giải pháp mở rộng theo hướng khác
Trong hệ thống async/await cũ của Zig, hàm có thể suspend/resume.
Tôi từng muốn dùng tính năng này để khi phát triển OS có thể hiện thực tạm dừng/tiếp tục frame dựa trên interrupt của thiết bị.
Thật tiếc là với hệ thống io mới, có vẻ phải tự triển khai chuyện này
@asyncSuspendvà@asyncResume.Io mới là một lớp trừu tượng chung cho sync, thread và event-based, nên không bao gồm cơ chế suspend
Nhìn vào prototype Io.Evented hiện tại, có vẻ thư viện bên thứ ba cũng có thể xử lý việc này dựa trên stackless coroutine
Trong đoạn mã ví dụ, người ta nói rằng khi
writeAll()trả về thì công việc đã hoàn tất,nhưng vì có thể tồn tại nhiều cách hiện thực IO khác nhau nên trên thực tế phải bảo đảm hoàn tất ngay khi
deferbắt đầu.Nếu không, sẽ cần phải theo dõi quan hệ phụ thuộc giữa
createFilevàwriteAll.Nếu vậy thì rốt cuộc trông cũng chẳng khác gì lời gọi blocking.
Ngoài ra, cũng không rõ vì sao giao diện này lại có tên là IO.
Thực chất nó gần với một lớp trừu tượng kiểu “chạy ở ngữ cảnh khác” hơn
Tài liệu liên quan: std.Io
Ví dụ sau khá thú vị
Trong Rust hay Python, coroutine sẽ không tiến triển nếu không được await.
Ngược lại, nếu trong ví dụ của Zig mà
io.asynctự nó vẫn tiến triển, thì điều đó sẽ giống với việc tạo task.Đây là một thiết kế hợp lệ, nhưng không phải hướng mà các ngôn ngữ khác đã chọn
asyncchạy trên thread gọi cho tới trước khi yield.await(io)thì việc thực thi mới được bảo đảm.Nó có chạy ngay hay bị đưa vào hàng đợi của thread pool còn tùy thuộc vào cách hiện thực runtime Io
await.Với evented io, hai tác vụ có thể chạy xen kẽ (interleaved), còn với threaded io thì chúng có thể tiến triển ở nền.
Tức là không có chuyện “task bí mật chạy ở đâu đó”
Là người dùng Go hằng ngày, tôi thấy Io của Zig đang sửa lại nhiều điểm yếu của Go.
Nhưng tôi cũng tò mò không biết Zig có khái niệm channel hay không.
Ở Go có từ khóa select, nhưng việc không dùng được với socket lúc nào cũng khiến tôi thấy tiếc
Channel của Go có overhead ở mức hàng chục chu kỳ, nên không hiệu quả với IO hạt nhỏ.
Bù lại, nó hữu ích cho di chuyển dữ liệu hạt lớn hoặc đồng bộ hóa nhiều-nhiều
std.Io.Queue, khá giống channel của Go.Câu lệnh select cũng có thể hiện thực theo cách tương tự, nhưng về mặt cú pháp thì kém ergonomic hơn.
Bù lại, nó có lợi thế là chạy được trên nhiều runtime IO khác nhau mà không cần GC
Tôi cho rằng cách tiếp cận “colorless” của Zig tốt hơn nhiều
Goroutine chỉ là green thread, còn channel chỉ là queue thread-safe, và Zig cũng đã cung cấp những thứ đó trong thư viện chuẩn
Phiên bản async của Io trong Zig trông gần như giống hệt cách tiếp cận của Go.
Chỉ khác là trong Go, khi gọi thư viện C thì chi phí cấp phát stack lớn, còn syscall trực tiếp thì gặp vấn đề tương thích đa nền tảng.
Có vẻ Zig đã làm cho những thứ này có thể cấu hình được, để có thể chọn nhiều kiểu đánh đổi khác nhau mà không phải sửa mã
Async IO mới rất tuyệt cho các ví dụ đơn giản, nhưng với IO phức tạp ở cấp độ server thì có thể sẽ có giới hạn.
Tôi đã đưa vấn đề liên quan lên GitHub
Vấn đề cốt lõi là người thiết kế ngôn ngữ hoặc thư viện phải cung cấp cách để kết nối các execution context khác nhau (sync/async).
Để làm điều đó, cần một cách bọc context thành FSM (máy trạng thái hữu hạn), đồng thời cung cấp kênh giao tiếp giữa hai phía
Bài viết liên quan: Function colors represent different execution contexts