Một máy chủ hiện đại phục vụ hàng nghìn kết nối cùng lúc bằng một số ít luồng: mỗi luồng chờ trên nhiều socket, và khi cái nào có dữ liệu thì xử lý cái đó. Câu hỏi cốt lõi: chờ trên nghìn fd tốn bao nhiêu? Ba công cụ trả lời khác nhau — select, poll, và epoll — và khác biệt giữa chúng chính là thứ đã giải bài toán "mười nghìn kết nối". Tôi đo cả ba trong container, và vấp đúng cái bẫy khét tiếng của epoll.

epoll cho nhiều kết nối

Ba cách chờ trên nhiều fd

Cả ba đều giải cùng một bài toán: "trong N fd tôi đang theo dõi, cái nào sẵn sàng đọc/ghi?". Nhưng cơ chế khác nhau căn bản.

selectpoll không có trạng thái: mỗi lần gọi, bạn đưa cho nhân toàn bộ danh sách N fd, nhân quét hết cả N để xem cái nào sẵn sàng, rồi trả về. Lần chờ sau, bạn lại đưa cả danh sách, nhân lại quét hết. Chi phí mỗi lần chờ tỉ lệ với N — O(N) — dù thực tế chỉ một fd có việc.

epoll có trạng thái: bạn đăng ký các fd một lần bằng epoll_ctl, và nhân giữ sẵn tập đó trong một cấu trúc bền. Khi bạn gọi epoll_wait, nhân không quét lại cả N; nó đã theo dõi sẵn và chỉ trả về những fd đang sẵn sàng. Chi phí tỉ lệ với số fd sẵn sàng, không phải tổng số fd — O(số sẵn sàng), gần như phẳng khi N tăng.

Tôi kiểm bằng cách tạo N ống (pipe), chỉ làm một cái sẵn sàng, rồi đo thời gian một lần chờ với mỗi công cụ, cho N tăng dần.

Đo: O(N) so với đường phẳng

N (số fd) select poll epoll_wait
10 296 ns 305 ns 138 ns
100 1758 ns 1697 ns 123 ns
500 8274 ns 7933 ns 125 ns
900 17718 ns 19550 ns 125 ns

Con số vẽ ra hai đường hoàn toàn khác nhau. selectpoll tăng tuyến tính: từ 10 lên 900 fd, thời gian mỗi lần chờ tăng khoảng 60 lần (296 lên 17718 ns) — đúng như O(N), vì nhân phải quét cả danh sách mỗi lần dù chỉ một fd có việc. epoll_wait thì phẳng lì ở ~125 ns bất kể N là 10 hay 900, vì nó chỉ trả về đúng cái fd đang sẵn sàng, không quan tâm có bao nhiêu fd nhàn rỗi đang được theo dõi. Ở N=900, epoll nhanh hơn select/poll khoảng 140 lần.

Đây chính là lý do mọi máy chủ hiệu năng cao (nginx, Redis, Node.js) đều dùng epoll (hay kqueue trên BSD/macOS): khi số kết nối lên hàng nghìn hàng vạn mà tại mỗi thời điểm chỉ vài cái có dữ liệu, cái phẳng lì đó là khác biệt sống còn.

Một lần tôi đo hớ: epoll im lặng trong khi dữ liệu còn nằm đó

Đang thử epoll, tôi dựng một fd, ghi 2 byte vào nó, gọi epoll_wait — nó báo 1 fd sẵn sàng, đúng. Tôi đọc một nhịp (lấy 1 byte), rồi gọi epoll_wait lần nữa để xem còn gì không. Nó trả về 0 — không fd nào sẵn sàng. Phản xạ của tôi: "hết dữ liệu rồi, xử lý xong". Nhưng khoan — tôi mới đọc 1 trong 2 byte, fd vẫn còn một byte chưa đọc. Sao epoll lại bảo không có gì?

Vì tôi đã đăng ký fd ở chế độ edge-triggered (EPOLLET). Ở chế độ này, epoll chỉ báo đúng một lần tại khoảnh khắc fd chuyển từ "rỗng" sang "có dữ liệu" — cái cạnh (edge) của sự kiện. Sau khi đã báo, nó im lặng cho tới khi có một sự kiện mới (thêm dữ liệu tới), dù dữ liệu cũ vẫn còn nằm chưa đọc. Tôi đọc không cạn, epoll không báo lại, và nếu đây là code thật, kết nối đó sẽ treo vĩnh viễn với một byte kẹt trong đệm — không phải lỗi epoll, mà do tôi dùng sai hợp đồng của nó.

Để đối chiếu, tôi làm lại với chế độ level-triggered (mặc định của epoll): ghi 2 byte, epoll_wait báo sẵn sàng, đọc 1 byte, epoll_wait lần nữa — vẫn báo 1 fd sẵn sàng, vì còn dữ liệu là còn báo. Đọc thiếu vẫn an toàn.

Bài học đo lường: một con số "0" hợp lệ vẫn có thể là câu trả lời cho câu hỏi khác câu tôi tưởng mình đang hỏi. epoll_wait trả về 0 không có nghĩa "fd không còn dữ liệu"; với edge-triggered nó có nghĩa "không có cạnh mới kể từ lần trước". Muốn dùng edge-triggered (nó nhanh hơn vì ít lần báo lặp), bạn phải đọc mỗi fd cho tới khi cạn (read trả EAGAIN) trong một lần, nếu không sẽ đánh mất dữ liệu còn lại. Đọc kỹ hợp đồng ngữ nghĩa của công cụ trước khi tin cái nó nói.

Cái bẫy thứ hai: giới hạn cứng của select

Còn một lý do nữa để không dùng select cho máy chủ nhiều kết nối, không phải về tốc độ mà về giới hạn cấu trúc. Kiểu fd_set của select là một mảng bit có kích thước cố định — tôi đo được sizeof(fd_set) = 128 byte = đúng 1024 bit. Nghĩa là nó chỉ có thể biểu diễn các fd từ 0 đến 1023; một fd mang số ≥ 1024 không có chỗ trong mảng đó. Trên một máy chủ mở hàng nghìn kết nối, số fd dễ dàng vượt 1024, và FD_SET với một fd như vậy sẽ ghi ra ngoài mảng — hỏng bộ nhớ âm thầm, một lỗi vừa khó tìm vừa nguy hiểm bảo mật. pollepoll không có giới hạn này vì chúng dùng thẳng giá trị fd, không nhét vào một mảng bit cố định.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên: dùng epoll (hoặc thư viện bọc nó) cho bất kỳ thứ gì phục vụ nhiều kết nối đồng thời. Đừng tự viết vòng select/poll cho một máy chủ thật; sự khác biệt O(N) so với O(1) quyết định bạn phục vụ được trăm hay vạn kết nối trên cùng phần cứng. Hầu hết ngôn ngữ đã có sẵn: epoll được gói trong các thư viện sự kiện (libuv của Node, asyncio của Python, Netty của Java) — bạn hiếm khi gọi thẳng, nhưng nên biết cái gì chạy bên dưới.

Hệ quả thứ hai: chọn mức kích hoạt có chủ đích. Level-triggered đơn giản và tha thứ (đọc thiếu vẫn được báo lại), hợp cho phần lớn ứng dụng. Edge-triggered nhanh hơn ở tải rất cao (ít lần báo hơn) nhưng đòi hỏi bạn luôn đọc/ghi cho tới khi cạn; sai một chỗ là kết nối treo. Nếu không chắc, dùng level-triggered — mặc định đúng cho hầu hết trường hợp.

Hệ quả thứ ba là bài học đo lường: một công cụ có thể đúng ở quy mô nhỏ và sai ở quy mô lớn. Con số mang theo: chờ trên 900 fd với 1 cái sẵn sàng, select/poll tốn ~18000 ns (O(N), quét hết) còn epoll_wait chỉ 125 ns (phẳng, chỉ trả fd sẵn sàng) — nhanh ~140 lần; nhưng edge-triggered đòi đọc tới cạn nếu không epoll im lặng, và select không đại diện nổi fd ≥ 1024. Chọn đúng công cụ dùng đúng hợp đồng của nó — đo ở quy mô thật mới lộ ra cả hai.

Thử ba mươi giây

Nếu bạn có một tiến trình máy chủ đang chạy (nginx, redis, một app Node), tìm PID của nó rồi chạy ls /proc/<pid>/fd | wc -l để đếm số fd nó đang mở — con số đó chính là số "kết nối và tài nguyên" mà nó đang chờ trên đó. Rồi strace -p <pid> -e trace=epoll_wait,poll,select -c trong vài giây (Ctrl-C để dừng): bạn sẽ thấy nó gọi epoll_wait chứ gần như chắc chắn không phải select — và mỗi lần trả về chỉ vài fd sẵn sàng trong số hàng trăm nó theo dõi. Đó là cái đường phẳng bài này đo, đang chạy thật trong phần mềm bạn dùng hằng ngày.