Vì sao chọn async/await thay vì thread
(notgull.net)async/awaitcủ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/awaitnhường quyền thực thi tại điểmawait, để 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
asyncbằng cách kết hợpracevàTimer, trong khi với mã thread đồng bộ lại cần wrapper chuyên choTcpStreamcù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
asyncbằ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 Rustasyncnằ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
- Như
- 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ọihandle_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
- Trong cấu trúc nhận client bằng
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
- Thread chính tiếp tục
- 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 fnkhông phải là hàm truyền thống mà là hàm trả về một máy trạng tháiawaitđư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::Executorsẽ chạy một máy trạng thái khác được tạo bằngspawnthay 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ớimain - Khi
mainnhườ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
- Khối
- 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
Iteratorcho 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ậnIterator- 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ểurecv.try_iter().filter(...).map(...)
async/awaitmang 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ộawaitcác bướcread_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 - 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 + AsyncWritelà 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
- Chỉ cần đối tượng đó triển khai
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ư
readhaywrite, và cách như đóng file descriptor cũng không dùng được trong Rust TcpStreamcó cung cấpset_read_timeoutvàset_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ư
DeadlineStreambọc quanhTcpStream, 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_timeoutvàset_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
- Bị buộc vào
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/awaitlà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
towerlà ví dụ tiêu biểu cho khả năng kết hợp củaasync/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
- Nếu triển khai service dưới dạng hàm
macroquadlà một game engine nhỏ cho Rust và chạy engine bằngasync/awaittrong hàm mainasync/awaitphù 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ùngasyncthay 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/awaitban đầ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ọnasync/awaitchỉ 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
asynctươ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
- Với tác vụ CPU bound, workflow dựa trên thread có thể nhanh hơn workflow
async/awaitkhô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óaasyncvàawait - Tuy vậy, cũng có quan điểm cho rằng việc đóng khung
async Rustthà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/awaitcủa Rust nên làm nổi bật rõ hơn các thế mạnh là tính kết hợp và năng lực biểu đạt - Nếu muốn
async/awaittrở 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
Ý 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,
Mutexchuẩn và các kênhcrossbeam-channelcó 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ấpNế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
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
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
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-...
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
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
Đ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
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ùngtokio::spawn_blocking(foo).awaitthì 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úcVớ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 đề
“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
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ó
awaitnên điều đó được bảo đảm; nhưng nếu thêmawaitvà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ềnasync 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
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ó
Vì 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
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 choawait? Khi đó về thực chất là đang lập trình bằng threadThread 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.waitcó 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àmQuay lại ví dụ web server, khi triển khai timeout bằng
driver.race(timeout).await, sau khiracebá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_racedùng timer để theo dõi timeout song song với thread, và khi đến hạn thì gọiclient_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émInterruptedException. Vì đây là checked exception, compiler buộc phải bọcclient.read_to_end(&mut data)bằng try/catch hoặc khai báo exception tronghandle_client, nên lập trình viên sẽ không quên đóng socket của clientrace()sẽ đượcDrop, còn bản thândrivervẫn cònNếu dùng nguyên kiểu đó, Rust sẽ phàn nàn rằng
Resultkhô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ầnsleep()để nhường quyền như thread, nên nhanh và dễ suy luận hơnThread.interruptcủ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ênTrong 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 threadMọ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
poll()vàselect(), nhưng đó lại là một cách riêng khácThậ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/AsyncReadtrô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 descriptorKhá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ể
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
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ậySai lầm nằm ở việc đẩy nó vào cả đa số use case không cần thứ đó
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
Dropguard, 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ậyVí dụ, dù hủy (
drop) một thứ dùngspawn_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ủyNhữ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?”
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ỏ
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
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-uringcù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
mapcủa thư viện chuẩn với async closure, hoặc dùng các traitio::Read/Writechuẩn là thấy.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ư
gototrong 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 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.
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!
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.
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.
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