深入理解 Linux 的 epoll 機制
在 Linux 系統之中有一個核心武器:epoll 池,在高併發的,高吞吐的 IO 系統中常常見到 epoll 的身影。
IO 多路複用
在 Go 裏最核心的是 Goroutine ,也就是所謂的協程,協程最妙的一個實現就是異步的代碼長的跟同步代碼一樣。比如在 Go 中,網絡 IO 的 read
,write
看似都是同步代碼,其實底下都是異步調用,一般流程是:
write ( /* IO 參數 */ )
請求入隊
等待完成
後臺 loop 程序
發送網絡請求
喚醒業務方
Go 配合協程在網絡 IO 上實現了異步流程的代碼同步化。核心就是用 epoll 池來管理網絡 fd 。
實現形式上,後臺的程序只需要 1 個就可以負責管理多個 fd 句柄,負責應對所有的業務方的 IO 請求。這種一對多的 IO 模式我們就叫做 IO 多路複用。
多路是指?多個業務方(句柄)併發下來的 IO 。
複用是指?複用這一個後臺處理程序。
站在 IO 系統設計人員的角度,業務方咱們沒辦法提要求,因爲業務是上帝,只有你服從的份,他們要創建多個 fd,那麼你就需要負責這些 fd 的處理,並且最好還要併發起來。
業務方沒法提要求,那麼只能要求後臺 loop 程序了!
要求什麼呢?快!快!快!這就是最核心的要求,處理一定要快,要給每一個 fd 通道最快的感受,要讓每一個 fd 覺得,你只在給他一個人跑腿。
那有人又問了,那我一個 IO 請求(比如 write )對應一個線程來處理,這樣所有的 IO 不都併發了嗎?是可以,但是有瓶頸,線程數一旦多了,性能是反倒會差的。
這裏不再對比多線程和 IO 多路複用實現高併發之間的區別,詳細的可以去了解下 nginx 和 redis 高併發的祕密。
1 最樸實的實現方式?
我不用任何其他系統調用,能否實現 IO 多路複用?
可以的。那麼寫個 for
循環,每次都嘗試 IO 一下,讀 / 寫到了就處理,讀 / 寫不到就 sleep
下。這樣我們不就實現了 1 對多的 IO 多路複用嘛。
while True:
for each 句柄數組 {
read/write(fd, /* 參數 */)
}
sleep(1s)
慢着,有個問題,上面的程序可能會被卡死在第三行,使得整個系統不得運行,爲什麼?
默認情況下,我們 create
出的句柄是阻塞類型的。我們讀數據的時候,如果數據還沒準備好,是會需要等待的,當我們寫數據的時候,如果還沒準備好,默認也會卡住等待。所以,在上面僞代碼第三行是可能被直接卡死,而導致整個線程都得到不到運行。
舉個例子,現在有 11,12,13 這 3 個句柄,現在 11 讀寫都沒有準備好,只要 read/write(11, /*參數*/)
就會被卡住,但 12,13 這兩個句柄都準備好了,那遍歷句柄數組 11,12,13 的時候就會卡死在前面,後面 12,13 則得不到運行。這不符合我們的預期,因爲我們 IO 多路複用的 loop 線程是公共服務,不能因爲一個 fd 就直接癱瘓。
那這個問題怎麼解決?
只需要把 fd 都設置成非阻塞模式。這樣 read/write
的時候,如果數據沒準備好,返回 EAGIN
的錯誤即可,不會卡住線程,從而整個系統就運轉起來了。比如上面句柄 11 還未就緒,那麼 read/write(11, /*參數*/)
不會阻塞,只會報個 EAGIN
的錯誤,這種錯誤需要特殊處理,然後 loop 線程可以繼續執行 12,13 的讀寫。
以上就是最樸實的 IO 多路複用的實現了。但好像在生產環境沒見過這種 IO 多路複用的實現?爲什麼?
因爲還不夠高級。for
循環每次要定期 sleep 1s
,這個會導致吞吐能力極差,因爲很可能在剛好要 sleep
的時候,所有的 fd 都準備好 IO 數據,而這個時候卻要硬生生的等待 1s,可想而知。。。
那有同學又要質疑了,那 for
循環裏面就不 sleep
嘛,這樣不就能及時處理了嗎?
及時是及時了,**但是 CPU 估計要跑飛了。**不加 sleep
,那在沒有 fd 需要處理的時候,估計 CPU 都要跑到 100% 了。這個也是無法接受的。
糾結了,那 sleep
吞吐不行,不 sleep
浪費 cpu,怎麼辦?
這種情況用戶態很難有所作爲,只能求助內核來提供機制協助來。因爲內核才能及時的管理這些事件的通知和調度。
我們再梳理下 IO 多路複用的需求和原理。IO 多路複用就是 1 個線程處理 多個 fd 的模式。我們的要求是:這個 “1” 就要儘可能的快,避免一切無效工作,要把所有的時間都用在處理句柄的 IO 上,不能有任何空轉,sleep 的時間浪費。
有沒有一種工具,我們把一籮筐的 fd 放到裏面,只要有一個 fd 能夠讀寫數據,後臺 loop 線程就要立馬喚醒,全部馬力跑起來。其他時間要把 cpu 讓出去。
能做到嗎?能,但這種需求只能內核提供機制滿足你。
2 這事 Linux 內核必須要給個說法?
是的,想要不用 sleep 這種辣眼睛的實現,Linux 內核必須出手了,畢竟 IO 的處理都是內核之中,數據好沒好內核最清楚。
內核一口氣提供了 3 種工具 select
,poll
,epoll
。
爲什麼有 3 種?
歷史不斷改進,矬 -> 較矬 -> 臥槽、高效
的演變而已。
Linux 還有其他方式可以實現 IO 多路複用嗎?
好像沒有了!
這 3 種到底是做啥的?
**這 3 種都能夠管理 fd 的可讀可寫事件,**在所有 fd 不可讀不可寫無所事事的時候,可以阻塞線程,切走 cpu 。fd 有情況的時候,都要線程能夠要能被喚醒。
而這三種方式以 epoll 池的效率最高。爲什麼效率最高?
其實很簡單,這裏不詳說,**其實無非就是 epoll 做的無用功最少,**select 和 poll 或多或少都要多餘的拷貝,盲猜(遍歷才知道)fd ,所以效率自然就低了。
舉個例子,以 select 和 epoll 來對比舉例,池子裏管理了 1024 個句柄,loop 線程被喚醒的時候,select 都是蒙的,都不知道這 1024 個 fd 裏誰 IO 準備好了。這種情況怎麼辦?只能遍歷這 1024 個 fd ,一個個測試。假如只有一個句柄準備好了,那相當於做了 1 千多倍的無效功。
epoll 則不同,從 epoll_wait
醒來的時候就能精確的拿到就緒的 fd 數組,不需要任何測試,拿到的就是要處理的。
epoll 池原理
下面我們看一下 epoll 池的使用和原理。
1 epoll 涉及的系統調用
epoll 的使用非常簡單,只有下面 3 個系統調用。
epoll_create
epollctl
epollwait
就這?是的,就這麼簡單。
-
epollcreate
負責創建一個池子,一個監控和管理句柄 fd 的池子; -
epollctl
負責管理這個池子裏的 fd 增、刪、改; -
epollwait
就是負責打盹的,讓出 CPU 調度,但是隻要有 “事”,立馬會從這裏喚醒;
2 epoll 高效的原理
Linux 下,epoll 一直被吹爆,作爲高併發 IO 實現的祕密武器。其中原理其實非常樸實:epoll 的實現幾乎沒有做任何無效功。 我們從使用的角度切入來一步步分析下。
首先,epoll 的第一步是創建一個池子。這個使用 epoll_create
來做:
原型:
int epoll_create(int size);
示例:
epollfd = epoll_create(1024);
if (epollfd == -1) {
perror("epoll_create");
exit(EXIT_FAILURE);
}
這個池子對我們來說是黑盒,這個黑盒是用來裝 fd 的,我們暫不糾結其中細節。我們拿到了一個 epollfd
,這個 epollfd
就能唯一代表這個 epoll 池。注意,這裏又有一個細節:用戶可以創建多個 epoll 池。
然後,我們就要往這個 epoll 池裏放 fd 了,這就要用到 epoll_ctl
了
原型:
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
示例:
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, 11, &ev) == -1) {
perror("epoll_ctl: listen_sock");
exit(EXIT_FAILURE);
}
上面,我們就把句柄 11 放到這個池子裏了,op(EPOLL_CTL_ADD
)表明操作是增加、修改、刪除,event 結構體可以指定監聽事件類型,可讀、可寫。
**第一個跟高效相關的問題來了,添加 fd 進池子也就算了,如果是修改、刪除呢?**怎麼做到快速?
這裏就涉及到你怎麼管理 fd 的數據結構了。
最常見的思路:用 list ,可以嗎?功能上可以,但是性能上拉垮。list 的結構來管理元素,時間複雜度都太高 O(n),每次要一次次遍歷鏈表才能找到位置。池子越大,性能會越慢。
那有簡單高效的數據結構嗎?
有,紅黑樹。Linux 內核對於 epoll 池的內部實現就是用紅黑樹的結構體來管理這些註冊進程來的句柄 fd。紅黑樹是一種平衡二叉樹,時間複雜度爲 O(log n),就算這個池子就算不斷的增刪改,也能保持非常穩定的查找性能。
現在思考第二個高效的祕密:怎麼才能保證數據準備好之後,立馬感知呢?
epoll_ctl 這裏會涉及到一點。祕密就是:回調的設置。在 epoll_ctl 的內部實現中,除了把句柄結構用紅黑樹管理,另一個核心步驟就是設置 poll 回調。
思考來了:poll 回調是什麼?怎麼設置?
先說說 file_operations->poll
是什麼?
在 文件描述符 fd 究竟是什麼 說過,Linux 設計成一切皆是文件的架構,這個不是說說而已,而是隨處可見。實現一個文件系統的時候,就要實現這個文件調用,這個結構體用 struct file_operations
來表示。這個結構體有非常多的函數,精簡了一些,如下:
struct file_operations {
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
__poll_t (*poll) (struct file *, struct poll_table_struct *);
int (*open) (struct inode *, struct file *);
int (*fsync) (struct file *, loff_t, loff_t, int datasync);
// ....
};
你看到了 read
,write
,open
,fsync
,poll
等等,這些都是對文件的定製處理操作,對於文件的操作其實都是在這個框架內實現邏輯而已,比如 ext2 如果有對 read/write
做定製化,那麼就會是 ext2_read
,ext2_write
,ext4 就會是 ext4_read
,ext4_write
。在 open
具體 “文件” 的時候會賦值對應文件系統的 file_operations
給到 file 結構體。
那我們很容易知道 read
是文件系統定製 fd 讀的行爲調用,write
是文件系統定製 fd 寫的行爲調用,file_operations->poll
呢?
這個是定製監聽事件的機制實現。通過 poll 機制讓上層能直接告訴底層,我這個 fd 一旦讀寫就緒了,請底層硬件(比如網卡)回調的時候自動把這個 fd 相關的結構體放到指定隊列中,並且喚醒操作系統。
舉個例子:網卡收發包其實走的異步流程,操作系統把數據丟到一個指定地點,網卡不斷的從這個指定地點掏數據處理。請求響應通過中斷回調來處理,中斷一般拆分成兩部分:硬中斷和軟中斷。poll 函數就是把這個軟中斷回來的路上再加點料,只要讀寫事件觸發的時候,就會立馬通知到上層,採用這種事件通知的形式就能把浪費的時間窗就完全消失了。
劃重點:這個 poll 事件回調機制則是 epoll 池高效最核心原理。
劃重點:epoll 池管理的句柄只能是支持了 file_operations->poll
的文件 fd。換句話說,如果一個 “文件” 所在的文件系統沒有實現 poll 接口,那麼就用不了 epoll 機制。
第二個問題:poll 怎麼設置?
在 epoll_ctl
下來的實現中,有一步是調用 vfs_poll
這個裏面就會有個判斷,如果 fd 所在的文件系統的 file_operations
實現了 poll ,那麼就會直接調用,如果沒有,那麼就會報告響應的錯誤碼。
static inline __poll_t vfs_poll(struct file *file, struct poll_table_struct *pt)
{
if (unlikely(!file->f_op->poll))
return DEFAULT_POLLMASK;
return file->f_op->poll(file, pt);
}
你肯定好奇 poll 調用裏面究竟是實現了什麼?
總結概括來說:掛了個鉤子,設置了喚醒的回調路徑。epoll 跟底層對接的回調函數是:ep_poll_callback
,這個函數其實很簡單,做兩件事情:
-
把事件就緒的 fd 對應的結構體放到一個特定的隊列(就緒隊列,ready list);
-
喚醒 epoll ,活來啦!
當 fd 滿足可讀可寫的時候就會經過層層回調,最終調用到這個回調函數,把對應 fd 的結構體放入就緒隊列中,從而把 epoll 從 epoll_wait
出喚醒。
這個對應結構體是什麼?
結構體叫做 epitem
,每個註冊到 epoll 池的 fd 都會對應一個。
就緒隊列需要用很高級的數據結構嗎?
就緒隊列就簡單了,因爲沒有查找的需求了呀,只要是在就緒隊列中的 epitem ,都是事件就緒的,必須處理的。所以就緒隊列就是一個最簡單的雙指針鏈表。
小結下:epoll 之所以做到了高效,最關鍵的兩點:
-
內部管理 fd 使用了高效的紅黑樹結構管理,做到了增刪改之後性能的優化和平衡;
-
epoll 池添加 fd 的時候,調用
file_operations->poll
,把這個 fd 就緒之後的回調路徑安排好。通過事件通知的形式,做到最高效的運行; -
epoll 池核心的兩個數據結構:紅黑樹和就緒列表。紅黑樹是爲了應對用戶的增刪改需求,就緒列表是 fd 事件就緒之後放置的特殊地點,epoll 池只需要遍歷這個就緒鏈表,就能給用戶返回所有已經就緒的 fd 數組;
3 哪些 fd 可以用 epoll 來管理?
再來思考另外一個問題:由於並不是所有的 fd 對應的文件系統都實現了 poll 接口,所以自然並不是所有的 fd 都可以放進 epoll 池,那麼有哪些文件系統的 file_operations 實現了 poll 接口?
首先說,類似 ext2,ext4,xfs 這種常規的文件系統是沒有實現的,換句話說,這些你最常見的、真的是文件的文件系統反倒是用不了 epoll 機制的。
那誰支持呢?
最常見的就是網絡套接字:socket 。網絡也是 epoll 池最常見的應用地點。Linux 下萬物皆文件,socket 實現了一套 socket_file_operations
的邏輯( net/socket.c ):
static const struct file_operations socket_file_ops = {
.read_iter = sock_read_iter,
.write_iter = sock_write_iter,
.poll = sock_poll,
// ...
};
我們看到 socket 實現了 poll 調用,所以 socket fd 是天然可以放到 epoll 池管理的。
還有支持的嗎?
有的,很多。其實 Linux 下還有兩個很典型的 fd ,常常也會放到 epoll 池裏。
-
eventfd:eventfd 實現非常簡單,故名思義就是專門用來做事件通知用的。使用系統調用
eventfd
創建,這種文件 fd 無法傳輸數據,只用來傳輸事件,常常用於生產消費者模式的事件實現; -
timerfd:這是一種定時器 fd,使用
timerfd_create
創建,到時間點觸發可讀事件;
小結一下:
-
ext2,ext4,xfs 等這種真正的文件系統的 fd ,無法使用 epoll 管理;
-
socket fd,eventfd,timerfd 這些實現了 poll 調用的可以放到 epoll 池進行管理;
其實,在 Linux 的模塊劃分中,eventfd,timerfd,epoll 池都是文件系統的一種模塊實現。
思考
前面我們已經思考了很多知識點,有一些簡單有趣的知識點,提示給讀者朋友,這裏只拋磚引玉。
問題:單核 CPU 能實現並行嗎?
不行。
問題:單線程能實現高併發嗎?
可以。
問題:那併發和並行的區別是?
一個看的是時間段內的執行情況,一個看的是時間時刻的執行情況。
問題:單線程如何做到高併發?
IO 多路複用唄,今天講的 epoll 池就是了。
問題:單線程實現併發的有開源的例子嗎?
redis,nginx 都是非常好的學習例子。當然還有我們 Golang 的 runtime 實現也盡顯高併發的設計思想。
總結
-
IO 多路複用的原始實現很簡單,就是一個 1 對多的服務模式,一個 loop 對應處理多個 fd ;
-
IO 多路複用想要做到真正的高效,必須要內核機制提供。因爲 IO 的處理和完成是在內核,如果內核不幫忙,用戶態的程序根本無法精確的抓到處理時機;
-
fd 記得要設置成非阻塞的哦,切記;
-
epoll 池通過高效的內部管理結構,並且結合操作系統提供的 poll 事件註冊機制,實現了高效的 fd 事件管理,爲高併發的 IO 處理提供了前提條件;
-
epoll 全名 eventpoll,在 Linux 內核下以一個文件系統模塊的形式實現,所以有人常說 epoll 其實本身就是文件系統也是對的;
-
socketfd,eventfd,timerfd 這三種” 文件 “fd 實現了 poll 接口,所以網絡 fd,事件 fd,定時器 fd 都可以使用 epoll_ctl 註冊到池子裏。我們最常見的就是網絡 fd 的多路複用;
-
**ext2,ext4,xfs 這種真正意義的文件系統反倒沒有提供 poll 接口實現,所以不能用 epoll 池來管理其句柄。**那文件就無法使用 epoll 機制了嗎?不是的,有一個庫叫做 libaio ,通過這個庫我們可以間接的讓文件使用 epoll 通知事件,以後詳說,此處不表;
後記
epoll 池使用很簡潔,但實現不簡單。還是那句話,Linux 內核幫你包圓了。今天並沒有羅列太多源碼實現,以很小的思考點爲題展開,簡單講了一些 epoll 的思考,以後有機會可以分享下異步 IO( aio )和 epoll 能產生什麼火花?Go 是怎樣使用 epoll 池的 ?敬請期待哦。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/P1UKE5jrBhGnIRI_-Aarrg