1 điểm bởi GN⁺ 2023-10-08 | 1 bình luận | Chia sẻ qua WhatsApp
  • Tranh luận về runtime async Rust gần với việc lựa chọn giữa work-stealing để cân bằng tải hay share-nothing để giảm di chuyển dữ liệu hơn là chuyện có dùng “mỗi lõi một luồng OS” hay không
  • async Rust đã đáp ứng các điều kiện cốt lõi của thread-per-core là xử lý đồng thời trong không gian người dùng và I/O bất đồng bộ; điểm còn lại là có thể tránh chia sẻ trạng thái giữa các lõi đến mức nào
  • Work-stealing cố giảm thời gian nhàn rỗi của luồng để cải thiện tail latency và mức sử dụng CPU, nhưng trong Rust, trạng thái đi qua điểm yield phải an toàn luồng nên kéo theo ràng buộc Send
  • Share-nothing có thể giữ dữ liệu trong cache của một lõi cụ thể để giảm độ trễ, nhưng độ khó khác nhau rất lớn giữa trường hợp dễ phân mảnh trạng thái như kho key-value và trường hợp cần giao dịch hoặc thay đổi nguyên tử
  • Bài báo của Enberg so sánh share-nothing với shared-state, nhưng cả hai phía đều không dùng work-stealing, nên khó nối trực tiếp kết quả đó với việc phê phán executor work-stealing của Rust

Cốt lõi của tranh luận về runtime async Rust

  • Trong cộng đồng Rust, vẫn có tranh luận xoay quanh việc các runtime async chủ chốt như tokio mặc định dùng executor đa luồng và cân bằng động nhiều task bằng work-stealing
  • Một số người dùng chỉ trích rằng mặc định này làm các ràng buộc Send + 'static hoặc Send + Sync + 'static gây hại cho trải nghiệm viết mã
  • Cũng có trường hợp ưa chuộng máy chủ đơn luồng, nhưng ở đây giả định hệ thống Rust đang muốn dùng thời gian của từ một lõi CPU trở lên
  • Phương án thay thế được nêu ra là “thread-per-core”, thường được kỳ vọng là nhanh hơn và cũng dễ triển khai hơn, nhưng khó có thể đạt được cả hiệu năng lẫn sự đơn giản cùng lúc

Sự nhầm lẫn do cái tên “thread-per-core” tạo ra

  • Các executor async đa luồng hiện có, theo nghĩa rộng, cũng thuộc kiểu thread-per-core
    • Tạo một luồng OS cho mỗi lõi
    • Lập lịch số lượng task lớn hơn rất nhiều số lõi lên các luồng đó
  • Pekka Enberg tóm tắt thread-per-core như sự kết hợp của ba ý tưởng
    • Xử lý đồng thời trong không gian người dùng thay vì dùng các luồng kernel đắt đỏ
    • Dùng I/O bất đồng bộ để luồng theo từng lõi không bị chặn
    • Phân mảnh dữ liệu giữa các lõi CPU để loại bỏ chi phí đồng bộ hóa và việc di chuyển dữ liệu giữa các cache CPU
  • Enberg cho rằng hai ý đầu quan trọng để xây dựng hệ thống thông lượng cao, còn ý thứ ba có thể chỉ cần thiết trên các máy đa lõi rất lớn
  • Nếu dùng async Rust thì hai điều kiện đầu, là đồng thời trong không gian người dùng và I/O bất đồng bộ, đã được đáp ứng
  • Vì vậy, trọng tâm tranh luận không phải bản thân thread-per-core mà là chọn tối ưu hóa nào giữa work-stealingshare-nothing

Mục tiêu và chi phí của work-stealing

  • Work-stealing là tối ưu hóa nhằm bảo đảm mọi luồng luôn có việc để làm, từ đó giảm tail latency
  • Trong hệ thống thực tế, lượng công việc cần cho mỗi task là khác nhau
    • Một HTTP request có thể đòi hỏi nhiều việc hơn hẳn request khác
    • Dù ban đầu phân chia công việc giữa các luồng có cân bằng, khác biệt khó dự đoán giữa các task vẫn có thể khiến tải lệch đi theo thời gian
  • Ở tải tối đa, có thể có luồng nhận nhiều việc hơn khả năng xử lý, trong khi luồng khác lại nhàn rỗi
  • tokio, async-std và smol đều triển khai work-stealing với mục tiêu giảm tail latency và cải thiện mức sử dụng CPU
  • Cái giá là task có thể dừng trên một luồng rồi tiếp tục chạy trên luồng khác
    • Trạng thái được dùng qua điểm yield phải an toàn luồng
    • Trong API Rust, điều này xuất hiện dưới dạng future phải là Send
    • Nếu không nắm rõ trạng thái hệ thống, rất khó quyết định phải bảo đảm Send theo cách nào
  • Khi trạng thái di chuyển sang luồng khác, sẽ phát sinh chi phí đồng bộ hóa và cache miss, điều này xung đột với nguyên tắc share-nothing là mỗi CPU chỉ xử lý trạng thái của chính nó

Lý do hiệu năng của share-nothing

  • Share-nothing là thiết kế nhằm giữ dữ liệu trong cache nhanh hơn của một lõi CPU cụ thể thay vì trong cache chậm do nhiều lõi cùng chia sẻ, qua đó giảm tail latency
  • Bài báo của Enberg The Impact of Thread-Per-Core Architecture on Application Tail Latency benchmark một kho key-value theo kiến trúc share-nothing và memcached theo cấu trúc shared-state, cho thấy tail latency được cải thiện
  • Cách cộng đồng Rust trích dẫn đơn giản bài báo này kiểu như “cải thiện hiệu năng 71%” là hời hợt và không hữu ích
  • Kho key-value của Enberg phân chia trạng thái và kết nối để đạt cấu trúc share-nothing
    • Dùng hàm băm để phân chia keyspace cho nhiều luồng
    • Dùng SO_REUSEPORT để phân chia các kết nối TCP đi vào giữa các luồng
    • Định tuyến request từ luồng quản lý kết nối sang luồng quản lý keyspace tương ứng bằng kênh truyền thông điệp
  • memcached thì để mọi luồng cùng chia sẻ quyền sở hữu keyspace đã phân mảnh, và mỗi partition được mutex bảo vệ
  • Kết quả của Enberg cho thấy cấu trúc dùng channel có thể đạt tail latency thấp hơn cấu trúc dùng mutex
    • Có thể hiểu là cache miss giảm đi vì cùng một partition được truy cập lặp lại và nằm trong cache của một lõi
  • Tuy vậy, khó có thể nói rằng một thiết kế tránh di chuyển dữ liệu bằng tính năng kernel nâng cao và kiến trúc được lên kế hoạch cẩn thận lại dễ triển khai hơn cách bọc dữ liệu trong mutex

Khi việc phân mảnh trạng thái dễ và khi nó khó

  • Kho key-value là ví dụ rất hợp với share-nothing vì trạng thái ứng dụng dễ chia ra cho nhiều luồng
  • Nếu ứng dụng phức tạp hơn cần thay đổi trạng thái ở nhiều partition theo cách giao dịch hoặc nguyên tử, thì việc triển khai đúng sẽ đòi hỏi cẩn trọng hơn rất nhiều
  • Việc cổ vũ share-nothing có nét giống với sự hưng phấn quá mức trước đây quanh các cơ sở dữ liệu eventual consistency
    • Hiệu năng có thể tốt hơn
    • Nhưng đổi lại cần thiết kế cẩn thận để tránh lỗi do dữ liệu không nhất quán

Khoảng cách giữa bài báo của Enberg và tranh luận về work-stealing trong Rust

  • Cả hiện thực của Enberg lẫn memcached đều không dùng work-stealing
  • Vì vậy, rất khó nối trực tiếp kết quả hiệu năng cốt lõi của bài báo với kiến trúc work-stealing của Rust
  • Nếu thêm work-stealing vào cấu trúc của Enberg thì mức di chuyển dữ liệu có thể tăng lên phần nào, nhưng cũng có khả năng giúp tăng mức sử dụng CPU
  • Trong trường hợp thêm work-stealing vào memcached, khó hình dung lý do vì sao nó lại không có ích
  • Hiện thực trong bài báo được thiết kế để chia đều công việc từ trước nhờ phân chia keyspace cân bằng và SO_REUSEPORT
  • Nhưng trong môi trường thực tế, mất cân bằng động có thể xuất hiện
    • Hot key có thể nhận nhiều lần đọc và ghi hơn, làm tăng tải cho luồng quản lý keyspace đó
    • Một số kết nối có thể thực hiện nhiều request hơn các kết nối khác, làm tăng tải cho luồng quản lý các kết nối đó
  • Có thể hiểu rằng benchmark trong bài báo, nơi mỗi kết nối thực hiện lượng công việc cố định trên các khóa ngẫu nhiên, không tái hiện các điều kiện mất cân bằng này
  • Ngay trong hệ thống share-nothing, vẫn có thể thiết kế để giảm mất cân bằng, chẳng hạn cache hot key ở thêm partition khác
  • Cũng có thể cố định một số task vào lõi cụ thể để tránh di chuyển trạng thái, trong khi dùng các dạng work-stealing khác như một tối ưu hóa

Kết luận thực tế

  • Nếu thiết kế hệ thống cẩn thận để tránh di chuyển dữ liệu giữa các cache CPU, bạn có thể đạt hiệu năng tốt hơn so với khi không làm vậy
  • Nhưng nếu phàn nàn lớn nhất chỉ là phải thêm ràng buộc Send vào generics, thì khó có thể nói bạn đang làm kiểu kỹ thuật hiệu năng tinh vi đó
  • Dù sao đi nữa, nếu hệ thống của bạn sẽ dùng shared state, thì khó có thể cho rằng work-stealing không cải thiện mức sử dụng CPU khi có tải

1 bình luận

 
GN⁺ 2023-10-08
Các ý kiến trên Hacker News
  • Cá nhân tôi có cảm giác bài này bỏ lỡ bức tranh lớn và sa vào tiểu tiết
    Trọng tâm của cuộc tranh luận không phải là executor kiểu đánh cắp công việc với thread-per-core, mà là liệu async/await trong Rust có phải là một abstraction tốt cho việc đó hay không
    Càng viết nhiều mã async, tôi càng thấy abstraction bị rò rỉ và khó lập trình dựa trên nó
    Mô hình đồng thời thay thế mà mọi người muốn là structured concurrency thông qua coroutine có stack và channel bên trên một executor đánh cắp công việc
    Cho đến khi ai đó triển khai nó và so sánh với async/await cùng mô hình dựa trên future, sẽ khó có một cuộc thảo luận hiệu quả; những người ghét async sẽ tránh nó, còn những người không bận tâm đến Send + Sync + 'static sẽ tiếp tục dùng

    • Nếu vậy, trừ khi thật sự cần quy mô C10M, chẳng phải cứ dùng một thread cho mỗi kết nối và không dùng thứ phức tạp hơn là được sao?
      Tôi nghĩ những việc cần quy mô như vậy trong đa số trường hợp là rất hiếm
      Những bài kiểu này thường chỉ nói “kernel thread đắt đỏ” rồi lướt qua như thể đó là chân lý hiển nhiên, nhưng thực tế không phải vậy
      Nếu công việc không phải chỉ liên tục tạo các task không làm gì, overhead của “thread thật” nhiều khả năng là nhỏ, trong khi sự đơn giản nhận lại thì rất lớn
    • Tôi cho rằng cuộc tranh luận async/await so với coroutine có stack thú vị hơn, nhưng cuộc tranh luận trong bài này không phải là chuyện đó
      Đoạn trích mà withoutboats xử lý là phần trong bài được liên kết phê phán cụ thể đa luồng mặc định và work stealing
      [1] https://www.reddit.com/r/rust/comments/16p47f1/the_state_of_...
    • Trên đời có nhiều chủ đề mà mọi người không đồng ý với nhau
      Bài này chỉ bàn về một cuộc tranh luận khác, không phải cuộc tranh luận mà tôi mong nó viết
    • Có thể dùng async/await cùng với channel, đồng thời tự giới hạn rằng tham số của async function chỉ truyền immutable reference hoặc kiểu copy, còn các kiểu chia sẻ có thể thay đổi thì giao tiếp qua channel
      Bạn cũng có thể tạo server kiểu Erlang sở hữu kiểu có thể thay đổi và giao tiếp qua channel, hoặc cứ dùng Arc đến cùng
      Rust cho bạn sức mạnh để làm cả hai
    • Theo tôi hiểu thì không có gì ngăn việc dùng channel bên trên hỗ trợ async hiện có của Rust
      Coroutine có stack đến mức đó thì dường như không có nhiều ý nghĩa, vì chỉ cần dùng thread riêng là được
  • Vấn đề ban đầu mà mô hình thread-per-core cố giải quyết khoảng 15 năm trước là đạt được khả năng mở rộng và hiệu quả tính toán trên các server đa lõi đa dụng
    Trái với một số lập luận, thread-per-core được thiết kế rõ ràng để tối ưu các workload thiên về CPU, và về sau dù cần xử lý I/O tinh vi hơn, người ta cũng nhận ra nó xuất sắc với các workload thiên về I/O thông lượng cao
    Khi đọc những bài như thế này, tôi thấy chúng dường như đang nhanh chóng lặp lại nhiều sai lầm thiết kế phần mềm từng xảy ra khi kiến trúc thread-per-core được đưa vào
    Công bằng mà nói, khoa học máy tính liên quan đến thread-per-core chủ yếu bắt nguồn từ HPC nên tài liệu hóa còn thiếu
    Bài này tập trung vào một vấn đề khó của kiến trúc thread-per-core: cân bằng công việc giữa các lõi
    Mô hình cơ bản gồm bốn kiểu push/pull của dữ liệu/tải, và work stealing về bản chất là mô hình kéo tải (load pull)
    Cách này có overhead thấp chỉ khi hầu như không cần dùng đến nó, tức là khi có sự cân bằng tự nhiên, điều hiếm gặp trong các bài toán thực tế
    Với những workload thú vị hơn, nơi độ lệch tải động giữa các lõi là phổ biến, overhead điều phối khiến work stealing trở thành nút thắt hiệu năng
    Dù vậy, vì dễ hiểu nên nó vẫn được dùng cho các workload phù hợp, nhưng không khái quát hóa tốt
    Trong số các workload hiếm mà bài viết không nhắc đến, có thể có trường hợp nó là lựa chọn tốt nhất
    Mô hình đang được ủng hộ mạnh nhất hiện nay có vẻ là đẩy dữ liệu (data push); nó kém trực quan hơn nhưng cần ít điều phối giữa các thread hơn nhiều
    Mô hình này cũng có những workload không phù hợp, nhưng khái quát hóa tốt cho phần lớn workload phổ biến
    Kiến trúc thread-per-core sẽ còn tồn tại. Về khả năng mở rộng và hiệu quả thì không thể đánh bại nó
    Tuy nhiên, hầu hết kỹ sư phần mềm thiếu trực giác về một thiết kế thread-per-core hiện đại, mang tính idiomatic sẽ trông như thế nào; tình hình càng tệ hơn vì có ít bài viết hoặc paper đào sâu chủ đề này

    • Lý do chính các framework async của Rust dùng work stealing có vẻ là vì nó dễ bật ở cấp framework và cải thiện hiệu năng cho nhiều ứng dụng, đặc biệt là các ứng dụng không được thiết kế lý tưởng
      Nhìn vào phần tự giới thiệu trong hồ sơ và lời giải thích này, tôi đoán đó không phải là loại ứng dụng mà bạn chủ yếu xử lý
      Nếu có liên kết đến tài liệu liên quan thì tôi muốn đọc
    • Tôi tò mò ý tưởng đẩy dữ liệu là gì
  • Tôi đồng cảm với câu “nói là thiên về I/O thực ra có nghĩa là khi viết bằng Rust thì lượng công việc không đủ nhiều để bão hòa một lõi đơn. Vậy thì dĩ nhiên hãy viết một hệ thống đơn luồng”
    Khá nhiều ứng dụng tôi dùng là các daemon phản ứng với sự kiện ở nền, và nếu làm đơn luồng thì có thể loại bỏ overhead của ArcMutex
    Ở thời điểm đó, overhead này phần lớn giống gánh nặng cú pháp hơn, nhưng việc debug và bảo trì sẽ dễ hơn
    Điều tôi thích ở Rust là chỉ phải trả chi phí cho những gì mình cần
    Bài gốc mà bài này phản hồi phê phán rằng tokio và các thư viện async khác khiến việc quay lại một kiến trúc đơn luồng đơn giản trở nên khó khăn
    Dù có phần phóng đại, nhưng nhìn chung tôi đồng ý với phê phán đó
    Việc làm mặc định phức tạp hơn chỉ vì nó tốt hơn cho các ứng dụng throughput cao có vẻ đi ngược lại lý tưởng của Rust

    • Tôi từng viết những dịch vụ như vậy, nhưng chắc tôi sẽ không gọi chúng là thiên về I/O
      Chúng không bị ràng buộc bởi throughput, mà phần lớn ở trạng thái nhàn rỗi rồi khi có việc thì cố hoàn thành nhanh để giảm sử dụng tài nguyên hệ thống
      Trừ khi thỉnh thoảng có những đợt bùng nổ công việc khổng lồ và khi đó độ trễ cực kỳ quan trọng, dùng nhiều hơn một luồng chỉ tăng độ phức tạp và overhead mà không có lợi ích
    • Đúng vậy. Không phải thứ nào muốn có tính đồng thời cũng là web server
      Trong hệ điều hành, mọi dịch vụ hệ thống đều phải xử lý đồng thời các yêu cầu IPC, nhưng phần lớn xử lý bằng một luồng để giảm tổng mức tiêu thụ CPU
      Trên thiết bị 4 lõi, nếu biến hàng chục dịch vụ thành thread-per-core thì sẽ lãng phí CPU và RAM
    • tokio hỗ trợ executor đơn luồng để dùng khi thật sự cần, và cũng không khó
      Trong API của tokio, nó được gọi là LocalSet
      https://docs.rs/tokio/latest/tokio/task/struct.LocalSet.html...
    • Tôi cũng đang định viết về cùng đoạn trích đó
      Ngay cả khi một luồng trên một CPU đơn là đủ, bạn vẫn có thể muốn tính đồng thời
    • Thay vì ArcMutex thì có lẽ sẽ dùng RcRefCell, nhưng xét về mặt code thì tôi nghĩ nó cũng phức tạp và dài dòng tương tự
      Tôi hiểu là hiệu quả thấp hơn, nhưng trong trường hợp được mô tả thì chi phí thêm của vài thao tác nguyên tử dù sao cũng có lẽ không đáng kể?
  • Nói về đoạn trích, tôi đồng ý rằng cách nói “tội tổ tông của lập trình async trong Rust là đặt mặc định thành đa luồng… Send + 'static, tệ hơn nữa là Send + Sync + 'static, giết chết niềm vui khi dùng Rust” là quá kịch tính
    Tôi không nghĩ việc bỏ Send + Sync sẽ tạo ra khác biệt lớn đến vậy
    Điều khó chịu nhất là 'static, và chuyện đó không phải do work stealing
    Thứ tôi muốn là concurrency có scope
    Ví dụ như <https://github.com/tokio-rs/tokio/issues/2596>
    Một điều nữa tôi thật sự ghét trong Rust async hiện nay là thiếu instrumentation
    Ở công ty có một vấn đề production trong đó một số task cứ dừng hẳn lại, và tôi muốn làm được thứ tương đương với gdb; thread apply all bt
    Ít nhất tôi mong <https://github.com/tokio-rs/tokio/issues/5638> được đưa vào
    Hiện tại nó cũng đã tồn tại dưới dạng thử nghiệm, nhưng theo kinh nghiệm của tôi thì đôi khi gây panic
    Hôm nay tôi thực sự định viết một PR để thử dùng bản thử nghiệm khi nhận SIGTERM
    Vì dù sao cũng đang trong quá trình tắt, nên nếu crash cũng không sao
    Không bất mãn nào trong số này được giải quyết bằng cách loại bỏ work stealing
    Dù liệt kê thêm nữa, việc loại bỏ work stealing gần như chẳng giúp được gì

    • Java bị phê phán nhiều, nhưng phần này thì đã hoạt động tốt từ khoảng 25 năm trước và cực kỳ hữu ích cho việc xử lý sự cố
      Không cần debugger; gửi SIGQUIT cho JVM là nó dump stack trace của tất cả luồng ra stderr rồi tiếp tục chạy
      Bao gồm cả việc mỗi luồng đang giữ hoặc đang chờ lock nào
      Mỗi khi dùng ngôn ngữ khác tôi đều nhớ tính năng này
      Cũng có thể dùng để profiling tạm thời trong production
      Chỉ cần chụp nhiều snapshot rồi dùng grep/sed/sort/uniq để tìm hotspot
    • Để giảm nhẹ những vấn đề kiểu này, ở công ty chúng tôi dùng một thư viện định kỳ log task nào đang chạy và mỗi task hiện đang ở file/dòng nào
      Phải rải thủ công r.set_location(file!(), line!()); trước mọi điểm await, nhưng nó đã nhiều lần giúp giải thích vì sao hệ thống trông như bị treo
      [1] https://github.com/antialize/tokio-tasks/blob/main/src/run_t...set_location(), còn task.rslist_tasks()
  • Vấn đề này không có đáp án đúng, tất cả đều phụ thuộc vào trường hợp sử dụng
    Rốt cuộc đây là vấn đề workload thiên về I/O hay thiên về CPU, và những thứ như đẩy dữ liệu khỏi cache hay tranh chấp lock gây ảnh hưởng xấu đến mức nào
    Nếu bạn đưa một HTTP server giao tiếp với cơ sở dữ liệu bên ngoài và có một ít logic nghiệp vụ nhẹ ở giữa lên một máy chủ ảo dùng chung, thì về trực giác, work stealing và tái sử dụng thread là hợp lý
    Tất nhiên lúc nào cũng phải benchmark
    Ngược lại, nếu bạn đang xây dựng một cơ sở dữ liệu hoặc hệ thống tương tự, và dưới tải cao, độ đồng thời lớn cùng nhiều lần chuyển ngữ cảnh gây ra cache eviction và tranh chấp ở khắp nơi, bạn sẽ rất vất vả
    Khi đó thread-per-core rất hợp lý, và bản thân async framework có thể cũng chẳng có ý nghĩa
    Không có đáp án võ đoán nào rằng cái gì “tốt hơn”
    Bạn phải profile ứng dụng của mình
    Như đã nói trước đây, tôi cảm thấy trọng tâm chung của Rust đang bị bóp méo bởi làn sóng lớn đổ vào phát triển web service
    Tôi vẫn chưa chắc Rust có phải ngôn ngữ phù hợp cho những việc đó không, nhưng có vẻ nó hoạt động khá ổn với họ, nên cũng đành vậy
    Tuy nhiên, các thảo luận công khai về ngôn ngữ và những crate được đẩy lên tuyến đầu hiện nhìn chung đang phản ánh thiên kiến này
    Đây cũng là thiên kiến của nhiều kỹ sư phần mềm trên diễn đàn này

    • Tôi không thấy async gây ảnh hưởng nhiều đến các phần khác của Rust
      Các ứng dụng hệ thống như game engine, thư viện mật mã, kernel, công cụ dòng lệnh, compiler, v.v. vẫn đang được xây dựng thành công mà không cần đụng đến async
      Tôi đang bảo trì một thư viện mật mã lớn, và hoàn toàn không bị ảnh hưởng bởi những chuyện bên async
    • Tôi đồng ý là có làn sóng lập trình viên web đổ vào
      Sẽ rất đáng tiếc nếu hệ quả là tính hữu dụng của Rust cho lập trình hệ thống bị phá hỏng
    • Tôi từng nghĩ web app lại là thứ lý tưởng cho thread-per-core
      Bản thân ứng dụng gần như không có trạng thái ngoài request, còn socket listener và kết nối cơ sở dữ liệu có thể chia theo từng thread
      Những trạng thái còn lại nhiều khả năng cũng phần lớn là tĩnh giữa các request, nên cache invalidation sẽ không xảy ra thường xuyên
      Vì không có trạng thái chia sẻ nên xử lý ownership cũng đáng ra phải dễ hơn
    • Tôi nghĩ chuyện này xảy ra vì cộng đồng Rust đã khá tích cực thu hút nhóm lập trình viên đó để giành thị trường và độ nhận biết
      Tôi không định nói điều đó tốt hay xấu, nhưng giờ Rust phải sống chung với một dòng chảy vô tận các thư viện và framework liên quan đến web với chất lượng rất không đồng đều
      Và vì khá nhiều thư viện nền tảng cốt lõi và crate đã chọn cách tiếp cận async-first, async sẽ tiếp tục là chủ đề được bàn luận
      Giờ đây, đến mức một lập trình viên bình thường khó mà viết mã đồng bộ bình thường cho vấn đề nghiệp vụ, trừ khi họ tuyên bố không dùng async như một phương châm chính của dự án
  • Tôi cho rằng việc yêu cầu ràng buộc Send để cho phép task di chuyển giữa các thread của executor là một khiếm khuyết rõ ràng của chính hệ thống async trong Rust
    Vì cùng với vấn đề căn bản của async Drop, nó ngăn cản việc triển khai các API có scope
    Cũng như với thread, chỉ các hàm như spawn hoặc gửi dữ liệu qua channel mới cần có ràng buộc Send là đủ
    Cách tiếp cận không chia sẻ thường chẳng hơn gì một workaround để che giấu khiếm khuyết này
    Việc tùy chọn ghim task vào một thread/core cụ thể có những ưu điểm và thực sự hữu ích trong một số tình huống, nhưng đó là một thảo luận tinh tế hơn, và không liên quan mấy đến những phàn nàn của người dùng async về Send

  • Bài viết hay, khuyên mọi người đừng chỉ nhìn tiêu đề mà hãy đọc thêm
    Câu tôi thích nhất là: “Nếu bất mãn lớn nhất là phải thêm ràng buộc Send vào một generic nào đó, thì thật khó tin người đó đang làm engineering ở mức đó”
    Chỉnh sửa: Tôi hoàn toàn đồng ý với bình luận của “duped”
    Vì không biết bối cảnh lớn hơn của cuộc thảo luận này, có thể tôi đã trích câu đó quá vội vàng

  • Đoạn “những người không nhìn đúng trạng thái hệ thống có thể khó tìm ra cách tốt nhất để đảm bảo future là Send” nghe có vẻ hơi kiêu ngạo
    Các vấn đề về lifetime 'static và ràng buộc Send/Sync rất quen thuộc với lập trình viên, và tôi không có cảm giác họ là đồ ngốc

    • Đây là đang chỉ lại điều đã nói trước đó
      Mọi người nói rằng không work stealing thì dễ hơn và nhanh hơn
      Lập luận của tôi là chỉ có một trong hai điều đó đúng
      Để cách không chia sẻ nhanh hơn, bạn phải thiết kế mã theo một cách không hề dễ hơn việc làm cho kiến trúc trạng thái chia sẻ trở nên thread-safe
      Ở đoạn sau có một câu song song đối ứng với “chậm”
      Tôi không nghĩ những người gặp khó khăn để làm cho Rust song song/đồng thời compile được là đồ ngốc
      Chỉ là tôi không thích việc họ hành xử như thể các API chúng ta tạo ra đã phá hỏng cuộc đời họ
    • Việc đặt “khó” trong dấu ngoặc kép là vì không nhất thiết nó khó hơn
      Nếu đó là việc dù sao cũng phải làm, thì nó không khó hơn
      Thay vì “mọi người phản ứng thái quá với việc nhỏ nhặt”, ý đúng hơn là async buộc bạn phải giải quyết sớm hơn những vấn đề mà cuối cùng rồi cũng phải giải quyết
      Cảm giác giống borrow checker
      Đôi khi nó quá hạn chế, nhưng đôi khi đúng là có những edge case mà bạn đã không cân nhắc khi giả định mọi thứ đều ổn
    • Cá nhân tôi đôi khi cũng thật sự gặp khó khăn trong việc hiểu state của mình có phải Send hay không
      Vì vậy câu được trích dẫn đó gây đồng cảm với tôi
  • Đây là một bài viết sa đà vào chi tiết mà bỏ lỡ bức tranh lớn
    Không có cách nào luôn đúng để đạt hiệu năng tốt nhất trong mọi chương trình
    Có thể tranh luận bao nhiêu cũng được, nhưng ưu và nhược điểm của thread-per-core là kiểu “tùy tình huống” điển hình
    Vấn đề là ngay từ đầu, việc dùng async tự thân đã là tối ưu hóa quá sớm
    99% chương trình Rust không phải là những thứ như redis hay linkerd
    Chúng là CLI tool hoặc web app mà viết bằng Python hay Ruby cũng đã đủ nhanh
    Vậy nên tôi tự hỏi tại sao cộng đồng lại bỏ blocking I/O Rust để mọi thứ đều trở thành async, và các lập trình viên thì gắn #[tokio::main] vào mọi thứ như thể đó là mặc định

    • Một lý do có thể là nếu hiệu năng thread-per-core đã đủ, thì ngay từ đầu bạn nhiều khả năng đã không muốn dùng Rust
      Có những ngôn ngữ đem lại trải nghiệm lập trình tốt hơn để đổi lấy tốc độ, và Python là một ví dụ
      Nếu bạn muốn dùng Rust, có lẽ bạn cần hiệu năng bổ sung; và vì đã chấp nhận một ngôn ngữ kém tiện lợi hơn, bạn cũng có thể chấp nhận một phong cách kém tiện lợi hơn để có hiệu năng tốt hơn
  • Tôi chưa từng dùng Rust, nhưng hiểu được sự bất mãn này
    Có lẽ sẽ khá khó chịu nếu phải viết code theo một cách đặc biệt để trạng thái có thể được chuyển giữa các thread, chỉ nhằm tái phân phối tải — thứ vốn có thể làm tăng độ trễ end-to-end của một request đơn lẻ trong những tình huống không cần thiết và CPU còn dư dả
    Nếu đây là một nền tảng nơi trạng thái có thể di chuyển là mặc định và hầu như không bị phá vỡ, thì cách tiếp cận này có thể hợp lý, nhưng có vẻ với Rust thì không phải vậy
    Điều tôi tò mò là trải nghiệm sử dụng
    Không rõ nó gần với kiểu “nếu không thêm câu thần chú Send thì code không compile”, hay gần với kiểu “trạng thái bị hỏng trong lúc work stealing và thỉnh thoảng lỗi theo cách khó debug” hơn

    • Giả sử ta viết đại khái đoạn code như thế này
      Có một Server, trong serve đọc message rồi spawn(async move { ... }) mỗi handler của từng message thành một task mới
      Ban đầu mọi thứ hoạt động tốt
      Rồi một ngày nào đó bạn đổi implementation của do_this khiến type của this không còn là Send nữa, và spawn(...) sẽ trả về một lỗi compile đáng sợ rằng type do scope async move { } ẩn danh tạo ra không phải là Send nên không được
      Lý do không nhất thiết rõ ràng, và thông báo lỗi cũng không hữu ích
      Nếu this không phải Send thì không thể giữ nó xuyên qua .await của do_that(arg).await
      Vì mỗi .await là một điểm thực thi nơi future nhường quyền và có thể được executor schedule sang thread khác
      Nếu có thể làm cho type thành Send thì ổn
      Nhưng cũng có nhiều trường hợp không thể, và khi đó phải đổi cách schedule future sang thứ như spawn_local
      Để gọi cái này, có thể phải thêm khá nhiều boilerplate
      Đây là vấn đề của Send
      Không đơn giản chỉ là thêm type annotation; vì không phải lúc nào cũng rõ một type có implement Send hay không, nên nó có thể ngấm ngầm len vào code và sau này vỡ theo cách không rõ ràng
    • Nhìn chung compiler phát hiện được một thứ có phải SendSync hay không
      Nếu viết chương trình thread-safe thì sẽ không có vấn đề
      Điểm cốt lõi là thế này
      Mọi người phàn nàn rằng Tokio async khó vì chỗ nào cũng đòi SendSync, nhưng thực ra bản thân việc viết code concurrent an toàn, dù thuộc loại nào, đã khó rồi
      Nó không trực quan, và vấn đề là async tạo cảm giác như tự động “xử lý giúp” chuyện đó
      Nhưng thực tế thì không
      Bạn phải biết mình đang làm gì, và compiler chỉ hỗ trợ bạn ở đây thôi
      Nếu chuyển sang thread-per-core, trong một số tình huống có thể che giấu nhu cầu về Send, nhưng không phải tất cả
      Và về dài hạn, nó có thể quay lại cắn bạn ở khía cạnh kiến trúc
    • Code sẽ không compile nếu mọi thứ ở những nơi cần thiết không đều là Send/Sync
      Tôi có thể sai, nhưng cách lười biếng để đạt được điều này thường là bọc những thứ có thể được chia sẻ bằng Arc hoặc Mutex
    • Tôi nghĩ lập luận phản đối việc lấy thread-per-core làm mặc định có thể nói rất đơn giản
      Nếu thiên về CPU, work stealing có lẽ tốt hơn trong hầu hết trường hợp
      Nếu thiên về I/O, thread-per-core có thể hoạt động tốt hơn, nhưng dù sao CPU cũng còn dư khá nhiều nên hiệu năng không quá quan trọng
      Theo tôi, work stealing là mặc định tốt hơn để đưa vào API của ngôn ngữ