iocp vs epoll 大比拼,同時揭秘使用 epoll 實作 gevent 過程中遇到的坑
之前曾經使用 epoll 建構過一個輕量級的 tcp 服務架構:
一個工業級、跨平台、輕量級的 tcp 網絡服務架構:gevent
在調試的過程中,發現一些 epoll 之前沒怎麼注意到的特性。
a) iocp 是完全線程安全的,即同時可以有多個線程等待在 iocp 的完成隊列上;
而 epoll 不行,同時隻能有一個線程執行 epoll_wait 操作,是以這裡需要做一點處理,
網上有人使用 condition_variable + mutex 實作 leader-follower 線程模型,但我隻用了一個 mutex 就實作了,
當有事件發生了,leader 線程在執行事件處理器之前 unlock 這個 mutex,
就可以允許等待在這個 mutex 上的其它線程中的一個進入 epoll_wait 進而擔任新的 leader。
(不知道多加一個 cv 有什麼用,有明白原理的提示一下哈)
b) epoll 在加入、删除句柄時是可以跨線程的,而且這一操作是線程安全的。
之前一直以為 epoll 會像 select 一像,添加或删除一個句柄需要先通知 leader 從 epoll_wait 中醒來,
在重新 wait 之前通過 epoll_ctl 添加或删除對應的句柄。但是現在看完全可以在另一個線程中執行 epoll_ctl 操作
而不用擔心多線程問題。這個在 man 手冊頁也有描述(man epoll_wait):
NOTES
While one thread is blocked in a call to epoll_pwait(), it is possible for another thread to
add a file descriptor to the waited-upon epoll instance. If the new file descriptor becomes
ready, it will cause the epoll_wait() call to unblock.
For a discussion of what may happen if a file descriptor in an epoll instance being monitored
by epoll_wait() is closed in another thread, see select(2).
c) epoll 有兩種事件觸發方式,一種是預設的水準觸發(LT)模式,即隻要有可讀的資料,就一直觸發讀事件;
還有一種是邊緣觸發(ET)模式,即隻在沒有資料到有資料之間觸發一次,如果一次沒有讀完全部資料,
則也不會再次觸發,除非所有資料被讀完,且又有新的資料到來,才觸發。使用 ET 模式的好處是,
不用在每次執行處理器前将句柄從 epoll 移除、在執行完之後再加入 epoll 中,
(如果不這樣做的話,下一個進來的 leader 線程還會認為這個句柄可讀,進而導緻一個連接配接的資料被多個線程同時處理)
進而導緻頻繁的移除、添加句柄。好多網上的 epoll 例子也推薦這種方式。但是我在親自驗證後,發現使用 ET 模式有兩個問題:
1)如果連接配接上來了大量資料,而每次隻能讀取部分(緩存區限制),則第 N 次讀取的資料與第 N+1 次讀取的資料,
有可能是兩個線程中執行的,在讀取時它們的順序是可以保證的,但是當它們通知給使用者時,第 N+1 次讀取的資料
有可能在第 N 次讀取的資料之前送達給應用層。這是因為線程的排程導緻的,雖然第 N+1 次資料隻有在第 N 次資料
讀取完之後才可能産生,但是當第 N+1 次資料所在的線程可能先于第 N 次資料所在的線程被排程,上述場景就會産生。
這需要細心的設計讀資料到給使用者之間的流程,防止線程搶占(需要加一些保證順序的鎖);
2)當大量資料發送結束時,連接配接中斷的通知(on_error)可能早于某些資料(on_read)到達,其實這個原理與上面類似,
就是用戶端在所有資料發送完成後主動斷開連接配接,而擷取連接配接中斷的線程可能先于末尾幾個資料所在的線程被排程,
進而在應用層造成混亂(on_error 一般會删除事件處理器,但是 on_read 又需要它去做回調,好的情況會造成一些
資料丢失,不好的情況下直接崩潰)
鑒于以上兩點,最後我還是使用了預設的 LT 觸發模式,幸好有 b) 特性,我僅僅是增加了一些移除、添加的代碼,
而且我不用在應用層加鎖來保證資料的順序性了。
d) 一定要捕捉 SIGPIPE 事件,因為當某些連接配接已經被用戶端斷開時,而服務端還在該連接配接上 send 應答包時:
第一次 send 會傳回 ECONNRESET(104),再 send 會直接導緻程序退出。如果捕捉該信号後,則第二次 send 會傳回 EPIPE(32)。
這樣可以避免一些莫名其妙的退出問題(我也是通過 gdb 挂上程序才發現是這個信号導緻的)。
e) 當管理多個連接配接時,通常使用一種 map 結構來管理 socket 與其對應的資料結構(特别是回調對象:handler)。
但是不要使用 socket 句柄作為這個映射的 key,因為當一個連接配接中斷而又有一個新的連接配接到來時,linux 上傾向于用最小的
fd 值為新的 socket 配置設定句柄,大部分情況下,它就是你剛剛 close 或用戶端中斷的句柄。這樣一來很容易導緻一些混亂的情況。
例如新的句柄插入失敗(因為舊的雖然已經關閉但是還未來得及從 map 中移除)、舊句柄的清理工作無意間關閉了剛剛配置設定的
新連接配接(清理時 close 同樣的 fd 導緻新配置設定的連接配接中斷)……而在 win32 上不存在這樣的情況,這并不是因為 winsock 比 bsdsock 做的更好,
相同的, winsock 也存在新配置設定的句柄與之前剛關閉的句柄一樣的場景(當大量用戶端不停中斷重連時);而是因為 iocp 基于提前
配置設定的記憶體塊作為某個 IO 事件或連接配接的依據,而 map 的 key 大多也依據這些記憶體位址建構,是以一般不存在重複的情況(隻要還在 map 中就不釋放對應記憶體)。
經過觀察,我發現在 linux 上,即使新的連接配接占據了舊的句柄值,它的端口往往也是不同的,是以這裡使用了一個三元組作為 map 的 key:
{ fd, local_port, remote_port }
當 fd 相同時,local_port 與 remote_port 中至少有一個是不同的,進而可以區分新舊連接配接。
f) 如果連接配接中斷或被對端主動關閉連接配接時,本端的 epoll 是可以檢測到連接配接斷開的,但是如果是自己 close 掉了 socket 句柄,則 epoll 檢測不到連接配接已斷開。
這個會導緻用戶端在不停斷開重連過程中積累大量的未釋放對象,時間長了有可能導緻資源不足進而崩潰。
目前還沒有找到産生這種現象的原因,Windows 上沒有這種情況,有清楚這個現象原因的同學,不吝賜教啊
最後,再亂入一波 iocp 的特性:
iocp 在異步事件完成後,會通過完成端口完成通知,但在某些情況下,異步操作可以“立即完成”,
就是說雖然隻是送出異步事件,但是也有可能這個操作直接完成了。這種情況下,可以直接處理得到的資料,相當于是同步調用。
但是我要說的是,千萬不要直接處理資料,因為當你處理完之後,完成端口依舊會在之後進行通知,導緻同一個資料被處理多次的情況。
是以最好的實踐就是,不論是否立即完成,都交給完成端口去處理,保證資料的一次性。