[apue] epoll 的一些不為人所注意的特性

goodcitizen發表於2020-06-05

之前曾經使用 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 在非同步事件完成後,會通過完成埠完成通知,但在某些情況下,非同步操作可以“立即完成”,

就是說雖然只是提交非同步事件,但是也有可能這個操作直接完成了。這種情況下,可以直接處理得到的資料,相當於是同步呼叫。

但是我要說的是,千萬不要直接處理資料,因為當你處理完之後,完成埠依舊會在之後進行通知,導致同一個資料被處理多次的情況。

所以最好的實踐就是,不論是否立即完成,都交給完成埠去處理,保證資料的一次性。

 

相關文章