Chương 63 · Mô hình I/O thay thế Chapter 63 · Alternative I/O Models
Khi một tiến trình phải theo dõi hàng nghìn kết nối cùng lúc Khi một chương trình phải trông chừng rất nhiều kết nối một lúc When one process must watch thousands of connections at once
Mô hình I/O chặn (blocking) mà phần lớn chương trình trong sách dùng — mỗi lời gọi read()/write() nằm chờ cho đến khi xong — là đủ cho nhiều ứng dụng, nhưng không phải tất cả. Khi một tiến trình phải cùng lúc theo dõi nhiều bộ mô tả tệp (file descriptor), hoặc kiểm tra xem I/O có khả thi mà không bị chặn, nó cần cách khác.1 Kerrisk giới thiệu ba lựa chọn thay thế: ghép kênh I/O (I/O multiplexing) qua select() và poll(); I/O do tín hiệu điều khiển (signal-driven I/O); và API epoll riêng của Linux.1
Hãy tưởng tượng một người gác cổng chỉ biết đứng chờ ở một cánh cửa cho tới khi có người tới. Nếu toà nhà có hàng nghìn cửa, cách đó vô vọng — thuê nghìn người gác cũng tốn kém. Chương này dạy ba cách để một người gác trông được tất cả các cửa cùng lúc: hỏi dồn một lượt (select/poll), nhờ chuông báo (tín hiệu), và danh sách thông minh của Linux (epoll). Nhớ nhé: thay vì chờ từng cửa một, hãy trông tất cả các cửa cùng lúc.
The blocking I/O model that most programs in the book use — each read()/write() waits until it completes — is sufficient for many applications, but not all. When a process must simultaneously monitor multiple file descriptors, or check whether I/O is possible without blocking, it needs another approach.1 Kerrisk introduces three alternatives: I/O multiplexing via select() and poll(); signal-driven I/O; and the Linux-specific epoll API.1
Trước khi tới ba lựa chọn, sách nhắc hai cách một phần đã gặp: I/O không chặn (bật cờ O_NONBLOCK, rồi "thăm dò" — poll — từng bộ mô tả theo chu kỳ) và dùng nhiều tiến trình hoặc luồng (mỗi kết nối một người xử lý). Cả hai đều hạn chế: thăm dò quá thưa thì chậm phản ứng, thăm dò trong vòng lặp chặt thì ngốn CPU; còn một-luồng-mỗi-kết-nối thì tốn bộ nhớ và chi phí chuyển ngữ cảnh khi số kết nối lên tới hàng nghìn — chính là "bài toán C10K".111Before the three alternatives, the book recalls two partial techniques already met: nonblocking I/O (set O_NONBLOCK, then periodically "poll" each descriptor) and using multiple processes or threads (one handler per connection). Both have limits: polling too rarely means sluggish response, polling in a tight loop wastes CPU; and one-thread-per-connection costs memory and context-switch overhead once connections reach the thousands — the "C10K problem."111
Luận đề của chương: không mô hình nào trong số này tự thực hiện I/O — chúng chỉ cho biết bộ mô tả tệp nào đã sẵn sàng, rồi ta mới dùng các lời gọi I/O truyền thống. Chìa khoá để chọn đúng là phân biệt thông báo mức (level-triggered) với thông báo cạnh (edge-triggered), và hiểu vì sao select()/poll() mở rộng kém theo cỡ O(n) trong khi epoll tỉ lệ theo số sự kiện.1
Ý chính: ba công cụ này không tự đọc hay ghi hộ bạn — chúng chỉ mách "cửa này có người rồi!", việc mở cửa vẫn là của bạn. Chọn công cụ nào tuỳ vào số cửa và cách bạn muốn được báo tin. Nhớ nhé: chúng chỉ báo tin, việc I/O vẫn do bạn làm.
The chapter's thesis: none of these models performs I/O itself — they only tell you which file descriptors are ready, after which you use the traditional I/O calls. The key to choosing well is distinguishing level-triggered from edge-triggered notification, and seeing why select()/poll() scale poorly at O(n) while epoll scales with the number of events.1
Cụm khái niệm 01 Concept cluster 01
I/O chặn là đủ cho nhiều việc, nhưng sụp đổ khi phải trông nhiều bộ mô tả Chờ từng cửa một thì ổn, nhưng hỏng khi có hàng nghìn cửa Blocking I/O is fine for many tasks, but collapses when monitoring many descriptors
Với I/O trên ống (pipe), FIFO, socket và nhiều loại tệp khác, một lời gọi read() sẽ chặn nếu chưa có dữ liệu, và write() sẽ chặn nếu chưa đủ chỗ.1 Mô hình này đơn giản và đủ dùng cho hầu hết chương trình. Nhưng một số ứng dụng cần làm một trong hai (hoặc cả hai) việc sau: kiểm tra xem I/O có khả thi trên một bộ mô tả mà không bị chặn nếu chưa; và theo dõi nhiều bộ mô tả cùng lúc để biết cái nào sẵn sàng.1 Máy chủ mạng phục vụ hàng nghìn khách là ví dụ kinh điển.111
Khi bạn gọi "đọc" trên một đường truyền chưa có dữ liệu, chương trình phải đứng chờ — gọi là bị "chặn". Với một việc thì chẳng sao. Nhưng nếu bạn phải trông mười, trăm, nghìn đường truyền cùng lúc, mà đứng chờ ở đường thứ nhất thì các đường khác bị bỏ bê. Cần một cách trông tất cả cùng lúc. Nhớ nhé: một người không thể đứng chờ ở nhiều cửa một lúc.
For I/O on pipes, FIFOs, sockets, and many other file types, a read() blocks if no data is available, and a write() blocks if there is not enough room.1 This model is simple and sufficient for most programs. But some applications need to do one or both of the following: check whether I/O is possible on a descriptor without blocking if it is not; and monitor multiple descriptors at once to see which are ready.1 A network server serving thousands of clients is the classic case.111
Định nghĩa. Ghép kênh I/O (I/O multiplexing) cho phép một tiến trình đồng thời theo dõi nhiều bộ mô tả tệp để biết I/O có khả thi trên bất kỳ cái nào không. select() và poll() thực hiện việc này. Điểm mấu chốt: không lời gọi nào tự làm I/O — chúng chỉ chặn cho tới khi có ít nhất một bộ mô tả sẵn sàng, rồi báo lại.12
Nói đơn giản. "Ghép kênh" nghĩa là một người trông nhiều cửa cùng lúc. Người đó không tự mở cửa; chỉ reo lên "cửa số 7 có người!" để bạn ra mở. Nhớ nhé: ghép kênh là một người canh nhiều cửa.
Definition. I/O multiplexing lets a process simultaneously monitor multiple file descriptors to learn whether I/O is possible on any of them. select() and poll() perform this. The crux: neither call does the I/O — each blocks until at least one descriptor is ready, then reports back.12
blocking: read(fd) ──► chờ mãi
nonblock: O_NONBLOCK ──► EAGAIN
multiplex: select/poll(nhiều fd)
signal: O_ASYNC ──► SIGIO
epoll: epoll_wait(nhiều fd)
1 việc --> đứng chờ cũng được
nhiều --> cần select / poll / epoll
rất nhiều --> epoll là tốt nhất
blocking: read(fd) ──► waits
nonblock: O_NONBLOCK ──► EAGAIN
multiplex: select/poll(many fds)
signal: O_ASYNC ──► SIGIO
epoll: epoll_wait(many fds)
-
nonblocking I/O
I/O không chặn, và vì sao "thăm dò tay" vẫn kém Đặt chế độ không chờ — nhưng tự hỏi vòng vòng vẫn tệ Nonblocking I/O, and why hand-polling is still poor
Bật cờ
O_NONBLOCKkhiến mọi lời gọi I/O không hoàn tất được ngay sẽ trả về lỗiEAGAIN(hoặcEWOULDBLOCK) thay vì chặn.1 Ta có thể đánh dấu tất cả bộ mô tả là không chặn rồi lần lượt "thăm dò" từng cái. Nhưng theo Kerrisk, cách thăm dò tay này thường không ổn: thăm dò thưa thì độ trễ phản ứng dài khó chấp nhận, còn thăm dò trong vòng lặp chặt thì phí thời gian CPU.1Chế độ "không chờ" bảo chương trình: nếu chưa có gì thì báo "chưa có" rồi đi tiếp, đừng đứng ngây ra. Nhưng nếu bạn cứ chạy quanh hỏi từng cửa liên tục, bạn mệt lả mà chẳng hiệu quả. Nhớ nhé: không chờ thì tốt, nhưng hỏi vòng vòng mãi vẫn phí sức.
Enabling the
O_NONBLOCKflag makes any I/O call that cannot complete immediately return the errorEAGAIN(orEWOULDBLOCK) instead of blocking.1 We could mark all descriptors nonblocking and "poll" each in turn. But Kerrisk notes this hand-polling is usually undesirable: polling infrequently gives unacceptably long latency, while polling in a tight loop wastes CPU time.1Bạn gặp nó ở đâuWhere you meet it Trong sách, từ "poll" mang hai nghĩa: tên lời gọipoll(), và hành động "kiểm tra không chặn trạng thái một bộ mô tả" — đừng lẫn hai nghĩa.1 Chữ "poll" có hai nghĩa: tên một hàm, và việc "ghé hỏi nhanh xem có gì chưa". In the book, "poll" has two meanings: the name of thepoll()call, and the act of "performing a nonblocking check on a descriptor's status" — don't conflate them.1 -
thread-per-connection
Một luồng mỗi kết nối: dễ viết, nhưng không mở rộng Mỗi kết nối một người xử lý: đơn giản mà tốn kém One thread per connection: easy, but doesn't scale
Thay vì chặn, ta có thể tạo một tiến trình (hoặc luồng) mới cho mỗi bộ mô tả: cái đó chặn còn cha vẫn làm việc khác.1 Cách này dễ hiểu nhưng tốn kém — tạo và duy trì nhiều tiến trình/luồng ngốn bộ nhớ và chi phí chuyển ngữ cảnh; khi số kết nối lên hàng chục nghìn, mô hình sụp đổ. Đó chính là động cơ của các mô hình trong chương này.111
Một cách khác: cứ mỗi cửa thuê một người gác riêng. Dễ nghĩ, nhưng nếu toà nhà có mười nghìn cửa thì bạn phá sản vì tiền lương. Máy tính cũng vậy: quá nhiều luồng thì hết bộ nhớ và chậm. Nhớ nhé: thuê nghìn người gác không phải lời giải.
Instead of blocking, we can create a new process (or thread) per descriptor: that one blocks while the parent carries on.1 This is intuitive but costly — creating and maintaining many processes/threads consumes memory and context-switch overhead; at tens of thousands of connections the model collapses. That is precisely the motivation for this chapter's models.111
Bạn gặp nó ở đâuWhere you meet it "Bài toán C10K" của Dan Kegel (được sách dẫn ở mục Further information) mô tả đúng thách thức này cho máy chủ web phục vụ hàng chục nghìn khách.111 Dân lập trình gọi đây là "bài toán C10K" — làm sao phục vụ mười nghìn khách cùng lúc. Dan Kegel's "C10K problem" (cited in the book's Further information) describes exactly this challenge for web servers serving tens of thousands of clients.111 -
no I/O performed
Các cơ chế này chỉ báo "sẵn sàng" — I/O vẫn do bạn làm Chúng chỉ mách cửa nào có người, không mở cửa hộ These mechanisms only signal "ready" — you still do the I/O
Điểm chung quan trọng nhất của cả ba mô hình: không cái nào tự thực hiện I/O. Chúng chỉ giúp xác định bộ mô tả nào đã sẵn sàng; khi biết rồi, ta vẫn dùng các lời gọi
read()/write()truyền thống để làm thật.1 Hiểu điều này tránh được ngộ nhận rằng epoll hay select "đọc dữ liệu giúp".1Cả ba công cụ chỉ là người đưa tin: "cửa số 3 có khách!". Việc ra mở cửa, tiếp khách vẫn là của bạn. Nhớ nhé: công cụ báo tin, bạn mới là người làm I/O.
The single most important thing all three models share: none performs I/O itself. They only help determine which descriptor is ready; once you know, you still use the traditional
read()/write()calls to do the work.1 Grasping this avoids the myth that epoll or select "reads the data for you."1Bạn gặp nó ở đâuWhere you meet it Mục tóm tắt của chương khẳng định đúng điểm này: các cơ chế cho biết bộ mô tả sẵn sàng, còn I/O thực thì dùng lời gọi hệ thống thông thường.1 Trong phần tóm tắt, tác giả nhắc lại: báo "sẵn sàng" là một chuyện, làm I/O là chuyện khác. The chapter's summary states exactly this: the mechanisms report readiness, while the actual I/O uses the ordinary system calls.1
Cụm khái niệm 02 Concept cluster 02
Thông báo mức và thông báo cạnh — phân biệt mấu chốt của chương Báo theo "đang có" hay báo theo "vừa thay đổi" Level-triggered vs edge-triggered — the chapter's key distinction
Kerrisk tách rõ hai mô hình thông báo sự sẵn sàng của một bộ mô tả. Thông báo mức (level-triggered): một bộ mô tả được coi là sẵn sàng nếu lúc này có thể thực hiện một lời gọi I/O mà không bị chặn. Thông báo cạnh (edge-triggered): thông báo được đưa ra khi có hoạt động I/O (ví dụ dữ liệu mới) trên bộ mô tả kể từ lần theo dõi trước.1
Hãy tưởng tượng một bình nước. "Theo mức" là: hễ trong bình còn nước thì đèn vẫn sáng, nhắc bạn mãi. "Theo cạnh" là: đèn chỉ chớp một lần đúng lúc có người vừa rót thêm nước vào; sau đó tắt, dù bình vẫn còn nước. Nhớ nhé: "mức" nhắc khi còn, "cạnh" chỉ báo lúc vừa thay đổi.
Kerrisk draws a sharp line between two models of readiness notification for a descriptor. Level-triggered: a descriptor is considered ready if it is currently possible to perform an I/O call without blocking. Edge-triggered: notification is provided when there is I/O activity (e.g. new input) on the descriptor since it was last monitored.1
Định nghĩa (Bảng 63-1). Mỗi mô hình I/O chỉ hỗ trợ một hoặc cả hai kiểu: select() và poll() chỉ thông báo theo mức; I/O do tín hiệu điều khiển chỉ thông báo theo cạnh; còn epoll hỗ trợ cả hai (mặc định là mức, chuyển sang cạnh bằng cờ EPOLLET).14
Nói đơn giản. select/poll chỉ biết kiểu "còn thì nhắc". Tín hiệu chỉ biết kiểu "vừa có mới báo". Riêng epoll làm được cả hai. Nhớ nhé: epoll linh hoạt nhất, làm được cả hai kiểu báo.
Definition (Table 63-1). Each I/O model supports one or both kinds: select() and poll() provide only level-triggered notification; signal-driven I/O provides only edge-triggered; epoll supports both (level-triggered by default, switched to edge-triggered with the EPOLLET flag).14
Bảng 63-1 (kiểu thông báo)
select, poll ──► mức
signal-driven ──► cạnh
epoll ──► mức + cạnh
select/poll --> "còn thì nhắc"
tín hiệu --> "vừa có mới báo"
epoll --> cả hai
Table 63-1 (notification)
select, poll ──► level
signal-driven ──► edge
epoll ──► level + edge
-
level-triggered
Thông báo mức: báo hoài khi còn chưa xử lý Mức: còn việc chưa làm thì còn nhắc Level-triggered: keeps reporting while work remains
Với thông báo mức, bạn không bắt buộc phải đọc hết dữ liệu ngay. Nếu lần này chỉ đọc một phần, lần gọi
select()/poll()sau vẫn báo bộ mô tả đó sẵn sàng vì phần còn lại vẫn đọc được mà không chặn.1 Nhờ vậy, lập trình với mô hình mức khoan dung hơn, ít rủi ro "bỏ sót" dữ liệu.1Kiểu "mức" rất bao dung: nếu bạn mới dọn một nửa, lần sau nó vẫn nhắc "còn nữa kìa!". Bạn không sợ quên. Nhớ nhé: còn việc thì nó còn nhắc, khỏi lo bỏ sót.
With level-triggered notification you are not obliged to consume all the data at once. If you read only part this time, the next
select()/poll()still reports that descriptor ready because the remainder can still be read without blocking.1 This makes programming the level-triggered model more forgiving, with less risk of "missing" data.1Bạn gặp nó ở đâuWhere you meet it Đây là hành vi mặc định củaselect(),poll()và của epoll khi không đặtEPOLLET— kiểu an toàn cho người mới.14 Đây là cách select/poll luôn làm, và cũng là cách epoll làm nếu bạn không đổi gì. This is the default behavior ofselect(),poll(), and of epoll whenEPOLLETis not set — the beginner-safe mode.14 -
edge-triggered
Thông báo cạnh: báo một lần, nên phải vét sạch Cạnh: chỉ báo một lần, nên phải làm cho hết Edge-triggered: reports once, so you must drain fully
Với thông báo cạnh, bạn chỉ được báo một lần cho mỗi lần có hoạt động mới. Vì vậy, sau khi được báo, chương trình thường phải thực hiện I/O lặp lại cho đến khi lời gọi liên quan (ví dụ
read()) thất bại vớiEAGAIN/EWOULDBLOCK— tức đã vét sạch.1 Nếu chỉ đọc một phần rồi dừng, phần còn lại sẽ "kẹt" vì sẽ không có thông báo mới cho tới lần có hoạt động kế tiếp.1Kiểu "cạnh" chỉ chớp đèn một lần lúc có người vừa tới. Nếu bạn chỉ tiếp một khách rồi bỏ đi, những khách còn xếp hàng sẽ bị kẹt — vì đèn sẽ không chớp lại. Nên khi đèn chớp, phải tiếp cho tới khi hết sạch người. Nhớ nhé: cạnh báo một lần, nên phải làm cho đến khi hết.
With edge-triggered notification you are told once per new activity. So after being notified, the program typically performs I/O repeatedly until the relevant call (e.g.
read()) fails withEAGAIN/EWOULDBLOCK— i.e. it has drained fully.1 If you read only part and stop, the rest gets "stuck" because no new notification comes until the next activity.1Bạn gặp nó ở đâuWhere you meet it I/O do tín hiệu điều khiển luôn là kiểu cạnh; epoll trở thành kiểu cạnh khi đặt cờEPOLLETlúc đăng ký bộ mô tả.14 Tín hiệu luôn báo kiểu cạnh; epoll chỉ báo kiểu cạnh khi bạn bật cờ EPOLLET. Signal-driven I/O is always edge-triggered; epoll becomes edge-triggered when you set theEPOLLETflag at registration time.14 -
O_NONBLOCK + edge
Vì sao thông báo cạnh đòi bộ mô tả không chặn Kiểu cạnh buộc phải đặt chế độ không chờ Why edge-triggered requires nonblocking descriptors
Mô hình cạnh thường đi kèm I/O không chặn. Lý do: sau khi được báo, ta đọc/ghi lặp lại cho tới khi gặp
EAGAIN. Nếu bộ mô tả vẫn ở chế độ chặn, thì lần đọc "dư" cuối cùng — khi đã hết dữ liệu — sẽ chặn vô thời hạn, treo luôn chương trình (và nếu đang trông nhiều bộ mô tả bằng một luồng, cả vòng lặp sự kiện bị đứng).14Vì kiểu cạnh buộc bạn làm cho tới khi hết, nên lần "làm thử" cuối cùng phải được phép trả lời "hết rồi" ngay, chứ không được đứng chờ. Muốn vậy phải đặt chế độ không chờ. Nhớ nhé: dùng cạnh thì bắt buộc đặt bộ mô tả sang chế độ không chờ.
The edge-triggered model usually goes with nonblocking I/O. Reason: after being notified, we read/write repeatedly until we hit
EAGAIN. If the descriptor is still in blocking mode, the final "extra" read — once data is exhausted — would block indefinitely, hanging the program (and if a single thread monitors many descriptors, the whole event loop stalls).14Bạn gặp nó ở đâuWhere you meet it Đây là cái bẫy kinh điển khi mới chuyển sang epoll edge-triggered: quên đặtO_NONBLOCKkhiến vòng lặp sự kiện treo ở lần đọc cuối.14 Người mới dùng epoll kiểu cạnh hay quên bước này, rồi chương trình bị treo không hiểu vì sao. This is the classic trap when first moving to edge-triggered epoll: forgettingO_NONBLOCKhangs the event loop on the final read.14
Cụm khái niệm 03 Concept cluster 03
select() và poll(): khả chuyển, lâu đời — nhưng mở rộng theo cỡ O(n) select và poll chạy mọi nơi, nhưng chậm dần khi quá nhiều cửa select() and poll(): portable, long-standing — but scale at O(n)
select() làm việc với kiểu fd_set — một tập bit các bộ mô tả — được thao tác qua bốn macro FD_ZERO(), FD_SET(), FD_CLR(), FD_ISSET(); nó nhận ba tập (đọc, ghi, ngoại lệ) và sửa tại chỗ các tập này để khi trả về chúng chứa những bộ mô tả đã sẵn sàng.12 poll() dùng mảng cấu trúc pollfd gồm các trường fd, events (quan tâm cái gì) và revents (nhân điền cái gì đã xảy ra).13
Hai công cụ này đều là kiểu "hỏi dồn": bạn đưa cho chúng danh sách các cửa cần trông, chúng đứng chờ hộ, rồi trả lại danh sách "cửa nào đang có người". select đánh dấu bằng một bảng ô vuông, còn poll dùng một danh sách gọn hơn. Nhớ nhé: cả hai nhận danh sách cửa, trả về cửa nào sẵn sàng.
select() works with the fd_set type — a bit-set of descriptors — manipulated by four macros FD_ZERO(), FD_SET(), FD_CLR(), FD_ISSET(); it takes three sets (read, write, exception) and modifies them in place so that on return they hold the ready descriptors.12 poll() uses an array of pollfd structures with fields fd, events (what you want) and revents (what the kernel fills in as having happened).13
Định nghĩa. Một fd_set có kích thước tối đa cố định, do hằng FD_SETSIZE quy định — trên Linux giá trị này là 1024. Muốn vượt giới hạn phải sửa định nghĩa trong header của glibc rồi biên dịch lại — bất tiện, và là một lý do để chuyển sang epoll khi cần trông nhiều bộ mô tả.12 poll() không có giới hạn nội tại này về phạm vi số hiệu bộ mô tả.13
Nói đơn giản. Bảng của select chỉ chứa được 1024 ô — quá số đó là chật. poll thì không bị trần cứng đó. Nhớ nhé: select có trần 1024, poll thì không.
Definition. An fd_set has a fixed maximum size set by the constant FD_SETSIZE — on Linux this value is 1024. Going beyond it requires editing the glibc header and recompiling — awkward, and one reason to switch to epoll when many descriptors are needed.12 poll() has no such intrinsic limit on the range of descriptor numbers.13
#include <sys/select.h>
int select(nfds, &rd, &wr, &ex, &to);
/* FD_SETSIZE = 1024 trên Linux */
#include <poll.h>
int poll(fds[], nfds, timeout);
/* pollfd{fd, events, revents} */
select(...) --> bảng tối đa 1024 ô
poll(...) --> danh sách, không trần
trả về: cửa nào sẵn sàng
#include <sys/select.h>
int select(nfds, &rd, &wr, &ex, &to);
/* FD_SETSIZE = 1024 on Linux */
#include <poll.h>
int poll(fds[], nfds, timeout);
/* pollfd{fd, events, revents} */
-
rebuild every call
select() sửa tập đầu vào — phải dựng lại mỗi lần gọi select làm hỏng danh sách bạn đưa — phải làm lại mỗi lượt select() mangles its input — rebuild it every call
Vì
select()sửa ngay trên cácfd_settruyền vào để trả kết quả, mỗi lần gọi lại phải khởi tạo lại các tập (bằngFD_ZERO()rồiFD_SET()).12 Ngoài ra sau mỗi lời gọi, chương trình phải duyệt mọi phần tử của cấu trúc trả về để biết bộ mô tả nào sẵn sàng — một phần của chi phí O(n).1Lưu ý nhỏ nhưng hay vấp:
selectghi đè lên chính danh sách bạn đưa, nên lần sau bạn phải dựng lại từ đầu. Rồi còn phải đọc lướt cả danh sách để tìm cửa nào sẵn sàng. Nhớ nhé: với select, mỗi lượt phải dựng lại danh sách.Because
select()modifies the passedfd_sets in place to return results, each call must reinitialize the sets (viaFD_ZERO()thenFD_SET()).12 Also, after each call the program must inspect every element of the returned structure to learn which descriptors are ready — part of the O(n) cost.1Bạn gặp nó ở đâuWhere you meet it Lỗi thường gặp của người mới: quên dựng lạifd_settrước mỗi vòng, khiến từ vòng thứ hai trở đi các tập không còn đúng.2 Lỗi hay gặp: quên làm lại danh sách, nên từ lần thứ hai là sai hết. Common beginner bug: forgetting to rebuild thefd_setbefore each loop, so from the second iteration on the sets are wrong.2 -
O(n) scaling
Chi phí tỉ lệ với số bộ mô tả, không phải số sự kiện Càng nhiều cửa, càng chậm — dù chỉ một cửa có việc Cost scales with descriptor count, not event count
Mỗi lần gọi
select()haypoll(), nhân phải kiểm tra tất cả bộ mô tả được nêu, và chương trình phải truyền vào rồi duyệt lại toàn bộ danh sách. Vì vậy, với N lớn, hiệu năng của chúng tỉ lệ tuyến tính với N — đến N = 10000 còn tệ hơn tuyến tính.1 Bảng 63-9 trong sách (100.000 thao tác theo dõi) cho thấy ở N = 10000,poll()mất 990 giây CPU vàselect()mất 930 giây, trong khi epoll chỉ mất 0,66 giây.1Hai công cụ này có một nhược điểm lớn: mỗi lần hỏi, nhân phải rà toàn bộ danh sách cửa, dù chỉ một cửa có người. Càng nhiều cửa càng chậm. Sách đo được: với 10000 cửa, chúng mất gần 1000 giây, còn epoll chưa tới 1 giây. Nhớ nhé: select/poll càng nhiều cửa càng ì ạch.
On each
select()orpoll()call, the kernel must check all the named descriptors, and the program must pass in and re-scan the whole list. So for large N their performance scales linearly with N — by N = 10000 even worse than linear.1 The book's Table 63-9 (100,000 monitoring operations) shows that at N = 10000,poll()takes 990 s of CPU andselect()930 s, while epoll takes only 0.66 s.1Con số của tác giảThe author's numbers
Bảng 63-9 (thời gian CPU, giây, cho 100.000 thao tác): N=10 → poll 0,61 / select 0,73 / epoll 0,41; N=100 → 2,9 / 3,0 / 0,42; N=1000 → 35 / 35 / 0,53; N=10000 → 990 / 930 / 0,66. Để chạy phép đo,
FD_SETSIZEđược đổi thành 16.384 trong header glibc.1Table 63-9 (CPU seconds for 100,000 operations): N=10 → poll 0.61 / select 0.73 / epoll 0.41; N=100 → 2.9 / 3.0 / 0.42; N=1000 → 35 / 35 / 0.53; N=10000 → 990 / 930 / 0.66. To run the test,FD_SETSIZEwas changed to 16,384 in the glibc headers.1Vì sao quan trọngWhy it matters
Điểm cốt lõi: chi phí tỉ lệ với số bộ mô tả được theo dõi, bất kể bao nhiêu trong số đó thực sự có sự kiện — rất lãng phí khi theo dõi nghìn kết nối mà mỗi lúc chỉ vài cái hoạt động.1The crux: cost is proportional to the number of descriptors monitored, regardless of how many actually have events — very wasteful when monitoring thousands of connections of which only a few are active at a time.1
-
O_ASYNC · SIGIO
I/O do tín hiệu điều khiển: nhân báo bằng SIGIO Nhờ chuông báo: khi có việc, nhân gửi một tín hiệu Signal-driven I/O: the kernel signals with SIGIO
Trong I/O do tín hiệu điều khiển, tiến trình yêu cầu nhân gửi cho nó một tín hiệu (mặc định
SIGIO) khi có thể đọc/ghi trên một bộ mô tả. Thiết lập gồm: đặt chủ sở hữu nhận tín hiệu bằngfcntl()vớiF_SETOWN, rồi bật cờ trạng tháiO_ASYNC(thường kèmO_NONBLOCK).19 Khi theo dõi nhiều bộ mô tả, cách này nhanh hơn hẳnselect()/poll()vì không phải rà toàn danh sách.1Thay vì đứng hỏi liên tục, bạn gắn một cái chuông: khi cửa nào có người, nhân tự "bấm chuông" (gửi tín hiệu) báo cho bạn. Bạn rảnh tay làm việc khác, nghe chuông mới chạy ra. Cách này nhanh khi có nhiều cửa. Nhớ nhé: đừng hỏi hoài, hãy gắn chuông và chờ nó reo.
In signal-driven I/O, the process asks the kernel to send it a signal (by default
SIGIO) when I/O is possible on a descriptor. Setup: set the signal owner withfcntl()usingF_SETOWN, then enable theO_ASYNCstatus flag (usually together withO_NONBLOCK).19 When monitoring many descriptors, this is far faster thanselect()/poll()because it avoids scanning the whole list.1Tên gọi lịch sửA historical name
Trước đây I/O do tín hiệu điều khiển đôi khi bị gọi là "asynchronous I/O" — phản ánh trong tên cờ
O_ASYNC. Cờ này có trong POSIX.1g nhưng không được đưa vào SUSv3 vì đặc tả hành vi bị coi là chưa đủ rõ; vài hệ UNIX cũ dùng tênFASYNCvà glibc định nghĩa nó như từ đồng nghĩa củaO_ASYNC.19Signal-driven I/O was historically sometimes called "asynchronous I/O" — reflected in theO_ASYNCflag name. The flag was in POSIX.1g but was not included in SUSv3 because its required behavior was deemed insufficiently specified; some older UNIX systems use the nameFASYNC, and glibc defines it as a synonym forO_ASYNC.19Khi nào nó không ổnWhen it does not scale
I/O do tín hiệu điều khiển là thuần kiểu cạnh và dựa trên hàng đợi tín hiệu; nếu tín hiệu đến quá nhanh, hàng đợi có thể tràn và tín hiệu "thực-thời" bị mất, buộc phải dự phòng về
select()/poll(). Nó cũng khó dùng đúng hơn epoll, nên trên Linux epoll thường được ưa chuộng.14Signal-driven I/O is purely edge-triggered and relies on a signal queue; if signals arrive too fast the queue can overflow and realtime signals be lost, forcing a fallback toselect()/poll(). It is also harder to use correctly than epoll, so on Linux epoll is usually preferred.14
Cụm khái niệm 04 Concept cluster 04
API epoll: danh sách quan tâm, danh sách sẵn sàng, và chờ cả tín hiệu lẫn bộ mô tả epoll — danh sách thông minh của Linux, và cách chờ cả tín hiệu The epoll API: interest list, ready list, and waiting on signals too
epoll là tính năng riêng của Linux, mới từ nhân 2.6. Cấu trúc dữ liệu trung tâm là một phiên bản epoll (epoll instance), gắn với hai danh sách trong nhân: danh sách quan tâm (interest list) — các bộ mô tả ta muốn theo dõi — và danh sách sẵn sàng (ready list) — các bộ mô tả đang có I/O khả thi.14 API gồm ba lời gọi: epoll_create() tạo phiên bản và trả về một bộ mô tả trỏ tới nó; epoll_ctl() sửa danh sách quan tâm; epoll_wait() trả về các mục từ danh sách sẵn sàng.1567
epoll là "danh sách thông minh" của riêng Linux. Bạn khai báo một lần danh sách các cửa cần trông (danh sách quan tâm). Khi cửa nào có việc, nhân tự bỏ nó vào một danh sách khác (danh sách sẵn sàng). Bạn chỉ cần hỏi "có gì trong danh sách sẵn sàng?" là xong — khỏi rà lại toàn bộ. Nhớ nhé: khai một lần, rồi chỉ hỏi những cửa vừa có việc.
epoll is a Linux-specific feature, new in kernel 2.6. Its central data structure is an epoll instance, associated with two in-kernel lists: the interest list — the descriptors we want to monitor — and the ready list — the descriptors on which I/O is currently possible.14 The API has three calls: epoll_create() creates an instance and returns a descriptor referring to it; epoll_ctl() modifies the interest list; epoll_wait() returns items from the ready list.1567
Định nghĩa. epoll_ctl(epfd, op, fd, ev) thao tác danh sách quan tâm: EPOLL_CTL_ADD thêm một bộ mô tả, EPOLL_CTL_MOD đổi tập sự kiện quan tâm, EPOLL_CTL_DEL gỡ bỏ. Trường ev.events là mặt nạ bit (ví dụ EPOLLIN, EPOLLOUT) tương tự các bit của poll().16 Một lời gọi epoll_wait() trả về thông tin về các bộ mô tả sẵn sàng — số lượng tỉ lệ với số sự kiện, không phải tổng số bộ mô tả theo dõi.17
Nói đơn giản. epoll_ctl là nút "thêm / sửa / xoá" cửa trong danh sách trông. epoll_wait là câu hỏi "cửa nào vừa có việc?" — và nó chỉ trả về đúng những cửa đó. Nhớ nhé: ctl để sửa danh sách, wait để lấy cửa đang có việc.
Definition. epoll_ctl(epfd, op, fd, ev) manipulates the interest list: EPOLL_CTL_ADD adds a descriptor, EPOLL_CTL_MOD changes the events of interest, EPOLL_CTL_DEL removes one. The ev.events field is a bit mask (e.g. EPOLLIN, EPOLLOUT) like the bits of poll().16 One epoll_wait() call returns information about the ready descriptors — their count scales with the number of events, not the total monitored.17
#include <sys/epoll.h>
epfd = epoll_create(size);
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
/* ev.events = EPOLLIN | EPOLLET; */
n = epoll_wait(epfd, evlist, max, to);
tạo: epoll_create()
thêm: epoll_ctl(... ADD ...)
chờ: epoll_wait()
cạnh: thêm cờ EPOLLET
#include <sys/epoll.h>
epfd = epoll_create(size);
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
/* ev.events = EPOLLIN | EPOLLET; */
n = epoll_wait(epfd, evlist, max, to);
-
why epoll is fast
epoll nhanh vì danh sách quan tâm sống lâu trong nhân epoll nhanh vì khai một lần, nhân nhớ giúp epoll is fast because the interest list lives in the kernel
Khác với
select()/poll()— mỗi lần gọi phải truyền lại toàn bộ danh sách và nhân phải rà hết — với epoll, ta đăng ký các bộ mô tả quan tâm một lần quaepoll_ctl(); danh sách đó được giữ trong nhân. Mỗiepoll_wait()chỉ trả về những bộ mô tả thực sự sẵn sàng. Nhờ vậy hiệu năng epoll tỉ lệ (tuyến tính) theo số sự kiện I/O xảy ra, chứ không theo tổng số bộ mô tả.14select/poll bắt bạn đưa lại cả danh sách cửa mỗi lần hỏi; epoll chỉ cần bạn khai một lần, rồi nó tự nhớ. Khi hỏi, nó chỉ đưa những cửa vừa có việc. Ít việc hơn hẳn. Nhớ nhé: epoll khai một lần, trả lời gọn, nên nhanh.
Unlike
select()/poll()— which re-pass the whole list each call and make the kernel scan it all — with epoll you register the descriptors of interest once viaepoll_ctl(); that list is kept in the kernel. Eachepoll_wait()returns only the descriptors actually ready. So epoll's performance scales (linearly) with the number of I/O events that occur, not with the total number of descriptors.14Bạn gặp nó ở đâuWhere you meet it Đây là lý do mọi vòng lặp sự kiện hiệu năng cao trên Linux (máy chủ web, thư viện async) đều dựng trên epoll thay vì select/poll.14 Vì thế các máy chủ web và thư viện tốc độ cao trên Linux đều dùng epoll. This is why every high-performance event loop on Linux (web servers, async libraries) is built on epoll rather than select/poll.14 -
EPOLLET
EPOLLET bật kiểu cạnh — và đòi bộ mô tả không chặn Cờ EPOLLET chuyển epoll sang kiểu "chỉ báo lúc vừa có" EPOLLET turns on edge-triggered — and needs nonblocking fds
Mặc định epoll là thông báo mức. Đặt cờ
EPOLLETtrongev.eventskhi đăng ký sẽ chuyển bộ mô tả đó sang thông báo cạnh.14 Vì là kiểu cạnh, bộ mô tả thường phải đặtO_NONBLOCK, và sau mỗi thông báo ta đọc/ghi lặp cho tới khi gặpEAGAIN— nếu không, dữ liệu chưa đọc hết sẽ kẹt tới lần có hoạt động sau.14Thêm cờ EPOLLET là bảo epoll: "chỉ báo lúc vừa có việc thôi". Khi đó phải đặt cửa ở chế độ không chờ và tiếp khách cho tới khi hết sạch người, vì đèn chỉ chớp một lần. Nhớ nhé: bật EPOLLET thì nhớ đặt không chặn và vét cho hết.
By default epoll is level-triggered. Setting the
EPOLLETflag inev.eventsat registration switches that descriptor to edge-triggered.14 Being edge-triggered, the descriptor usually must beO_NONBLOCK, and after each notification we read/write repeatedly until we hitEAGAIN— otherwise unread data stalls until the next activity.14Bạn gặp nó ở đâuWhere you meet it Kiểu cạnh giảm số lầnepoll_wait()trả về cùng một bộ mô tả, hữu ích khi tối ưu sâu — nhưng khó viết đúng hơn kiểu mức.14 Kiểu cạnh gọn hơn nhưng khó viết đúng, nên người mới thường dùng kiểu mức trước. Edge-triggered reduces how oftenepoll_wait()returns the same descriptor, useful for deep tuning — but harder to write correctly than level-triggered.14 -
signalfd · self-pipe
Chờ cả tín hiệu lẫn bộ mô tả: self-pipe, signalfd, pselect Trông cả "chuông tín hiệu" lẫn các cửa cùng một chỗ Waiting on signals and descriptors: self-pipe, signalfd, pselect
Một bài toán hóc búa: làm sao chờ đồng thời tín hiệu và sự sẵn sàng của bộ mô tả, không bỏ sót cái nào? Kỹ thuật cổ điển là self-pipe trick: trình xử lý tín hiệu ghi một byte vào đầu ghi của một ống, còn đầu đọc được đưa vào tập theo dõi của
select()/poll()/epoll_wait()— nhờ đó tín hiệu "biến" thành một sự kiện I/O bình thường.1 Linux còn cung cấpsignalfd(): một bộ mô tả tệp để đọc tín hiệu đến, cũng theo dõi được bằng các API trên.18Khó ở chỗ: bạn vừa muốn nghe "chuông tín hiệu", vừa muốn trông các cửa — mà hai thứ báo theo kiểu khác nhau. Mẹo cũ: cho chuông reo bằng cách thả một viên bi vào một cái ống, rồi trông cái ống đó như một cửa bình thường. Linux còn có
signalfdbiến tín hiệu thành một "cửa" để đọc. Nhớ nhé: biến tín hiệu thành một cửa, thế là trông chung một chỗ được.A thorny problem: how to wait simultaneously on signals and descriptor readiness without missing either? The classic technique is the self-pipe trick: the signal handler writes a byte to the write end of a pipe, and the read end is placed in the watch set of
select()/poll()/epoll_wait()— turning a signal into an ordinary I/O event.1 Linux also providessignalfd(): a file descriptor from which to read incoming signals, also monitorable by those APIs.18Bộ ba có mặt nạ tín hiệuThe signal-mask trio
Ngoài self-pipe, có các biến thể "nguyên tử" gỡ bỏ mặt nạ tín hiệu trong lúc chờ:
pselect()(chuẩn), cùngppoll()vàepoll_pwait()(riêng Linux). Chúng tránh được điều kiện tranh đua giữa việc kiểm tra tín hiệu và việc gọi chờ.17Beyond the self-pipe, there are "atomic" variants that unmask signals while waiting:pselect()(standard), plusppoll()andepoll_pwait()(Linux-specific). They avoid the race between checking for a signal and entering the wait call.17Mốc phiên bảnVersion milestones
Sách ghi:
ppoll()được thêm ở Linux 2.6.16 (quan hệ vớipoll()giống nhưpselect()vớiselect()), vàepoll_pwait()có từ nhân 2.6.19 — xem trang man ppoll(2) và epoll_pwait(2).17The book notes:ppoll()was added in Linux 2.6.16 (its relation topoll()mirrorspselect()toselect()), andepoll_pwait()dates from kernel 2.6.19 — see the ppoll(2) and epoll_pwait(2) manual pages.17
Ba ngộ nhận thường gặp Three common misconceptions
“select/poll/epoll tự đọc dữ liệu giúp tôi” “select/poll/epoll read the data for me”
Ngộ nhận: gọi epoll xong là đã có dữ liệu trong tay.
Thực tế: không cơ chế nào tự thực hiện I/O — chúng chỉ cho biết bộ mô tả nào sẵn sàng; sau đó ta vẫn phải tự read()/write().1
Myth: once epoll returns, the data is in hand.
Reality: none of these mechanisms performs I/O — they only report which descriptors are ready; you still read()/write() yourself.1
“Kiểu cạnh chỉ là kiểu mức nhanh hơn, thay thẳng được” “Edge-triggered is just faster level-triggered, a drop-in”
Ngộ nhận: cứ thêm EPOLLET là xong, không đổi gì khác.
Thực tế: kiểu cạnh đòi bộ mô tả O_NONBLOCK và phải đọc/ghi tới khi EAGAIN; thiếu điều đó sẽ mất sự kiện hoặc treo vòng lặp.14
Myth: just add EPOLLET and nothing else changes.
Reality: edge-triggered requires O_NONBLOCK descriptors and draining until EAGAIN; without that you lose events or hang the loop.14
“epoll luôn tốt hơn, select đã lỗi thời” “epoll is always better, select is obsolete”
Ngộ nhận: không bao giờ còn lý do dùng select().
Thực tế: select()/poll() là chuẩn POSIX, khả chuyển; epoll chỉ có trên Linux. Với N nhỏ, hiệu năng gần như nhau — chọn theo tính khả chuyển và quy mô.1410
Myth: there is never a reason to use select() anymore.
Reality: select()/poll() are POSIX-standard and portable; epoll is Linux-only. For small N performance is similar — choose by portability and scale.1410
Tự kiểm Self-check
Năm câu hỏi để biết bạn đã nắm các mô hình I/O thay thế Năm câu hỏi kiểm tra nhanh Five questions to check you grasped the alternative I/O models
Trả lời được những câu này nghĩa là bạn đã nắm vì sao I/O chặn không mở rộng được, ba mô hình thay thế, và phân biệt mức–cạnh — nền cho việc viết máy chủ mạng hiệu năng cao.
Nếu trả lời được, nghĩa là bạn đã hiểu chương này.
Answering these means you grasp why blocking I/O does not scale, the three alternative models, and the level/edge distinction — the groundwork for writing high-performance network servers.
-
Vì sao mô hình I/O chặn với một tiến trình/luồng cho mỗi kết nối không mở rộng được? Hai nhu cầu nào thúc đẩy các mô hình thay thế?
Gợi ý sâu hơn: vì sao "thăm dò tay" với
O_NONBLOCKvẫn kém — hai cực đoan thưa và dày đều có vấn đề gì? -
Phân biệt thông báo mức (level-triggered) và thông báo cạnh (edge-triggered). Mô hình nào hỗ trợ kiểu nào (Bảng 63-1)?
Gợi ý sâu hơn: vì sao thông báo cạnh gần như luôn đòi bộ mô tả ở chế độ không chặn?
-
select()vàpoll()nhận và trả về gì? Vì sao chúng mở rộng theo cỡ O(n), vàFD_SETSIZEtrên Linux bằng bao nhiêu?Gợi ý sâu hơn: vì sao sau mỗi
select()ta phải dựng lại cácfd_set? -
Mô tả ba lời gọi của API epoll và vai trò của danh sách quan tâm so với danh sách sẵn sàng. Vì sao epoll nhanh hơn select/poll khi N lớn?
Gợi ý sâu hơn: cờ
EPOLLETlàm gì, và ba thao tác củaepoll_ctl()là gì? -
Làm sao chờ đồng thời tín hiệu và sự sẵn sàng của bộ mô tả? Mô tả self-pipe trick và vai trò của
signalfd(),pselect()/ppoll()/epoll_pwait().Gợi ý sâu hơn: vì sao các biến thể "p" (pselect/ppoll/epoll_pwait) tránh được điều kiện tranh đua mà self-pipe phải xử lý thủ công?
Vì sao một người không thể đứng chờ ở nhiều cửa cùng lúc?
"Báo khi còn" và "báo khi vừa có" khác nhau chỗ nào?
select và poll làm gì, và vì sao chúng chậm khi quá nhiều cửa?
epoll giúp nhanh hơn bằng cách nào?
Làm sao trông cả "chuông tín hiệu" lẫn các cửa ở cùng một chỗ?
Why does the blocking I/O model with one process/thread per connection fail to scale? What two needs motivate the alternatives?
Distinguish level-triggered from edge-triggered notification. Which model supports which (Table 63-1)?
What do
select()andpoll()take and return? Why do they scale at O(n), and what isFD_SETSIZEon Linux?Describe epoll's three calls and the role of the interest list vs the ready list. Why is epoll faster than select/poll at large N?
How do you wait on signals and descriptor readiness at once? Describe the self-pipe trick and the role of
signalfd(),pselect()/ppoll()/epoll_pwait().
Khép lại chương Closing the chapter
Khi một tiến trình phải trông nhiều bộ mô tả cùng lúc, I/O chặn và một-luồng-mỗi-kết-nối không còn đủ. Ba lời giải — ghép kênh (select/poll), tín hiệu, và epoll — đều chỉ báo "sẵn sàng" chứ không tự làm I/O. Nắm được mức so với cạnh và vì sao epoll tỉ lệ theo số sự kiện là chìa khoá để viết phần mềm mạng mở rộng được tới hàng nghìn kết nối.1
Khi phải trông nhiều cửa, đứng chờ từng cửa là vô vọng. Ba công cụ — hỏi dồn, chuông tín hiệu, và danh sách thông minh epoll — chỉ mách cửa nào có việc, việc mở cửa vẫn là của bạn. Nhớ nhé: trông tất cả cùng lúc, rồi chỉ xử lý cửa nào vừa có việc.
When a process must watch many descriptors at once, blocking I/O and one-thread-per-connection no longer suffice. The three answers — multiplexing (select/poll), signals, and epoll — all report "ready" without performing I/O themselves. Grasping level vs edge and why epoll scales with events is the key to writing network software that scales to thousands of connections.1