phần epoll ta thấy cách một luồng chờ nhiều fd hiệu quả. Nhưng để mẫu đó hoạt động, còn một mảnh nữa: non-blocking I/O. Đây là một khái niệm bị hiểu sai nhiều — người ta thường nghĩ "đặt fd thành non-blocking để I/O nhanh hơn". Sự thật khác hẳn: non-blocking không làm I/O nhanh hơn chút nào, và dùng sai nó (không ghép epoll) còn đốt trọn một lõi CPU. Tôi đo trong container gcc:13 (ARM) để làm rõ nó thực sự làm gì.

Non-blocking I/O

Blocking vs non-blocking, và cái bẫy

read() thường là blocking: nếu fd chưa có dữ liệu, luồng gọi ngủ trong nhân tới khi dữ liệu tới. Đơn giản, nhưng một luồng đang chờ một fd thì kẹt ở đó — không làm được gì cho các fd khác. Với một server nhiều kết nối, không thể để một luồng chờ một client.

Đặt O_NONBLOCK đổi hành vi: read() trả về ngay lập tức — nếu chưa có dữ liệu, nó không ngủ mà trả về lỗi EAGAIN ("thử lại sau"). Luồng không bị kẹt. Nghe như một cải tiến tốc độ, nhưng đây là chỗ bẫy: non-blocking không làm việc đọc nhanh hơn. Một read có dữ liệu vẫn là một syscall; một read trả EAGAIN cũng là một syscall — bạn vẫn trả chi phí vào/ra nhân, chỉ khác là nó không ngủ. Và nếu bạn cứ quay vòng gọi read non-blocking cho tới khi có dữ liệu, bạn đang busy-loop — đốt CPU.

Đo: cùng tốc độ đọc, nhưng busy-loop đốt cả lõi

Trước hết, chi phí một lần đọc — non-blocking EAGAIN (fd rỗng) so với read có dữ liệu:

EAGAIN (non-block read khi rỗng) : 121 ns
read có data sẵn                 : 127 ns   (~= một syscall)

Gần y hệt — cả hai ~120 ns, đúng bằng một syscall. Non-blocking không nhanh hơn; một EAGAIN không "miễn phí", nó là một chuyến vào nhân đầy đủ. Nếu bạn tưởng non-blocking tăng tốc I/O, con số này bác bỏ ngay.

Giờ phần quan trọng — chờ dữ liệu tới sau ~50 ms, một luồng, đo CPU đốt:

busy-loop non-block (quay đọc) : chờ 59 ms, ĐỐT 58.736 µs CPU  (quay đốt lõi)
epoll_wait (ngủ tới khi sẵn)   : chờ 60 ms, ĐỐT      14 µs CPU  (ngủ, nhường lõi)

Đây là sự khác biệt sống còn. Busy-loop trên non-blocking read — quay vòng gọi read liên tục cho tới khi có dữ liệu — đốt 58.736 µs CPU cho 59 ms chờ, tức gần 100% một lõi suốt thời gian chờ, chỉ để hỏi đi hỏi lại "có chưa? có chưa?". Trong khi epoll_wait (ngủ tới khi có fd sẵn) chỉ đốt 14 µs CPU — nó ngủ, nhường lõi cho việc khác, và chỉ thức khi thật sự có dữ liệu. Busy-loop đốt CPU gấp ~4.200 lần. Và cái CPU đó không phải "làm việc" — nó là 100% một lõi phí phạm hỏi "có chưa?", cướp CPU khỏi các kết nối khác đúng lúc chúng cũng cần, biến một server đáng lẽ nhàn khi rảnh thành một cỗ máy luôn nóng.

Một lần tôi đo hớ: non-blocking không phải nút tăng tốc

Tôi vào đo với niềm tin phổ biến: "đặt fd thành non-blocking là để I/O nhanh hơn". Phép đo phá tan: một read non-blocking cùng tốc độ với read blocking (đều một syscall ~120 ns). Non-blocking không tăng tốc một thao tác đọc.

Vậy nó để làm gì? Non-blocking cho phép một luồng quản lý nhiều kết nối mà không bị kẹt chờ bất kỳ fd nào — nền tảng của mẫu event loop. Nhưng — và đây là mấu chốt — nó chỉ có ích khi ghép với epoll: epoll_wait ngủ tới khi có fd nào sẵn, rồi bạn read non-blocking chỉ fd đó (biết chắc có dữ liệu, không ngủ, không EAGAIN vô ích). Non-blocking một mình — không epoll — là một busy-loop đốt trọn một lõi, tệ hơn cả blocking.

Bài học đo lường: một cơ chế có thể là mảnh ghép cần thiết cho một mẫu thiết kế mà bản thân nó không phải "tối ưu" — hiểu vai trò của nó, đừng dùng như một nút bấm tốc độ. Non-blocking là viên gạch để dựng event loop (cùng epoll), không phải phép "làm I/O nhanh hơn". Nếu tôi tin "non-blocking = nhanh" và rải O_NONBLOCK khắp nơi rồi busy-loop chờ, tôi đã biến một chương trình đang ngủ tiết kiệm thành một cỗ máy đốt CPU. Đúng như chi phí đánh thức/ngủ mà sê-ri đồng thời đo: ngủ chờ (nhường CPU) gần như miễn phí, quay chờ (busy-poll) đốt trọn lõi — và non-blocking không epoll rơi đúng vào cái bẫy busy-poll.

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

Hệ quả đầu tiên: non-blocking chỉ đi cùng epoll (hay event loop) — đừng busy-loop. Nếu bạn đặt fd non-blocking, hãy chắc bạn dùng nó trong một event loop: epoll_wait ngủ chờ, đọc chỉ khi được báo sẵn. Đừng bao giờ viết while(read(...)==EAGAIN){} — đó là busy-loop đốt một lõi cho không. Nếu bạn không dùng event loop và chỉ chờ một fd, cứ dùng blocking read — nó ngủ tiết kiệm.

Hệ quả thứ hai: non-blocking không giúp gì cho hiệu năng một thao tác — nó là công cụ kiến trúc. Đừng đặt O_NONBLOCK với hy vọng đọc/ghi nhanh hơn; nó không. Nó chỉ đáng khi kiến trúc của bạn là một luồng phục vụ nhiều kết nối đồng thời (server, proxy, async runtime). Với một chương trình đọc tuần tự một file hay một client đơn giản, non-blocking chỉ thêm phức tạp (phải xử lý EAGAIN, quản trạng thái từng kết nối) mà không lợi gì.

Hệ quả thứ ba là tinh thần đo lường: một cơ chế là mảnh ghép của một mẫu, không phải một tối ưu độc lập — đo để hiểu vai trò thật. Con số mang theo: non-blocking I/O KHÔNG làm đọc nhanh hơn — một read có data 127 ns, một EAGAIN 121 ns, đều một syscall; nó cho MỘT luồng quản NHIỀU kết nối không kẹt chờ fd nào, NHƯNG chỉ khi ghép epoll (ngủ tới khi sẵn rồi đọc fd sẵn). Non-blocking một mình = busy-loop đốt trọn một lõi (58.736 µs CPU cho 59 ms chờ) so với epoll_wait ngủ chỉ 14 µs (~4.200 lần ít hơn). Non-blocking là viên gạch của event loop, không phải nút "nhanh hơn".

Thử ba mươi giây

Nếu code của bạn có O_NONBLOCK (hay socket.setblocking(False), SOCK_NONBLOCK), hỏi: nó có đi cùng một epoll_wait/select/event loop ngủ chờ không, hay ở đâu đó có một vòng lặp cứ gọi read/recv liên tục cho tới khi có dữ liệu? Nếu là cái sau, top sẽ cho thấy một lõi ở 100% dù chương trình "chẳng làm gì" — đó là busy-loop đốt CPU. Cách chữa: đặt fd non-blocking chỉ trong một event loop dùng epoll để ngủ chờ, hoặc nếu chỉ chờ một fd thì bỏ non-blocking đi và để read blocking ngủ. Ba mươi giây kiểm "non-blocking có ngủ chờ hay quay chờ" đó bắt được một trong những lỗi đốt-CPU âm thầm phổ biến nhất — và nhắc rằng non-blocking không phải để nhanh hơn, mà để một luồng phục vụ nhiều kết nối, và chỉ đúng khi ngủ chờ bằng epoll.