Các nguyên tắc xây dựng ứng dụng Tokio hiệu năng cao
Bài viết tổng hợp những nguyên tắc cốt lõi để viết ứng dụng bất đồng bộ chạy nhanh trên Tokio, từ việc cân bằng giữa độ trễ và thông lượng, xử lý mutex, đến cô lập worker khỏi các luồng khác. Đây là ghi chú từ một buổi thảo luận tại RustConf về gỡ lỗi và đo hiệu năng ứng dụng async.

Các nguyên tắc xây dựng ứng dụng Tokio hiệu năng cao
Bài viết này tổng hợp những nguyên tắc giúp lập trình viên Rust viết ứng dụng bất đồng bộ chạy nhanh trên Tokio — runtime async phổ biến nhất của Rust. Nội dung được đúc kết từ một buổi thảo luận tại RustConf về gỡ lỗi và đo hiệu năng ứng dụng async, kết hợp với kinh nghiệm thực tế của tác giả Russell. Điểm mấu chốt: hiệu năng phụ thuộc rất nhiều vào ngữ cảnh, và phần lớn vấn đề nằm ở chính code ứng dụng chứ không phải ở Tokio.
Trước tiên, hãy xác định xem bạn có vấn đề thật không
Nếu bạn bắt đầu tìm kiếm các dấu hiệu xấu trong một ứng dụng Tokio, bạn sẽ luôn tìm thấy chúng. Gần như mọi ứng dụng thực tế đều có các poll (khoảng thời gian giữa các điểm .await, khi code trả quyền điều khiển về runtime) dài hơn mức 10–100 microsecond mà Alice Ryhl khuyến nghị trong bài viết nổi tiếng What is Blocking?.
Tuy nhiên, những vấn đề này có thể không ảnh hưởng đến chỉ số hay hành vi mà bạn thực sự quan tâm. Điều quan trọng là phải làm ngược lại từ một chỉ số cụ thể mà bạn muốn cải thiện. Một ứng dụng có thể có những poll dài hoàn toàn vô hại, và việc "sửa" chúng sẽ không cải thiện được chỉ số nào mà người dùng cảm nhận được.
Trong phần lớn các vấn đề mà tác giả gặp phải, nguyên nhân nằm ở chính code ứng dụng, thường là sự tương tác giữa nhiều thành phần trong một hệ phân tán — chứ không thực sự nằm ở Tokio. Chỉ số Tokio hữu ích nhất là biểu đồ độ trễ lập lịch (schedule latency histogram) mới được bổ sung. Độ trễ lập lịch là khoảng thời gian từ lúc task của bạn sẵn sàng chạy (ví dụ: socket đã có dữ liệu) đến lúc Tokio thực sự poll future đó.
Tách để giảm độ trễ, gộp để tăng thông lượng
Trả quyền điều khiển thường xuyên hơn để tối ưu độ trễ
Độ trễ thấp trên nhiều kết nối đòi hỏi sự công bằng giữa các kết nối. Hãy xét Redis (hoặc bất kỳ ứng dụng nào hỗ trợ request pipelining). Một cài đặt ngây thơ sẽ đọc dữ liệu trực tiếp từ kết nối khi còn dữ liệu. Khi các request được xếp hàng, toàn bộ pipeline (hoặc phần lớn) sẽ nằm trong bộ đệm trên bộ nhớ. Khi đọc từng frame ra, mỗi frame sẽ trả về Poll::Ready mà không cần quay lại mạng.
Điều này tạo ra cả những poll dài lẫn sự bất công bằng giữa các client. Tác động lên thông lượng thường nhỏ hơn, nhưng độ trễ thay đổi rất mạnh vì toàn bộ pipeline này có thể phải chờ sau một pipeline khác.
Phân bố độ trễ của mini-redis
Trong ví dụ này, việc chủ động nhường quyền sau mỗi request có thể giảm độ trễ khoảng 10 lần. Bạn có thể làm tốt hơn nữa bằng cách chỉ nhường sau vài lần đọc sẵn sàng liên tiếp.
async fn handle_conn(&mut self) -> crate::Result {
while !self.shutdown.is_shutdown() {
// Nếu kết nối có dữ liệu đệm, lệnh này có thể lặp lại
// và trả về Poll::Ready mà không nhường quyền về runtime.
let frame = tokio::select! {
res = self.connection.read_frame() => res?,
_ = self.shutdown.recv() => {
return Ok(());
}
};
execute_command(&self.db, &mut self.connection, frame).await?;
// Để cải thiện tính công bằng:
// tokio::task::yield_now().await;
}
}
Nhường quyền sau bốn lần đọc sẵn sàng liên tiếp giúp các request pipeline công bằng hơn nhiều mà không từ bỏ hoàn toàn lợi ích của việc gộp lô.
Dấu hiệu nhận biết vấn đề này:
- P99 lớn hơn P50 rất nhiều
- Các poll mất nhiều thời gian hơn mức công việc bên trong đòi hỏi
- Nhiều span nằm gọn trong một poll duy nhất
Gộp lô công việc để chia nhỏ chi phí
Tính công bằng không miễn phí. Bạn càng làm được nhiều việc hữu ích trong mỗi sự kiện của runtime — chuyển task, poll, di chuyển giữa các worker hoặc đổi luồng — thì ứng dụng càng hiệu quả.
Ví dụ điển hình nhất là tokio::fs. Tác giả thậm chí từng nói rằng "tokio::fs được xem là có hại". Không có io_uring, Tokio chạy mỗi thao tác hệ thống tệp trên blocking pool. Mỗi lần gọi spawn_blocking đều tốn chi phí, và mỗi runtime đều dùng chung một blocking pool.
Nếu biết trước mình sẽ thực hiện một chuỗi thao tác hệ thống tệp — hoặc bất kỳ công việc blocking nào — hãy gộp chúng thành một đoạn blocking lớn nhất hợp lý. Trong một số trường hợp, một luồng hệ điều hành riêng là lựa chọn tốt hơn.
Ngay cả việc spawn task cũng không miễn phí! Spawn một task thì rẻ, nhưng nếu bạn spawn hàng trăm hay hàng nghìn task, mỗi task là một đơn vị công việc mà runtime phải xử lý riêng. Việc spawn một đơn vị công việc chỉ 10 microsecond vào task riêng có lẽ phản tác dụng.
Dấu hiệu nhận biết vấn đề này:
- Các API như
spawn_blockingchiếm thời gian đáng kể trên flamegraph - Một vòng lặp thực hiện nhiều thao tác hệ thống tệp hoặc blocking nhỏ lẻ
- Thông lượng tăng khi gộp cùng công việc thành đơn vị lớn hơn
Cẩn trọng với tài nguyên toàn cục
Runtime Tokio lập lịch công việc trên các worker — những luồng chuyên dụng poll các task sẵn sàng. Worker mở rộng theo số nhân, nhưng một số tài nguyên của runtime vẫn cần phối hợp chung.
Blocking pool hiện là tài nguyên toàn cục. Ở tốc độ đủ cao, việc đẩy công việc vào hàng đợi blocking trở thành điểm nghẽn và spawn_blocking có thể hiện rõ trên flamegraph. Tác giả từng quan sát hiệu ứng tiêu cực về hiệu năng ở khoảng 50.000 task blocking mỗi giây trên máy chủ 32 nhân.
Tokio cũng có một hàng đợi task toàn cục. Task rơi vào đó khi hàng đợi cục bộ của worker tràn (thường hiếm), hoặc khi công việc được lập lịch từ bên ngoài một worker (thường gặp hơn trong một số ứng dụng). Một ví dụ là channel có sender chạy trên luồng không phải Tokio.
Dấu hiệu nhận biết vấn đề này:
- Các thao tác toàn runtime như
spawn_blockingnổi bật trên flamegraph - Hàng đợi toàn cục luôn sâu. Trong ứng dụng khỏe mạnh, nó thường gần như trống; trong ứng dụng quá tải, nó mất rất lâu để rút cạn
Cực kỳ cẩn trọng với mutex
Một trong những cách dễ nhất để làm đình trệ toàn bộ runtime là chặn một worker trên một mutex đang bị tranh chấp.
Những thứ như registry metrics nằm sau mutex hoặc read-write lock đặc biệt dễ gặp vấn đề này. Nếu một thao tác flush giữ lock trong khi làm việc nặng, mọi worker Tokio có thể dần dần lập lịch một task thử ghi metric và bị chặn trên cùng một lock. Việc stole work trở nên bất khả thi vì mọi worker đều kẹt!
Tranh chấp lock trong Tokio
Hãy giữ các vùng tới hạn (critical section) trong ứng dụng async cực kỳ ngắn (ví dụ: một lần cập nhật hashmap). RWLocks gần như không bao giờ là primitive đúng để dùng vì chúng vẫn tạo tranh chấp trên các atomic, ngay cả ở đường đọc. Đừng giữ lock trong khi flush, thực hiện I/O, hoặc await một future khác.
tokio::sync::Mutex đánh đổi vấn đề này lấy vấn đề khác: mutex của Tokio đắt hơn nhiều khi lock, dễ gặp các vấn đề tinh vi như FutureLock, và thực sự chỉ phù hợp nếu vùng tới hạn kéo dài vài mili-giây.
Dấu hiệu nhận biết vấn đề này:
- P99 tăng vọt ở những khoảng thời gian dự đoán được, ví dụ mỗi phút một lần khi task nền chạy
- Trong dial9, nhiều task đột nhiên bị chặn và rời CPU trong khoảng thời gian đáng kể
- Một mutex blocking bị tranh chấp làm kẹt cả bốn worker runtime cùng lúc
Giới hạn mức song song — thường là vậy
Tokio có thể spawn nhiều task hơn mức phần còn lại của hệ thống có thể xử lý. Việc vô tình mở 3.000 kết nối đồng thời đến S3 vì một workload fan-out số task không giới hạn là rất phổ biến.
Câu trả lời thật nhàm chán: giới hạn mức đồng thời. Các thuật toán thích ứng phức tạp đôi khi phù hợp, nhưng một Semaphore thường là đủ.
Cô lập worker Tokio khỏi các luồng khác
Thiết kế của Tokio dựa vào việc worker được đánh thức nhanh chóng. Tuy nhiên, nếu hệ điều hành đang quá tải, kernel có thể mất 10–20 ms hoặc hơn để lập lịch một worker sau khi Tokio cố đánh thức nó. Nếu bạn đo độ trễ P99 ở mức một vài mili-giây, đây là thảm họa.
Tác giả từng quan sát điều này khi di chuyển dần từ Java sang Rust tại Amazon, khi cả hai tiến trình chạy trên cùng một máy chủ và tiến trình Rust dần đảm nhận nhiều việc hơn. Tiến trình Java càng làm ít việc, tiến trình Rust càng nhanh — dù nó xử lý nhiều việc hơn. Hiệu ứng này còn mạnh hơn khi ứng dụng khác dùng số lượng lớn luồng.
Giải pháp cơ bản nhất là dùng cgroups hoặc các API tương tự để ghim worker Tokio và code khác vào các nhân CPU riêng biệt. Vấn đề tương tự cũng có thể phát sinh từ các luồng Rust khác. Các luồng nền như những luồng do tracing_appender dùng đôi khi có thể làm việc hơn 100 ms mà không nhường CPU.
Bạn hiếm khi cần dùng mọi nhân cho Tokio, và việc dành riêng nhân cho công việc khác thường cải thiện độ trễ.
Dấu hiệu nhận biết vấn đề này:
- dial9 cho thấy độ trễ lập lịch của kernel giữa sự kiện đánh thức worker và lúc worker thực sự chạy
Mẹo cho khi bạn hiểu rõ hệ thống hơn
Những mẫu dưới đây nói chung không phải là điều nên làm, nhưng đôi khi chính là thứ mà một workload cần.
Chặn executor có thể ổn — đôi khi
Trong một ứng dụng async lý tưởng, mọi công việc diễn ra thành từng đợt nhỏ với tần suất nhường quyền cao. Thực tế không phải lúc nào cũng vậy, và những đợt nhỏ không nhất thiết là cách chạy phần mềm nhanh nhất. Gộp lô công việc có thể hiệu quả hơn.
Trên thực tế, poll dài không phải lúc nào cũng là vấn đề. Dưới tải nhẹ, cơ chế stole work của Tokio có thể bù đắp khi một worker bận lâu hơn bình thường. Điều này bắt đầu sụp đổ trong hai điều kiện:
- Runtime Tokio bị tải nặng và không còn dung lượng worker dự phòng
- Hệ điều hành bị tải nặng, nên việc đánh thức worker thường bị trễ
!!! Lưu ý quan trọng: Lời khuyên này không áp dụng nếu bạn dùng những thứ như tokio::join! và tokio::select! vốn dùng tính đồng thời trong task. Trong một task đơn lẻ, không có stole work; nếu bạn chặn executor, không gì khác chạy trên task đó có thể tiến triển. Điều này đôi khi biểu hiện thành timeout bất ngờ và độ trễ tệ nói chung.
Dùng nhiều runtime để cô lập công việc theo mức ưu tiên
Sự cô lập mạnh nhất đến từ việc gán công việc vào các runtime riêng biệt và ghim chúng vào các nhân chuyên dụng. Nhiều dịch vụ mạng có cả công việc nhạy cảm với độ trễ lẫn công việc nền ưu tiên thấp. Đặt chúng vào các runtime riêng tạo ranh giới lập lịch giữa hai loại.
Bạn cũng có thể đặt mức ưu tiên (niceness) của hệ điều hành khi các luồng runtime khởi động, thông qua hook on_thread_start của Tokio. Tại TokioConf, ấn tượng chung từ hầu hết các bài nói là mọi người cuối cùng đều chuyển sang giải pháp dùng ít nhất hai runtime.
Quay vòng (spin) để giữ quyền kiểm soát
Đây là chiến thuật rất nâng cao để theo đuổi độ trễ ở mức microsecond. Tác giả không khuyến nghị dùng nó trước tiên, nhưng nó chắc chắn có thể hiệu quả.
Mỗi lần bạn nhường quyền về bộ lập lịch Tokio — hoặc Tokio park một luồng worker và nhường nó cho hệ điều hành — bạn tạo thêm một cơ hội để công việc đó bị trễ khi thức dậy lần nữa. Với công việc cực nhạy cảm với độ trễ, một lựa chọn là cố ý quay vòng trong một khoảng ngắn định trước, có thể 50 microsecond, thay vì nhường quyền trong khi chờ đợi mảnh công việc hữu ích tiếp theo. Cách này chiếm một nhân và có thể gây hại cho workload lân cận, nên có lẽ sai với hầu hết ứng dụng.
Phụ lục: Mô hình tư duy về Tokio trong bốn gạch đầu dòng
- Future of Rust tiến triển từng bước giữa các điểm await. Những đoạn hoạt động này gọi là poll, theo tên phương thức
Future::poll. - Khi future không được poll, chúng ở trạng thái nhàn rỗi và chờ executor chạy lại. Một executor tốt chỉ poll future khi nó có việc để làm.
- Tokio chạy N worker, thường là một worker cho mỗi nhân khả dụng. Mỗi worker có hàng đợi cục bộ. Khi hàng đợi tràn hoặc công việc không thể thêm vào hàng đợi cục bộ, task sẽ vào hàng đợi toàn cục.
- Khi hàng đợi của một worker bị ứ, worker khác có thể stole công việc — nếu runtime phát hiện được sự mất cân bằng và worker khác còn dung lượng.
Lưu ý kỹ thuật: Tokio 1.52.0 từng phát hành blocking queue phân mảnh nhưng 1.52.1 đã hoàn nguyên sau một lỗi hồi quy có thể khiến
spawn_blockingbị treo. Sau đó, PR #8337 của Tokio đã đưa lại hàng đợi phân mảnh dưới dạng tính năng không ổn định, mặc định bị tắt.


