So sánh epoll và io_uring trên Linux
(sibexi.co)- Proxy ngược TinyGate đã cải thiện hiệu năng khi chuyển từ kiến trúc dựa trên worker sang epoll, nhưng sau đó gặp giới hạn và được viết lại bằng io_uring
- epoll là mô hình trạng thái sẵn sàng thông báo thời điểm I/O có thể thực hiện, nên sau
epoll_waitvẫn phải gọi riêngread()/write() - io_uring là mô hình hoàn tất vận hành theo việc I/O đã hoàn thành hay chưa, trong đó ứng dụng và kernel trao đổi hàng đợi gửi và hàng đợi hoàn tất qua ring buffer dùng chung
io_uring_enter()về cơ bản vẫn cần thiết, nhưng có thể gửi và thu hồi nhiều tác vụ trong một lần;IORING_SETUP_SQPOLLgiảm syscall nhưng phải đánh đổi bằng mức sử dụng CPU- Nếu bắt đầu một dự án mới trên máy chủ Linux hiện đại dùng kernel v5.1+, io_uring được đánh giá là lựa chọn phù hợp hơn so với epoll
Giới hạn của epoll mà TinyGate bộc lộ
- TinyGate là máy chủ proxy ngược được tạo cùng các sinh viên, và phiên bản đầu tiên có cấu trúc đơn giản dựa trên worker
- Dù hoạt động tốt như một dự án phục vụ học tập, nó có giới hạn kiến trúc khá lớn nếu so với các công cụ như nginx hay haproxy
- Phiên bản thứ hai chuyển sang dựa trên epoll và hiệu năng được cải thiện đáng kể so với phiên bản đầu
- Tuy vậy, trong benchmark nó vẫn chưa vượt được nginx/haproxy
- Sau đó, vì các giới hạn của epoll, dự án chuyển sang io_uring và phải được viết lại từ đầu
epoll: thông báo trạng thái sẵn sàng và syscall lặp lại
- epoll là phương thức quản lý I/O bất đồng bộ đã được dùng từ lâu trên Linux, được đưa vào Linux kernel năm 2002
- Cốt lõi của nó là thông báo trạng thái sẵn sàng cho biết thời điểm I/O có thể thực hiện
- epoll cho biết “có thể đọc hoặc ghi”
- Việc đọc và ghi dữ liệu thực tế sau đó do ứng dụng thực hiện bằng syscall
read()hoặcwrite()
- Trong luồng xử lý thông thường, chi phí syscall bị lặp lại ở mỗi sự kiện
epoll_ctllà syscall một lần để đăng ký file descriptor- Với mỗi sự kiện I/O thực tế, cần
epoll_waitvàread()/write() - Kết quả là việc xử lý sự kiện liên tục phát sinh thêm syscall
- Syscall tạo ra chuyển ngữ cảnh giữa user mode và kernel mode, và overhead này càng lớn khi số lượng kết nối tăng lên
io_uring: mô hình hoàn tất và ring buffer dùng chung
- io_uring xuất hiện vào năm 2019, khoảng 17 năm sau khi epoll được đưa vào Linux kernel, và được hỗ trợ từ kernel v5.1+
- Khác với epoll, nó không dựa trên việc I/O có sẵn sàng hay không mà dựa trên việc I/O đã hoàn tất hay chưa
- Ứng dụng và kernel cùng sử dụng ring buffer trong bộ nhớ dùng chung
- Ứng dụng đưa các tác vụ cần kernel xử lý vào hàng đợi gửi
- Kernel đưa kết quả hoàn tất trở lại vào hàng đợi hoàn tất
- Ở cấu hình mặc định, vẫn phải gọi
io_uring_enter()để kernel kiểm tra hàng đợi gửi- Một lần gọi có thể gửi nhiều tác vụ và thu hồi nhiều kết quả hoàn tất
- Nó không phải cấu trúc lặp lại cặp syscall cho từng tác vụ như tổ hợp epoll và
read()
- Khi dùng
IORING_SETUP_SQPOLL, một kernel thread sẽ polling hàng đợi gửi- Ở trạng thái vận hành bình thường, syscall gần như có thể bị loại bỏ
- Vì kernel thread vẫn chạy ngay cả khi hàng đợi trống nên nó vẫn tiêu tốn CPU
- Sau
sq_thread_idlenó có thể chuyển sang sleep, nhưng chi phí không biến mất hoàn toàn
Khác biệt qua ví dụ mã
-
Ví dụ epoll
- Đăng ký file descriptor của
stdin, rồi khi có sự kiện thì gọi riêngread() - Tạo instance epoll bằng
epoll_create1 - Đăng ký
STDIN_FILENObằngepoll_ctl - Chặn bằng
epoll_waitcho tới khi có thể đọc - Khi có sự kiện, đọc dữ liệu bằng syscall
read() - Trong luồng này, mỗi sự kiện I/O thực tế đều cần
epoll_waitvàread
- Đăng ký file descriptor của
-
Ví dụ io_uring
- Sử dụng
liburing - Khởi tạo ring bằng
io_uring_queue_init - Lấy submission queue entry bằng
io_uring_get_sqe - Chuẩn bị tác vụ đọc
stdinbằngio_uring_prep_read - Gửi bằng
io_uring_submitvà chờ hoàn tất bằngio_uring_wait_cqe - Trong ví dụ io_uring không có bước kiểm tra trạng thái sẵn sàng riêng, và cũng không gọi
read()riêng tại thời điểm hoàn tất - Để đơn giản hóa, cả hai ví dụ đều lược bỏ xử lý ngoại lệ quan trọng
- Nếu
stdinkhông có dữ liệu, chúng có thể bị chặn vĩnh viễn - Ví dụ io_uring không kiểm tra trường hợp
io_uring_get_sqe()trả vềNULLkhi hàng đợi gửi đã đầy
- Sử dụng
Các điều kiện bổ sung khi dùng io_uring
- Để dùng zero-copy I/O, cần đăng ký trước buffer bằng
io_uring_register_buffers()- Nhờ đó tránh việc kernel phải ánh xạ lại bộ nhớ cho từng tác vụ
- Trong truyền dữ liệu mạng,
IORING_OP_SEND_ZCtừ kernel 6.0+ cung cấp cách gửi không sao chép buffer vào kernel
IORING_SETUP_SQPOLLcó thể giảm syscall nhưng cái giá phải trả là mức sử dụng CPU- Ngay cả khi hàng đợi trống, kernel thread vẫn tiếp tục polling
- Sau idle timeout nó có thể chuyển sang sleep, nhưng chi phí không biến mất
- Lỗi trong io_uring không được trả trực tiếp như giá trị trả về của syscall đồng bộ mà quay về bất đồng bộ trong trường
rescủa completion queue entry- Việc xử lý lỗi cần thực hiện qua
cqe->res
- Việc xử lý lỗi cần thực hiện qua
Lựa chọn trên máy chủ Linux hiện đại
- epoll là phương thức I/O bất đồng bộ lâu đời trên Linux, dựa trên thông báo thời điểm I/O khả dụng và các lời gọi syscall riêng biệt
- io_uring trên Linux hiện đại cung cấp mô hình dựa trên hoàn tất cùng khả năng gửi theo lô và xử lý hoàn tất theo lô
- Nếu xây dựng một dự án mới từ đầu trên máy chủ Linux hiện đại, chọn io_uring là hướng đi tự nhiên hơn
- Nếu có thể ngừng hỗ trợ các hệ thống cũ vào thời điểm hợp lý, thì trong môi trường kernel v5.1+ không còn nhiều lý do để chọn epoll
1 bình luận
Ý kiến trên Hacker News
Mình chỉ lướt rất nhanh kho lưu trữ GitHub https://github.com/sibexico/TinyGate và có vẻ ghim CPU vẫn chưa được dùng
Ghim luồng và socket lắng nghe vào CPU, rồi dùng
sockopt SO_INCOMING_CPU, có thể vắt thêm được một chút hiệu năngNếu cả socket đi ra cũng được căn chỉnh theo CPU thì có thể cải thiện khá lớn, nhưng theo mình biết thì chưa có API tốt cho việc này. Linux có API steering lưu lượng/steering luồng cho các NIC tương thích, và nếu biết NIC đang dùng hàm băm gì—rất có thể là Toeplitz—thì có thể chọn source port đi tới backend sao cho hàm băm khớp
Mục tiêu là để proxy xử lý gói tin mà không cần giao tiếp giữa các CPU
Có lẽ nên xem https://github.com/concurrencykit/ck và https://github.com/microsoft/mimalloc. Chúng sẽ rất hợp với reverse proxy zero-copy và căn chỉnh bộ nhớ
Nếu muốn thêm chống DDoS và các tính năng L4 nâng cao hơn thì https://docs.ebpf.io/ebpf-library/libxdp/libxdp/ cũng đáng để xem
Bài viết thật sự rất hay
Chính bài này đã khiến mình rơi vào hang thỏ với
uring, phát triển kernel và C. Mình đã làm Rust và C++ khá lâu, nhưng ở những chương trình C nhỏ, quy mô vừa phải, vẫn có một sự đơn giản và thậm chí là cảm giác nghệ thuậtMình vẫn chưa thử shared buffer trong web server dựa trên
io_uring. Lý do là nó gửi trực tiếp từ vùngmmapthay vì đọc từ file rồi ghi raThực ra mình muốn dùng
sendfilevớiio_uring, nhưng hiện vẫn chưa được hỗ trợMột bài viết với đủ buzzword như Rust và kTLS: https://blog.habets.se/2025/04/io-uring-ktls-and-rust-for-ze...
Cũng đã được đăng lên HN: https://news.ycombinator.com/item?id=44980865
splice(2)đã được triển khai nên có thể dùng cách gần giống sendfile vớiuring. Nó không tiện nhưsendfile, nhưng có lẽ sẽ hoạt động gần tương tựNếu làm bằng DPDK thì sẽ phức tạp hơn nhiều, nhưng sẽ có cơ hội vượt xa nginx về hiệu năng
Nếu còn làm cho nó chạy trên FPGA thì lại càng phức tạp hơn
Bài học ở đây là: để đạt hiệu năng, đôi khi phải xuyên thủng các lớp trừu tượng như dao nóng cắt bơ, nhưng đổi lại mọi thứ cũng trở nên khó khăn hơn nhiều. Mô hình socket và một luồng cho mỗi kết nối là cách tiếp cận tốt vào thời mà mạng chậm hơn CPU rất nhiều, và đến nay trong nhiều trường hợp nó vẫn là cách đơn giản nhất
Mình cũng luôn tò mò về điều này, nên gần đây đã tự viết vài bản cài đặt HTTP file server để nắm được những khác biệt cốt lõi
https://theconsensus.dev/p/2026/05/18/serving-files-three-wa...
Trong bối cảnh proxy, cũng nên nhắc tới busy polling với
epoll_wait. Gần đây khi xem xét các tùy chọn độ trễ thấp, mình đã tìm hiểu và thấy rằng ngay cả chỉ với socket thuần, không cần DPDK/VMA/io_uring, dường như vẫn có thể tiến khá gần tới busy polling trong user space, và Fastly đã đóng góp cho phần này và đang sử dụng nóNó quá thấp tầng nên mình không dám nói là đã hiểu hết, mới chỉ nắm được ý tưởng nên để lại các liên kết. Nó chỉ hoạt động theo từng ngữ cảnh NAPI
epoll, và không dễ kiểm soát NAPI ID, nhưng nếu dành cả máy chỉ để chạy proxy thì có thể dùng một mẹo đơn giản là gán socket theo từng NAPI ID cho các poller chuyên dụngTrường hợp sử dụng của mình không phải proxy, mà là polling N socket trên một máy rồi xử lý dữ liệu nhận được. Trong trường hợp đó có vẻ không khả thi, dù có thể làm được nếu một luồng đơn polling các ngữ cảnh NAPI theo kiểu round-robin. Sẽ thật tuyệt nếu một ngày nào đó có thể dễ dàng nói với kernel rằng “cứ tin tôi, cuối cùng tôi sẽ polling cái socket đơn này, nên đừng bao giờ đi qua đường IRQ”
Thảo luận HN trước đây về tính năng kernel này: https://news.ycombinator.com/item?id=43749271
Một bộ slide thuyết trình rất hay từ người đóng góp của Fastly, có các sơ đồ giúp hiểu bức tranh tổng thể dễ hơn: https://netdevconf.info/0x18/docs/netdev-0x18-paper10-talk-s...
Bài trên LWN: https://lwn.net/Articles/1008399/, https://lwn.net/Articles/997491/, https://lwn.net/Articles/959462/
Tài liệu kernel: https://docs.kernel.org/networking/napi.html#irq-mitigation
Nếu thích C++ và networking bất đồng bộ, có Boost.Asio
epolltự viết thì RPS cải thiện khoảng 16%. Đây là kết quả trên một máy chủ SQL quy mô vừa, nên cần cẩn thận khi dùng các thư viện được đóng gói quá sẵnepollcủa Asio bằngio_uringtrên máy chủ cơ sở dữ liệu, mức sử dụng CPU tăng vọt. Kết quả có thể khác nhiều tùy vào cách sử dụng và cách tích hợp vào mã xử lý sự kiệnĐến khoảng năm 2050 chắc Linux sẽ có chừng 20 cách để polling socket
io_uringcũng thế. Để chạy nhanh hơn thì xuất hiện kiểu one-shot củaio_uring, rồi sau đó còn có cả kiểu multishotĐúng,
io_uringchắc chắn nhanh hơnepoll. Trong trường hợp của tôi,io_uringcó vẻ nhanh hơn khoảng 20% tính theo số yêu cầu mỗi giâyVấn đề là phải bật rõ ràng trong kernel, và hiện bị vô hiệu hóa gần như ở mọi nơi vì lý do bảo mật. Có vẻ như có cơ chế chia sẻ bộ nhớ trực tiếp giữa kernel và user space, điều này khá đáng ngại. Gần đây cũng đã có nhiều exploit nhắm vào
io_uringVì vậy ngay cả các dự án kỹ thuật muốn tối đa hóa hiệu năng như Go cũng không tích hợp
io_uringsâu như một mặc định hợp lý. Nếu muốn chấp nhận rủi ro thì bạn vẫn có thể tự chạy nó trong ngôn ngữ mình thích. Nó nhanh hơn, nhưng cái giá là khả năng có exploit tiềm tàngio_uringkiểu POSIX của tôi làm bằngpollchứ không phảiepolllại nhanh hơnio_uring. Tuy nhiên với các buffer zero-copy lớn thìio_uringlà tốt nhấtio_uringvẫn hữu ích ngay cả khi không phải I/O bất đồng bộ. Ví dụ, có thể triển khai chuỗi thao tác nhưmkdirrồi mở chính thư mục đó như một tác vụ nguyên tử duy nhấtTrong networking, nếu cố tối đa hóa số packet mỗi giây thì bạn sẽ đụng trần của kernel[1] rất nhanh, và cuối cùng sẽ phải tận dụng các tính năng như GSO/GRO hoặc bỏ qua hoàn toàn network stack
1: https://github.com/axboe/liburing/discussions/1346
io_uringtheo mặc định. Đây là chuyện rất mới, nhưng như vậy đã bao phủ nhiều môi trường cài đặt Linux doanh nghiệp. Gemini có “nói” rằng Ubuntu và SuSE cũng hỗ trợ, nhưng không đưa liên kết nào để chứng minhhttps://access.redhat.com/solutions/4723221
Go cũng nên xem xét lại việc hỗ trợ. Đáng để thử một lần
io_uringmột lần khi runtime khởi động không? Chuyện exploit đâu chỉ là vấn đề của riêng chương trình quyết định dùngio_uring, mà là vấn đề của toàn bộ OS đúng không?io_uring—cuối cùng đều có xu hướng buộc người dùng tự chịu trách nhiệm về cách ly bộ nhớTuy nhiên với
io_uring, ring nằm trong kernel nên người dùng cũng không làm được nhiềuTôi hy vọng nhờ LLM mà tình hình sẽ tốt hơn trong tương lai, nhưng đây là vấn đề rất khó giải quyết. Ngay cả phía kernel cũng cực kỳ khó xử lý, và nhiều người cũng không thực sự hiểu cách tuning nó cho đúng