select、poll 與輸入輸出多工(I/O multiplexing)
/ EE-poll /
假設你的程式同時透過一百條連線跟一百個客戶端交談。若你對連線 1 呼叫 read() 而還沒有資料到,這個呼叫會阻塞——你現在卡住了,即使另外 99 個之中有一個可能正有資料在等,你也無法服務它們。一個執行緒只能阻塞在一件事上。輸入輸出多工就是出路:一個同時盯著許多描述符的呼叫,告訴你哪些已就緒,於是你只去讀或寫那些不會阻塞的。
經典的呼叫是 select() 與 poll()。你把一串你在意的檔案描述符交給它們,問「這些之中哪些可讀、哪些可寫、哪些出錯了?」這個呼叫會阻塞,直到至少有一個就緒(或逾時觸發),然後回傳就緒的集合。現在你只迭代那些、對它們動作,因為你知道每個操作都會立刻進行。這就是「就緒(readiness)」通知:核心不替你搬資料,它只告訴你「描述符 14 現在有資料可讀」。一個執行緒、許多連線,不在任何單一連線上浪費地阻塞。select() 與 poll() 做同樣的工作,只是介面不同(poll() 擴展性稍好,且沒有硬性的描述符編號上限)。
誠實的限制在於規模:select() 與 poll() 每次呼叫都得掃描每一個描述符,所以盯著一萬條連線會變慢——成本隨被盯著的總數成長,而非隨真正就緒的數量。這就是為什麼 Linux 加入了 epoll、BSD 加入了 kqueue:同樣的想法(等候許多、得知哪些就緒),但它們把你的關注登記一次,只回報有變化的描述符,於是成本隨活動量、而非隨總數成長。這些是每個高並行伺服器內部的引擎。這裡的模型是就緒(在我可以動作時告訴我),相對於完成(替我做完工作再告訴我做完了),後者是某些較新介面採用的另一種風格。
fd_set r; FD_ZERO(&r); FD_SET(sock_a,&r); FD_SET(sock_b,&r); select(maxfd+1, &r, NULL, NULL, NULL); /* 阻塞直到 a 或 b 可讀 */ if (FD_ISSET(sock_a,&r)) read(sock_a,...); /* 只讀就緒的那一個 */
盯著許多、阻塞一次、只對就緒的動作。一個執行緒就是這樣服務上千條連線。
select() 與 poll() 回報就緒,但 read()/write() 仍由你自己做,而且每次呼叫都重新掃描所有被盯著的描述符——少量可以,上千就慢。數量龐大時,epoll(Linux)或 kqueue(BSD/macOS)的擴展性好得多。