Hình dung một người bồi bàn. Cách vụng thứ nhất: anh ta đứng chôn chân ở một bàn, chờ khách bàn đó quyết định gọi món, không nhúc nhích phục vụ ai khác — đó là blocking. Cách thứ hai, tưởng khôn hơn: anh ta không đứng yên nữa, mà chạy vòng quanh liên tục hỏi từng bàn một "gọi món chưa? gọi chưa?" không nghỉ — đó là non-blocking dùng một mình, và nó vắt kiệt sức anh ta (đốt CPU) dù chẳng phục vụ được nhanh hơn. Cách đúng: mỗi bàn có một nút gọi, anh ta nghỉ cho tới khi có nút sáng lên — đó là epoll. Non-blocking là thứ giúp người bồi không bị đứng chôn chân; nhưng thiếu cái nút gọi, nó biến thành chạy vòng vô nghĩa.

Ở 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ì.

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 — đúng người bồi đứng chôn chân ở một bàn.

Đặ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, đúng người bồi chạy vòng hỏi mãi.

Đ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ôi trường đo: container gcc:13 trên host ARM AArch64, dữ liệu tới sau ~50 ms, một luồng, đo CPU đốt. Con số tuyệt đối đổi theo máy; cái bền vững là khoảng cách khổng lồ giữa quay-chờ và ngủ-chờ.)

Hai niềm tin đối nghịch, cùng sai

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ì? Nó 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 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).

Và đây là niềm tin ngược, sinh ra ngay khi mới nếm mùi non-blocking, còn tai hại hơn: "non-blocking để luồng không bị kẹt — vậy cứ đặt O_NONBLOCK rồi quay vòng đọc là xong, khỏi cần epoll rườm rà". Đó chính xác là cái busy-loop đốt trọn một lõi ở trên — tệ hơn cả blocking, vì blocking ít ra còn ngủ nhường CPU. Non-blocking một mình, không epoll, không phải tối ưu mà là phản tối ưu. Hai niềm tin gặp nhau ở một sự thật: non-blocking là viên gạch để dựng event loop (cùng epoll), không phải nút "làm I/O nhanh hơn" và cũng không phải cách "thoát blocking" đứng một mình. Đú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ề đến code hằng ngày

Điều đầ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.

Điều 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ì.

Chỗ này là bộ khung của gần như mọi thứ "async" bạn dùng. Node.js nổi tiếng "một luồng xử lý hàng nghìn kết nối" chính là non-blocking + event loop (libuv) — đúng cặp này; nginx cũng vậy, và đó là lý do nó nhàn CPU khi tải cao trong khi kiến trúc thread-per-connection thì ngộp. Java NIO là channel.configureBlocking(false) + một Selector (epoll dưới nền), và Netty dựng trên đúng đó. Điều tinh tế đẹp nhất là Go: bạn viết conn.Read() trông như blocking, nhưng runtime bên dưới đặt fd non-blocking và dùng netpoller (epoll) — khi read trả EAGAIN, nó park goroutine (ngủ, nhường lõi) thay vì quay vòng, rồi đánh thức khi epoll báo sẵn. Nói cách khác, Go cho bạn cú pháp blocking dễ đọc với hiệu năng non-blocking + epoll — vì viết tay busy-loop là cái bẫy quá dễ sa. Cùng một bài học người-bồi-và-nút-gọi, ẩn dưới mọi runtime mạng hiện đại.

Muốn tự thấy thì 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ủ. Vài phút 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.