2 điểm bởi GN⁺ 2023-09-09 | 1 bình luận | Chia sẻ qua WhatsApp
  • async/await của Rust nhắm tới mức đồng thời lớn khi xử lý hàng chục nghìn kết nối, nhưng xung đột với các mục tiêu của Rust là kiểm soát cấp thấp và kiểm chứng tĩnh về lifetime, tạo ra trải nghiệm phát triển khác với Rust thông thường
  • Thread và channel là đủ cho nhiều phần mềm, nhưng ở quy mô như C10K, chi phí của mô hình mỗi kết nối một thread trở nên lớn, khiến cần đến task trong user-space và lập lịch bằng runtime
  • Trong Rust async, dữ liệu phải được di chuyển dưới dạng Send hoặc được xử lý qua tham chiếu 'static; do tính “lây lan” của async, các ràng buộc này lặp lại khắp codebase
  • Arc có thể giải quyết vấn đề biên dịch, nhưng làm mờ lifetime của object và resource; tiếp đó là các cạm bẫy như async đệ quy, khác biệt giữa future và task, và lời gọi blocking làm chặn thread của runtime
  • Trong Haskell hoặc Go, “async code” hoạt động như code thông thường, còn runtime và GC che giấu khác biệt; vì vậy trong kiểu lập trình này, khả năng kiểm soát tường minh của Rust có thể không chỉ toàn là lợi thế

Vì sao cần concurrency và parallelism

  • Một chương trình nhanh thường có đồng thời hai yêu cầu
    • Phải tận dụng toàn bộ máy tính bằng cách dùng nhiều core CPU
    • Phải tiếp tục làm việc khác trong khi chờ các thao tác chậm như gửi thông điệp qua Internet hoặc mở file
  • Parallelism là vấn đề chạy code đồng thời trên nhiều CPU
  • Concurrency là cách chia bài toán thành các phần độc lập
  • Hai khái niệm này không giống nhau, nhưng khi chia chương trình thành các mảnh concurrent, các mảnh đó có thể chạy song song và giữ cho các core luôn bận

Process, thread, channel

  • Một cách đơn giản để xây dựng hệ thống concurrent là chia code thành nhiều process
    • Scheduler của hệ điều hành chạy các lát thời gian của những process có thể chạy trên các core CPU sẵn có
    • Mô hình này cũng được dùng khi nối các lệnh shell bằng pipe
  • Cách dùng process có chi phí giao tiếp giữa các process cao
    • Trong nhiều triển khai, dữ liệu phải được sao chép vào bộ nhớ của OS rồi lấy ra lại
    • Có thể giảm chi phí bằng shared memory, nhưng khi đó lợi ích OS cô lập các process với nhau sẽ yếu đi
  • Thread chia sẻ cùng bộ nhớ nên tránh được overhead này, nhưng nếu dùng sai các công cụ đồng bộ như mutex, condition variable, semaphore thì có thể gây data race và deadlock
  • Mô hình Communicating Sequential Processes của Tony Hoare nối các thread bằng queue hoặc channel
    • Thread không chia sẻ bộ nhớ nên có được sự cô lập giống process
    • Input và output của từng thread được thể hiện qua channel, giúp suy luận và debug dễ hơn
    • Bản thân channel đóng vai trò đồng bộ: nếu rỗng thì receiver chờ, nếu đầy thì sender chờ
  • Thư viện chuẩn Rust có std::sync::mpsc::sync_channel
  • Với nhiều phần mềm, sự kết hợp giữa thread, channel và các công cụ như Rayon để song song hóa các vòng lặp nặng CPU là đủ

Concurrency trong user-space và Rust async

  • Với bài toán C10K như web server có hàng chục nghìn người dùng kết nối đồng thời, cách gắn một thread cho một kết nối sẽ chạm giới hạn
    • Trên Linux, mỗi thread có control block 4kB, và chuyển thread cần một context switch đi vào scheduler của hệ điều hành
  • Để đạt mức đồng thời lớn, một số ngôn ngữ tạo và quản lý task trong user-space
    • Runtime lập lịch task lên một pool các OS thread
    • Thường cấu hình pool sao cho mỗi core CPU có một thread để tối đa hóa parallelism
    • Cách này được gọi là green thread, lightweight thread, lightweight process, fiber, coroutine, v.v.
  • Rust dùng mô hình async/await thấy trong C# hoặc Node.js
    • async fn không trả về giá trị trực tiếp, mà trả về một future hoặc promise; kết quả được lấy bằng .await
  • Future của Rust rất nhỏ và nhanh nhờ cooperative scheduling và thiết kế stackless
  • Rust muốn cung cấp trừu tượng future mà vẫn hứa hẹn khả năng kiểm soát cấp thấp cho lập trình viên
    • Muốn kiểm chứng tĩnh lifetime của mọi object và reference tại thời điểm biên dịch
    • Future chia code và dữ liệu mà code đó tham chiếu thành hàng nghìn mảnh, rồi có thể chạy bất cứ lúc nào trên bất cứ thread nào tùy theo các điều kiện chỉ biết sau khi bắt đầu chạy
    • Future đọc dữ liệu từ client chỉ nên chạy khi socket tương ứng có dữ liệu để đọc, nhưng lifetime annotation không cho biết thời điểm đó
  • Rust không tích hợp runtime cho future vào ngôn ngữ, mà giao cho các thư viện như Tokio
    • Người dùng có quyền tự do chọn giải pháp thay thế phù hợp với môi trường
    • Tuy nhiên, ngay cả khi tưởng tượng một thế giới nơi Tokio được tích hợp vào ngôn ngữ, các quy tắc tương tự vẫn áp dụng, nên đây là chi tiết phụ trong luận điểm này

Áp lực do Send, 'static, Arc tạo ra

  • Để thuyết phục compiler, dữ liệu phải được đánh dấu là Send để di chuyển, hoặc phải được truyền qua tham chiếu có lifetime 'static
  • Trong code async, nhiều task thường chia sẻ trạng thái chung, nên cách di chuyển dữ liệu mà không clone thường không phù hợp
  • Reference cũng khó dùng, và không có đối ứng nào giống thread::scope để giới hạn lifetime của future ngắn hơn “mãi mãi”
  • async có tính lây lan, nên hàm gọi một hàm async cũng phải trở thành async
    • Vì vậy, các vấn đề lifetime và khả năng di chuyển này không chỉ cần giải quyết ở vài hàm, mà cứ lặp lại liên tục
    • Có thể cắt chuỗi bằng cách dùng runtime chờ future hoàn tất với block_on, nhưng cách này không composable và khi lồng nhau có thể khiến runtime panic
  • Arc là công cụ để xử lý lifetime động trải qua nhiều thread, giúp vượt qua borrow check và làm code biên dịch được
  • Nhưng dùng Arc rộng rãi sẽ làm mờ lifetime của object và resource
    • Không rõ khi nào các resource như bộ nhớ, file, socket sẽ được giải phóng
    • Bạn chịu các tổn thất giống GC nhưng không có những lợi ích mà GC thật sự cung cấp, như allocation throughput, fragmentation thấp, và tránh cycle leak

Các cạm bẫy khác của Rust async

  • Coroutine của Rust là stackless, nên compiler biến mỗi coroutine thành một state machine tiến tới từng điểm .await
    • Hàm async đệ quy trở thành một kiểu được định nghĩa đệ quy
    • Người dùng chỉ muốn gọi chính nó phải tự boxing thủ công hoặc dùng crate như async-recursion
  • Future không làm gì cho đến khi được await
  • Task bắt đầu công việc trên thread pool của runtime và trả về một future biểu thị việc hoàn tất
  • Không có cơ chế nào ngăn gọi code blocking bên trong future
    • Cũng không ngăn việc lời gọi đó chặn chính thread runtime mà nó được đưa lên
    • Điều này xung đột với mục đích cốt lõi của việc dùng async

Khác biệt giữa Rust thông thường, Haskell và Go

  • Rust async có hương vị rất khác với Rust “normal”
    • Có nhiều cạm bẫy hơn
    • Khó hiểu và khó dạy hơn
  • Người dùng đứng giữa hai lựa chọn
    • Hiểu sâu cách trừu tượng thật sự hoạt động và viết code phức tạp
    • Rải các yếu tố như Arc, Pin, 'static khắp code rồi hy vọng mọi thứ ổn
  • Ngay cả các đội ngũ developer giàu kinh nghiệm khi cố dùng Rust cho dự án mới cũng có thể bị vướng vào những chi tiết này
  • Trong Haskell hoặc Go, “async code” là code thông thường
    • Cả hai ngôn ngữ che giấu khác biệt giữa code blocking và non-blocking phía sau runtime dày
    • Vấn đề lifetime được chuyển cho garbage collection
  • Trong kiểu phần mềm user-space có mức đồng thời lớn này, cách runtime và GC che giấu khác biệt trở thành một lợi thế thuần túy
  • Rust có thể không phải công cụ tốt cho phần mềm user-space có mức đồng thời lớn, và có lẽ phù hợp hơn với các dự án không có yêu cầu như vậy

1 bình luận

 
GN⁺ 2023-09-09
Ý kiến trên Hacker News
  • Tôi đang viết một client metaverse hiệu năng cao bằng Rust, hiện có khoảng 40 nghìn dòng
    Video demo có tại https://video.hardlimit.com/w/tp9mLAQoHaFR32YAVKVDrz
    Với một metaverse đúng nghĩa, nội dung do người dùng tạo phải được xử lý gần như theo thời gian thực, nên cần VRAM gấp 2–3 lần so với các game tương tự; để tải asset từ server cần băng thông hàng trăm Mbps, nhiều CPU, và Vulkan để vừa render vừa upload lên GPU
    Đây không phải cấu trúc kiểu concurrency “quy mô web”, nơi các server nhỏ chạy riêng trong cùng không gian địa chỉ, mà là cấu trúc trong đó một render thread ưu tiên cao, một thread cập nhật sự kiện mạng, các thread tải asset/giải nén, cùng nhiều thread phụ trách vật thể chuyển động, LOD, dọn cache... phối hợp với nhau
    Trong Rust, tôi dùng khá nhiều khóa mà không có trạng thái toàn cục ngoài hằng số; dùng channel ở những chỗ phù hợp; cây đối tượng chính do thread cập nhật xử lý chủ yếu theo mô hình sở hữu đơn. Liên kết giữa các đối tượng đồ họa được quản lý bằng đếm tham chiếu Arc, rồi đưa mesh và texture lên GPU thông qua Rend3/WGPU/Vulkan
    Nếu làm bằng C++ thì có lẽ tôi đã phải liên tục vật lộn với crash, còn trong Rust thì crash liên quan đến bộ nhớ chỉ xảy ra khoảng mỗi năm một lần, mà thường là do mã unsafe của người khác. Tôi đã cấm unsafe trong mã của mình; việc compile thì khó, nhưng một khi chạy được thì thường “cứ thế mà hoạt động”, nên tôi thấy vẫn tốt hơn nhiều so với debug concurrency
    Cũng có điểm không hài lòng. Rust mạnh về data race nhưng không ngăn được deadlock, nên cần một bộ phân tích tĩnh theo dõi thứ tự khóa dọc theo đường gọi. async không phù hợp với các tác vụ thiên về tính toán và nhiều thread có mức ưu tiên khác nhau, nhưng cứ len lỏi vào dependency. Các cấu trúc phổ biến có sở hữu đơn kèm tham chiếu ngược quá khó nếu không có RcWeak; hệ thống trait cũng phức tạp, khiến phần xử lý asset vốn hợp với hướng đối tượng tự nhiên lại phát sinh mã lặp
    Các crate đồ họa cốt lõi cũng chưa đủ hoàn thiện. Câu “Rust có 5 game và 50 game engine” không phải vấn đề của ngôn ngữ mà là vấn đề hệ sinh thái; ngay cả so với https://gamedev.rs/, có vẻ việc phát triển game nghiêm túc bằng Rust vẫn còn thiếu. Nếu là phát triển game chuyên nghiệp có lịch trình, hệ sinh thái game Rust vẫn chưa sẵn sàng; tôi cho rằng cần khoảng 5 người làm thêm chừng 1 năm nữa

    • Trong 3 năm qua tôi đã làm một trình mô phỏng robot bằng Rust và trải nghiệm gần như giống hệt. Suốt 3 năm chỉ có khoảng 5 bug runtime thực sự; Rust và async đúng là có vấn đề, nhưng nhìn chung lợi ích lớn hơn rất nhiều
    • Tìm deadlock tiềm ẩn bằng cách theo dõi thứ tự khóa có vẻ là một ý tưởng hay
      Giống lockdep của Linux, có thể phân tích việc đang giữ khóa nào thì lại lấy khóa nào khác, và cảnh báo các tổ hợp nguy hiểm ngay cả trước khi hệ thống thực sự bị treo. Với các khóa phức tạp có thể cần chú thích kiểu “lớp khóa này luôn được lấy theo thứ tự địa chỉ”, nhưng có vẻ triển khai được
    • Tôi đang làm gần như việc tương tự trong MMO bằng Java, và JDK khiến mọi thứ rất dễ. Chỉ cần tạo model từ mạng, chuyển đối tượng sang UI thread bằng concurrent queue là xong; đơn giản đến khá nhàm chán mà vẫn nhanh
    • Rust không phải là không có race condition, mà là không có data race
      Race condition vẫn có thể xảy ra bên ngoài việc truy cập dữ liệu: https://news.ycombinator.com/item?id=23599598
    • Vấn đề ưu tiên có thể giải quyết tương đối dễ
      Có thể tạo nhiều thread pool rồi route future tới đúng nơi, hoặc tự viết event loop để lấy việc từ nhiều event queue có độ ưu tiên khác nhau. Cách thứ hai, nếu thời gian chạy tác vụ được giới hạn, có thể vừa tiếp tục xử lý tác vụ ưu tiên thấp khi CPU ở mức 100%, vừa cung cấp bảo đảm soft real-time cho tác vụ ưu tiên cao
  • Về Rust async, có một điểm khá lạ
    Nếu dùng quá nhiều Arc, RwLock và trạng thái chia sẻ thì mã sẽ bừa bộn; đặc biệt nhận xét rằng khi 'static bắt đầu lan khắp nơi thì nó lây nhiễm mọi thứ như colored function là đúng. Trước đây tôi từng cố gắn Arc và xử lý lifetime mượn một cách thông minh, rồi mọi thứ rối tung lên
    Nhưng Rust cũng có channel. Phần lớn mã tôi đang viết hiện nay có cấu trúc là một vài task phục vụ các channel, đọc message đến và nếu cần thì đưa message cần gửi cho task khác vào channel tương ứng. Không chia sẻ object. Nếu nhiều task cần một object lớn, tôi đặt nó bên trong một task gửi kết quả truy vấn liên quan qua message, hoặc để mỗi task tự tạo bản sao của mình trong luồng message
    Dù vậy vẫn có quá nhiều bài viết nói về cách dùng Arc và xử lý lifetime. Nếu đang triển khai một runtime async thì có thể cần, nhưng tôi không hiểu vì sao người dùng thư viện trung bình lại phải tập trung nhiều vào chuyện này đến vậy

    • Tôi thấy lời phê bình hơi kỳ. async không đồng nghĩa với đa luồng; nếu là async trong cùng một thread thì vì không có chia sẻ, cũng không cần gắn mọi keyword kỳ diệu lên đối tượng được chia sẻ
      Khi đi qua lại giữa các thread, thay vì đặt thật nhiều trạng thái chia sẻ, hãy gửi tín hiệu qua channel. Nếu có trạng thái toàn cục thật sự cần thiết, hãy tạo một struct nhỏ bọc cơ chế truy cập độc quyền như Arc/RwLock, để từ phía caller nó trông như một lời gọi hàm đơn giản
      Tôi cũng không hiểu lắm nỗi lo về Send+Sync. Theo kinh nghiệm, đa số thứ đều dễ dàng là Send+Sync, còn những thứ không phải thì là những thứ không nên hoặc không thể như vậy. Có lúc tôi cũng muốn viết mã mà không phải nghĩ đến chi tiết, nhưng nếu cần concurrency/parallelism hiệu quả thì microsecond và throughput trở nên quan trọng, và khi đó phải viết đúng với cách máy tính thật sự hoạt động
    • Mô hình truyền thông điệp thật sự rất tốt, và các ngôn ngữ như Erlang đã cho thấy đây là lựa chọn xuất sắc cho hệ thống phân tán
      Nhưng kiểu viết mã này rất khác với JavaScript async, vốn có cảm giác như mã đồng bộ được gắn thêm green thread. Có vẻ mọi người cố viết theo cách quen thuộc nên trong Rust lại rơi vào con đường ArcRwLock
    • Giấc mơ của Smalltalk và hướng đối tượng đúng nghĩa vẫn còn sống
    • Hồi đại học tôi học được lời khuyên này từ giáo sư, và nó thật sự giúp ích rất nhiều
      Cấu trúc hóa vấn đề thành dữ liệu chảy giữa các tác vụ, nối chúng bằng queue và tránh trạng thái chia sẻ là cách tốt hơn để xử lý đa luồng, bất kể dùng ngôn ngữ nào
    • Như một lập trình viên khôn ngoan từng nói, “Đừng giao tiếp bằng cách chia sẻ bộ nhớ; hãy chia sẻ bộ nhớ thông qua giao tiếp”
  • async thực chất là Rust khó hơn rất nhiều, và có lẽ chỉ khoảng 1% dự án thật sự cần đến nó, nên thật đáng tiếc khi nó gần như bị áp đặt cho tất cả mọi người
    Tuy vậy, trong 1% đó thì nó thật sự tuyệt vời. Với các dịch vụ xử lý khối lượng lớn lời gọi mạng làm phần cốt lõi như linkerd hay nginx, các trường hợp chạy số lượng khổng lồ tác vụ nhẹ trong game, hoặc khi cần tính đồng thời hợp tác trong nhúng, async Rust trở thành một vũ khí rất mạnh
    Phần lớn mã ở cấp hệ thống/ứng dụng không cần I/O bất đồng bộ. Ứng dụng REST chỉ cần thread pool là đủ, và ngay cả khi cần async thì thường mô hình phù hợp là mô hình lai, chỉ giới hạn nó ở một phần nhỏ như mạng, còn phần còn lại nối với nhau bằng thread và channel
    Cộng đồng Rust đã dùng async quá bừa bãi ở khắp nơi, khiến Rust với I/O blocking, vốn có trải nghiệm người dùng tốt hơn, trở thành công dân hạng hai trong hệ sinh thái. Về web framework cũng có nhiều framework bất đồng bộ được thiết kế tốt như Axum, Warp, trong khi phía blocking thì lựa chọn hạn chế hơn nhiều, như tiny_http, rouille, astra

    • Điểm cốt lõi là Rust đã triển khai coroutine sai cách
      Việc chọn coroutine không có stack đã tạo ra async/await và vấn đề hàm có “màu”, dẫn đến ma sát như bài viết nói. Go dùng coroutine có stack nên không gặp vấn đề này
      Ban đầu Rust cũng từng cân nhắc coroutine có stack, nhưng cho rằng cần runtime có khả năng preempt coroutine và chi phí lớn, nên đã đi theo mô hình không stack. Nhưng phần lớn mọi người không dùng async Rust không runtime, mà dùng Tokio; và Tokio về thực chất làm gần như toàn bộ những việc mà runtime từng bị muốn tránh sẽ làm
      Vì vậy, nhiều người dùng async Rust rốt cuộc nhận cả điểm dở của hai bên. Ở phía nhúng, cũng có người dùng async Rust với runtime rất mỏng, nhưng số đó ít, và ngay cả họ cũng chưa hoàn toàn bị thuyết phục
    • Tôi thấy Tokio lại bị kéo vào chương trình của mình như một dependency. Tôi thậm chí không dùng trực tiếp, nhưng một hàm tôi không dùng trong crate nào đó kéo reqwest vào, nó kéo h2 vào, rồi lại kéo tokio vào
    • Nếu nền tảng hỗ trợ virtual thread thì tôi tự hỏi còn lý do gì để dùng async không
      Từ góc nhìn của người dùng Java, tôi đang muốn bỏ toàn bộ paradigm bất đồng bộ và viết lại mã theo mô hình blocking trên virtual thread, nơi blocking là chấp nhận được
  • async đã lan ra quá nhiều crate, khiến toàn bộ chương trình hoặc phải trở thành async, hoặc ít nhất phải phụ thuộc vào Tokio cho rất nhiều việc
    Nếu muốn web server thì gần như là async + tokio hoặc biến đi, còn với SQL connector thì bầu không khí cũng giống như nếu không muốn bất đồng bộ thì tự viết lấy. Mỗi bên đang giải quyết các vấn đề do async mang lại theo những cách khác nhau, còn những thứ như closure async thì có cảm giác như mở cổng địa ngục trong compiler
    Việc bản thân Rust và compiler giúp giải quyết vấn đề là tốt, nhưng một hệ sinh thái gần như “không async thì tự làm lấy” là chưa đủ

    • Nếu trong thư viện chuẩn hoặc crate futures có các primitive bất đồng bộ tốt hơn, có thể đã giảm được rất nhiều đau đớn
      Cần có những thứ như trait mà executor phải triển khai, hoặc executor blocking cơ bản để chạy mã bất đồng bộ từ mã đồng bộ. Hiện nay, chỉ riêng việc tạo thư viện hỗ trợ nhiều runtime bất đồng bộ đã là khổ sai, nên cuối cùng thường chỉ hỗ trợ Tokio, hoặc cùng lắm thêm async-std
  • Tôi không phải chuyên gia async Rust, nhưng khi viết vài nghìn dòng Rust đồng bộ trong tháng này, điều tôi cảm nhận là khi rustc khiến một cách tiếp cận nào đó trở nên khó khăn, thường có lý do chính đáng, và thường có một cách tốt hơn để đạt kết quả tương tự
    Nếu đang học ngôn ngữ này, tôi khuyên trước hết nên làm quen với mã đồng bộ thông thường, vòng lặp và điều kiện, cùng các quy tắc borrow. async vẫn đang phát triển rất nhiều, không chỉ ở khâu triển khai mà cả ở cấp độ triết lý về “bất đồng bộ là gì và nó nên hiện ra với người dùng như thế nào”
    Compiler phụ thuộc nhiều vào trait, nhưng các tính năng để trait xử lý async vẫn chưa được ổn định hóa. Ví dụ có công việc như https://blog.rust-lang.org/inside-rust/2022/11/17/async-fn-i...
    Nếu tính năng bất đồng bộ trong trait chưa được ổn định hóa, thì việc công kích rằng mã bất đồng bộ của Rust vẫn chưa đẹp rốt cuộc cũng giống như phê bình bản nháp đầu tiên của một cuốn sách sẽ được hoàn thiện sau này

    • Tôi tò mò “thiết kế API bất đồng bộ tốt” là gì. Nếu thiết kế một server hoàn toàn xoay quanh bất đồng bộ, nhưng vẫn có khả năng mở rộng, dễ bảo trì và dễ hiểu, thì nó nên trông như thế nào
      Tôi cũng băn khoăn làm sao ngăn bất đồng bộ lan ra toàn bộ codebase
      Ý tưởng hiện tại là luồng I/O chia các sự kiện hệ thống từ liburing hoặc epoll thành hai giai đoạn “submit” và “handle”, rồi gửi chúng tới các component khác. Ví dụ, nếu tạo tcp-connection, ta có thể subscribe các sự kiện bất đồng bộ như “sẵn sàng ghi”, “sẵn sàng đọc”; sự kiện sẵn sàng ghi sẽ lấy dữ liệu từ buffer được lấp đầy bằng mutex thông thường rồi gửi qua EPOLLOUT/io_uring_prep_writev
      Để truyền sự kiện giữa các thread, có thể dùng ring buffer nhiều producer/nhiều consumer theo mẫu LMAX Disruptor. Thread ứng dụng hoặc thread pool mỗi bên có event loop riêng và xử lý ring buffer này
      Tôi cũng đang làm cú pháp để biểu diễn thứ tự phát sinh các sự kiện bất đồng bộ; nó trông giống pipeline của Bash và được gọi là statelines: initialstate1 initialstate2 = state1 | {state1a state1b state1c} {state2a state2b state2d} | state3
    • Nếu chưa được ổn định hóa thì cũng không nên dùng trong production
    • Những bình luận giả định tác giả là người mới học Rust khá thú vị. Ngược lại, có khi kinh nghiệm của người viết còn nhiều hơn họ
  • Vòng đời của Arc không phải là không thể biết, mà được quyết định bởi nơi và cách nó được giữ
    Có vẻ sự lệch pha trong bài này đến từ việc tác giả cố áp mô hình tư duy cũ kiểu garbage collection vào Rust, thay vì học Rust rồi làm việc theo cách phù hợp với ngôn ngữ. Đây là cái bẫy thường gặp khi học một ngôn ngữ mới, nhưng Rust đặc biệt dễ khiến người ta vấp hơn

    • Nếu hiểu theo nghĩa đó thì vòng đời của object trong hệ thống garbage collection cũng có một cận dưới là “chừng nào còn được tham chiếu”
      Nhưng điều đó gần như ngược với mục tiêu của borrow checker, vốn muốn giới hạn tĩnh vòng đời object tại thời điểm biên dịch
      Thực tế với tôi gần như là ngược lại. Sau khoảng 10 năm lập trình hệ thống bằng C, C++, Rust, hiện ở công ty tôi dùng Haskell khá nhiều, và việc nhận ra rằng một runtime ngôn ngữ lớn cùng garbage collection không phải là quái vật trong một số miền bài toán đã khá mở mang tầm mắt
    • Phần lớn phê bình có cảm giác như vậy. Tôi đã tưởng đây sẽ là bài viết về việc phép biến đổi async cản trở các tối ưu hóa mà trình biên dịch có thể làm với code không bất đồng bộ như thế nào
      Đoạn nói về việc vật lộn với Weak có vẻ là nỗ lực tạo ra một cấu trúc ownership phức tạp, mà đây vốn không phải việc dễ trong Rust nói chung. Tôi rất hiếm khi dùng weak smart pointer
      Channel hầu như không được nhắc tới, trong khi đó là công cụ chính để các phần khác nhau của chương trình giao tiếp với nhau khi viết code bất đồng bộ hoặc nối giữa code bất đồng bộ và đồng bộ. Cũng có các abstraction tín hiệu như Notify, semaphore
      Mutex chậm và dễ trở thành nút cổ chai, còn shared state thì nhanh chóng trở nên phức tạp. Điều này đã được biết từ lâu. Vấn đề có thể nằm ngay từ đầu ở một cấu trúc kiểu BIG_GLOBAL_STATIC_REF_OR_SIMILAR_HORROR
      Nhận xét rằng không thể ngăn việc gọi code blocking trong context bất đồng bộ là hợp lý, nhưng khi cần thì có thể quản lý tương đối được bằng những thứ như tokio::spawn_blocking
    • Reference counting cũng là một dạng garbage collection https://en.wikipedia.org/wiki/Garbage_collection_(computer_s...
      Nhiều khả năng tác giả biết Arc là gì và hoạt động ra sao; ý chính có vẻ gần với việc trong Rust async, bạn sẽ dùng Arc thường xuyên hơn rất nhiều so với RAII thông thường trong code đồng bộ
      Nếu 90% object của chương trình đều được reference counting, có thể dùng tracing garbage collection sẽ tốt hơn là trả chi phí cho rất nhiều lần cấp phát/giải phóng heap nhỏ và các thao tác atomic. Ví dụ trong tutorial của Tokio cũng cho thấy hướng tương tự: https://tokio.rs/tokio/tutorial/shared-state
      Tôi tò mò liệu một tracing garbage collection thực sự trong Rust có thể làm các ứng dụng bất đồng bộ phổ biến như HTTP server nhanh hơn một cách đáng kể không: https://manishearth.github.io/blog/2015/09/01/designing-a-gc...
    • Vòng đời của Arc không phải là ngẫu nhiên, mà là không thể biết một cách tĩnh
    • Arc của Rust có thể được move hoặc borrow, và cũng có thể được dùng mà không đụng đến reference count
      Trong nhiều trường hợp, nó rẻ hơn nhiều so với object trong các ngôn ngữ reference counting ngầm định
  • Tôi thích Rust, nhưng async là một mớ hỗn độn, và không thể viết code bất đồng bộ như thể đang viết code đồng bộ
    Tôi ngày càng tin rằng trộn hai thứ này là ý tưởng tệ, và có thể cách tiếp cận kiểu Go — giữ mọi thứ đồng bộ và chỉ cung cấp một primitive channel async — mới là đúng
    Hiện tôi đang nối logic để gọi các method đồng bộ từ một struct implement Future, và đó là một thử thách khá thú vị. Có thể làm cho abstraction bất đồng bộ zero-cost trở nên phần nào dễ dùng với người dùng, nhưng nỗi đau sẽ do nhà phát triển thư viện gánh

    • Tôi không đồng ý với đoạn cuối. async chắc chắn cũng gây đau khổ cho người dùng cuối, và nó có cảm giác như đang dùng một ngôn ngữ riêng thiếu các tính năng cốt lõi của Rust là lifetime và kiểu tường minh, lại còn bị rắc đầy Pin
      Không thể chạy fiber có scope nên rốt cuộc phải gắn rất nhiều Arc; Pin khó dùng nếu không có unsafe; và một thay đổi rất nhỏ trong hàm bất đồng bộ có thể biến future trên toàn codebase thành !Send
    • Nhà phát triển thư viện có nhiều khả năng gánh độ phức tạp hơn người dùng. Giao những việc như vậy cho các developer giàu kinh nghiệm xây dựng hạ tầng nền tảng là hướng đi đúng
    • Tôi từng thấy một wasm VM cho Rust cung cấp thứ trông giống transparent M:N scheduling, và cách đó có vẻ có thể giải quyết phần lớn khó khăn của async. Cần chờ xem nó sẽ phát triển thế nào
  • Async Everything là một ngôn ngữ tệ
    async/await là một ý tưởng khủng khiếp nhằm sửa vấn đề JavaScript không có luồng blocking đúng nghĩa, và giờ nó đang bị gắn thêm vào mọi ngôn ngữ. Nó sẽ chia hệ sinh thái ngôn ngữ và thư viện làm hai, tạo ra đau khổ trong thời gian dài
    Ai từng làm đa luồng bên ngoài JavaScript đều biết actor hoặc communicating sequential processes là cách tốt nhất cho đa luồng
    Trong luận văn của Joe Armstrong cũng giải thích rằng cách duy nhất để hiểu chương trình đa luồng là viết mã tuần tự nghiêm ngặt cho từng luồng, và không trộn mã của nhiều luồng vào một chỗ. Một hoạt động đồng thời thực tế của bài toán phải tương ứng chính xác với một tiến trình đồng thời trong ngôn ngữ lập trình thì khoảng cách khái niệm mới được tối thiểu hóa: https://erlang.org/download/armstrong_thesis_2003.pdf
    Bài phê bình async/await của Ron Pressler, người triển khai Project Loom của Java, cũng rất hay: https://www.youtube.com/watch?v=oNnITaBseYQ

    • Ghét JavaScript thì vui thật, nhưng xem lại bài thuyết trình đầu tiên của Ryan Dahl khi giới thiệu Node.js cũng khá thú vị: https://www.youtube.com/watch?v=EeYvFl7li9E
      Ông ấy khá lưỡng lự với chính JavaScript, và mục tiêu chính là tìm một abstraction để xử lý vòng lặp sự kiện I/O epoll() theo cách không khiến người ta muốn tự chọc vào mắt mình. Trước đó ông cũng đã thử nhiều cách khác
    • async/await thực ra bắt đầu từ C#, không phải JavaScript
      Anders Hejlsberg của C# cũng tạo ra TypeScript, và các tính năng như class, arrow function, async/await của TypeScript cuối cùng đã đi vào ES6+
      Tôi cho rằng đó là một giải pháp tuyệt vời trong JS/TS, vốn là event loop đơn luồng. Nhưng ngôn ngữ càng cấp thấp thì nó càng tệ với tư cách là một abstraction, nên phần lớn các phê bình async Rust xuất phát từ đây là xác đáng
  • Bài viết giải thích tốt sự phức tạp và khó khăn của async Rust, nhưng cũng cần nhớ rằng một trong các triết lý cốt lõi của Rust là an toàn bộ nhớ mà không hy sinh hiệu năng
    Các pattern bất đồng bộ của Rust, đặc biệt là cách buộc trình biên dịch đảm bảo an toàn dữ liệu, thể hiện rõ triết lý này. Dù có phức tạp, nó vẫn có giá trị như một mô hình đồng thời an toàn hơn, buộc lập trình viên suy nghĩ sâu về dữ liệu và luồng thực thi
    Rust có thể không phải đáp án cho mọi ứng dụng user-space đồng thời quy mô lớn, nhưng trong các hệ thống đặt độ vững chắc và an toàn lên hàng đầu, sự đánh đổi này có thể được biện minh. Khi hệ sinh thái phát triển, nhiều abstraction và thư viện giúp giảm các nỗi đau này cũng có khả năng xuất hiện hơn

  • Tôi đang viết khá nhiều Rust lock-free dựa trên async. Vấn đề chính là Tokio future là 'static, và điều này đến từ một sai lầm thiết kế ăn sâu trong hệ sinh thái Rust: quyết định rằng rò rỉ bộ nhớ là an toàn
    Vì thế không thể đảm bảo tĩnh rằng future sẽ được dọn dẹp đúng cách. Khi tạo một tác vụ bất đồng bộ, nếu ai đó dùng std::mem::forget để quên future đó, borrow checker không thể biết rằng các tham chiếu mà future đó truyền tiếp vẫn còn sống
    Thay vì rải Arc khắp nơi, tôi dùng crate unsafe này: https://docs.rs/async-scoped/latest/async_scoped/
    Như vậy đã bắt được 99% lỗi mà tôi có thể mắc trong C++, nên đây là một đánh đổi hợp lý. Công việc triển khai future không-'static theo cách an toàn cũng đang được tiến hành, hy vọng sẽ thành công
    Một vấn đề lớn khác là async trait hiện yêu cầu future được boxing, khiến mỗi ranh giới gọi hàm đều thêm malloc/free, nhưng việc này nằm trong roadmap sửa trong năm nay
    Lời khuyên “cứ dùng channel đi” cũng làm luồng điều khiển bị phân tán khắp nơi trong codebase lớn. Channel có cảm giác như GOTO phiên bản hiện đại; tôi cũng dùng, nhưng không mấy khi dùng trong trường hợp chỉ chạy vài việc song song rồi chờ chúng hoàn tất

    • Điểm phân biệt quan trọng là không phải bản thân Tokio future là 'static, mà chỉ những future có thể được spawn để tận dụng tính đồng thời của runtime mới phải là 'static
      Để được poll(), future phải được Pin, và T: !Unpin đã được Pin thì cuối cùng phải gọi Drop: https://doc.rust-lang.org/std/pin/#drop-guarantee
      Future được tạo bởi tính năng async của trình biên dịch có tính chất này, và future viết thủ công cũng có thể thêm PhantomPinned. Nhờ vậy, sau khi đã poll(), các trò với mem::forget có thể được xem là hành vi không xác định, và các thư viện future intrusive/tự tham chiếu cũng trở nên khả thi: https://docs.rs/futures-intrusive/latest/futures_intrusive/
      Future vẫn có thể tiếp tục sống và bị rò rỉ do Arc/Rc, nhưng từ góc nhìn của người phát triển thư viện, việc đó hoặc không thể phân biệt hợp lý với cách dùng bình thường, hoặc không đáng phải bận tâm nhiều
    • Nếu xem việc rò rỉ bộ nhớ được coi là an toàn là một sai lầm thiết kế, tôi tò mò liệu bạn sẽ thích hướng bỏ interior mutability, bỏ Rc, hay gắn thêm ranh giới trait unsafe có tính lây lan hơn