深入理解 Linux 的 epoll 機制

在 Linux 系統之中有一個核心武器:epoll 池,在高併發的,高吞吐的 IO 系統中常常見到 epoll 的身影。

IO 多路複用

在 Go 裏最核心的是 Goroutine ,也就是所謂的協程,協程最妙的一個實現就是異步的代碼長的跟同步代碼一樣。比如在 Go 中,網絡 IO 的 readwrite 看似都是同步代碼,其實底下都是異步調用,一般流程是:

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 種工具 selectpollepoll

爲什麼有 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

就這?是的,就這麼簡單。

 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);
    // ....
};

你看到了 readwriteopenfsyncpoll 等等,這些都是對文件的定製處理操作,對於文件的操作其實都是在這個框架內實現邏輯而已,比如 ext2 如果有對 read/write 做定製化,那麼就會是 ext2_readext2_write,ext4 就會是 ext4_readext4_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,這個函數其實很簡單,做兩件事情:

  1. 把事件就緒的 fd 對應的結構體放到一個特定的隊列(就緒隊列,ready list);

  2. 喚醒 epoll ,活來啦!

當 fd 滿足可讀可寫的時候就會經過層層回調,最終調用到這個回調函數,把對應 fd 的結構體放入就緒隊列中,從而把 epoll 從 epoll_wait 出喚醒。

這個對應結構體是什麼?

結構體叫做 epitem ,每個註冊到 epoll 池的 fd 都會對應一個。

就緒隊列需要用很高級的數據結構嗎?

就緒隊列就簡單了,因爲沒有查找的需求了呀,只要是在就緒隊列中的 epitem ,都是事件就緒的,必須處理的。所以就緒隊列就是一個最簡單的雙指針鏈表

小結下:epoll 之所以做到了高效,最關鍵的兩點:

  1. 內部管理 fd 使用了高效的紅黑樹結構管理,做到了增刪改之後性能的優化和平衡;

  2. epoll 池添加 fd 的時候,調用 file_operations->poll ,把這個 fd 就緒之後的回調路徑安排好。通過事件通知的形式,做到最高效的運行;

  3. 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 池裏。

小結一下

  1. ext2,ext4,xfs 等這種真正的文件系統的 fd ,無法使用 epoll 管理;

  2. socket fd,eventfd,timerfd 這些實現了 poll 調用的可以放到 epoll 池進行管理;

其實,在 Linux 的模塊劃分中,eventfd,timerfd,epoll 池都是文件系統的一種模塊實現。

思考

前面我們已經思考了很多知識點,有一些簡單有趣的知識點,提示給讀者朋友,這裏只拋磚引玉。

問題:單核 CPU 能實現並行嗎?

不行。

問題:單線程能實現高併發嗎?

可以。

問題:那併發和並行的區別是?

一個看的是時間段內的執行情況,一個看的是時間時刻的執行情況。

問題:單線程如何做到高併發?

IO 多路複用唄,今天講的 epoll 池就是了。

問題:單線程實現併發的有開源的例子嗎?

redis,nginx 都是非常好的學習例子。當然還有我們 Golang 的 runtime 實現也盡顯高併發的設計思想。

總結

  1. IO 多路複用的原始實現很簡單,就是一個 1 對多的服務模式,一個 loop 對應處理多個 fd ;

  2. IO 多路複用想要做到真正的高效,必須要內核機制提供。因爲 IO 的處理和完成是在內核,如果內核不幫忙,用戶態的程序根本無法精確的抓到處理時機;

  3. fd 記得要設置成非阻塞的哦,切記;

  4. epoll 池通過高效的內部管理結構,並且結合操作系統提供的 poll 事件註冊機制,實現了高效的 fd 事件管理,爲高併發的 IO 處理提供了前提條件;

  5. epoll 全名 eventpoll,在 Linux 內核下以一個文件系統模塊的形式實現,所以有人常說 epoll 其實本身就是文件系統也是對的;

  6. socketfd,eventfd,timerfd 這三種” 文件 “fd 實現了 poll 接口,所以網絡 fd,事件 fd,定時器 fd 都可以使用 epoll_ctl 註冊到池子裏。我們最常見的就是網絡 fd 的多路複用;

  7. **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