邊緣觸發與位準觸發通知(edge-triggered / level-triggered)
想像門鈴與一盞「門開」指示燈的差別。位準觸發的指示燈在門開著的整段時間都「亮著」——你任何時候抬頭都看得見。邊緣觸發的門鈴只在門打開的那一刻響「一次」,之後即使門一直開著也不再響。就緒通知器可以用任一種方式運作,而把這個差別弄錯,是讓高效能伺服器卡死的經典手法之一。
位準觸發(epoll 的預設,也是 select/poll 的行為):只要一個通訊端「還有」未讀資料,每一次對等待器的呼叫都會回報它就緒,一次又一次,直到你把它抽乾。這很寬容——若你這次只讀了部分位元組,下次只會被告知「仍就緒」。邊緣觸發(epoll 加上 EPOLLET 旗標):你只在「從不就緒到就緒」的「轉變」上被告知就緒——就在新資料抵達的那一瞬。若你那時沒把所有可用的資料讀完,在「更多」資料抵達前你「不會」再被告知。它強制的紀律是:當一個邊緣觸發描述符觸發時,你必須以迴圈反覆讀取直到 read() 回傳 EAGAIN,證明你已把通訊端完全抽乾。
為何重要與其風險:邊緣觸發觸發的事件較少,在重負載下可能意味較少的喚醒,而且它與非阻塞描述符天生相配。但其失敗模式很殘酷——若你忘了那個「讀到 EAGAIN 為止」的迴圈,剩下的位元組就無人注意地擱著,連線看起來會永遠無聲地停滯,因為邊緣已經觸發過、不會再觸發。對多數程式碼而言位準觸發是較安全的預設;只有在你量測到真正的好處、且願意遵守它的抽乾契約時,才動用邊緣觸發。
ev.events = EPOLLIN | EPOLLET; /* 邊緣觸發 */ /* 觸發時你「必須」抽乾: */ for (;;) { ssize_t n = read(fd, buf, sizeof buf); if (n <= 0) { if (errno == EAGAIN) break; /* 否則為關閉/錯誤 */ } }
加上 EPOLLET 你只在邊緣被通知,所以必須迴圈讀到 EAGAIN,否則有讓資料永遠未讀的風險。
邊緣觸發描述符基本上要求非阻塞模式:抽乾迴圈靠 read() 回傳 EAGAIN 來判斷何時停止,而阻塞式 read() 反而會在最後那次空讀上凍結執行緒。在多執行緒伺服器中,位準觸發加上 EPOLLONESHOT 是常見的折衷,用以避免兩個執行緒處理同一個描述符。