Mỗi lõi một luồng (Thread-per-core)
(without.boats)- 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 + 'statichoặcSend + Sync + 'staticgâ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-stealing và share-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
Sendtheo 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
Sendvà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
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 + 'staticsẽ tiếp tục dùngTô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
Đ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_...
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
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ùngRust cho bạn sức mạnh để làm cả hai
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
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 đồ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
ArcvàMutexỞ 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
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
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
Trong API của tokio, nó được gọi là
LocalSethttps://docs.rs/tokio/latest/tokio/task/struct.LocalSet.html...
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
ArcvàMutexthì có lẽ sẽ dùngRcvàRefCell, 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ínhTôi không nghĩ việc bỏ
Send + Syncsẽ 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 stealingThứ 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ì
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 hotspotPhải rải thủ công
r.set_location(file!(), line!());trước mọi điểmawait, 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... có
set_location(), còntask.rscólist_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
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
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
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 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 RustVì 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
Sendlà đủ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ề
SendBà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
Sendvà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ạoCác vấn đề về lifetime
'staticvà ràng buộcSend/Syncrấ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ốcMọ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ọ
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
Sendhay khôngVì 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
asynctự thân đã là tối ưu hóa quá sớm99% 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 địnhCó 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ú
Sendthì 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ơnCó một
Server, trongserveđọc message rồispawn(async move { ... })mỗi handler của từng message thành một task mớiBan đầu mọi thứ hoạt động tốt
Rồi một ngày nào đó bạn đổi implementation của
do_thiskhiến type củathiskhông còn làSendnữa, vàspawn(...)sẽ trả về một lỗi compile đáng sợ rằng type do scopeasync move { }ẩn danh tạo ra không phải làSendnên không đượcLý 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
thiskhông phảiSendthì không thể giữ nó xuyên qua.awaitcủado_that(arg).awaitVì mỗi
.awaitlà một điểm thực thi nơi future nhường quyền và có thể được executor schedule sang thread khácNếu có thể làm cho type thành
Sendthì ổnNhư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
SendKhô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
Sendhay 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àngSendvàSynchay khôngNế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
SendvàSync, 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ồiNó 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
Send/SyncTô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
ArchoặcMutexNế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ữ