1 điểm bởi GN⁺ 3 giờ trước | 1 bình luận | Chia sẻ qua WhatsApp
  • 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_wait vẫn phải gọi riêng read()/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_SQPOLL giả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ặc write()
  • Trong luồng xử lý thông thường, chi phí syscall bị lặp lại ở mỗi sự kiện
    • epoll_ctl là syscall một lần để đăng ký file descriptor
    • Với mỗi sự kiện I/O thực tế, cần epoll_waitread()/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_idle nó 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êng read()
    • Tạo instance epoll bằng epoll_create1
    • Đăng ký STDIN_FILENO bằng epoll_ctl
    • Chặn bằng epoll_wait cho 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_waitread
  • 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 stdin bằng io_uring_prep_read
    • Gửi bằng io_uring_submit và chờ hoàn tất bằng io_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 stdin khô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ề NULL khi hàng đợi gửi đã đầy

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_ZC từ kernel 6.0+ cung cấp cách gửi không sao chép buffer vào kernel
  • IORING_SETUP_SQPOLL có 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 res của completion queue entry
    • Việc xử lý lỗi cần thực hiện qua cqe->res

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ăng
    Nế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

    • v0 và v1 của kho lưu trữ là hai cách triển khai hoàn toàn khác nhau, gần như được viết lại từ đầu, và hiện giờ tác giả đang làm bản triển khai thứ ba, có lẽ cũng là bản cuối cùng. Các lựa chọn kiến trúc cũng đã thay đổi hoàn toàn
    • Mình muốn xem benchmark của bản vá đó
  • Có lẽ nên xem https://github.com/concurrencykit/ckhttps://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

    • Kế hoạch là áp dụng tối ưu hóa ở các tầng khác trước rồi mới chuyển sang allocator. Hiện tại tôi đang nghiên cứu allocator cùng sinh viên, và bài viết trước đó trên blog là về một allocator tùy biến được viết bằng ngôn ngữ Zig
  • 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ật

  • Mì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ùng mmap thay vì đọc từ file rồi ghi ra
    Thực ra mình muốn dùng sendfile với io_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

    • Nhân tiện, splice(2) đã được triển khai nên có thể dùng cách gần giống sendfile với uring. 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ụng
    Trườ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

    • Gần đây tôi thay Asio bằng vòng lặp sự kiện epoll tự 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ẵn
    • Khi thay backend epoll của Asio bằng io_uring trê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
    • Boost quá bất tiện. Đây là những thư viện động khổng lồ, khó build và khó dùng. Dù tôi đã dùng CMake, quá trình cài Boost để hệ thống có thể tìm thấy nó vẫn cực kỳ phiền phức. Tuy vậy đây là trải nghiệm trên Mac
  • Đến khoảng năm 2050 chắc Linux sẽ có chừng 20 cách để polling socket

    • Đúng vậy, ngay trong io_uring cũng thế. Để chạy nhanh hơn thì xuất hiện kiểu one-shot của io_uring, rồi sau đó còn có cả kiểu multishot
  • Đúng, io_uring chắc chắn nhanh hơn epoll. Trong trường hợp của tôi, io_uring có vẻ nhanh hơn khoảng 20% tính theo số yêu cầu mỗi giây
    Vấ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_uring
    Vì 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_uring sâ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àng

    • Lý do chính khiến nó bị vô hiệu hóa giờ đã được giải quyết. Bản RC mới nhất đã có hỗ trợ cBPF, nên thay vì tắt toàn bộ thì giờ có thể giới hạn những tác vụ được phép chạy
    • Còn tùy trường hợp. Đã có lúc bản mô phỏng io_uring kiểu POSIX của tôi làm bằng poll chứ không phải epoll lại nhanh hơn io_uring. Tuy nhiên với các buffer zero-copy lớn thì io_uring là tốt nhất
      io_uring vẫ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ư mkdir rồi mở chính thư mục đó như một tác vụ nguyên tử duy nhất
      Trong 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
    • RHEL 9 và 10 giờ đã hỗ trợ đầy đủ io_uring theo 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 minh
      https://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
    • Với một dự án như Go, liệu có thể chọn cách chỉ phát hiện tính năng io_uring mộ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ùng io_uring, mà là vấn đề của toàn bộ OS đúng không?
    • Mọi kiểu networking ở chế độ polling—RDMA, DPDK, 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ều
      Tô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