Kiến trúc cổng VPN không gian người dùng (userspace) phát triển bằng các ngôn ngữ hiện đại như Rust, Go hoặc C đang trở thành lựa chọn chủ đạo cho các giải pháp truy cập Zero-Trust và cổng phân tách lưu lượng linh hoạt. Khác với các mô-đun nằm trong nhân, phần mềm mạng không gian người dùng đem lại tính an toàn bộ nhớ cao, chu kỳ phát hành nhanh và khả năng cách ly tiến trình hoàn hảo. Tuy nhiên, khi đẩy thông lượng mạng lên mức hàng chục gigabit mỗi giây, ứng dụng sẽ chạm phải rào cản nền tảng của hệ điều hành Linux: chi phí chuyển đổi ngữ cảnh của các lệnh gọi hệ thống (syscall) và cơ chế thông báo sự kiện I/O.

Suốt hai thập kỷ qua, epoll(7) đóng vai trò là tiêu chuẩn vàng cho mô hình đa ghép kênh I/O trên Linux. Mặc dù epoll xử lý rất tốt hàng trăm nghìn kết nối nhàn rỗi, mô hình đọc ghi đồng bộ của nó tạo ra độ trễ lớn và tiêu tốn chu kỳ CPU khi phải xử lý các luồng gói tin tốc độ cao. Ở tốc độ đường truyền 10 Gbps và 40 Gbps, giao diện io_uring của Linux nổi lên như một bước chuyển đổi kiến trúc mang tính đột phá, thay thế việc gọi syscall liên tục bằng các hàng đợi vòng bộ nhớ chia sẻ không khóa.

Rào cản Epoll: Chi phí chuyển đổi ngữ cảnh và Syscall

Để hiểu rõ nguyên nhân khiến vòng lặp sự kiện truyền thống bị nghẽn trong các cổng VPN, hãy phân tích luồng xử lý gói tin UDP mã hóa khi dùng epoll(7):

[Vòng lặp sự kiện Epoll truyền thống]
Ứng dụng người dùng ──> epoll_wait() [Chuyển ngữ cảnh Syscall] ──> Chờ trong Nhân
                                                                        │
Gói tin đến ──> Ngắt phần cứng NIC ──> Hàng đợi nhận Socket <───────────┘
                     │
Đánh thức ứng dụng <─┘ [Chuyển ngữ cảnh & Lưu vết thanh ghi]
        │
Ứng dụng người dùng ──> recvmmsg() [Syscall] ──> Sao chép gói lên Userspace
        │
Ứng dụng: Giải mã gói tin + Tra bảng định tuyến
        │
Ứng dụng người dùng ──> sendmmsg() [Syscall] ──> Gửi ra NIC chiều đi

Mỗi vòng lặp đều đòi hỏi ứng dụng phải liên tục vượt qua ranh giới đặc quyền giữa người dùng và nhân. Trên các dòng vi xử lý x86_64 và ARM64 hiện đại có cơ chế phòng chống tấn công kênh bên (Meltdown, Spectre), mỗi lệnh gọi hệ thống buộc CPU phải thực hiện cô lập bảng phân trang (KPTI), xóa thanh ghi và tái lập các thanh ghi dự đoán rẽ nhánh.

Khi một cụm cổng VPN phải xử lý 1,5 triệu gói mỗi giây (Mpps), các lõi CPU tiêu tốn hơn 45% tổng thời gian thực thi chỉ để chuyển đổi qua lại giữa không gian người dùng và nhân thay vì thực hiện thuật toán mật mã. Kể cả khi gom nhóm qua recvmmsg() và sendmmsg(), thông lượng của một lõi đơn lẻ cũng khó vượt qua mốc 1,8 Mpps.

Kiến trúc io_uring: Bộ đệm vòng trên bộ nhớ chia sẻ

Được Jens Axboe giới thiệu từ Linux kernel 5.1 và hoàn thiện mạnh mẽ trong các bản phát hành gần đây, io_uring giải quyết triệt để rào cản này.

Thay vì gọi syscall để kiểm tra tính sẵn sàng rồi lại gọi syscall tiếp theo để đọc/ghi dữ liệu, io_uring thiết lập hai hàng đợi vòng đơn nhà sản xuất - đơn người tiêu dùng (SPSC) không khóa, được ánh xạ thẳng vào cả không gian người dùng lẫn nhân thông qua mmap():

[Đường truyền bộ nhớ chia sẻ io_uring]
Bộ nhớ không gian người dùng                  Không gian nhân Linux
┌───────────────────────────┐                ┌───────────────────────────┐
│ Hàng đợi gửi (SQ)         │ ──Không khóa─> │ Tiến trình tiêu thụ SQ    │
│ [SQE: Nhận gói Multishot] │  Vòng chia sẻ  │ [Luồng SQPOLL chuyên dụng]│
└───────────────────────────┘                └───────────────────────────┘
                                                           │
                                                           ▼
┌───────────────────────────┐                ┌───────────────────────────┐
│ Hàng đợi hoàn tất (CQ)    │ <─Không khóa── │ Tiến trình ghi nhận CQ    │
│ [CQE: Gói tin sẵn sàng]   │  Vòng chia sẻ  │ [Chuyển giao phần cứng]   │
└───────────────────────────┘                └───────────────────────────┘
  1. Hàng đợi gửi (Submission Queue - SQ): Ứng dụng người dùng chuẩn bị các mục nhập SQE mô tả thao tác mong muốn (như IORING_OP_RECVMSG hoặc IORING_OP_SENDMSG) và ghi trực tiếp vào vòng bộ nhớ.
  2. Hàng đợi hoàn tất (Completion Queue - CQ): Khi nhân hoàn tất xử lý yêu cầu, nó ghi các mục nhập CQE chứa cờ trạng thái và độ dài dữ liệu vào vòng hoàn tất để ứng dụng đọc ngay lập tức.

Vì cả hai vòng đều nằm trên vùng nhớ chia sẻ liên tục, ứng dụng cập nhật con trỏ đầu và đuôi bằng các thao tác nguyên tử (atomic), loại bỏ hoàn toàn việc gọi syscall trong suốt quá trình chuyển tiếp gói tin ổn định.

Cơ chế tăng tốc Zero-Syscall: SQPOLL, Fixed Buffers và Multishot

io_uring cung cấp ba cơ chế tối ưu chuyên biệt cho hạ tầng đường hầm mạng tốc độ cao:

1. Luồng thăm dò trong nhân (IORING_SETUP_SQPOLL)

Khi kích hoạt cờ IORING_SETUP_SQPOLL, nhân Linux sẽ tạo ra một luồng kthread chuyên trách liên tục thăm dò Hàng đợi gửi (SQ). Khi ứng dụng người dùng ghi gói tin mã hóa mới vào vòng SQ, luồng thăm dò này sẽ tiếp nhận và chuyển tiếp ngay lập tức mà không cần một lệnh gọi io_uring_enter(2) nào. Toàn bộ tiến trình truyền gói đạt trạng thái zero-syscall thực thụ.

2. Bộ đệm cố định đã đăng ký (IORING_REGISTER_BUFFERS)

Trong mô hình thông thường, mỗi thao tác mạng buộc hệ điều hành phải ánh xạ con trỏ người dùng, xác thực quyền truy cập trang và tăng biến đếm tham chiếu qua get_user_pages().

Với io_uring, cổng VPN cấp phát sẵn một vùng nhớ đệm liên tục và đăng ký một lần với nhân qua IORING_REGISTER_BUFFERS. Nhân sẽ ghim (pin) các trang vật lý này trong bộ nhớ RAM, xóa bỏ hoàn toàn chi phí dịch địa chỉ và triệt tiêu hiện tượng đẩy lùi bộ đệm TLB.

3. Cơ chế nhận liên tục Multishot (IORING_RECV_MULTISHOT)

Ở các socket truyền thống, việc đọc nhiều gói tin đòi hỏi gọi syscall lặp đi lặp lại. Với IORING_RECV_MULTISHOT, ứng dụng chỉ cần gửi một mục SQE duy nhất đến socket UDP. Nhân sẽ tự động nhận các datagram tiếp theo, điền vào vòng đệm đã đăng ký và tạo các CQE tương ứng cho tới khi có yêu cầu dừng.

Triển khai thực tế: Khởi tạo tiến trình I/O UDP với io_uring

Đoạn mã C dưới đây minh họa cách cấu hình một worker io_uring bất đồng bộ phục vụ xử lý gói tin UDP tải cao:

#include <liburing.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>

#define QUEUE_DEPTH 4096
#define BUF_SIZE 2048
#define NUM_BUFFERS 4096

struct vpn_worker {
    struct io_uring ring;
    int sockfd;
    char *buffer_pool;
};

int init_vpn_worker(struct vpn_worker *worker, int port)
{
    struct io_uring_params params = {0};
    
    /* Bật luồng thăm dò phía nhân để đạt đường truyền zero-syscall */
    params.flags = IORING_SETUP_SQPOLL | IORING_SETUP_CQSIZE;
    params.sq_thread_idle = 2000; /* thời gian nhàn rỗi (ms) trước khi ngủ */
    params.cq_entries = QUEUE_DEPTH * 2;

    if (io_uring_queue_init_params(QUEUE_DEPTH, &worker->ring, &params) < 0) {
        perror("io_uring_queue_init_params thất bại");
        return -1;
    }

    /* Khởi tạo socket UDP không chặn */
    worker->sockfd = socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK, 0);
    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_port = htons(port),
        .sin_addr.s_addr = INADDR_ANY
    };
    bind(worker->sockfd, (struct sockaddr *)&addr, sizeof(addr));

    /* Đăng ký trước bảng mô tả tệp để loại bỏ khóa nguyên tử fget/fput */
    io_uring_register_files(&worker->ring, &worker->sockfd, 1);

    /* Cấp phát vùng nhớ đệm gói tin liên tục căn chỉnh theo trang */
    posix_memalign((void **)&worker->buffer_pool, 4096, NUM_BUFFERS * BUF_SIZE);

    struct iovec iov = {
        .iov_base = worker->buffer_pool,
        .iov_len = NUM_BUFFERS * BUF_SIZE
    };
    io_uring_register_buffers(&worker->ring, &iov, 1);

    return 0;
}

Mô hình này tạo ra các kênh giao tiếp ánh xạ bộ nhớ trực tiếp giữa worker người dùng và trình điều khiển mạng của nhân, tinh gọn luồng dữ liệu xuống thành các thao tác ghi bộ nhớ thuần túy.

Bảng so sánh hiệu năng thực nghiệm

Trong các bài kiểm thử chịu tải trên hệ thống máy chủ kép AMD EPYC 9654 (192 lõi vật lý) với 100.000 đường hầm WireGuard hoạt động đồng thời, io_uring thể hiện ưu thế vượt bậc trước epoll:

Tiêu chí đánh giá Epoll + recvmmsg / sendmmsg io_uring + SQPOLL + Fixed Buffers
Thông lượng trên mỗi lõi 1,72 Mpps 6,84 Mpps (Tăng 3,9 lần)
Số syscall trên 100k gói ~3.125 Syscall (Gom nhóm 32) 0 Syscall (Trạng thái ổn định)
Chi phí chuyển ngữ cảnh 38% Thời gian CPU < 2% Thời gian CPU
Độ trễ đuôi (Phân vị 99.9) 48,2 microgiây 4,1 microgiây
Cấp độ cách ly tiến trình Không gian người dùng chuẩn Không gian người dùng (Không cần root)

Khác với các bộ khung can thiệp trực tiếp phần cứng như DPDK—vốn đòi hỏi quyền root, chiếm dụng 100% lõi CPU để thăm dò liên tục và tước quyền quản lý của Linux—io_uring đạt hiệu năng tiệm cận DPDK nhưng vẫn tuân thủ đầy đủ ranh giới bảo mật tiêu chuẩn của hệ điều hành.

Ứng dụng thực tế trên cụm Anycast RiceGuard

Hạ tầng RiceGuard tích hợp sâu io_uring trên các cụm cổng Anycast phân tán toàn cầu để tối ưu hóa việc định tuyến mã hóa và cân bằng tải:

  • Duy trì thông lượng tốc độ dây: Các nút Anycast RiceGuard xử lý hơn 18 triệu gói mỗi giây trên mỗi máy chủ kép, xóa bỏ hoàn toàn mức trần hiệu năng vốn có của các đường hầm không gian người dùng.
  • Giảm thiểu độ trễ điều hướng: Việc loại bỏ các vòng lặp syscall giúp hạ độ trễ xử lý gói tin bên trong cổng xuống còn 1,8 microgiây, ngăn chặn hiện tượng giật cục (jitter) trên các giao thức nhạy cảm như QUIC, WebRTC hay truyền phát luồng dữ liệu thời gian thực.
  • Bảo toàn ranh giới bảo mật: Nhờ việc io_uring tương thích hoàn toàn với cơ chế phân quyền Linux, network namespaces và cgroups, RiceGuard vận hành đường truyền gói tin bên trong các container phi đặc quyền, ngăn chặn nguy cơ leo thang đặc quyền.

Bằng việc kết hợp bộ đệm vòng io_uring và cơ chế zero-syscall, kiến trúc cổng VPN hiện đại mang lại sự linh hoạt và tính an toàn của phần mềm không gian người dùng đi cùng hiệu năng thông lượng tầm cỡ nhân hệ điều hành.