Khi chương trình gọi read trên một socket chưa có dữ liệu, chuyện gì xảy ra? Mặc định, nó chặn — đứng chờ cho tới khi có dữ liệu. Nhưng bạn có thể bật cờ O_NONBLOCK để read trả về ngay lập tức với lỗi EAGAIN thay vì chờ. Nghe như không chặn thì "nhanh nhạy" hơn: không phải đứng đợi, kiểm tra liên tục, phản hồi tức thì. Tôi đo cả hai cách trong container — và con số tốc độ suýt khiến tôi khuyên một điều tai hại.

Chặn và không chặn

Hai cách chờ dữ liệu

Sự khác biệt nằm ở chỗ tiến trình làm gì trong lúc chờ.

Với read chặn (blocking, mặc định), nếu chưa có dữ liệu, nhân đưa tiến trình vào trạng thái ngủ — nó rời khỏi hàng đợi CPU, không tiêu tốn một chu kỳ nào, và nhường lõi cho việc khác. Khi dữ liệu tới, nhân đánh thức nó dậy. Tiến trình không làm gì trong lúc chờ, theo đúng nghĩa đen.

Với read không chặn (O_NONBLOCK), nếu chưa có dữ liệu, read trả về ngay với EAGAIN ("chưa có gì, thử lại sau"). Bản thân điều này không sai — nhưng nếu bạn dùng nó theo cách ngây thơ nhất, là lặp lại read liên tục cho tới khi có dữ liệu (busy-poll, quay bận), thì tiến trình không bao giờ ngủ: nó cứ chạy vòng lặp hỏi đi hỏi lại, đốt CPU.

Để đo, tôi cho một tiến trình con đọc một pipe rỗng (dữ liệu không tới trong ~1,2 giây), theo hai cách, ghim vào một lõi, rồi quan sát trạng thái và CPU của nó qua /proc.

Đo: ngủ 0% hay đốt 100%

Cách chờ Trạng thái /proc CPU dùng khi chờ 1,2s Nhận ra data
Chặn (blocking) S (ngủ) 0,000 s (~0%) 97 µs
Không chặn (busy-poll) R (chạy) 1,21 s (~100% một lõi) 11 µs

Hai dòng này kể một câu chuyện rõ như ban ngày qua trường /proc/<pid>/stat. Tiến trình chặn nằm ở trạng thái S (sleeping — đang ngủ) và tiêu tốn 0 giây CPU suốt 1,2 giây chờ: nó thật sự không làm gì, nhân giữ nó ngủ. Tiến trình busy-poll nằm ở trạng thái R (running — đang chạy) và ngốn 1,21 giây CPU trong 1,2 giây chờ — tức chiếm trọn 100% một lõi để quay vòng hỏi "có chưa? có chưa?" mà chẳng làm được việc gì có ích.

Một lần tôi đo hớ: nhìn số "nhanh" mà quên cột bên cạnh

Tôi đo thêm một thứ: khi dữ liệu cuối cùng cũng tới, mỗi bên mất bao lâu để nhận ra. Kết quả:

Busy-poll : nhận ra data sau 11 micro giây
Chặn      : nhận ra data sau 97 micro giây

Busy-poll nhận ra dữ liệu nhanh gấp ~9 lần. Phản xạ đầu tiên của tôi là viết: "không chặn phản hồi nhanh hơn hẳn, dùng nó cho độ trễ thấp". Con số 9 lần nghe thuyết phục. Nhưng đó là con số "nhanh" đứng một mình, và tôi suýt quên nhìn sang cột ngay bên cạnh: cái nhanh 9 lần đó mua bằng cả một lõi CPU. Busy-poll biết tin sớm hơn 86 micro giây, nhưng để có được điều đó nó đã đốt 100% một lõi suốt cả quãng chờ, trong khi đọc chặn ngủ ở 0% và chỉ trả một cái giá 97 micro giây khi thức dậy — cái giá gần như không đáng kể với hầu hết công việc.

Đây đúng là bài học "nhanh vô nghĩa nếu chưa hỏi tốn gì". Một con số hiệu năng tách khỏi cái giá của nó là nửa sự thật. Busy-poll không "tốt hơn" — nó đổi một tài nguyên đắt (cả một lõi CPU) lấy một cải thiện độ trễ mà đa số ứng dụng chẳng cần. Với một tiến trình chờ dữ liệu thưa thớt, đọc chặn vừa đơn giản hơn, vừa rẻ hơn vô cùng: 0% CPU, để lõi làm việc khác hoặc tiết kiệm điện.

Vậy O_NONBLOCK sinh ra để làm gì, nếu busy-poll là phí phạm? Nó không sinh ra để busy-poll ngây thơ. Công dụng thật của nó là ghép với epoll (bài trước): một luồng đặt mọi fd ở chế độ không chặn, rồi ngủ trong epoll_wait (0% CPU, y như đọc chặn), và khi được đánh thức vì có fd sẵn sàng, nó đọc từng fd đó theo kiểu không chặn cho tới khi EAGAIN. Cách này lấy được cả hai điều tốt: ngủ ở 0% CPU khi rảnh, lo được hàng nghìn fd cùng lúc. Busy-poll một mình vứt bỏ đúng cái lợi lớn nhất — được ngủ.

Một trạng thái ngủ thứ ba: D

Ở trên ta thấy hai trạng thái: S (ngủ, chờ mà nhường CPU) và R (chạy). Nhưng khi đọc /proc bạn sẽ gặp một trạng thái ngủ thứ ba đáng biết: Duninterruptible sleep, ngủ không thể ngắt. Đây cũng là ngủ (0% CPU), nhưng khác S ở chỗ tiến trình đang chờ một thao tác mà nhân không cho phép hủy giữa chừng — điển hình là I/O đĩa đang dang dở. Một tiến trình ở S có thể bị đánh thức bởi một tín hiệu (ví dụ Ctrl+C); một tiến trình ở D thì không — nó phải chờ thao tác kia xong, kể cả khi bạn cố giết nó. Đó là lý do một tiến trình đọc từ một ổ đĩa hỏng hay một mount mạng chết có thể "không giết được": nó kẹt ở D, và kill -9 cũng chịu. Thấy nhiều tiến trình D trong top thường là dấu hiệu hệ thống đang nghẽn I/O, không phải nghẽn CPU — hai thứ cần cách chữa hoàn toàn khác nhau.

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

Hệ quả đầu tiên: đừng busy-poll trừ khi bạn thật sự biết mình đang làm gì. Vòng lặp while (read(...) == EAGAIN); trông vô hại nhưng biến một tiến trình đang chờ thành một tiến trình đốt trọn một lõi. Đây là nguyên nhân kinh điển của những sự cố "CPU 100% mà chẳng làm gì": một thư viện hay một đoạn code kiểm tra trạng thái trong vòng lặp chặt không có điểm ngủ. Busy-poll chỉ đáng ở những niche cực đoan (giao dịch tần suất cao, mạng bỏ qua nhân) nơi vài micro giây độ trễ đáng giá cả một lõi dành riêng.

Hệ quả thứ hai: chặn không phải là "chậm", nó là "ngủ". Nhiều người tránh đọc chặn vì sợ "đứng hình". Nhưng một tiến trình chặn không lãng phí gì — nó nhường CPU và được đánh thức đúng lúc. Vấn đề của đọc chặn không phải hiệu năng mà là mô hình: một luồng chặn chỉ chờ được một thứ tại một thời điểm. Khi cần chờ nhiều nguồn cùng lúc, giải pháp không phải busy-poll mà là epoll (một luồng ngủ, chờ nhiều fd) hoặc nhiều luồng (mỗi luồng chặn một việc).

Hệ quả thứ ba là bài học đo lường xuyên suốt: luôn đọc con số tốc độ kèm cái giá của nó. Con số mang theo: đọc chặn ngủ ở trạng thái S dùng 0% CPU và được nhân đánh thức sau ~97µs khi có data; busy-poll không chặn nhận ra data nhanh gấp 9 lần (~11µs) nhưng đốt trọn 100% một lõi suốt thời gian chờ — "nhanh hơn" ở đây được mua bằng cả một lõi CPU, một cái giá hầu như không đáng. O_NONBLOCK là để ghép với epoll, không phải để quay bận. Trước khi khen một cách làm "nhanh hơn", hãy hỏi nó tốn gì.

Thử ba mươi giây

Tìm một tiến trình đang chờ (một server nhàn rỗi, hay sleep 100 &) và xem trạng thái của nó: ps -o pid,stat,%cpu,comm -p <pid> — cột STAT bắt đầu bằng S nghĩa là nó đang ngủ (chờ mà không tốn CPU), R nghĩa là đang chạy. Một server khỏe mạnh lúc rảnh phải là S với %CPU gần 0. Nếu bạn thấy một tiến trình "đang chờ" mà lại R và ngốn 100% CPU, gần như chắc chắn nó đang busy-poll đâu đó — hãy strace -p <pid> xem: nếu bạn thấy cùng một read (hay poll timeout 0) lặp lại như súng liên thanh trả về EAGAIN, đó chính là cái vòng quay bận mà bài này đo, đang đốt một lõi của bạn vô ích.