4 điểm bởi GN⁺ 2025-12-04 | 1 bình luận | Chia sẻ qua WhatsApp
  • 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.ThreadedIo.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()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
  • 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

 
GN⁺ 2025-12-04
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ưng std.Io.Threaded mặ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_threaded thì 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ành concurrent()

    • Tôi là Daroc. Tôi đã phản ánh góp ý này và áp dụng hai chỉnh sửa vào bài viết.
      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
    • Tôi có câu hỏi cho Andrew.
      Nếu lỡ dùng asyncConcurrent() ở chỗ đáng ra phải dùng async(), 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?
    • Điểm hay của 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ộ

    • Nếu chỉ cần gọi cùng hàm với tham số khác mà đã thành “hàm bị tô màu”, thì suy ra mọi hàm đều bị tô màu và khái niệm đó mất ý nghĩa ;)
      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
    • Thực ra vấn đề cốt lõi của function coloring là sự trùng lặp giữa các đường đi mã sync/async.
      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
    • io của Zig không phải là một effect type có tính lây lan.
      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)
    • Về bản chất, Zig dường như tô màu mọi thứ là async, rồi chỉ cho phép chọn có dùng worker thread hay không.
      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
    • Từ góc nhìn của một lập trình viên Haskell, Zig trông như đang hiện thực IO monad mà không có hỗ trợ ở cấp ngôn ngữ
  • 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

    • Nếu dùng trực tiếp OS thread thì sẽ chạm trần khả năng mở rộng theo định luật Little.
      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
    • Tham khảo ExecutionContext API của Scala sẽ giúp hiểu rõ hơn các khái niệm liên quan
  • 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

    • Có các builtin cấp thấp@asyncSuspend@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
    • Cuối cùng thì suspend/resume có thể sẽ được hiện thực thành hàm thư viện chuẩn ở user space.
      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
    • Tôi cũng tò mò không biết có thể chỉ dùng một thread pool mà vẫn hiện thực suspend/resume được không
    • Tôi cũng nghi ngờ việc hiện thực cooperative coroutine bằng async có tính tiền nhiệm thì thực sự có ý nghĩa gì
  • 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 defer bắt đầu.
    Nếu không, sẽ cần phải theo dõi quan hệ phụ thuộc giữa createFilewriteAll.
    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ị

    var a_future = io.async(saveFile, .{io, data, "saveA.txt"});
    var b_future = io.async(saveFile, .{io, data, "saveB.txt"});
    const a_result = a_future.await(io);
    const b_result = b_future.await(io);
    

    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.async tự 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

    • C# cũng hoạt động tương tự. Hàm async chạy trên thread gọi cho tới trước khi yield
    • Trong Zig cũng vậy, chỉ khi gọi .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
    • Trên thực tế, việc thực thi diễn ra tại thời điểm 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 đó”
    • JavaScript cũng hoạt động theo kiểu này
  • 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

    • Có người chỉ ra rằng nếu bọc mọi IO thành channel thì chi phí sẽ cao.
      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
    • Zig có 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 muốn hỏi liệu bạn đã thử ngôn ngữ Odin chưa. Đây là kiểu “better C” lấy cảm hứng từ Go nhiều hơn Zig
    • Tôi thích việc nó không ép buộc hàm bị tô màu như async/await của C#.
      Tôi cho rằng cách tiếp cận “colorless” của Zig tốt hơn nhiều
    • Việc tưởng rằng mô hình concurrency của Go là thứ gì đó đặc biệt là một vấn đề.
      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