題幹與適用場景
一個 Linux TCP 服務把監聽 socket 和已連線 socket 都設為非阻塞,並用 epoll 驅動事件迴圈。客戶端傳送的資料已有 8 KiB 進入接收緩衝區。一次 epoll_wait 返回 EPOLLIN 後,處理函式只呼叫一次 recv,讀走 4 KiB 就返回。
使用預設的水平觸發(level-triggered,LT)時,服務下一次呼叫 epoll_wait 往往還能看到該連線。改成 EPOLLET 的邊緣觸發(edge-triggered,ET)後,同一實作偶爾不再收到可讀通知,客戶端一直等不到回應。請解釋:
epoll的 interest list、ready list 和 I/O 就緒分別表示什麼;- LT 與 ET 的通知契約為什麼會產生不同結果;
- ET 下如何正確處理
accept、recv、send、EAGAIN、半關閉和錯誤; - 多執行緒 worker、
EPOLLONESHOT、熱門連線公平性和 FD 重複使用會帶來什麼額外風險; - 如何透過可重複實驗確認停滯原因並驗證修復。
8 KiB 和 4 KiB 是面試場景資料,不代表 TCP 一次傳送、一次系統呼叫或一個應用訊息的固定大小。題目的核心是 Linux I/O 就緒契約,適用於後端、基礎設施、SRE、系統軟體和通用軟體工程職位,因此歸為 general。
面試官考察點
第一,看候選人能否準確說出 epoll 報告的是「目前執行某類 I/O 是否可能不阻塞」的就緒狀態,不是「一個完整請求已經到達」,也不是非同步 I/O 的完成通知。TCP 是位元組流,一次 recv 可以返回目前可用的任意正位元組數。
第二,看是否真正理解 LT 與 ET。LT 會在關注的就緒條件仍成立時繼續報告;ET 不承諾只因條件仍然為真就重複通知。ET 收到事件後,應把 FD 視為可操作,直到非阻塞讀寫返回 EAGAIN 或 EWOULDBLOCK。只背「ET 只通知一次」過於絕對,也無法指導實作。
第三,看能否把同一原則正確套用到讀、寫和監聽 socket。讀取端要迴圈到 EAGAIN;監聽端要迴圈 accept4 到 EAGAIN;寫入端不能永久關注通常一直可寫的 EPOLLOUT,而應只在應用緩衝區還有未傳送資料時關注它。
第四,看併發狀態管理。EPOLLONESHOT 在一次通知後停用該 FD,worker 完成狀態更新後必須用 EPOLLCTLMOD 重新啟用。重新啟用太早可能讓兩個 worker 同時操作一個連線,忘記重新啟用則表現為永久停滯。
第五,看候選人是否能處理「必須讀到 EAGAIN」與事件迴圈公平性的張力。一個持續有資料的連線可能占用執行緒很久;若為公平性提早停止,應用必須把連線保留在自己的可執行佇列,不能期待 ET 再提供一個沒有發生的新邊緣。
回答前需要澄清的問題
- socket 是否真的非阻塞? ET 搭配阻塞 FD 時,第二次讀寫可能把負責許多連線的執行緒卡住。
- 停滯發生在監聽、讀取還是寫回階段? 漏掉
accept、讀取剩餘位元組、EPOLLOUT更新或EPOLLONESHOT重新啟用,表象都可能是「連線卡住」。 - 應用協定如何判斷完整訊息? TCP 沒有訊息邊界;長度前綴、分隔符、HTTP 解析狀態或連線關閉會決定何時可以處理請求。
- 處理函式一次讀多少、何時返回? 固定只讀一次是本題最直接的缺陷;迴圈中做昂貴業務計算又會引出公平性問題。
- 連線是否跨執行緒處理? 需要知道誰擁有讀取緩衝、寫入緩衝、關閉權和
epoll_ctl權限,是否啟用了EPOLLONESHOT。 - 是否永久訂閱
EPOLLOUT? socket 大部分時間都可寫,LT 下永久訂閱會讓epoll_wait立即反覆返回,形成 CPU 忙碌迴圈。 - 如何處理
EPOLLRDHUP、EPOLLHUP和EPOLLERR? HUP 到達時仍可能有資料未讀,ERR/HUP 即使沒有明確訂閱也會報告。 - FD 是否可能被關閉後快速重複使用? 只把整數 FD 當連線身分,可能讓延遲事件或非同步任務誤操作新連線。
30 秒回答框架
「epoll 維護關注清單並從就緒清單返回事件,它報告 I/O 就緒,不報告完整訊息。LT 在條件仍就緒時會繼續報告,所以只讀 4 KiB 後剩餘資料通常還會觸發下一次等待;ET 不保證重複報告未變化的就緒狀態,因此部分讀取後可能一直等不到新通知。
ET 下所有相關 FD 都應非阻塞。監聽 socket 要 accept4 到 EAGAIN,讀取要 recv 到 EAGAIN,寫入要 send 到 EAGAIN,而且只在寫入緩衝非空時訂閱 EPOLLOUT。recv 返回 0 表示對端有序關閉寫入半邊,其他錯誤要分別處理。多 worker 時可用 EPOLLONESHOT 保證單一連線依序處理,完成狀態更新後再 MOD 重新啟用。為防熱門連線獨占執行緒,可以設定處理預算,但提早停止時必須放入應用自己的就緒佇列。最後用分段傳送、部分讀寫、半關閉和併發壓力重現,並檢查每個連線最終到達 EAGAIN、正確重新啟用且沒有忙碌迴圈。」
分步驟深入解答
第一步:先建立 epoll 的就緒模型
epoll_create1 建立一個 epoll 執行個體,它本身由一個 FD 引用。概念上,執行個體包含兩組狀態:
- interest list: 應用透過
epollctl(EPOLLCTL_ADD/MOD/DEL)告訴核心要監控哪些 FD、哪些事件; - ready list: interest list 中目前有事件可用的項目,
epoll_wait從這裡向應用返回結果。
「可讀」表示一次讀取操作現在有機會取得資料、EOF 或錯誤而不必等待;「可寫」表示目前至少可以推進一些寫入,不表示整個回應一定能一次寫完。應用仍要執行 recv、send、accept4 並檢查返回值。事件是行動提示,系統呼叫結果才決定狀態機下一步。
第二步:用未讀完的 4 KiB 比較 LT 與 ET
預設 LT 的規則接近 poll:只要接收緩衝區仍有資料,讀取就緒條件仍成立,下一次 epoll_wait 可以再次返回這個 FD。因此錯誤的「每次事件只讀一次」實作有時看起來能運作,只是製造額外喚醒和系統呼叫。
設定 EPOLLET 後,核心報告就緒狀態發生變化的邊緣。處理函式讀走 4 KiB 後,緩衝區裡還有 4 KiB,FD 仍處於可讀狀態;應用沒有把它推進到「目前沒有更多資料」的邊界。ET 不承諾因這個未變化的狀態繼續報告,所以等待新事件可能無限阻塞。
可靠規則是:收到 ET 事件後,把 FD 當作就緒,持續執行非阻塞 I/O,直到返回 EAGAIN 或 EWOULDBLOCK。這兩個值表示「目前已無法繼續,在不阻塞的情況下完成更多操作」,應用此時才把通知權交回 epoll。
第三步:把讀取實作為明確的狀態機
下面是接近 C 的示意程式碼;協定解析、連線物件生命週期和日誌細節由應用補充:
void drain_read(Connection *conn) {
unsigned char buf[4096];
for (;;) {
ssize_t n = recv(conn->fd, buf, sizeof buf, 0);
if (n > 0) {
append_and_parse(conn, buf, (size_t)n);
continue;
}
if (n == 0) {
conn->peer_write_closed = true;
break;
}
if (errno == EINTR) {
continue;
}
if (errno == EAGAIN || errno == EWOULDBLOCK) {
break;
}
close_with_error(conn, errno);
return;
}
if (conn->peer_write_closed && output_is_empty(conn)) {
close_connection(conn);
}
}n > 0 只表示收到這些位元組,解析器可能仍缺少完整請求;n == 0 表示對端對串流 socket 做了有序關閉,緩衝中的既有資料應先被消費;EINTR 可重試;EAGAIN/EWOULDBLOCK 是本輪排空完成;其他錯誤進入關閉路徑。程式碼不能把「小於緩衝區長度」當作訊息結束或已經讀空的證明。
監聽 socket 也遵循同樣模式:收到可讀通知後迴圈呼叫 accept4,為新連線原子設定 SOCKNONBLOCK | SOCKCLOEXEC,直到 EAGAIN。只接受一個連線就返回,會讓已經排隊的其他連線在 ET 下失去後續通知保證。
第四步:正確管理寫入緩衝和 EPOLLOUT
應用先嘗試直接傳送寫入緩衝。若 send 寫入部分位元組,就推進偏移並繼續;若返回 EINTR 就重試;若返回 EAGAIN/EWOULDBLOCK,保留剩餘資料並透過 EPOLLCTLMOD 加入 EPOLLOUT。下一次寫入就緒時繼續排空。寫入緩衝清空後立即從關注遮罩移除 EPOLLOUT。
永久訂閱 EPOLLOUT 會造成另一個典型故障:socket 通常長時間可寫,LT 會讓等待持續立即返回,CPU 使用率升高但業務沒有進展。ET 也不能消除應用寫入緩衝、背壓、最大緩衝量和慢客戶端策略;它只改變通知方式。
第五步:處理半關閉、HUP、ERR 與關閉順序
EPOLLRDHUP 表示串流 socket 的對端關閉連線或關閉寫入半邊。EPOLLHUP 只說明對端已關閉通道;既有資料可能仍待讀取,不能看到 HUP 就先丟掉緩衝。EPOLLERR 和 EPOLLHUP 無需在 interest mask 中明確設定,epollwait 仍會報告。錯誤路徑可用 getsockopt(SOERROR) 取得待處理 socket 錯誤,再依協定記錄並關閉。
關閉路徑應先停止把新工作交給該連線,並確保非同步任務不再持有過期身分。關閉指向底層 open file description 的最後一個 FD 會讓核心移除對應註冊;若描述被 dup 或 fork 共用,關閉其中一個 FD 不一定立刻消除相關事件,需要依所有權協定明確 DEL 或關閉全部引用。連線物件應使用受控生命週期和 generation/token,避免整數 FD 被快速重複使用後讓舊任務碰到新連線。
第六步:用 EPOLLONESHOT 管理 worker 併發
多個執行緒可以等待同一個 epoll 執行個體。對 ET FD,事件變為就緒時核心通常只喚醒一個等待者,但這不等於連線在整個處理期間天然只有一個 owner。事件分發到 worker 後,新的事件和應用佇列仍可能造成併發狀態存取。
EPOLLONESHOT 會在一次事件交付後停用該 FD。worker 排空讀寫、更新協定狀態和 interest mask 後,若連線仍存活,就呼叫 epollctl(EPOLLCTL_MOD) 重新啟用。重新啟用必須是交接的最後一步;忘記 MOD 會永久停滯,過早 MOD 可能讓另一個 worker 在舊 owner 尚未結束時進入。
第七步:同時滿足 ET 排空與公平性
「一直處理到 EAGAIN」在熱門連線持續到流時可能占用執行緒,延遲其他連線。事件迴圈可以給每個連線設定位元組、訊息或時間預算。預算耗盡但尚未遇到 EAGAIN 時,不能直接返回並等待核心;應用必須把該連線加入自己的 ready queue,稍後繼續處理,直到真正遇到 EAGAIN。
佇列要防止重複入隊,並讓連線關閉、worker 轉移和 EPOLLONESHOT 重新啟用遵守同一個所有權協定。這樣既保留 ET 的正確性,也避免一個熱門 FD 餓死其他 FD。
第八步:設計能揭露遺漏邊緣的驗證
驗證不能只跑一次正常請求。至少涵蓋:
- 客戶端一次寫入 8 KiB,服務端故意每次最多讀 4 KiB,確認修復前 ET 可停滯、修復後會繼續讀到
EAGAIN; - 客戶端把一個應用訊息拆成多次傳送,並在任意邊界暫停,確認解析不依賴單次
recv大小; - 限制服務端傳送能力製造部分寫入和
EAGAIN,確認寫入緩衝不遺失位元組,清空後取消EPOLLOUT; - 測試對端半關閉、帶未讀資料的 HUP、連線重設和
EINTR; - 多 worker 下反覆觸發
EPOLLONESHOT,確認每次都有且只有一個 owner,並且存活連線都完成重新啟用; - 用一個持續傳送的熱門連線和許多低速連線檢查公平性、CPU、事件迴圈延遲和 ready queue 長度;
- 高頻建立與關閉連線,確認延遲任務不會因 FD 編號重複使用操作錯誤連線。
觀測項應包含每輪 recv/send/accept4 的終止原因、EAGAIN 次數、interest mask 變更、oneshot rearm、應用 ready queue、單一連線處理預算、事件迴圈延遲和無進展的連線數。通過條件是位元組與協定狀態正確、無永久停滯、無空轉、無併發 owner,而且低速連線不會被熱門連線長期餓死。
高品質示範回答
「epoll 執行個體維護 interest list,並由 epoll_wait 返回 ready list 中的事件。它提供的是 I/O 就緒資訊,不是完整 TCP 訊息或非同步完成通知。系統呼叫的返回值才是狀態機依據。
本題在 LT 下看起來能繼續,是因為讀走 4 KiB 後接收緩衝區仍有 4 KiB,讀取就緒條件仍成立,下一次等待會繼續報告。啟用 EPOLLET 後,FD 沒有回到不可讀狀態;ET 不保證為一個持續為真的條件反覆通知,所以只讀一次後重新等待可能永久卡住。
我會確保監聽和連線 socket 都是非阻塞。監聽事件迴圈 accept4 到 EAGAIN;連線讀取事件迴圈 recv 到 EAGAIN,把正位元組交給增量協定解析器,0 視為對端關閉寫入半邊,EINTR 重試,其他錯誤關閉。回應先直接 send,部分寫入就保留偏移;只有 EAGAIN 且還有待傳資料時訂閱 EPOLLOUT,清空後立即取消它。HUP 到達時先消費剩餘資料,ERR 用 SO_ERROR 診斷。
多 worker 時我會給連線單一 owner,並可使用 EPOLLONESHOT:worker 完成讀寫排空、狀態更新和 mask 計算後再用 MOD 重新啟用。如果設定公平性預算卻在 EAGAIN 前停止,我會把連線放入去重的應用 ready queue,不等待不存在的新邊緣。連線身分使用帶生命週期或 generation 的物件,避免 FD 重複使用污染。
我會用 8 KiB 寫入和 4 KiB 限讀穩定重現,再加入部分寫入、半關閉、連線重設、oneshot、多 worker 和熱門連線壓力。修復後的證據是每個 ET 處理週期最終到達 EAGAIN 或明確關閉,待寫資料完整傳送,EPOLLOUT 不空轉,所有存活 oneshot 連線都重新啟用,而且沒有永久無進展或連線飢餓。」
常見錯誤
- 把就緒當成完整訊息 → TCP 是位元組流,單次
recv不對應應用訊息 → 使用增量協定解析和獨立輸入緩衝。 - 把 ET 簡化成「永遠只通知一次」→ 多個變化仍可能產生多個事件,關鍵是未變化的就緒條件沒有重複通知保證 → 處理到
EAGAIN。 - ET 下只讀一次 → 緩衝仍可讀卻沒有新邊緣 → 迴圈
recv到EAGAIN/EWOULDBLOCK。 - 阻塞 socket 搭配 ET → 排空迴圈可能阻塞並餓死整個事件迴圈 → 在註冊前設為非阻塞。
- 監聽 socket 每次只
accept一個連線 → 已排隊連線可能沒有後續通知 → 迴圈accept4到EAGAIN。 - 永久訂閱
EPOLLOUT→ 通常可寫的 socket 讓等待持續返回 → 只在寫入緩衝非空時訂閱,清空後移除。 - 看到 HUP 立即關閉 → HUP 時仍可能有未消費資料 → 先按讀取狀態機排空,再依 EOF 和輸出狀態關閉。
- 使用
EPOLLONESHOT後忘記重新啟用 → FD 已在 interest list 中停用 → 狀態更新完成後用EPOLLCTLMODrearm。 - 為公平性提早返回後等待新 ET 事件 → FD 可能一直保持就緒而沒有新邊緣 → 放入應用 ready queue 繼續處理。
- 只用整數 FD 標識連線 → 關閉後的編號可能被新連線重複使用 → 使用有生命週期控制和 generation 的連線身分。
- 宣稱 ET 必然更快 → 實際結果取決於活躍比例、系統呼叫、業務處理和實作正確性 → 用真實負載比較 CPU、延遲、吞吐和公平性。
追問及應對
追問一:為什麼 recv 返回小於緩衝區長度,仍不能說明已經讀完?
recv 通常返回當時可用且不超過請求長度的位元組數。網路分段、排程和傳送時機都可能讓它返回短讀,後續位元組仍會到達。ET 的排空邊界是非阻塞呼叫返回 EAGAIN/EWOULDBLOCK;應用訊息邊界則由協定解析器決定,這兩個邊界也不相同。
追問二:ET 是否一定比 LT 效能更好?
不能只憑模式下結論。ET 可以減少一個持續就緒 FD 的重複通知,但會增加排空、使用者空間佇列和狀態管理複雜度。若大部分連線不活躍、業務處理成本更高或實作產生額外 epoll_ctl,收益可能很小。應在目標連線數和活躍分布下測 CPU、系統呼叫、吞吐、p99 和公平性,並把正確性放在首位。
追問三:為什麼寫入緩衝清空後要取消 EPOLLOUT?
可寫通常表示核心傳送緩衝區還能接收一些位元組,許多連線長時間滿足這個條件。繼續關注它會產生沒有業務價值的通知,LT 下尤其容易忙碌迴圈。應用有待傳送位元組時才需要寫入通知;清空後移除,下一次業務產生回應時先直接嘗試 send。
追問四:EPOLLONESHOT 與 ET 是同一件事嗎?
不是。EPOLLET 改變就緒通知模式;EPOLLONESHOT 讓 FD 在一次事件交付後被停用,直到應用用 EPOLLCTLMOD 重新啟用。它們可以一起使用。ONESHOT 常用於 worker 所有權交接,但應用仍要正確排空 ET,並維護重新啟用協定。
追問五:預算耗盡時還沒遇到 EAGAIN,怎樣避免遺失事件?
把連線標為使用者空間仍就緒,並放入去重 ready queue。排程器稍後繼續 recv/send,直到 EAGAIN、關閉或再次耗盡預算。連線在使用者空間佇列期間要保持有效 owner;若結合 ONESHOT,應在使用者空間工作完成並重新需要核心通知時才 rearm。
追問六:關閉 FD 後,為什麼還要擔心舊事件?
事件批次可能已經由 epoll_wait 交到使用者空間,非同步 worker 也可能持有連線引用,而核心很快會把相同整數編號分配給新 socket。此外,多個 FD 可能引用同一個 open file description。關閉路徑要先撤銷新排程,管理連線物件生命週期並使舊 token 失效;只比較整數 FD 不足以證明事件屬於目前連線。