4 điểm bởi GN⁺ 2024-03-26 | 1 bình luận | Chia sẻ qua WhatsApp
  • async/await của Rust không chỉ là phương án thay thế đơn thuần cho thread, mà là một mô hình lập trình để biểu diễn mã đồng thời thiên về I/O dưới dạng các máy trạng thái có thể kết hợp
  • Những đoạn mã như web server phải xử lý nhiều kết nối cùng lúc sẽ nhanh chóng chạm giới hạn nếu chỉ chạy tuần tự; thread cho phép xử lý đồng thời bằng cách tách phần xử lý client qua thread::spawn
  • async/await nhường quyền thực thi tại điểm await, để executor tiếp tục chạy tác vụ khác, nhờ đó nhiều tác vụ được xen kẽ trong cùng một runtime
  • Các yêu cầu như timeout 3 giây có thể được gắn vào trong async bằng cách kết hợp raceTimer, trong khi với mã thread đồng bộ lại cần wrapper chuyên cho TcpStream cùng thiết lập timeout đọc/ghi nên kém tính tổng quát hơn
  • Nếu chỉ giải thích async bằng overhead hiệu năng thì sẽ có phản ví dụ ở các tác vụ CPU bound; điểm mạnh của Rust async nằm ở năng lực biểu đạt về mặt ngữ nghĩa và khả năng kết hợp trong hệ sinh thái

Điểm khởi đầu của bài toán đồng thời trong Rust

  • Mã Rust thông thường về cơ bản có cấu trúc thực thi tuyến tính
    • Như foo(), bar(), baz(), một tác vụ kết thúc rồi tác vụ tiếp theo mới chạy
  • Khi cần xử lý nhiều việc cùng lúc như web server, cấu trúc tuyến tính nhanh chóng bộc lộ giới hạn
    • Trong cấu trúc nhận client bằng TcpListener::accept() rồi gọi handle_client(), client thứ hai phải chờ trong lúc client thứ nhất đang được xử lý
    • Nếu handle_client() mất vài mili giây và có 2 client đồng thời thì thời gian chờ vẫn còn ngắn
    • Nhưng nếu có 2 triệu client đồng thời thì người dùng ở cuối hàng đợi có thể phải chờ vài phút

Cách thread giải quyết vấn đề

  • Thread của hệ điều hành có thể lưu giá trị thanh ghi và stack chương trình vào bộ nhớ, chạy một routine khác rồi sau đó tiếp tục lại routine ban đầu
  • Mã web server có thể giao phần xử lý client cho thread riêng dưới dạng thread::spawn(move || handle_client(client))
    • Thread chính tiếp tục accept() các kết nối mới
    • Nếu thread xử lý client bị blocking, OS có thể quay lại thread chính để nhận kết nối tiếp theo
    • Hai client có thể chạy song song sau độ trễ chỉ cỡ vài micro giây
  • Nếu một web server cấp production có hàng chục lõi CPU, OS không chỉ làm cho các thread trông như đang chạy đồng thời mà còn có thể thực sự chạy nhiều thread cùng lúc

async/await hoạt động như thế nào

  • Trong không gian đồng thời phía người dùng có nhiều mô hình như lập trình hướng sự kiện, actor, coroutine; cách Rust chọn là async/await
  • Đơn giản hóa thì chương trình được biên dịch thành một tập các máy trạng thái có thể chạy độc lập với nhau
    • async fn không phải là hàm truyền thống mà là hàm trả về một máy trạng thái
    • await đưa một máy trạng thái khác vào như một bước bên trong máy trạng thái hiện tại
    • Khi hàm bên trong nhường quyền thực thi, chẳng hạn lúc chờ kết nối mới, toàn bộ máy trạng thái sẽ trả quyền điều khiển về cho executor ở tầng trên
  • Executor như smol::Executor sẽ chạy một máy trạng thái khác được tạo bằng spawn thay vì máy trạng thái hiện tại
    • Khối async move { handle_client(client).await } là một máy trạng thái mới, tách biệt với main
    • Khi main nhường quyền, một tác vụ client sẽ được chạy; khi tác vụ đó lại nhường quyền thì executor chuyển sang tác vụ kế tiếp
  • Cấu trúc này cho phép xử lý đồng thời hàng triệu client, nhưng cũng làm tăng độ phức tạp vì xuất hiện thêm các khái niệm như executor, task và máy trạng thái

Tính kết hợp thể hiện qua ví dụ timeout

  • Một trong những điểm mạnh của Rust là tính kết hợp
    • Iterator cho phép gắn nhiều combinator với nhau rồi lại truyền kết quả cho một hàm khác nhận Iterator
    • Với mpsc::channel(), bạn có thể lọc, biến đổi và thêm giá trị vào danh sách theo kiểu recv.try_iter().filter(...).map(...)
  • async/await mang tính kết hợp đó sang cả các hàm I/O bound
  • Nếu handle_client() là một hàm bất đồng bộ await các bước read_to_end, do_something_with_data, write_all, thì timeout 3 giây có thể được cài bằng cách kết hợp hai Future
    • race chạy đồng thời hai Future
    • Timer sẽ trả về sau khoảng thời gian đã chỉ định
    • Chỉ cần bọc mã xử lý kết nối hiện có trong một khối async, rồi cho nó cạnh tranh với một Future trả về lỗi TimedOut sau 3 giây
  • Cách này không bị khóa chặt vào TcpStream
    • Chỉ cần đối tượng đó triển khai impl AsyncRead + AsyncWrite là có thể áp dụng cùng một mẫu
    • Có thể thay bằng stream GZIP trên stream thông thường, Unix socket, hay file

Những ràng buộc khi cài cùng timeout trong mã thread đồng bộ

  • Với mã blocking, thông thường rất khó ngắt các system call như read hay write, và cách như đóng file descriptor cũng không dùng được trong Rust
  • TcpStream có cung cấp set_read_timeoutset_write_timeout
    • Có thể đặt timeout riêng cho đọc và ghi
    • Nhưng nếu client gửi 1 byte mỗi 2,9 giây thì timeout đơn giản có thể cứ liên tục bị reset
  • Để phòng trường hợp đó, bạn phải tạo một kiểu như DeadlineStream bọc quanh TcpStream, rồi mỗi lần đều tính khoảng thời gian còn lại tới deadline tổng thể để đặt lại timeout đọc/ghi
  • Cách tiếp cận này có thể hoạt động nhưng có nhiều hạn chế
    • Bị buộc vào TcpStream
    • Rust không có trait trừu tượng hóa việc dùng set_read_timeoutset_write_timeout
    • Muốn áp dụng cho writer tổng quát thì cần thêm khá nhiều công sức
    • Phải phát sinh thêm các system call để thiết lập timeout
    • Trong logic web server thực tế, cách dùng cũng có thể phiền phức hơn

Ví dụ từ hệ sinh thái async của Rust

  • Việc hệ sinh thái HTTP, kể cả phía client, chọn async/await làm cơ chế runtime chính có liên quan trực tiếp đến khả năng kết hợp hàm
    • Các hàm tạo lời gọi HTTP có thể được cắm vào nhiều điểm và nhiều tình huống sử dụng khác nhau
  • tower là ví dụ tiêu biểu cho khả năng kết hợp của async/await
    • Nếu triển khai service dưới dạng hàm async, bạn có thể gắn thêm timeout, rate limiting, load balancing, hedging, và xử lý backpressure
    • Bất kể dùng runtime nào hay bên trong service làm gì, vẫn có thể áp dụng tower để tăng độ vững chắc
  • macroquad là một game engine nhỏ cho Rust và chạy engine bằng async/await trong hàm main
    • async/await phù hợp để biểu diễn tình huống trong Rust khi cần tạm dừng một hàm tuyến tính để chờ một tác vụ nào đó
    • Có thể xây dựng cấu hình như vừa poll kết nối mạng của game server vừa poll GUI framework trên cùng một thread

Giới hạn của việc chỉ giải thích async bằng hiệu năng

  • Rust Async Book so sánh rằng thread của OS giúp biểu diễn đồng thời dễ dàng mà không phải đổi mô hình lập trình, nhưng đồng bộ giữa các thread thì khó, overhead hiệu năng cao, và ngay cả thread pool cũng khó hỗ trợ đầy đủ các workload I/O bound quy mô lớn
  • Trong cộng đồng async, khi bị hỏi vì sao dùng async thay cho thread của OS, người ta thường có xu hướng trả lời kiểu “overhead thấp hơn, còn lại thì giống nhau”
  • Việc các tác giả web server chuyển sang async/await ban đầu là để giải bài toán C10k problem, nhưng không có nghĩa mọi người dùng đều phải chọn async/await chỉ vì hiệu năng
  • Lợi ích hiệu năng có thể biến mất tùy theo tình huống
    • Với tác vụ CPU bound, workflow dựa trên thread có thể nhanh hơn workflow async tương đương
    • Lợi thế hiệu năng mang tính tạm thời của Rust async đã từng bị nhấn mạnh quá mức, trong khi lợi ích về ngữ nghĩa lại bị đánh giá thấp
  • async/await không phải công cụ chỉ dành cho các trường hợp ngách, mà là một mô hình lập trình mạnh mẽ để xử lý những mẫu bài toán mà Rust đồng bộ khó biểu diễn gọn gàng nếu không dùng đến hàng chục thread và channel

Chấp nhận sự khác biệt thay vì cố biến nó thành sync Rust

  • Roadmap của Rust có định hướng rằng việc viết async Rust đôi khi nên dễ gần như viết mã đồng bộ, ngoài việc phải dùng từ khóa asyncawait
  • Tuy vậy, cũng có quan điểm cho rằng việc đóng khung async Rust thành “giống hệt sync Rust” về bản chất là rất khó
    • Dù có thể làm cho chúng giống nhau đến 99%, người dùng phổ thông vẫn sẽ buộc phải nhận ra sự khác biệt
  • Thay vì cố trở nên giống Rust đồng bộ, hệ sinh thái async/await của Rust nên làm nổi bật rõ hơn các thế mạnh là tính kết hợpnăng lực biểu đạt
  • Nếu muốn async/await trở thành lựa chọn mặc định khi cần đồng thời, thì mô hình này cần được giải thích bằng các lý do ngữ nghĩa nhiều hơn là chỉ bằng các lý do hiệu năng

1 bình luận

 
GN⁺ 2024-03-26
Ý kiến trên Hacker News
  • async/await đơn luồng là một mô hình đơn giản và quen thuộc, và JavaScript hoạt động theo cách đó
    Thread cho phép huy động nhiều CPU để giải quyết vấn đề, còn Rust giúp quản lý khóa. Cũng có thể có các thread với mức ưu tiên khác nhau, nên điều này có thể cần thiết cho các tác vụ thiên về tính toán
    Ngược lại, async/await đa luồng trở nên lộn xộn. Khi các đoạn thiên về tính toán xuất hiện nghiêm túc, chúng về cơ bản sẽ chặn các thread được chia sẻ với tác vụ khác, khiến mô hình dễ sụp đổ
    Đa luồng thiên về tính toán trong Rust không hoạt động tốt như kỳ vọng. Trong allocator của kho lưu trữ, nhiều thread có thể cùng đập vào một khóa, gây ra sụp đổ do tắc nghẽn futex; đặc biệt khi đang mở rộng buffer mà giữ khóa toàn bộ allocator để sao chép lại, chi phí sẽ rất lớn. Allocator thư viện trong .DLL mô phỏng thư viện Microsoft của Wine dễ bị vấn đề này, khiến thời gian CPU đổ hết vào spinlock và hiệu năng giảm hàng chục lần, trong khi bản triển khai của Microsoft thì không
    Ngoài ra, Mutex chuẩn và các kênh crossbeam-channel có thể gặp đói tài nguyên do mutex không công bằng. Nếu nhiều thread liên tục khóa tài nguyên, làm việc rồi mở khóa, một thread có thể cứ thắng mãi còn các thread khác bị đẩy lùi. Nếu cần mutex công bằng thì có parking-lot, nhưng nó không có cơ chế an toàn poisoning khi thread panic như mutex chuẩn cung cấp
    Nếu không phải I/O-bound thì mọi thứ phức tạp hơn nhiều
    https://users.rust-lang.org/t/mutex-starvation/89080

    • Đúng vậy. Chủ yếu tôi chỉ xử lý các tính toán I/O-bound, nhưng ngay cả ở đó cũng phát sinh vấn đề tranh chấp
      Nếu throughput I/O lại là giới hạn thì một triệu coroutine có ý nghĩa gì? Nếu bạn làm cạn ngay pool kết nối DB kích thước 10, coroutine không cứu được bạn, mà chỉ khiến việc debug và né lỗi khó hơn, cũng khó suy luận hơn
    • Tôi có cảm giác vấn đề này rốt cuộc có lẽ cần được suy nghĩ lại ở cấp phần cứng
      Các vấn đề CPU-bound dường như quy về việc ngắt/tiếp tục một cách có hệ thống; nếu có thể chuyển ngữ cảnh dựa trên hàng đợi một cách công bằng và hiệu quả cho n thread đang thực thi, thì chẳng hạn với một CPU có n ngữ cảnh đang chạy, liệu vấn đề có thể trở thành vấn đề phân bổ tài nguyên hay không
    • Tôi không hiểu vì sao những khó khăn của đa nhiệm hợp tác cứ liên tục được tái khám phá
      Go, vốn được xem là một ngôn ngữ được thiết kế có trách nhiệm, ban đầu cũng đi theo hướng hợp tác rồi cuối cùng buộc phải chuyển sang kiểu có tiền nhiệm. Điều đó không có nghĩa đa nhiệm hợp tác là vô dụng, nhưng nó cần có nhãn cảnh báo; thậm chí tốt hơn, có thể nên chặn tĩnh việc thực thi một số loại mã nhất định
      Có đính kèm bài liên quan “What color is your function”
      https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...
    • Tôi cứ phải nhắc đi nhắc lại rằng đây là chi tiết triển khai
      Executor đa luồng của async/await hoàn toàn có thể xử lý starvation, và bản triển khai .NET chịu được cả những đoạn mã rất tệ trộn lẫn lời gọi blocking với bất đồng bộ
      https://news.ycombinator.com/item?id=39530435
      https://news.ycombinator.com/item?id=39786142
      https://news.ycombinator.com/item?id=39721626
    • Hàm có màu do async/await tạo ra cũng làm tăng thêm chi phí phát triển và bảo trì phần mềm
      https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...
      Nếu không phải phần mềm cần khả năng mở rộng cao, sự đánh đổi của async có thể không đáng
  • Cốt lõi của cuộc tranh luận async/await so với thread không phải là bên nào phức tạp hơn, mà là việc chia hệ sinh thái làm hai, khiến một bên trở thành công dân hạng hai; nếu chọn sai cho dự án thì sẽ phát sinh ma sát
    Có thể dùng lẫn nhau, nhưng khi cần thì lại mang tính hack và kém hiệu quả. Hệ sinh thái Rust hiện nay đã gần như quyết định rằng cứ có I/O là bị buộc vào hệ sinh thái async/await, còn hầu như mọi việc muốn làm trong Rust, trừ rất ít ngoại lệ, đều có I/O, nên dù phần còn lại của ứng dụng có muốn async hay không thì nhìn chung vẫn phải bỏ qua các thư viện không bất đồng bộ
    Nếu Rust dùng một abstraction có khả năng kết hợp tốt hơn async/await, và tính kết hợp đó không đòi hỏi mọi thứ khác cũng phải biến thành async/await, thì có lẽ phần lớn bất mãn đã biến mất

    • Tôi đồng ý với chẩn đoán này. Trong bài viết của tôi về Rust async[0], tôi cũng đi đến cùng kết luận
      Điều tệ hơn là hệ sinh thái không chỉ bị chia đôi, mà ngay trong code async cũng thường bị ràng buộc chặt vào executor, phần lớn là Tokio. Nếu mở rộng vấn đề màu hàm, thay vì xanh dương(non-I/O), xanh lá(blocking I/O), đỏ(async I/O), thực tế lại thành xanh dương, xanh lá, đỏ(Tokio), tím(async-std), cam(smol)
      Tôi cho rằng mẫu sans-I/O là lời giải tốt nhất cho vấn đề này. Nếu tách toàn bộ code xanh dương ra và dùng đảo ngược điều khiển cho I/O và thời gian, ta có thể khiến logic giao thức cốt lõi không biết gì về I/O và dễ bọc nó bằng nhiều dạng I/O khác nhau
      0: https://hugotunius.se/2024/03/08/on-async-rust.html
    • Chỉ nên bỏ qua thư viện không bất đồng bộ khi có hai thư viện để chọn và mọi thứ còn lại là như nhau. Trường hợp như vậy hiếm lắm
      Dùng code blocking trong ứng dụng async không trơn tru như mong đợi, nhưng cũng không khó. Thay vì foo(), dùng tokio::spawn_blocking(foo).await thì code mới sẽ chạy trên một thread riêng và trả về một future hoàn tất khi thread đó kết thúc
    • C# về cơ bản cũng tương tự
      Với I/O vẫn có lựa chọn không bất đồng bộ, nhưng nếu dùng lựa chọn bất đồng bộ thì trên thực tế sẽ bị ép biến mọi thứ thành async đến tận Main(). Cũng có cách gọi an toàn method async từ method đồng bộ, nhưng nó khiến việc debug trở nên cực kỳ khó
  • Bài viết bỏ sót nhiều phần
    async/await chạy trong một ngữ cảnh thread nên không cần lock hay đồng bộ hóa, nhưng nếu chạy async/await trên nhiều thread để tận dụng các CPU core thì lại cần lock và đồng bộ hóa. Độ phức tạp này có thể bị che giấu trong code bên ngoài. Ví dụ, thay vì đồng bộ hóa quyền truy cập vào một kết nối DB đơn lẻ, mở một kết nối DB cho mỗi tác vụ async thì dễ hơn, nhưng với SQLite hoặc PostgreSQL, việc này có thể ảnh hưởng đến hiệu năng
    Trong async/await, lan truyền lỗi không rõ ràng. Đặc biệt là khi muốn gom các tác vụ async lại với nhau; Happy Eyeballs là ví dụ điển hình
    Nếu nói về network I/O thì cũng phải bàn đến backpressure. Việc triển khai async/await của CPython nổi tiếng là thiếu backpressure mạng, và điều đó gây ra vấn đề

    • async/await có nhiều vấn đề, nhưng bất mãn lớn nhất của tôi là chuyện này
      “Design Patterns” của Gang of Four phần lớn là một sách dạy nấu ăn để đi vòng các khiếm khuyết của C++, nhưng người ta lại áp dụng những pattern đó cho cả các ngôn ngữ không có các khiếm khuyết ấy
      Rust không phải JavaScript và có thể chạy nhiều thread rất tốt. Vì vậy không nhất thiết phải dùng async/await, và với tư cách là một ngôn ngữ hệ thống, Rust lẽ ra có thể thử các giải pháp khác
      Nhưng để nhồi Rust cho lập trình viên JavaScript thì cần async/await. without.boats đã viết rằng họ “thúc đẩy async/await với sự nhiệt thành chân thành dựa trên giả định rằng sự sống còn của Rust phụ thuộc vào tính năng này”
      https://without.boats/blog/why-async-rust/
      async/await có phù hợp về mặt kỹ thuật với Rust hay không không còn quan trọng; có vẻ chỉ vì lập trình viên JavaScript quen async/await nên Rust cũng phải có async/await
    • async/await cũng là một cơ chế đồng thời giống như thread, và khi truy cập bộ nhớ chia sẻ thì luôn cần lock. Tôi không hiểu câu không cần lock từ đâu ra
    • Về lock, async có thể còn đáng sợ hơn
      Một khối code nào đó đang dựa vào quyền truy cập độc quyền, và vì không có await nên điều đó được bảo đảm; nhưng nếu thêm await vào giữa thì code sẽ hỏng. Threading ít nhất buộc bạn phải thể hiện trong code cái gì cần quyền truy cập độc quyền
      async cũng có nghĩa là tự quản lý việc lập lịch thread của mình. Nếu nhiều I/O và code CPU-bound ngắn thì ổn, nhưng nếu có code CPU-bound, dù chỉ thỉnh thoảng, bạn sẽ phải chơi trò làm scheduler
    • Khi gia nhập một team dùng Node.js, tôi đã gặp vấn đề backpressure
      Nhiều service cứ thế chết bằng ABEND, và từ góc nhìn của một người đến từ Java, thiếu sót này thật đáng ngạc nhiên. Giải thích cách sửa cho team cũng khó
      lan truyền lỗi, nếu có lựa chọn thì tôi sẽ không dùng async/await. Nếu là dự án một mình thì có thể, nhưng nếu làm cùng người khác, dùng thư viện và phải đưa mọi người về cùng một cách hiểu thì tuyệt đối không muốn
      Tôi chưa thật sự dùng structured concurrency ở cấp ngôn ngữ, nhưng đang đặt kỳ vọng vào Project Loom của Java. Nhìn qua thì có vẻ nó sẽ khiến cuộc tranh luận này trở nên vô nghĩa
    • Trong Rust, async/await không chỉ chạy trong một ngữ cảnh thread duy nhất
  • Bài viết có vấn đề
    Ví dụ chỉ có một web server, còn cách giải bằng thread thì xử lý sai. Câu hỏi cũng như thể giả định rằng mọi người muốn dùng thread của OS thay vì async/await
    Điều lập trình viên muốn là thread ở mức khái niệm/ngữ nghĩa. Tức là viết logic tuần tự và không dùng những chú thích kỳ quặc như async. Nếu async/await tốt đến vậy, sao không biến mọi hàm thành async một cách ngầm định và dùng lời gọi hàm bình thường thay cho await? Khi đó về thực chất là đang lập trình bằng thread
    Thread của OS đắt đỏ vì stack được cấp phát tĩnh, còn thứ chúng ta muốn là thread rẻ có thể chạy hàng triệu cái trên một CPU đơn. Nhưng không có những từ thô kệch như async/await. wait có thể vẫn dành cho kiểu chờ blocking theo nghĩa cổ điển, như chờ sự kiện hoặc chờ thread khác hoàn tất, nhưng không muốn nó xuất hiện trong lời gọi hàm
    Quay lại ví dụ web server, khi triển khai timeout bằng driver.race(timeout).await, sau khi race báo lỗi timeout thì socket của client sẽ ra sao? Nó có bị rò rỉ trong trạng thái vẫn mở và vẫn kết nối không?
    Phiên bản timeout bằng thread cũng có thể được viết gần giống async/await, như threaded_race(client_thread, timeout).wait. threaded_race dùng timer để theo dõi timeout song song với thread, và khi đến hạn thì gọi client_thread.interrupt() theo kiểu Java. Thread.interrupt() chỉ đặt flag nếu thread không bị chặn; nếu đang bị chặn trong lời gọi I/O thì nó ném InterruptedException. Vì đây là checked exception, compiler buộc phải bọc client.read_to_end(&mut data) bằng try/catch hoặc khai báo exception trong handle_client, nên lập trình viên sẽ không quên đóng socket của client

    • Các giá trị bên trong race() sẽ được Drop, còn bản thân driver vẫn còn
      Nếu dùng nguyên kiểu đó, Rust sẽ phàn nàn rằng Result không được xử lý; còn nếu socket mới được tạo cục bộ bên trong future thì nó sẽ được dọn dẹp
      Điểm hay của Rust future là có thể định nghĩa toàn bộ hành vi xung quanh nó. Khác với mô hình mọi hàm đều blocking, Rust có thể chỉ định thời điểm hoãn thực thi sang tác vụ tiếp theo trong hàng đợi công việc, và poll tác vụ nhanh tùy ý với trạng thái được lưu giữ tường minh (struct Future). Vì vậy không cần sleep() để nhường quyền như thread, nên nhanh và dễ suy luận hơn
      Thread.interrupt của Java rốt cuộc cũng gần với vòng lặp sleep, và có thể ổn với phần lớn ứng dụng. Nhưng Rust là ngôn ngữ hệ thống, nên trong hệ thống nhúng không thể dùng cách đó; với kernel hoặc ứng dụng độ trễ thấp cũng không nên
    • Một số lập trình viên muốn logic tuần tự, nhưng nhiều người lại muốn điều hoàn toàn ngược lại
      Trong hầu hết trường hợp, việc đó là system call blocking hay non-blocking của OS không quá quan trọng, nhưng tôi muốn hiểu luồng điều khiển của chương trình đang đọc, biết nó chờ ở đâu và có thể chạy song song như thế nào
      Ngược lại, khi làm việc với hàm blocking, tôi còn muốn có cặp từ khóa blocking/block. Lời gọi blocking có thể âm thầm làm chậm toàn bộ, và tôi đã thấy quá nhiều ứng dụng ì ạch khó chịu vì có system call blocking trong UI thread
    • Cách biến mọi hàm thành async ngầm định rồi dùng như lời gọi hàm bình thường đã được thử nhiều lần trong vài chục năm qua. Cứ tìm “RPC” là thấy
      Mọi nỗ lực hợp nhất đồng bộ và bất đồng bộ đều đã thất bại. Có khác biệt ngữ nghĩa lớn giữa code chạy trong một thread, code chạy giữa các thread, thậm chí code chạy giữa các máy tính. Nếu cố trừu tượng hóa điều đó thì cuối cùng cũng sẽ thiếu hụt, nên tốt hơn là học cho đúng ngay từ đầu
    • Tôi nhớ withoutboats từng nói trong một bài nào đó rằng câu trả lời thật sự là khả năng tương thích với C
    • Cũng có cách viết code bằng poll()select(), nhưng đó lại là một cách riêng khác
  • Thật thú vị khi chứng kiến một chiến dịch gần như marketing nhằm giữ thể diện cho async/await
    Theo kinh nghiệm của tôi, đây không chỉ là một sai lầm kỹ thuật mà còn khiến cộng đồng trả giá lớn. Nỗ lực của Rust đã bị rẽ sang mớ hỗn loạn này thay vì tập trung vào các tính năng ngôn ngữ thực sự hữu ích
    Dù vậy tôi vẫn kỳ vọng rất lớn vào ngôn ngữ này, và xem nó là thứ tốt nhất trong số những gì hiện có. Chỉ lo rằng cuộc chiến này sẽ kéo dài mãi
    Tái bút: ví dụ AsyncWrite/AsyncRead trông có vẻ thuyết phục, nhưng thật ra nếu giới hạn trong *nix thì cũng có thể làm điều tương tự bằng thread và file descriptor

    • Tôi từng dùng async trong firmware và nó đúng là cứu tinh
      Khái quát hóa như vậy thiếu cơ sở và có vẻ thiên lệch theo một workload cụ thể
    • Tôi không rành Rust nên không biết có đúng không, nhưng theo kinh nghiệm, 9 trong 10 cuộc thảo luận về Rust mà tôi thấy trên HN/reddit dạo này dường như xoay quanh async
      Với tư cách người hoàn toàn không quan tâm đến async và chỉ muốn đọc chuyện về Rust, điều đó khá chán
    • Nếu bạn nghĩ thread nhanh hơn poll(), tôi muốn biết đó là use case nào. Cả đời tôi chưa từng thấy trường hợp như vậy
    • Đó không phải là sai lầm kỹ thuật; khi cần code async độ trễ siêu thấp thì nó là một giải pháp tuyệt vời
      Sai lầm nằm ở việc đẩy nó vào cả đa số use case không cần thứ đó
    • Tôi tò mò liệu có căn cứ nào cho lập luận rằng công việc về async đã lấy mất nguồn lực của các tính năng hữu ích khác không
      Nhiều dự án Rust lớn phụ thuộc vào async không chỉ vì cải thiện hiệu năng lớn so với các lựa chọn dựa trên thread, mà còn vì đặc tính thiết kế. Trong các workload chủ yếu bị ràng buộc bởi I/O, những lợi ích này rất dễ thấy. Việc những người thông minh đang giải quyết vấn đề thực tế áp dụng async rộng rãi trong các crate quan trọng là một tín hiệu mạnh cho thấy async thực sự là một tính năng ngôn ngữ hữu ích
      Cuộc chiến chủ yếu diễn ra trên Hacker News và reddit, dưới dạng những người không cần async nổi giận vì các crate I/O mà họ dùng giờ lại muốn async. Tôi hiểu đó là tình huống không vui, và async đúng là có vấn đề thực tế, một số vẫn đang được giải quyết. Nó không hoàn hảo. Nhưng cảm giác sự chia rẽ về async thấy trên diễn đàn không rộng hay kịch tính như trong các dự án thực tế
  • Phần bị bỏ sót lớn là hủy bỏ
    future rất dễ hủy. Ngược lại, hủy thread thì lộn xộn như trò đập chuột chũi, còn việc cưỡng bức dừng thread thì không đáng tin cậy vì có nguy cơ lock vẫn bị giữ nguyên ở trạng thái đã khóa
    Trong mô hình async của Rust, có thể gắn timeout từ bên ngoài cho mọi future. Không cần từng hàm I/O ở tầng cuối hỗ trợ tùy chọn timeout, cũng không cần truyền timeout đó xuyên suốt toàn bộ call stack
    Nếu dùng kèm Drop guard, thực hành tốt nhất của Rust để quản lý trạng thái đang diễn ra, thì ngay cả các tác vụ lớn và phức tạp cũng có thể được hủy một cách dễ dàng và đáng tin cậy

    • Không phải mọi future đều dễ hủy, mà là dễ làm như thể đã hủy mọi future
      Ví dụ, dù hủy (drop) một thứ dùng spawn_blocking, nó vẫn tiếp tục chạy ở nền và người dùng có thể không biết. Các thao tác hệ thống tệp async được triển khai bằng thread pool cũng vẫn tiếp tục chạy dù đã bị hủy
      Những điều như vậy có thể dẫn đến các lỗi khó hiểu kiểu “mình chắc là chẳng có gì đang ghi vào file đó, vậy tại sao dịch vụ lại thất bại vì file đang được sử dụng?”
    • Nếu đã có thể triển khai async future, thì lẽ ra cũng có thể triển khai thread có thể hủy thay thế
      Vấn đề khá đẳng cấu. System call thì khó, nhưng dù thực hiện trong thread hay trong async future, nếu gọi cùng một system call thì sẽ phát sinh đúng cùng một vấn đề hủy bỏ
    • Tôi không hiểu vì sao hủy thread lại khó
      Chỉ cần có một trạng thái như flag mà mọi thread đều truy cập được, rồi trong vòng lặp xử lý cho kiểm tra flag đó. Nếu là false thì return và join thread là xong
    • Theo kinh nghiệm của tôi, hủy bỏ không đáng để lo lắng nhiều
      Nếu một tác vụ không còn hữu ích nữa, chỉ cần thông tin đó cuối cùng được nhìn thấy bởi hàm được gọi thay cho tác vụ đó là đủ. Trừ khi ngay trước khi làm một việc rất tốn kém như bắt đầu RPC, tôi sẽ không cố kiểm tra
  • Câu hỏi đúng hơn có lẽ là “tại sao là async/await chứ không phải fiber?”
    Tôi biết Rust từng có green thread trước bản 1.0 và đã chủ đích loại bỏ chúng, nhưng có nhiều cách tiếp cận để triển khai đồng thời dựa trên fiber, chẳng hạn những cách không cần nhúng một runtime nặng vào ngôn ngữ.
    Nếu tôi hiểu đúng bài viết, có vẻ điểm được khen chủ yếu là có thể drop future bất cứ lúc nào. Với thread thì vì những lý do hiển nhiên không thể làm việc tương tự, và ngay cả khi về mặt kỹ thuật có thể thì cũng cực kỳ không an toàn. Nhưng khả năng này đi kèm chi phí rất lớn. Không chỉ không thể dùng executor dựa trên completion như io-uring cùng với mảng dựa trên stack, hoặc chạy tác vụ con trên thread executor khác, mà còn nảy sinh các cạm bẫy tinh vi và vấn đề độ tin cậy, vốn là những bất ngờ rất khó chịu khi đang viết Rust đồng bộ.
    https://smallcultfollowing.com/babysteps/blog/2022/06/13/asy...
    Tôi cho rằng hủy tác vụ về cơ bản phải là hủy hợp tác, còn hủy không hợp tác tuy nhìn bề ngoài tiện lợi nhưng gần như là một tính năng sai lầm với những vấn đề sâu xa bên dưới.
    Ngoài ra, việc khen tính kết hợp của async/await cũng kỳ lạ. Trong Rust hiện tại không có proper effect system, nó còn xa mới có thể gọi là dễ kết hợp vì tính lan truyền. Chẳng hạn cứ thử dùng phương thức map của thư viện chuẩn với async closure, hoặc dùng các trait io::Read/Write chuẩn là thấy.

    • Nếu muốn đưa fiber vào Rust như một abstraction đồng thời hợp tác trong user space, sẽ buộc phải đưa ra nhiều quyết định thiết kế.
      Cần chọn stack sẽ được triển khai bằng spaghetti stack, yêu cầu thư viện memory mapping ở cấp tiến trình, hay giới hạn ở stack kích thước cố định.
      Cả ba cách đều gặp vấn đề khi tương tác với mã của ngôn ngữ có ABI khác. Ví dụ, nếu một fiber gọi mã C và mã C đó lại cố resume một fiber khác thì mọi thứ có thể trở nên khá phức tạp.
      Một trong những ưu điểm của async/await chính là từ khóa await. Nhờ các điểm chờ tường minh, ta thực sự có thể suy luận về tương tác trong chương trình đồng thời.
      Fiber có thể yield giống như goto trong thế giới đồng thời. Khi gọi một method, bạn không biết liệu việc xử lý có dừng lại như một tác dụng phụ hay không, hoặc khi tiếp tục thì trạng thái thế giới đã thay đổi hay chưa. Vì phải viết phòng thủ khi tiếp xúc với thế giới bên ngoài, fiber phù hợp hơn với các tác vụ chạy cô lập và giao tiếp bằng completion.
      Green thread, fiber và coroutine đều chia sẻ cùng vấn đề này ở đây. Đồng thời hợp tác trong user space giống như chuyển đống giấy tờ trên bàn hơn là giải quyết phần khó của concurrency. Rust async/await tường minh hơn, nên không che giấu những tác dụng phụ mà các cơ chế khác che đi.
    • Tôi không rõ fiber giải quyết vấn đề hủy như thế nào. Chẳng phải gần như tương đương sao?
      Tôi thấy mã dựa trên fiber khó theo dõi vì phải lần theo trong đầu thread đang chạy dở. Ít nhất với tôi, theo dõi giá trị sẽ được hoàn tất dễ hơn nhiều.
    • Fiber có stack không phù hợp lắm với mã cấp thấp.
      Hãy xem bài review của Gor Nishanov cho ủy ban C++ http://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p136.... Nó cũng được liên kết ở https://devblogs.microsoft.com/oldnewthing/20191011-00/?p=10.... Phần tóm tắt cũng rất rõ: DO NOT USE FIBERS!
    • Vì là Ruby nên tình hình thread/GVL khác với Rust, nhưng tôi tự hỏi có phải đang nói đến kiểu này không:
      https://m.youtube.com/watch?v=qKQcUDEo-ZI
      Tôi nghĩ nó đưa ra khá rõ các căn cứ rằng async/await có tính lan truyền và vụng về, còn fiber, ít nhất trong triển khai của Ruby, là một paradigm tốt hơn nhiều.
    • Trong các bài toán toán học, hủy hợp tác có thể khá phiền.
      Một thuật toán tối ưu hóa có thể gọi bài toán tìm nghiệm, rồi bài toán đó lại gọi bộ tích phân ODE, cứ lồng nhau như vậy, và bất kỳ bước nào cũng có thể chạy rất lâu. Phải truyền cancellation token đi khắp nơi, nhưng các framework tính toán số thường không hỗ trợ.
      Ta có thể, và nên, đặt giới hạn số vòng lặp cho mọi thuật toán, nhưng trong các thuật toán lồng nhau thì cùng lắm chỉ đảm bảo nó sẽ dừng trong năm nay, chứ khó đảm bảo dừng trong 5 giây.
      Với loại vấn đề này, tôi có thể đảm bảo việc mình làm chỉ là rất nhiều phép tính toán học, cấp phát và page fault phát sinh từ đó, không có I/O, và ghi chuỗi log vào đối tượng Queue của thư viện chuẩn để Main thread, vốn sẽ không bị hủy, xử lý. Các chức năng khác cần thiết cũng có thể được chuyển trả về main thread qua Queue.
      Tôi cảm thấy trong thế kỷ 21, vấn đề này đáng lẽ phải được giải quyết mà không phải ép truyền cancellation token khắp mọi nơi và bắt người ta viết phòng thủ trước những đoạn mã chạy lâu nhưng không kiểm tra token.
  • Trong một cuộc thảo luận khác về async/await, mọi người không hiểu async/await, không hình dung được vì sao cần cơ chế đồng thời trên một thread đơn, và giả định rằng chẳng ai cần nó.
    Lập trình UI, giao tiếp với GPU, giao tiếp giữa các runtime là những ví dụ tốt, và chắc còn nhiều nữa.
    Thread, dù là green thread hay không, không phù hợp với những trường hợp này, còn async/await thì phù hợp.

    • Trong GUI có thể dùng thread dễ dàng, và trước đây tôi đã viết vài ứng dụng GUI tận dụng thread khá hiệu quả.
    • Khả năng quản lý tường minh nhiều tác vụ trên một thread đơn chắc chắn là quan trọng.
      Nếu có thể triển khai các tính năng ngôn ngữ khác ít gây phiền hơn cho người dùng ngôn ngữ mà vẫn tạo ra cùng một binary, thì điều đó cũng đáng bàn.
  • Một trong những ưu điểm chính của async/await trong Rust là nó có thể hoạt động ngay cả trong tình huống không có thread hay bộ nhớ động.
    Nó hoàn toàn đủ dùng để viết mã ngắn gọn trên microcontroller, chẳng hạn chờ cho đến khi interrupt đọc xong dữ liệu I2C đi vào một buffer nào đó. Đây là một abstraction cấp cao hơn, cho phép dùng concurrency mà không phơi bày quá nhiều tương tác với runtime bên dưới.
    Mọi phần mềm lớn mà tôi từng làm việc đều triển khai thứ kiểu này dưới một hình thức nào đó. Ngay cả trong các codebase không có khái niệm coroutine C++ hiện đại, chúng tôi cũng dùng Apple Grand Central Dispatch, Intel Threading Building Blocks, v.v. Nếu không, business logic hoặc sẽ bị chặn ở I/O một cách rất kém hiệu quả, hoặc sẽ có số lượng thread khổng lồ khiến việc phát triển và debug trở thành địa ngục, hoặc sẽ bị phủ kín bởi chi tiết triển khai runtime bên dưới, hoặc là sự pha trộn của cả ba.
    Nếu không dùng abstraction có sẵn của chính ngôn ngữ hoặc thư viện, cuối cùng bạn sẽ tự tạo ra nó; việc đó khó và nhiều khả năng nhìn chung sẽ kém hơn những thứ được dùng rộng rãi. Trước đây tôi cũng từng tự làm cho C++: https://github.com/goto-opensource/asyncly

  • Có vẻ tác giả đang nhầm lẫn hai thứ.
    Một là user-space thread/green thread, thứ còn lại là structured concurrency.
    Cái trước là một ưu điểm của async/await, nhưng không phải ưu điểm độc nhất. Có những ví dụ làm được mà không gặp vấn đề “màu” của hàm, như Go hoặc Java Loom.
    Cái sau có thể được triển khai bằng cả OS thread lẫn green thread. Có thể xem JEP Structured Concurrency của Java.
    https://openjdk.org/jeps/462