Máy chủ HTTPS zero-syscall sử dụng io_uring, kTLS và Rust
(blog.habets.se)- Để xây dựng máy chủ web hiệu năng cao, trước đây người ta sử dụng nhiều mô hình dựa trên sự kiện như select(), poll(), epoll
- Tuy nhiên, do giới hạn hiệu năng của các system call này, io_uring đã xuất hiện, đưa vào cách đưa yêu cầu vào hàng đợi để kernel xử lý bất đồng bộ
- kTLS đảm nhiệm việc mã hóa TLS trong kernel, cho phép thêm các tối ưu như khả năng dùng
sendfile()và offload phần cứng - Việc đưa vào descriptorless files cung cấp cách tiếp cận được tối ưu cho io_uring mà không cần truyền trực tiếp file descriptor
- Thông qua dự án mã nguồn mở tarweb kết hợp Rust, io_uring và kTLS, bài viết trình bày cách cung cấp HTTPS mà không cần thêm system call cho mỗi request, đồng thời bàn về các vấn đề liên quan đến an toàn và quản lý bộ nhớ
Sự tiến hóa của kiến trúc máy chủ web hiệu năng cao
- Từ đầu những năm 2000, nhu cầu về máy chủ web dung lượng lớn ngày càng tăng
- Ban đầu, cách tạo một tiến trình mới cho mỗi request là phổ biến, nhưng do chi phí cao nên kỹ thuật preforking đã xuất hiện
- Sau đó, hệ thống tiếp tục phát triển theo hướng đưa vào thread và sử dụng select(), poll() để giảm chi phí chuyển ngữ cảnh
- Tuy vậy, khi số lượng kết nối tăng lên, select() và poll() vẫn có giới hạn mở rộng vì phải thường xuyên truyền các mảng lớn vào kernel
Sự xuất hiện của epoll
- Trong môi trường Linux, epoll được đưa vào để xử lý nhiều kết nối hiệu quả hơn so với các cách trước đó
- epoll chỉ xử lý các thay đổi (delta), nhờ đó giảm lãng phí tài nguyên không cần thiết
- Dù không loại bỏ hoàn toàn mọi system call, chi phí đã được giảm đáng kể
Tổng quan về io_uring
- io_uring không gọi system call cho từng request, mà thêm yêu cầu vào hàng đợi trong bộ nhớ để kernel có thể xử lý bất đồng bộ
- Ví dụ, nếu đưa accept() vào hàng đợi, kernel sẽ xử lý rồi trả kết quả vào completion queue
- Máy chủ web hoạt động bằng cách thêm yêu cầu vào hàng đợi và kiểm tra kết quả trong một vùng nhớ riêng
- Để tránh busy loop, nếu không có thay đổi trong hàng đợi thì cả máy chủ web lẫn kernel chỉ gọi system call khi thực sự cần, giúp tiết kiệm điện năng
- Nếu sử dụng thư viện phù hợp, máy chủ đang hoạt động có thể xử lý request mà không cần system call bổ sung
Môi trường đa lõi và NUMA
- Khi xét đến môi trường CPU đa lõi hiện đại, chiến lược một thread trên mỗi lõi và giảm tối đa việc chia sẻ cấu trúc dữ liệu là hiệu quả
- Trong môi trường NUMA, mỗi thread được tối ưu để chỉ truy cập bộ nhớ ở node cục bộ của mình
- Việc cân bằng phân phối request một cách hoàn hảo vẫn cần thêm nghiên cứu
Cấp phát bộ nhớ
- Việc cấp phát bộ nhớ vẫn tồn tại ở cả phía kernel lẫn máy chủ web, và cấp phát trong user space cuối cùng cũng dẫn tới system call
- Ở phía máy chủ web, có thể cấp phát trước các khối bộ nhớ kích thước cố định cho từng kết nối để tránh phân mảnh và thiếu hụt
- Ở phía kernel cũng cần buffer I/O cho từng kết nối, và có thể điều chỉnh một phần bằng các tùy chọn socket
- Khi xảy ra thiếu bộ nhớ, hệ thống có thể dẫn đến sự cố nghiêm trọng
Giới thiệu kTLS (kernel TLS)
- kTLS là tính năng để Linux kernel đảm nhiệm các phép mã hóa và giải mã
- Phần handshake do ứng dụng xử lý, nhưng sau đó kernel sẽ truyền dữ liệu như thể đó là văn bản thuần
- Điều này cho phép sử dụng
sendfile(), từ đó giảm sao chép bộ nhớ giữa user space và kernel space - Nếu card mạng hỗ trợ, còn có lợi thế offload cả phép mã hóa sang phần cứng
Descriptorless Files
- Đây là cách tiếp cận nhằm giảm overhead phát sinh khi truyền trực tiếp file descriptor từ user space sang kernel space
- Thông qua
register_files, nó dùng một số file nguyên riêng chỉ có hiệu lực trong io_uring và không hiển thị trong /proc/pid/fd - Giới hạn
ulimitcủa hệ thống vẫn được áp dụng
Giới thiệu dự án tarweb
- tarweb là dự án máy chủ web mã nguồn mở minh họa cho việc áp dụng tất cả các công nghệ trên
- Nó có cấu trúc phục vụ nội dung từ một file tar duy nhất, kết hợp Rust, io_uring, kTLS và các công nghệ hiệu năng cao hiện đại
- Trong quá trình triển khai thực tế, đã có các vấn đề tương thích giữa io_uring và kTLS (như chưa hỗ trợ
setsockopt), và một số vấn đề đã được xử lý bằng Pull Request - Dự án vẫn đang ở giai đoạn chưa hoàn thiện, và thư viện rustls của Rust có thể thực hiện cấp phát bộ nhớ trong quá trình handshake
- Điểm cốt lõi là có thể cung cấp dịch vụ HTTPS mà không cần thêm system call cho từng request
Benchmark và đo hiệu năng
- Tác giả cho biết vẫn chưa thực hiện benchmark đầy đủ và dự định sẽ kiểm thử hiệu năng sau khi dọn dẹp lại mã nguồn
Vấn đề an toàn của io_uring và Rust
- Không giống system call đồng bộ, với io_uring thì buffer bộ nhớ không được giải phóng trước khi sự kiện hoàn tất xảy ra
- Crate io-uring không đảm bảo tính an toàn ở thời điểm biên dịch của Rust, và cũng thiếu các kiểm tra runtime
- Nếu dùng sai, nó có thể dẫn đến các vấn đề nghiêm trọng tương tự như trong C++, làm suy yếu tính an toàn vốn có của Rust
- Cần có một crate safer-ring riêng tận dụng mạnh mẽ pinning và borrow checker
- Vấn đề này hiện đã được cộng đồng thảo luận
Tham khảo và liên kết bổ sung
- Nội dung này là bài đăng được thảo luận trên HackerNews tính đến ngày 2025-08-22
1 bình luận
Ý kiến trên Hacker News
Khi gửi tác vụ ghi bằng io_uring, cần đảm bảo vị trí bộ nhớ không bị giải phóng hoặc ghi đè; nhưng có vẻ API của crate io-uring không được Rust borrow checker hỗ trợ ở phần này và cũng không có kiểm tra runtime
Tôi đã đọc bài viết và các bình luận về tình huống này, và kết luận là việc tạo một thư viện async Rust an toàn bọc io_uring thực sự rất khó
Tôi cũng nhớ Alice từ nhóm tokio gần đây có nhắc rằng hiện không còn nhiều sự quan tâm đến việc vượt qua vấn đề này
Vì hiệu năng hiện tại đã ở mức "đủ tốt"
Tham khảo: https://boats.gitlab.io/blog/post/io-uring/
Có khá nhiều điều khiến tôi thấy tiếc về Rust async, và đây là một trong số đó
Rust async được thiết kế vào thời epoll là tiêu chuẩn và gần như không quan tâm đến IOCP
Lý do syscall đồng bộ không gặp vấn đề này là vì khi gọi read, bạn chuyển tham chiếu khả biến của buffer cho kernel, và điều đó khá khớp với mô hình ownership/borrow gốc của Rust
Nhưng với completion-based IO, để thực sự khớp với mô hình ownership, cần phải đảm bảo mã người dùng không tiếp tục chạy cho đến khi tác vụ hoàn tất, và cấu trúc polling theo state machine không làm được điều đó
Mô hình threading hoặc cấu trúc green thread lại rất phù hợp ở đây
Nếu Rust từng thêm một "mục tiêu chỉ dành cho async" thì có lẽ đã tốt hơn
Đội ngũ Rust đã đặt rất nhiều kỳ vọng vào mô hình polling stackless cho async, và giờ chúng ta đang theo dõi kết cục của nó
Tôi nghĩ có những mô hình ownership mà borrow checker của Rust không thể hỗ trợ tốt
Tôi tạm gọi nó là “ownership kiểu hot potato”, tức là tạm thời giao buffer đi rồi nhận lại sau
Viết mẫu này bằng Rust sao cho an toàn rất khó và khiến mã trở nên khá rối
Trái với lời Alice bên nhóm tokio, phía file IO thì vẫn có sự quan tâm
File IO vốn đã được triển khai bằng kiểu spawn_blocking nên đang gặp đúng vấn đề buffer tương tự với io_uring, và việc chuyển sang io_uring không quá khó
Nhưng API hiện tại của tokio::net không tương thích với API buffer dựa trên io_uring, nên có thể kiểm tra readiness nhưng khó hỗ trợ đầy đủ
Để tạo ra một interface io_uring an toàn, tôi nghĩ cách phù hợp nhất là nhận buffer do ring sở hữu để sử dụng, rồi trả lại khi bắt đầu ghi
Không nhất thiết phải biểu diễn mọi thứ bằng borrows
Dùng cấu trúc dữ liệu như Slab thì có thể làm cho nó cancel safe
Tham khảo: https://github.com/steelcake/io2
Tôi thực sự rất thích bài viết này
Tôi mong chờ bài test hiệu năng, nhưng điều gây ấn tượng là tác giả nói sẽ dọn dẹp mã cho gọn gàng trước khi benchmark
Trong thời buổi chỉ chăm chăm vào benchmark, thật mới mẻ khi thấy có người suy nghĩ như vậy
Khoảng năm 11 tuổi tôi từng thử dựng cơ sở dữ liệu, khi đó tiếp xúc với cgi-bin mà giờ mới nhận ra nó hoạt động theo kiểu tạo tiến trình mới cho mỗi request
sendfile từng là bước ngoặt khi phải xử lý đồng thời tải demo trên các diễn đàn game lớn, và khi nhìn vào kết quả như giảm 40ms ở Netflix hay rút ngắn 70% thời gian tải của GTA 5, tôi cảm thấy còn rất nhiều kỹ thuật đầy tác động đang ẩn phía sau
Liên kết liên quan: Common Gateway Interface, trường hợp giảm 40ms của Netflix, rút ngắn thời gian tải GTA Online
Không chỉ CGI, mà các phiên HTTP cũ của họ CERN và Apache cũng vận hành bằng cách fork toàn bộ server
Theo thời gian mọi thứ đã tốt hơn, nhưng cách cấu hình của Apache đã khiến những server nhẹ được thiết kế ngay từ đầu cho I/O hướng sự kiện như nginx trở nên rất phổ biến
Tôi hoài nghi về hiệu quả của sendfile
Nó từng rất thịnh hành vào cuối thập niên 90, nhưng tôi nghĩ lợi ích hiệu năng thực tế là rất nhỏ
Hầu hết các trình điều phối workload trên cloud (CloudRun, GKE, EKS, Docker cục bộ, v.v.) đều tắt io_uring theo mặc định
Nếu phần này không được cải thiện, có lẽ io_uring sẽ còn là một công nghệ rất giới hạn trong một thời gian nữa
Tôi thắc mắc vì sao họ lại vô hiệu hóa io_uring
Nếu tình hình là như vậy thì phải quay lại self-hosting thôi
Đọc rất thú vị
Tôi sẽ chờ benchmark, cứ từ từ cũng được, và tư duy của tác giả khi ưu tiên dọn dẹp mã trước benchmark thật sự rất ấn tượng
Dạo này có quá nhiều dự án dồn toàn lực vào điểm benchmark, nên cách nghĩ này thật mới mẻ và đáng nể
Tôi không biết ktls hay io_uring lại có thể được dùng đa dạng đến vậy
Tình hình xử lý bất đồng bộ ở thời điểm hiện tại như sau
Rust: cần hiểu nhiều khái niệm như Futures, Pin, Waker, async runtime, ràng buộc Send/Sync, async trait object, v.v.
C++20: coroutines
Go: goroutines
Java21+: virtual threads
Coroutine của C++ dùng heap allocation để tránh vấn đề mà Pin giải quyết
Điều này lệch khá xa khỏi nguyên tắc "zero overhead" mà C++ theo đuổi
Một lý do khiến Rust mất nhiều thời gian mới đưa được async trait vào tương lai cũng là vì Rust không heap-allocate futures
Trade-off giữa hiệu năng/tính di động và độ phức tạp có thể được đánh giá khác nhau tùy từng dự án
Các ràng buộc liên quan đến Send/Sync vẫn có ý nghĩa trong các ngôn ngữ khác, và nếu không có các ràng buộc đó thì sẽ dễ viết ra mã sai một cách tinh vi hơn
Nếu bạn viết Rust ở mức "đủ ổn" và dùng các primitive cấp trung do người khác làm sẵn, thì không nhất thiết phải hiểu hết tất cả các khái niệm đó
Rust buộc bạn phải hiểu các khái niệm đó, nếu không thì thậm chí còn không compile được
Với Go, goroutine không phải là async, và nếu không hiểu channel thì cũng không thể hiểu goroutine
Cách triển khai channel của Go khá đặc biệt nên hành vi ở các trường hợp biên khó mà đoán theo trực giác
Go cho phép lập trình mà không cần hiểu quá sâu, nên điều đó vừa có ưu vừa có nhược
"Thread giá rẻ" không đồng nghĩa với async
tarweb (server trong bài blog) là cấu trúc single-thread dựa trên event loop của io_uring, với ý tưởng đặt một thread cho mỗi lõi CPU
Có lẽ nói "hiện trạng của thread giá rẻ" sẽ đúng hơn là "hiện trạng của tính đồng thời quy mô lớn"
Khác biệt lớn nhất giữa cheap thread và async loop là dễ reasoning hơn
Cũng có nhược điểm, là mỗi thread tuy nhẹ nhưng vẫn cần một kích thước stack nhất định
kTLS rõ ràng là một bước tiến
Vài năm trước tôi cũng đã thực sự tạo một server có 0 syscall cho mỗi request và viết blog về nó (https://wjwh.eu/posts/2021-10-01-no-syscall-server-iouring.html)
Tuy vậy, nhược điểm là luôn phải busy-looping
io_uring đã phát triển với tốc độ thực sự ấn tượng trong vài năm gần đây
Dự án này thật sự rất ngầu, và vì tôi đã nghĩ về thứ tương tự từ lâu nên rất vui khi thấy có người hiện thực hóa nó
Nếu muốn viết BPF bằng Rust thì tôi khuyên dùng Aya
Github của dự án Aya
Tôi tò mò về tình trạng hiện tại của kTLS
Cách đây không lâu tôi có hỏi một lập trình viên của Cilium, Thomas Graf nói là rất kỳ vọng, nhưng trên thực tế nhiều bản phân phối Linux vẫn thiếu hỗ trợ kernel nên còn lâu mới có thể bật mặc định
Hơi tiếc, nhưng tôi cũng muốn biết việc kích hoạt nó khó đến mức nào
Có cần kernel tùy biến không, hay có thể bật ngay ở runtime
FreeBSD từ phiên bản 13 đã có kTLS trong kernel/openssl, và có thể bật tắt lúc runtime bằng sysctl (
kern.ipc.tls.enable=1)Trong FreeBSD-15, mặc định sẽ được chuyển sang bật sẵn, và Netflix đã dùng kTLS để mã hóa lưu lượng gần 10 năm nay
kTLS nhìn chung cho tôi cảm giác là một ý tưởng tồi
Tôi nghi ngờ mô hình một thread cho mỗi lõi có thực sự phù hợp trong hệ thống dựa trên time-slicing hay không
Theo kinh nghiệm của tôi, kiểu "oversubscribing" (dùng nhiều thread hơn số lõi) lại mang lại lợi ích về thời gian thực tế đo được
Có lẽ chỉ khi không có preemptive scheduling thì một thread mỗi lõi mới hợp lý hơn
Tất nhiên như vậy thì không còn là câu chuyện Unix nữa
Nếu muốn độ trễ thấp và thông lượng cao thì cách cô lập lõi và ghim thread vào lõi đó khá hiệu quả
Cách này hoạt động tốt trên Linux, và trong các hệ thống như giao dịch tài chính người ta dùng khá nhiều dù phải chấp nhận sự kém hiệu quả
Hầu hết các lõi chỉ rảnh rỗi và spin mà thực tế không có việc, nhưng về latency và throughput thì là tối ưu
Cái bẫy của mô hình thread-per-core là nghĩ rằng có thể "chỉ lấy phần tiện lợi mà dùng"
Thực ra hoặc là chơi tới cùng, hoặc là đừng dùng
Một cách triển khai nửa vời sẽ không hiệu quả chút nào
Tuy nhiên nếu thiết kế đúng thì nó rất hiệu quả trong gần như mọi tình huống
Những lập trình viên thật sự hiểu know-how thiết kế TPC (như cân bằng tải giữa các lõi) là rất hiếm
Thread-per-core chỉ hiệu quả khi là "CPU-bound"
Với server như dự án này, nơi phần lớn công việc là bất đồng bộ và dựa trên sự kiện, server gần như chuyển sang request tiếp theo mà không phải chờ I/O hay syscall, nên về mặt lý thuyết một thread mỗi lõi là cấu trúc chính xác
Nhưng ngoài đời thực gần như không có tình huống lý tưởng như vậy, nên cần nhớ rằng việc cứng nhắc giới hạn ở nproc thread là khá rủi ro
Với io_uring, tôi nghĩ việc chỉ đặt một user thread cho mỗi lõi cũng không phải lựa chọn tệ
Vì trong kernel nó hoạt động như một thread pool
Tôi cũng muốn thấy kiểu làm bỏ qua hoàn toàn kernel như DPDK
Link bài báo: https://www.usenix.org/system/files/atc23-zhu-lingjun.pdf