深入理解 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 通知事件,以後詳說,此處不表;

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章