Chờ I/O thế nào — blocking, non-blocking và epoll
Một thread chờ một fd, hay một thread canh mười nghìn fd? Ba mô hình chờ, bài toán C10K, chi phí syscall mỗi vòng lặp, và io_uring khác epoll ở đâu.
TL;DR: Bài trước để tiến trình nằm ngủ ở chặng cuối; bài này hỏi lúc ngủ đó bạn trả giá bằng gì. Blocking cấp cho mỗi kết nối một thread, đơn giản nhưng mười nghìn kết nối là mười nghìn ngăn xếp và chi phí lập lịch. select/poll cho một thread canh nhiều fd, nhưng mỗi lần gọi phải chép cả tập fd sang kernel rồi duyệt hết — chi phí O(N) theo tổng số fd, dù chỉ vài fd có việc. epoll giữ tập fd sống trong kernel, nên mỗi lần gọi chỉ trả về O(M) fd sẵn sàng. Còn io_uring đổi hẳn câu hỏi: thay vì "fd nào sẵn sàng", nó nhận việc và báo "việc nào xong".
Năm 1999, Dan Kegel đặt tên cho một bài toán đang làm khó cả ngành: một máy chủ phục vụ cùng lúc mười nghìn kết nối thì phải viết thế nào? Gọi là C10K. Phần cứng thời đó đã đủ sức, mô hình lập trình thì không — và bài toán ấy chưa bao giờ biến mất, nó chỉ đổi con số.
1. Analogy — một lễ tân và mười nghìn khách
Quầy lễ tân khách sạn. Cách thứ nhất là thuê mỗi khách một nhân viên riêng — đơn giản, cho tới lúc có mười nghìn khách và bạn không đủ chỗ cho ngần ấy người đứng.
Cách thứ hai là một lễ tân duy nhất, cứ vài giây lại đi một vòng gõ cửa từng phòng hỏi "anh chị cần gì không". Chỉ tốn một người, nhưng vòng đi ấy dài đúng bằng số phòng, kể cả khi cả toà chỉ có ba người cần gọi taxi.
Cách thứ ba: gắn chuông ở mỗi phòng, nối hết về một bảng đèn ở quầy. Lễ tân ngồi yên nhìn bảng, đèn nào sáng thì tới đúng phòng đó. Số phòng tăng gấp mười cũng không làm bảng đèn nặng thêm — chỉ số phòng đang gọi mới đáng kể.
| Ở khách sạn | Trong hệ thống |
|---|---|
| Mỗi khách một nhân viên | Blocking — một thread cho một kết nối |
| Đi một vòng gõ cửa từng phòng | select/poll — quét toàn bộ tập fd |
| Bảng đèn báo phòng nào đang gọi | epoll — kernel trả về danh sách fd sẵn sàng |
| Ghi sổ việc rồi có người báo lại lúc xong | io_uring — hàng đợi gửi việc và hàng đợi hoàn tất |

2. Hai cách cũ — thread mỗi kết nối, rồi quét cả tập fd
Mô hình gốc: một thread gọi read() trên một socket rồi nằm chờ cho tới khi có dữ liệu. Code chạy thẳng từ trên xuống, không callback, không máy trạng thái — vẫn là mô hình dễ viết đúng nhất, và với vài trăm kết nối thì hoàn toàn hợp lý.
Cái giá hiện ra khi nhân lên. Mỗi thread cần ngăn xếp riêng, và nặng hơn là chi phí lập lịch: bộ lập lịch phải quản mười nghìn thực thể, mỗi lần đánh thức một thread là một cú chuyển ngữ cảnh kèm thiệt hại cache. Điểm mấu chốt: bản thân việc chờ chẳng tốn CPU. Cái tốn là chỗ cái chờ ấy được biểu diễn bằng một thread, thứ vốn đắt.
Bài đó chốt rằng thêm thread chỉ cứu được workload chờ đợi. Ở đây là mặt kia của cùng đồng xu: ngay cả với workload chờ đợi, thêm thread vẫn có trần, và trần đó nằm ở chi phí quản lý.
Ý tưởng tiếp theo tách chuyện chờ khỏi chuyện làm: đặt socket ở chế độ non-blocking (O_NONBLOCK) để read() trả về ngay EAGAIN khi chưa có dữ liệu, rồi dùng một syscall chuyên trách hỏi "trong đám fd này, cái nào đang sẵn sàng". Đó là select và poll.
Cả hai có chung một khiếm khuyết cấu trúc: kernel không nhớ gì giữa hai lần gọi. Mỗi lượt, ứng dụng chép toàn bộ tập fd sang kernel, kernel duyệt hết để xem cái nào sẵn sàng, rồi chép kết quả ngược lại. Chi phí vì thế là O(N) theo tổng số fd theo dõi, bất kể bao nhiêu fd thực sự có việc — mười nghìn kết nối mà chỉ mười cái đang gửi thì mỗi vòng lặp vẫn trả tiền cho cả mười nghìn. Đúng là ông lễ tân đi gõ cửa từng phòng.
3. epoll — để kernel giữ danh sách hộ
epoll sửa đúng khiếm khuyết đó: tập fd sống trong kernel, không phải chép qua lại mỗi lượt.
/* Register the fd once; the kernel keeps the set across calls. */
int ep = epoll_create1(0);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = conn_fd };
epoll_ctl(ep, EPOLL_CTL_ADD, conn_fd, &ev);
/* Each wait returns only the fds that are actually ready. */
int n = epoll_wait(ep, events, MAX_EVENTS, -1);
Đăng ký bằng epoll_ctl một lần cho mỗi fd; sau đó mỗi epoll_wait chỉ trả về những fd đang sẵn sàng, nên chi phí là O(M) theo số fd có việc. Thêm chín nghìn kết nối im lặng gần như không làm vòng lặp nặng thêm.
epoll có hai chế độ báo, và đây là chỗ sinh ra pitfall kinh điển nhất của nó. Level-triggered (mặc định) báo chừng nào còn dữ liệu chưa đọc hết, giống bóng đèn cứ sáng cho tới khi bạn xử lý xong. Edge-triggered (EPOLLET) chỉ báo đúng một lần mỗi khi trạng thái đổi sang sẵn-sàng, giống một tiếng chuông vang rồi thôi.
4. Tự điền — vòng lặp edge-triggered này hỏng ở đâu?
Đoạn dưới đăng ký fd với EPOLLET rồi xử lý sự kiện. Nó chạy đúng trong thử nghiệm nhẹ, nhưng khi tải lên thì một số kết nối treo cứng và không bao giờ nhận thêm dữ liệu. Điền vào chỗ trống trước khi đọc tiếp.
struct epoll_event ev = { .events = EPOLLIN | EPOLLET, .data.fd = fd };
epoll_ctl(ep, EPOLL_CTL_ADD, fd, &ev);
/* ... later, inside the event loop ... */
int n = epoll_wait(ep, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
ssize_t got = read(fd, buf, sizeof buf); /* TODO: sua cho dung ET */
handle(buf, got);
}
Hai câu hỏi định hướng, không phải đáp án. Thứ nhất: nếu kernel chỉ báo một lần mỗi khi trạng thái đổi, thì sau lời gọi read() ở trên, phần dữ liệu chưa lấy hết sẽ được báo lại vào lúc nào? Thứ hai: giá trị trả về nào của read() mới chứng minh được là bộ đệm đã cạn thật? Viết bản sửa của bạn ra trước.
Chỗ hỏng là read() chỉ được gọi một lần cho mỗi sự kiện. Giả sử bộ đệm socket có 64 KB còn buf chứa được 4 KB: bạn lấy 4 KB rồi quay lại epoll_wait. Với level-triggered thì không sao vì kernel báo tiếp. Nhưng edge-triggered đã dùng hết một tiếng chuông cho lần trạng thái đổi vừa rồi và sẽ không báo lại cho tới khi có dữ liệu mới. Nếu phía bên kia đang đợi phản hồi trước khi gửi tiếp, hai bên khoá nhau vĩnh viễn.
Bản sửa là đọc trong vòng lặp cho tới khi kernel nói thẳng rằng hết:
for (;;) {
ssize_t got = read(fd, buf, sizeof buf);
if (got > 0) { handle(buf, got); continue; }
if (got == 0) { close_conn(fd); break; } /* peer closed */
if (errno == EAGAIN || errno == EWOULDBLOCK) break; /* drained */
if (errno == EINTR) continue;
close_conn(fd); break; /* real error */
}
Điều kiện dừng đúng là EAGAIN, không phải "đọc được ít hơn kích thước bộ đệm". Một lần read() trả về ít hơn sức chứa hoàn toàn có thể vẫn còn dữ liệu phía sau, nên lấy nó làm dấu hiệu cạn là quay về đúng con bug vừa sửa.
5. io_uring — đổi từ "sẵn sàng chưa" sang "xong chưa"
epoll vẫn còn hai giới hạn. Thứ nhất, nó chỉ hữu ích với thứ có khái niệm sẵn sàng — socket, pipe, thiết bị. File thường thì luôn được coi là sẵn sàng, nên đăng ký nó vào epoll chẳng giúp gì: read() vẫn chặn cho tới khi đĩa trả dữ liệu. Thứ hai, mỗi vòng lặp vẫn tốn ít nhất một syscall.
io_uring đổi hẳn mô hình. Thay vì hỏi fd nào sẵn sàng rồi tự làm việc, ứng dụng gửi hẳn công việc vào một hàng đợi và nhận kết quả ở hàng đợi khác. Theo man page, hai hàng đợi này là vùng nhớ chia sẻ giữa kernel và user space, nên xếp việc và lấy kết quả không nhất thiết phải qua syscall; với cờ IORING_SETUP_SQPOLL, một thread kernel tự canh hàng đợi gửi và ứng dụng chạy trọn vòng lặp mà không gọi syscall nào.
Vì mô hình là hoàn tất chứ không phải sẵn sàng, io_uring async được cả thứ epoll bó tay, đứng đầu là đọc ghi file thường. Đó cũng là lý do cơ chế AIO cũ của Linux thất bại: nó không async nổi buffered I/O.
6. Pitfall của riêng concept này
❌ Nhầm 1 — dùng edge-triggered mà đọc một lần rồi quay lại epoll_wait.
✅ Kernel chỉ báo một lần cho mỗi lần trạng thái đổi. Phải đọc trong vòng lặp tới khi gặp EAGAIN hoặc EWOULDBLOCK. Đây là bug epoll phổ biến nhất, và triệu chứng của nó (vài kết nối treo dưới tải cao) cực khó tái hiện.
❌ Nhầm 2 — tưởng epoll làm cho đọc file nhanh hơn.
✅ File thường luôn được coi là sẵn sàng, nên epoll không giúp gì. Muốn không chặn khi đọc file thì phải dùng thread riêng hoặc io_uring.
❌ Nhầm 3 — cho nhiều thread cùng epoll_wait trên một fd lắng nghe.
✅ Đây là thundering herd: một kết nối tới đánh thức nhiều thread, nhưng chỉ một thread accept được, số còn lại tỉnh dậy vô ích. Cờ EPOLLEXCLUSIVE trong epoll_ctl(2) (từ Linux 4.5) giới hạn số thread được đánh thức.
❌ Nhầm 4 — dùng epoll cho một service chỉ có vài chục kết nối.
✅ Lợi thế của epoll chỉ xuất hiện khi số fd lớn hơn hẳn số fd đang bận. Vài chục kết nối thì blocking đơn giản hơn nhiều mà không chậm hơn đáng kể.
7. 📚 Đào sâu (tuỳ chọn)
- epoll(7) — man7.org — level-triggered so với edge-triggered, yêu cầu đọc tới
EAGAINvớiEPOLLET, vàEPOLLONESHOT. - io_uring_setup(2) — man7.org — hai hàng đợi chia sẻ với user space, cờ
IORING_SETUP_SQPOLL. - Ringing in a new asynchronous I/O API — LWN — vì sao AIO cũ không async nổi buffered I/O.
- The C10K Problem — Dan Kegel — bài viết gốc đặt tên cho bài toán.
8. Liên hệ các bài khác
- Hành trình một lệnh
read()— chỗ tiến trình nằm ngủ ở chặng cuối; bài này hỏi cái ngủ đó tốn gì. - CPU-bound so với I/O-bound — vì sao thêm thread có trần, nhìn từ phía bộ lập lịch.
- Zero-copy — bài kế tiếp tấn công trục khác: không phải cách chờ, mà số bản sao trên đường dữ liệu.
- Host và device — cùng bài học "thêm worker không cứu được nút thắt ở tầng dưới", đổi sang tầng bus.
9. Tóm tắt
- Chờ không tốn CPU; tốn là ở chỗ cái chờ đó được biểu diễn bằng một thread.
selectvàpollchép cả tập fd sang kernel mỗi lượt rồi duyệt hết, nên chi phí bám theo tổng số fd chứ không theo số fd có việc.epollgiữ tập fd trong kernel và chỉ trả về những fd sẵn sàng; đổi lại, edge-triggered đòi đọc tớiEAGAIN, sai là treo kết nối.io_uringcòn cắt cả syscall khỏi vòng lặp nóng nhờ hai hàng đợi nằm trong bộ nhớ chia sẻ, thứepollkhông làm được.- Chọn mô hình theo tỉ lệ giữa tổng số fd và số fd đang bận, không theo mốt.
10. Tự kiểm tra
Q1Một service chạy epoll ở chế độ edge-triggered, chạy tốt lúc thử nhưng khi lên production thì lác đác vài kết nối treo cứng, không nhận thêm dữ liệu và cũng không báo lỗi. Nguyên nhân nhiều khả năng là gì?▸
Vòng lặp gọi read() đúng một lần cho mỗi sự kiện. Edge-triggered chỉ báo một lần mỗi khi trạng thái đổi sang sẵn-sàng, nên phần dữ liệu còn lại trong bộ đệm socket không sinh ra sự kiện mới: kết nối đứng im chờ một thông báo không bao giờ tới. Chỉ lộ ra dưới tải vì lúc thử nhẹ, mỗi lần dữ liệu tới thường vừa đúng một bộ đệm nên một lần đọc là hết. Bản sửa là lặp read() tới khi gặp EAGAIN — và đừng lấy "đọc được ít hơn sức chứa" làm dấu hiệu cạn.
Q2Vì sao chi phí của select và poll bám theo tổng số fd, còn epoll thì bám theo số fd đang sẵn sàng? Khác biệt cấu trúc nào tạo ra điều đó?▸
Vì kernel không nhớ gì giữa hai lần gọi select hay poll: mỗi lượt, ứng dụng phải chép toàn bộ tập fd sang kernel, kernel duyệt hết, rồi chép kết quả về — công sức tỉ lệ với số fd theo dõi dù chỉ vài cái có việc. epoll đảo chuyện đó: fd được đăng ký một lần bằng epoll_ctl và tập ấy sống trong kernel, nên mỗi epoll_wait chỉ trả về danh sách fd đang sẵn sàng và thêm chín nghìn kết nối im lặng gần như không làm vòng lặp nặng thêm.
Q3Một service đọc nhiều file lớn từ đĩa đang bị chặn ở mỗi lần đọc. Đồng nghiệp đề xuất chuyển sang epoll cho 'non-blocking'. Vì sao cách này không giải quyết được, và những lựa chọn nào mới đúng?▸
Vì file thường luôn được kernel coi là sẵn sàng: epoll_wait báo ngay lập tức, nhưng read() sau đó vẫn nằm chờ đĩa trả dữ liệu. Khái niệm độ sẵn sàng mà epoll dựa vào chỉ có ý nghĩa với socket, pipe và thiết bị. Hai lựa chọn đúng là đẩy việc đọc sang thread riêng, hoặc dùng io_uring — cách sau hợp mô hình hơn vì nó làm việc theo hoàn tất chứ không theo độ sẵn sàng.
Q4Một API gateway nội bộ phục vụ khoảng 40 kết nối bền, mỗi kết nối gần như lúc nào cũng có việc. Chọn blocking hay epoll, và lý do?▸
Blocking. Lợi thế của epoll đến từ tỉ lệ giữa tổng số fd và số fd đang bận — nó tiết kiệm đúng ở chỗ không phải trả tiền cho những fd im lặng. Ở đây gần như mọi fd đều bận nên tỉ lệ đó bằng một. Đổi lại, blocking cho code chạy thẳng từ trên xuống, không máy trạng thái, không rủi ro của edge-triggered, và bốn mươi thread là chi phí không đáng kể. Chọn kiến trúc phức tạp hơn mà không có khoản tiết kiệm tương ứng là trả giá mà không mua được gì.
Q5io_uring được mô tả là chuyển từ mô hình 'độ sẵn sàng' sang mô hình 'hoàn tất'. Sự chuyển đó cho phép làm được gì mà epoll không làm được?▸
Nó async được những thao tác vốn không có khái niệm sẵn sàng, quan trọng nhất là đọc ghi file thường: với epoll bạn hỏi fd nào sẵn sàng rồi tự thực hiện thao tác, còn với io_uring bạn giao hẳn thao tác vào hàng đợi gửi và nhận kết quả ở hàng đợi hoàn tất. Lợi ích thứ hai là cắt syscall: hai hàng đợi nằm trong vùng nhớ chia sẻ, và với cờ IORING_SETUP_SQPOLL thì một thread kernel tự canh hàng đợi gửi nên vòng lặp chạy được mà không gọi syscall nào.
Bài tiếp theo: Zero-copy — cắt bớt bản sao trên đường đi
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên