I/O bất đồng bộ mới của Zig
(kristoff.it)- Việc đưa vào giao diện I/O bất đồng bộ mới của Zig cho phép bên gọi trực tiếp lựa chọn và tiêm cách triển khai I/O
- Giao diện Io được thiết kế lại mới hỗ trợ đồng thời cả tính bất đồng bộ và tính song song, tập trung vào khả năng tái sử dụng mã và tối ưu hóa
- Dự kiến sẽ cung cấp nhiều triển khai thư viện chuẩn như Blocking I/O, event loop, thread pool, green thread, stackless coroutine, v.v.
- Thông qua API mới, có thể thực hiện hủy future và quản lý tài nguyên, cũng như buffering và hành vi vào/ra chi tiết hơn
- Giải quyết bài toán function coloring hiện có, giúp một thư viện duy nhất có thể được tối ưu để vận hành cả đồng bộ/bất đồng bộ
Tổng quan
Gần đây Zig đang phát triển theo hướng tập trung vào tính linh hoạt của tác vụ I/O và hỗ trợ song song bằng cách thiết kế một giao diện I/O bất đồng bộ mới. Thay đổi lần này tách khỏi mô hình async/await hiện có, để người viết chương trình thực tế có thể áp dụng đa dạng chiến lược I/O hơn.
Giao diện I/O mới
Trước đây, các đối tượng liên quan đến I/O được tạo và sử dụng trực tiếp trong mã, nhưng giờ đây đã chuyển sang để bên gọi tiêm giao diện Io.
- Cách làm này tương tự mẫu Allocator, nơi phía gọi lựa chọn và tiêm triển khai I/O cụ thể
- Có thể áp dụng chiến lược I/O một cách nhất quán cả với mã từ gói bên ngoài
Các thay đổi chính
- Giao diện Io giờ đây cũng đảm nhiệm các thao tác concurrency
- Nếu mã biểu đạt concurrency đúng cách, tùy vào triển khai của Io mà có thể cung cấp parallelism
Mã ví dụ
- So sánh hai loại mã: mã không có concurrency (tuần tự) và mã biểu đạt khả năng song song bằng
io.asyncvàawait- Mã tuần tự: lưu lần lượt vào hai tệp, không thể tận dụng cơ hội song song
- Mã song song: lưu tệp bằng futures, hoạt động hiệu quả hơn trong event loop bất đồng bộ
Kết hợp await và try
- Khi dùng
awaitcùngtry, nếu xảy ra lỗi trong một future thì có vấn đề không thể trả lại tài nguyên của future khác - Có thể làm rõ việc hủy và dọn dẹp thích hợp bằng
defervàfuture.cancel
API Future.cancel
Future.cancel()vàFuture.await()đều idempotent (gọi nhiều lần cũng không gây tác dụng phụ)- Nếu gọi
cancelvới future đã hoàn tất thì chỉ giải phóng tài nguyên, còn tác vụ chưa hoàn tất sẽ trả vềerror.Canceled
Các triển khai I/O trong thư viện chuẩn
Giao diện Io là giao diện dựa trên đa hình thời gian chạy, có thể tự triển khai hoặc dùng triển khai từ gói bên thứ ba. Thư viện chuẩn của Zig có kế hoạch cung cấp nhiều kiểu triển khai I/O khác nhau.
- Blocking I/O: đơn giản dùng I/O blocking kiểu C hiện có, không có overhead bổ sung
- Thread pool: phân tán các Blocking I/O vào thread pool của OS, đưa vào một phần tính song song. Với network client v.v. thì cần tối ưu thêm
- Green thread: tận dụng system call bất đồng bộ như
io_uringtrên Linux để xử lý nhiều luồng green (nhẹ) trên các OS thread. Cần hỗ trợ nền tảng (x86_64 Linuxđược ưu tiên trước) - Stackless coroutine: coroutine dựa trên state machine không cần stack tường minh. Mục đích là tương thích với một số nền tảng như WASM. Cần đưa lại proprerative convention của trình biên dịch Zig
Mục tiêu thiết kế
Khả năng tái sử dụng mã
Vấn đề lớn nhất của I/O bất đồng bộ là khả năng tái sử dụng mã; ở các ngôn ngữ khác thường tồn tại riêng hàm blocking và hàm async, dẫn tới mã bị tách rời. Cách làm của Zig là
- Một thư viện duy nhất có thể hỗ trợ hiệu quả cả chế độ đồng bộ lẫn bất đồng bộ
- async/await loại bỏ hiện tượng “function coloring”, và thông qua hệ thống Io, ngay cả lúc chạy cũng không bị phụ thuộc vào các mô hình thực thi khác nhau
Kết quả là giải quyết hoàn toàn bài toán function coloring
Tối ưu hóa
- Giao diện Io mới được triển khai theo cách không generic, dùng gọi hàm ảo dựa trên vtable
- Gọi ảo giúp giảm code bloat nhưng có một chút overhead khi chạy. Trong bản build tối ưu, nếu chỉ có một triển khai Io thì có thể de-virtualization (loại bỏ gọi ảo)
- Khi dùng nhiều triển khai Io, gọi ảo vẫn được giữ lại (nhằm tránh trùng lặp mã)
Chiến lược buffering
- Trước đây mỗi triển khai (
reader/writer) tự đảm nhiệm buffering, nhưng giờ việc buffering được thực hiện ở cấp giao diện Reader và Writer - Ngoại trừ
flushbộ đệm, đường đi không phải qua gọi ảo nên dễ tối ưu hơn
Các thao tác I/O mang tính ngữ nghĩa
Giao diện Writer cung cấp hai primitive mới cho các thao tác tối ưu hóa cụ thể
- sendFile: lấy cảm hứng từ POSIX
sendfile, xử lý việc chuyển dữ liệu giữa các file descriptor ngay trong kernel. Giảm thiểu sao chép bộ nhớ - drain: hỗ trợ vectorized write + splatting. Có thể gửi hàng loạt nhiều đoạn dữ liệu và chuyển thành system call
writev. Tham số splat có thể dùng để lặp lại phần tử cuối (hữu ích với stream như nén)
Lộ trình
Một phần thay đổi này sẽ được đưa vào từ Zig 0.15.0, nhưng vì cần cải tổ lớn thư viện nên việc áp dụng toàn bộ sẽ phải chờ bản phát hành tiếp theo. Các mô-đun chính như SSL/TLS, HTTP server/client cũng sẽ được thiết kế lại theo hệ thống Io mới
FAQ
Q: Zig là ngôn ngữ low-level, vậy tại sao async lại quan trọng?
- Zig hướng đến tính vững chắc, tối ưu hóa và khả năng tái sử dụng
- Bằng cách chuẩn hóa I/O non-blocking, có thể điều chỉnh cả thư viện khác và mã bên thứ ba cho phù hợp với chiến lược I/O tổng thể, đồng thời bảo đảm khả năng tái sử dụng
Q: Vậy giờ tác giả package phải dùng async ở mọi nơi trong mã sao?
- Không
- Không phải mọi đoạn mã đều cần biểu đạt concurrency
- Mã tuần tự thông thường vẫn hoạt động theo chiến lược I/O mà người dùng lựa chọn
Q: Chỉ cần cắm plugin vào là mọi mô hình thực thi đều sẽ luôn chạy đúng sao?
- Phần lớn là có
- Tuy nhiên, nếu có lỗi lập trình trong mã (ví dụ: không đáp ứng yêu cầu của tác vụ đồng thời) thì sẽ không thể hoạt động đúng
Kèm theo ví dụ thực thi, bài viết cũng đề cập đến sự khác biệt giữa bất đồng bộ và song song, cũng như sự cần thiết của việc thiết kế luồng hoạt động đúng đắn
Kết luận
Với việc đưa vào giao diện Io mới, Zig đã nâng cao đáng kể tính linh hoạt trong lựa chọn chiến lược vào/ra, khả năng tái sử dụng mã và khả năng tối ưu hóa. Nhờ đó, không còn bị ràng buộc bởi cách viết hàm theo nền tảng bất đồng bộ/đồng bộ, lập trình viên có thể biểu đạt rõ ràng hơn cấu trúc concurrency và parallelism, đồng thời ứng phó hiệu quả với nhiều nền tảng và mô hình thực thi khác nhau.
1 bình luận
Ý kiến trên Hacker News
Tôi muốn nhấn mạnh lại điểm này. Bài viết nói rằng Zig đã hoàn toàn giải quyết vấn đề function coloring, nhưng tôi không đồng ý. Nếu nghĩ lại 5 quy tắc trong bài nổi tiếng "What color is your function?", thì trong Zig tuy không còn phân biệt màu như async/sync/red/blue, rốt cuộc vẫn chỉ tồn tại hai trường hợp: hàm IO và hàm không IO. Dù về mặt kỹ thuật họ đã giải quyết vấn đề cách gọi hàm thay đổi theo màu, nhưng các hàm cần IO vẫn phải được truyền IO vào làm tham số, còn hàm không cần thì không nhận. Cuối cùng tôi vẫn có cảm giác bản chất không thay đổi. Hàm IO chỉ có thể được gọi từ hàm IO, và điều này cũng không thoát khỏi vấn đề coloring. Tất nhiên có thể truyền một executor mới vào, nhưng tôi nghi ngờ đó có thật sự là điều người ta mong muốn hay không. Rust cũng có thể làm tương tự. Việc gọi các hàm có màu vẫn bất tiện như nhau. Vấn đề một vài hàm thư viện cốt lõi bị colored thì cả Zig lẫn Rust đều không gặp. Bản chất của coloring nằm ở chỗ các hàm cần context (tức async executor, auth, allocator, v.v.) bắt buộc phải được cung cấp context đó khi gọi. Khó có thể nói Zig thực sự đã giải quyết được phần này. Dù vậy, abstraction của Zig làm rất tốt, còn Rust thì thiếu sót ở điểm này. Nhưng bản thân vấn đề function coloring vẫn còn nguyên
Khác biệt cốt lõi với async function coloring điển hình là
Iocủa Zig không phải một giá trị đặc biệt chỉ dành cho xử lý bất đồng bộ, mà là giá trị tất yếu cần có cho mọi IO như đọc file, sleep, lấy thời gian, v.v.Iokhông phải thuộc tính của hàm mà là một giá trị thông thường có thể đặt ở bất kỳ đâu. Trên thực tế, nhờ đặc điểm này nên trông như vấn đề coloring đã được giải quyết. Trong phần lớn codebase, IO vốn đã tồn tại ở đâu đó trong scope, nên chỉ những hàm tính toán thuần túy mới không cần IO. Nếu một hàm đột nhiên cần IO, trong đa số trường hợp có thể lấy ngay từmy_thing.iođể dùng. Không có sự phiền toái như Rust khi phải truyềnAllocatorvào mọi hàm. Tức là nếu đường đi của code thay đổi và cần làm IO, bạn không phải lan truyền thay đổi qua từng hàm mà có thể dùng ngay. Về nguyên lý thì tôi đồng ý function coloring vẫn còn, nhưng trên thực tế gần như mọi hàm đều đã bị async-colored, nên vấn đề thực tiễn hầu như không đáng kể. Thực tế các lập trình viên Zig cũng cho rằng việc truyềnAllocatortường minh không gây ra sự phiền toái của function coloring. Tôi nghĩIocũng sẽ không phải vấn đề lớnCó vẻ đang bỏ sót điểm mấu chốt quan trọng. Khi dùng thư viện Rust, bạn bắt buộc phải khớp các điều kiện như async/await, tokio, send+sync, và nếu là sync API thì trong ứng dụng async gần như vô dụng. Ngược lại, cách truyền IO của Zig giải quyết tận gốc vấn đề này. Nhờ đó không cần khổ sở ép dùng procedural macro hay cố làm multi-version, mà thật ra những cách đó cũng không giải quyết tốt bài toán thư viện đa phiên bản. Có nhiều thảo luận về việc trộn async/sync trong Rust, và link sau cũng giải thích điều đó https://nullderef.com/blog/rust-async-sync/. Hy vọng Zig sau này cũng sẽ giải quyết tốt cooperative scheduling, async hiệu năng cao, async kiểu thread-per-core
Tôi không phải chuyên gia category theory, nhưng cuối cùng nếu đi theo con đường quản lý context này thì sẽ chạm tới IO monad. Context này có thể tồn tại ngầm, nhưng nếu muốn thực sự nhận được sự hỗ trợ của compiler thì phải làm nó hiện hình thành một thực thể trong hệ thống. Và trong khi tham vọng của các ngôn ngữ lập trình hệ thống thường bị chôn vùi trong nghĩa địa Async hay coroutine, việc Andrew theo cách nào đó tái khám phá IO monad và triển khai nó đúng cách là một niềm hy vọng của cả thế hệ. Các hàm trong thế giới thực có màu. Hoặc bạn đặt ra quy tắc di chuyển rõ ràng, hoặc sẽ trượt vào con đường ngày càng phức tạp như
co_awaitcủa C++ hay tokio. Tôi nghĩ đây chính là ‘The Way’Có một mẹo đơn giản để làm cho mọi hàm đều đỏ (hoặc xanh)
Chỉ cần đặt
iolàm biến toàn cục rồi dùng là khỏi phải lo coloring. Nói đùa thôi, nhưng đúng là việc phải dùng interfaceIotạo ra một chút ma sát; tuy vậy, về bản chất nó khác với friction thực sự phát sinh khi dùng async/await. Theo tôi, cốt lõi của function coloring là việc gán màu tĩnh bằng từ khóa async khiến code không còn tái sử dụng được. Trong Zig, dù bạn làm một hàm là async hay không thì vẫn đều nhận IO làm tham số, nên xét theo góc nhìn đó bản thân coloring trở nên vô nghĩa. Thứ hai, nếu dùng async/await thì bạn bị ép phải dùng stackless coroutine (tức chuyển stack do compiler điều khiển), nhưng hệ thống IO mới của Zig có thể dùng async bên trong mà vẫn hoạt động như Blocking IO. Đó mới là điều tôi cho là vấn đề function coloring thực sựGo cũng gặp vấn đề “coloring tinh vi”. Khi dùng goroutine, bạn luôn phải truyền tham số context để xử lý hủy bỏ, và nhiều hàm thư viện cũng đòi hỏi context nên toàn bộ code bị ô nhiễm. Về mặt kỹ thuật có thể không dùng context, nhưng việc truyền bừa
context.Backgroundkhông phải cách được khuyến nghịKhái niệm sans-io thực ra đã được bàn tới trong Rust và nhiều nơi khác, các link tham khảo là https://www.firezone.dev/blog/sans-io, https://sans-io.readthedocs.io/, https://news.ycombinator.com/item?id=40872020
Tôi nghĩ vấn đề của function coloring là dù xử lý trên stack hay unwind stack thì cuối cùng vẫn còn lại một trong hai. Zig tuy tuyên bố giải quyết coloring, nhưng cách cài đặt IO của nó vẫn cho phép dùng blocking/thread pool/green thread. Nhưng loại blocking IO này vốn dĩ chưa bao giờ là vấn đề. Chỉ cần giữ thói quen không dùng trạng thái toàn cục thì hầu như ngôn ngữ nào cũng làm được đến mức này. Stackless coroutine thì vẫn chưa được triển khai, nên có cảm giác như “chỉ còn vẽ nốt các bộ phận còn lại là xong”. Nếu thật sự muốn lời gọi hàm phổ quát, tôi nghĩ có hai cách
Biến mọi hàm thành async, nhưng cho thêm một tham số để quyết định có chạy đồng bộ hay không (đổi lại là giảm hiệu năng)
Biên dịch mỗi hàm hai lần để tùy tình huống mà chọn gọi (đổi lại là tăng kích thước mã và khó xử lý con trỏ hàm)
Tôi không thuộc core team, nhưng tôi được biết họ định để người dùng và người dùng thực tế thử nghiệm đầy đủ cài đặt semiblocking, ổn định API rồi sau đó áp dụng đúng giải pháp đó: chèn coroutine thực sự dựa trên stack jumping. Hiện tại compiler coroutine state machine của LLVM có vấn đề phụ thuộc vào libc hay malloc. Interface io mới của Zig hỗ trợ async/await ở userland, nên sau này khi có một giải pháp frame jumping đúng nghĩa thì việc port cũng dễ, debug cũng thuận tiện. Nếu coroutine quá khó thì API io vẫn có thể trụ được với vài chỉnh sửa nhỏ, nên họ không định quá vội với stackless coroutine
ValueTask<T>của C#/.NET cũng đóng vai trò tương tự. Nếu hoàn tất theo cách đồng bộ thì không có overhead, còn khi cần mới dùng nhưTask<T>. Code thường chỉ cầnawait, còn tại thời điểm thực thi thì runtime hoặc compiler sẽ tự chọn đồng bộ hay bất đồng bộTôi thích Zig, nhưng thấy họ tập trung vào green thread (fiber, stackful coroutine) thì hơi thất vọng. Rust cũng từng có
Runtimetrait tương tự trước 1.0 nhưng đã bỏ vì vấn đề hiệu năng. Thực tế OS, ngôn ngữ và thư viện đã nhiều lần học được tác hại của hướng tiếp cận này, và cũng có tài liệu liên quan https://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p1364r0.pdf. Fiber từng được ca ngợi vào thập niên 90 như cách xử lý đồng thời có khả năng mở rộng, nhưng ngày nay do stackless coroutine, sự tiến bộ của OS/phần cứng, v.v. nên không còn được khuyến nghị. Nếu cứ tiếp tục như vậy thì Zig sẽ đụng giới hạn hiệu năng giống Go, và khó trở thành đối thủ hiệu năng thực sự. Tôi hy vọngstd.fssẽ vẫn tồn tại cho những trường hợp cần hiệu năngẤn tượng rằng chúng tôi đang “all-in” vào green thread (fiber) là hiểu lầm. Trong bài được OP dẫn cũng nói rõ rằng họ kỳ vọng một triển khai dựa trên stackless coroutine, và cũng có đề xuất liên quan https://github.com/ziglang/zig/issues/23446. Hiệu năng là quan trọng, và nếu fiber không đạt kỳ vọng về hiệu năng thì nó sẽ không được dùng một cách phổ quát. Những điều được bàn trong bài này không ngăn việc stackless coroutine trở thành triển khai
Iomặc địnhTôi nghi ngờ nhận định rằng green thread có hiệu năng kém. Các nền tảng server đồng thời hàng đầu (Go, Erlang, Java) đều dùng hoặc muốn dùng green thread. Green thread có thể không phù hợp với các ngôn ngữ cấp thấp hơn như Rust vì vấn đề tương thích với C FFI, nhưng khó có thể nói bản thân hiệu năng lúc nào cũng là vấn đề
Vì đây chỉ là một trong nhiều lựa chọn nên tôi không nghĩ có thể gọi là “all-in”. Việc chọn triển khai nào được quyết định ở executable, không phải trong code thư viện
Zig đang nhắm tới hiệu ứng tương tự quyết định của Rust khi bỏ green thread và thay bằng async runtime. Điểm cốt lõi là trực giác đã được chính thức hóa:
async=IO, IO=async. Rust cung cấp async runtime có thể cắm được như tokio, còn Zig hướng tới IO runtime có thể cắm được. Rốt cuộc hướng đi là lấy runtime ra khỏi ngôn ngữ, cho phép gắn vào từ không gian người dùng, trong khi mọi bên cùng chia sẻ một interface chungTài liệu (P1364R0) mang tính tranh cãi, và tôi cho rằng đó là lập luận có động cơ nhằm loại bỏ một hướng tiếp cận nhất định. Có thể tham khảo thêm các thảo luận 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/ v.v.
Tôi thấy hơi lạ khi trong một ngôn ngữ hệ thống như Zig lại ép dùng runtime polymorphism ngay cả cho các thao tác IO tiêu chuẩn rất phổ biến. Trong đa số tình huống thực tế, triển khai IO có thể được xác định tĩnh, vậy tại sao phải ép thêm runtime overhead?
Tôi nghĩ overhead của dynamic dispatch trong IO trên thực tế gần như không đáng kể. Tùy đối tượng IO mà có khác nhau, nhưng nhìn chung IO hiếm khi là nút thắt CPU. Đó cũng là lý do có khái niệm IO-bound
Với câu hỏi “tại sao ép mọi người chịu runtime overhead?”, có vẻ ý định là trong các hệ thống chỉ dùng một loại io, compiler sẽ tối ưu để loại bỏ luôn chi phí double indirection đó. Và IO vốn đã có nút thắt ở chỗ khác, nên thêm một lần indirection hầu như không đáng kể
Theo triết lý của Zig thì họ quan tâm đến kích thước binary hơn.
Allocatorcũng có trade-off tương tự; ví dụArrayListUnmanagedkhông generic theo allocator nên mỗi lần cấp phát đều phát sinh dynamic dispatch. Trên thực tế, chi phí cấp phát file hay ghi file áp đảo hoàn toàn overhead của lời gọi gián tiếp. Việc ám ảnh với kích thước binary là rất kiểu Zig. Nhân tiện, devirtualization (tối ưu biến lời gọi động thành tĩnh) là chuyện hoang đườngRuntime polymorphism tự nó không phải thứ xấu về bản chất. Trừ khi nó tạo branch trong tight loop hoặc khiến compiler không thể inline, còn không thì không phải tình huống có vấn đề
Tôi không quá thích việc tham số io mới lộ ra khắp nơi, nhưng rất thích chỗ có thể dễ dàng dùng nhiều triển khai khác nhau (dựa trên thread, fiber, v.v.) mà không ép người dùng vào một implementation cụ thể, giống như interface
Allocator. Nhìn chung đây là cải tiến rất lớn, và nếu trong nhiều triển khai stdlib có cung cấp một triển khai io sync/blocking không có overhead riêng thì điều đó đúng với triết lý của Zig là “không phải trả giá cho thứ mình không dùng”Trong Zig,
io.asyncchỉ biểu đạt tính bất đồng bộ (thứ tự công việc có thể không được đảm bảo nhưng kết quả vẫn đúng), chứ không biểu đạt tính đồng thời (concurrency). Tức là họ đã tách ý nghĩa của async và lời gọi io ra khỏi nhau; tôi nghĩ đây là một thiết kế rất thông minhTôi thích việc interface IO này có thể cho phép tạo ra vfs (Virtual File System) ở cấp độ ngôn ngữ
Tôi đã thử làm một SSH server đơn giản để học Zig. Nhờ cấu trúc IO/event loop lần này mà tôi thấy luồng code dễ hiểu hơn hẳn. Cảm ơn Andy
Bài viết được viết rất hay và tôi thấy cực kỳ thú vị. Đặc biệt tôi rất mong chờ những hàm ý của nó đối với WebAssembly. Việc có thể dùng WASI trong userspace và còn có thể Bring Your Own IO khiến tôi thấy cực kỳ hấp dẫn