2 điểm bởi GN⁺ 2025-08-23 | 1 bình luận | Chia sẻ qua WhatsApp
  • Để 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
Quảng cáo

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 ulimit của hệ thống vẫn được áp dụng
Quảng cáo

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

 
GN⁺ 2025-08-23
Ý 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