epoll在多線程中的應用-EPOLLEXCLUSIVE和REUSEPORT(一)

以下均爲對epoll多線程中的使用的一些筆記,如果有不對的地方,煩請指出

主要對於我所遇到的問題進行討論,不會討論代碼如何改寫,探討如何解決這個問題

一.引言

這些問題均是我在編寫我的Web服務器遇到的,我在編寫多線程Web服務器的時候,思考如何利用多核的優勢來編寫Web服務器.在學習了muduo網絡庫之後,我的先前一個版本的Web服務器採用這種方式,一個master線程+多個工人線程,但是我覺得在高併發的情況下只有一個線程可以accept這無疑限制了accept吞吐量,並不算利用多核優勢.進而引發了我對於在多線程中如何高效合理的使用epoll有了探索.

二.epoll file descriptor 和 kernel file description 生命週期的問題

`談及這個問題,我們需要了解

  • (1) 進程維護file descriptor表 ,每個fd包含
  • fd 標誌
  • 指向內核的file descriptor表象的指針
  • (2)內核維護所有打開文件的file description表,每一個表都包含文件的狀態標誌
  • 當前文件的offest
  • 文件狀態標誌(讀,寫,阻塞,非阻塞)
  • 指向該文件v節點

每個進程的task_struct 包含了用於完全應該工作的成員

struct task_struct {
	//文件系統信息
	int link_count , total_link_count;
	---
	struct files_struct * files; //打開的文件信息
	---
}

struct files_struct {

	-----
	atomic_t count; //引用技術
}

圖片來源
上述圖片來源

  • 在用戶態使用close() 減少一次count , 當count 爲0,纔會從內核的file description 刪除

  • 實際上,epoll( ) 主要是混淆了用戶態的file descriptor和內核態中真正用於實現的 file description . 當進程調用close 關閉fd ,就會出現問題,也就是說我們在調用的時候,傳入的是用戶態的 file descriptor ,也就是平時我們所用的fd那個數字,但是在內核中,引用的是file description ,就是那個內核對象

  • epoll_ctl(EPOLL_CTL_ADD) 實現上並不是註冊一個file descriptor(fd) , 而是將fd 和一個指向內核file description 的指針一塊註冊給了epoll ,也就是說epoll中管理的fd 生命週期,是內核中的相對應的file descriptor

我們可以查看最權威的man手冊

Q6 Will closing a file descriptor cause it to be removed from all epoll sets automatically?
A6 Yes, but be aware of the following point. A file descriptor is a reference to an open file
description (see open(2)). Whenever a descriptor is duplicated via dup(2), dup2(2), fcntl(2)
F_DUPFD, or fork(2), a new file descriptor referring to the same open file description is created.
An open file description continues to exist until all file descriptors referring to it have been
closed. A file descriptor is removed from an epoll set only after all the file descriptors refer-
ring to the underlying open file description have been closed (or before if the descriptor is
explicitly removed using epoll_ctl() EPOLL_CTL_DEL). This means that even after a file descriptor
that is part of an epoll set has been closed, events may be reported for that file descriptor if
other file descriptors referring to the same underlying file description remain open.

同時也就是說當我們使用close去關閉掉一個fd 的時候,如果這個fd 是內核中file description的唯一引用
,內核中的 file description 同時也會跟着一起刪除,這樣沒有什麼問題,但是如果有其他引用,close並不會刪除這個file descrption. 這樣epoll會繼續上報這個已經close掉的fd 上的事件,並且此時上次的應用程序看到的現象是關閉了fd ,但是 epoll_wait 會不斷返回關閉的fd的事件信息,更爲頭疼的是fd已經被關閉了,我們無法通過epoll_ctl去從集合中刪除掉這個fd

從Marek的博客中的代碼中得到

rfd, wfd = pipe()
write(wfd, "a")             # Make the "rfd" readable
epfd = epoll_create()
epoll_ctl(efpd, EPOLL_CTL_ADD, rfd, (EPOLLIN, rfd))

rfd2 = dup(rfd)
close(rfd)

r = epoll_wait(epfd, -1ms)  # still recv event!!!

所以我們應該在close之前首先調用epoll_ct在epoll中刪除掉相應的fd

三.特定的TCP listen fd 的accept 的問題

這個問題是我思索很久的問題,如果我的web服務器在某種場景上,需要應對大量的短連接,那麼我會想如何把accpet()分發到不同的CPU 上,來利用多核的能力.

1)首先我想到的方案是所有線程共用一個isten fd每一個線程創建epollfd ,把listen fd加入到所有線程的epoll 中.

  • 一開始發現這種情況會引發驚羣問題,如果有新的連接,會喚醒所有的線程,但是隻有一個線程可以accpet成功,其他線程會進行失敗
  • 但是我發現這種形式效率並不低,按道理來說驚羣應該會降低效率,但是爲什麼會增加效率?
  • 經過查詢資料和自己的測試,可以這麼理解,如果說線程是一堆小雞的話,在飼料不多的情況下,是需要一些小雞進行休息的,但是在飼料很多的情況下,如何讓飼料更快的消耗?當然是讓所有小雞同時進行飼料的競爭

2)這樣說的話,將所有的線程共用一個epollfd也會引起驚羣問題

  • 在嘗試這個方法的同時,我想到了邊緣觸發會不會更好處理這個問題?
  • 但是並沒有,我們可以來看看下面這個例子
  1. 內核:收到第一個連接請求。線程 A 和 線程 B 兩個線程都在 epoll_wait() 上等待。由於採用邊緣觸發模式,所以只有一個線程會收到通知。這裏假定線程 A 收到通知
  2. 線程A:epoll_wait() 返回
  3. 線程A:調用 accpet() 並且成功
  4. 內核:此時 accept queue 爲空,所以將邊緣觸發的 socket 的狀態從可讀置成不可讀
  5. 內核:收到第二個建連請求
  6. 內核:此時,由於線程 A 還在執行 accept() 處理,只剩下線程 B 在等待 epoll_wait(),於是喚醒線程 B
  7. 線程A:繼續執行 accept() 直到返回 EAGAIN
    .8. 線程B:執行 accept(),並返回 EAGAIN,此時線程 B 可能有點困惑(“明明通知我有事件,結果卻返回 EAGAIN”)
    .9. 線程A:再次執行 accept(),這次終於返回 EAGAIN
  • 當然也有可能會造成線程飢餓的問題,我們來看看下面這個例子
  1. 內核:接收到兩個建連請求。線程 A 和 線程 B 兩個線程都在等在 epoll_wait()。由於採用邊緣觸發模式,只有一個線程會被喚醒,我們這裏假定線程 A 先被喚醒
  2. 線程A:epoll_wait() 返回
  3. 線程A:調用 accpet() 並且成功
  4. 內核:收到第三個建連請求。由於線程 A 還沒有處理完(沒有返回 EAGAIN),當前 socket 還處於可讀的狀態,由於是邊緣觸發模式,所有不會產生新的事件
  5. 線程A:繼續執行 accept() 希望返回 EAGAIN 再進入 epoll_wait() 等待,然而它又 accept() 成功並處理了一個新連接
  6. 內核:又收到了第四個建連請求
  7. 線程A:又繼續執行 accept(),結果又返回成功

線程A很忙但是線程B沒有活幹

3)說這多,那什麼是正確的做法?

從epoll的角度來考慮的話,有兩種做法

  • 最好也是唯一支持的是從內核4.5+開始新增的水平觸發模式的EPOLLEXCLUSIVE標誌,這個標誌會保證一個事件只有一個epoll_wait()會被喚醒,避免了"驚羣效應",並且可以完美的在多個CPU之間進行擴展
  • 那麼EPOLLEXCLUSIVE爲什麼可以做到這一點? 有這個標誌位的fd,會喚醒線程中的空閒隊列的頭一個
  • 當然內核不夠4.5怎麼辦?我們可以通過ET下的EPOLLONESHOT 來模擬 LT+ EPOLLEXCLUSIVE的效果
    這樣是有一定的代價的,需要在每次事件處理完成之後額外多調用一次 epoll_ctl(EPOLL_CTL_MOD) 重置這個 fd。這樣做可以將負載均分到不同的 CPU 上,但是同一時刻,只能有一個 worker 調用 accept(2)。顯然,這樣又限制了處理 accept(2) 的吞吐。

4)當然我們也有其他方案(SO_REUSEPORT)

  • 我們可以使用SO_REUSEPORT這個socket option ,創建多個listen socket 共用一個端口號
  • SO_REUSEPORT在TCP連接中是通過內核來選取套接字來進行接受連接
  • SO_REUSEPORT有兩種模式,熱備份和負載均衡模式,當然在3.9之後,全部是負載均衡模式
  • SO_REUSEPORT當有連接到來時,用數據包的源IP/源端口作爲一個HASH函數的輸入,將結果對reuseport套接字數量取模,得到一個索引,該索引指示的數組位置對應的套接字便是工作套接字。
  • 但是也有弊端,就是一個listen socket  fd 被關,被分到這個listen socket fd 的accept隊列上的東西會被丟棄掉
  • SO_REUSEPORT的好處:解決了epoll驚羣問題,程序有了更好的擴展性,只有在同一個用戶下相同的服務器進程才能監聽同一ip:port (安全性考慮)
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章