深入分析select&poll&epoll技術原理


深入分析select&poll&epoll技術原理

首先,我們要了解IO複用模型之前,先要了解在Linux內核中socket事件機制在內核底層是基於什麼機制實現的,它是如何工作的,其次,當我們對socket事件機制有了一個基本認知之後,那麼我們就需要思考到底什麼是IO複用,基於socket事件機制的IO複用是怎麼實現的,然後我們才來瞭解IO複用具體的實現技術,透過本質看select/poll/epoll的技術優化,逐漸去理解其中是為了解決什麼問題而出現的,最後本文將圍繞上述思維導圖列出的知識點進行分享,還有就是文章幅度較長且需要思考,需要認真閱讀!


Linux內核事件機制

在Linux內核中存在著等待隊列的數據結構,該數據結構是基於雙端鏈表實現,Linux內核通過將阻塞的進程任務添加到等待隊列中,而進程任務被喚醒則是在隊列輪詢遍歷檢測是否處於就緒狀態,如果是那麼會在等待隊列中刪除等待節點並通過節點上的回調函數進行通知然後加入到cpu就緒隊列中等待cpu調度執行.其具體流程主要包含以下兩個處理邏輯,即休眠邏輯以及喚醒邏輯.

休眠邏輯

  • linux 內核休眠邏輯核心代碼
<code>// 其中cmd = schedule(), 即一個調用schedule函數的指針
#define ___wait_event(wq_head, condition, state, exclusive, ret, cmd)
({\t\t\t\t\t\t\t\t\t\t
\t__label__ __out;\t\t\t\t\t\t\t
\tstruct wait_queue_entry __wq_entry;\t\t\t\t\t
\tlong __ret = ret;\t/* explicit shadow */\t\t\t\t
\t// 初始化過程(內部代碼這裡省略,直接說明)
\t// 1. 設置獨佔標誌到當前節點entry
\t// 2. 將當前任務task指向節點的private
\t// 3. 同時為當前entry節點傳遞一個喚醒的回調函數autoremove_wake_function,一旦喚醒將會自動被刪除
\tinit_wait_entry(&__wq_entry, exclusive ? WQ_FLAG_EXCLUSIVE : 0);\t
\tfor (;;) {
\t\t// 防止隊列中沒有entry產生不斷的輪詢,主要處理wait_queue與entry節點添加或者刪除
\t\tlong __int = prepare_to_wait_event(&wq_head, &__wq_entry, state);
\t\t// 事件輪詢檢查是否事件有被喚醒\t\t\t\t\t\t\t
\t\tif (condition)\t\t\t\t\t\t\t
\t\t break;\t\t\t\t\t\t\t
\t\t\t\t\t\t\t\t\t\t
\t\tif (___wait_is_interruptible(state) && __int) {\t\t\t
\t\t\t__ret = __int;\t\t\t\t\t\t
\t\t\tgoto __out;\t\t\t\t\t\t
\t\t}\t\t\t\t\t\t\t\t
\t\t
\t\t// 調用schedule()方法\t\t\t\t\t\t
\t\tcmd;\t\t\t\t\t\t\t\t
\t}
\t// 事件被喚醒,將當前的entry從隊列中移除\t\t\t\t
\tfinish_wait(&wq_head, &__wq_entry);\t\t\t\t\t
__out:\t__ret;\t\t\t\t\t\t\t\t\t
})/<code>
  • 對此,我們可以總結如下:
    • 在linux內核中某一個進程任務task執行需要等待某個條件condition被觸發執行之前,首先會在內核中創建一個等待節點entry,然後初始化entry相關屬性信息,其中將進程任務存放在entry節點並同時存儲一個wake_callback函數並掛起當前進程
    • 其次不斷輪詢檢查當前進程任務task執行的condition是否滿足,如果不滿足則調用schedule()進入休眠狀態
    • 最後如果滿足condition的話,就會將entry從隊列中移除,也就是說這個時候事件已經被喚醒,進程處於就緒狀態

喚醒邏輯

  • linux內核的喚醒核心代碼
<code>static int __wake_up_common(struct wait_queue_head *wq_head, unsigned int mode,
\t\t\tint nr_exclusive, int wake_flags, void *key,
\t\t\twait_queue_entry_t *bookmark)
{
\t// 省略其他非核心代碼...
\t// 循環遍歷整個等待隊列
\tlist_for_each_entry_safe_from(curr, next, &wq_head->head, entry) {
\t\tunsigned flags = curr->flags;
\t\tint ret;
\t\tif (flags & WQ_FLAG_BOOKMARK)
\t\t\tcontinue;
\t\t//執行回調函數
\t\tret = curr->func(curr, mode, wake_flags, key);

\t\tif (ret < 0)
\t\t\tbreak;
\t\t
\t\t// 檢查當前節點是否為獨佔節點,即互斥鎖,只能執行一個task,因此需要退出循環
\t\tif (ret && (flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive)
\t\t\tbreak;
\t\tif (bookmark && (++cnt > WAITQUEUE_WALK_BREAK_CNT) &&
\t\t\t\t(&next->entry != &wq_head->head)) {
\t\t\tbookmark->flags = WQ_FLAG_BOOKMARK;
\t\t\tlist_add_tail(&bookmark->entry, &next->entry);
\t\t\tbreak;
\t\t}
\t}
\treturn nr_exclusive;
}
struct wait_queue_entry {
\tunsigned int\t\tflags;
\tvoid\t\t\t*private;
\t// 這裡的func就是上述休眠的時候在init_wait_entry傳遞autoremove_wake_function
\twait_queue_func_t\tfunc;\t
\tstruct list_head\tentry;
};
// 喚醒函數
int autoremove_wake_function(struct wait_queue_entry *wq_entry, unsigned mode, int sync, void *key)
{
\t// 公用的喚醒函數邏輯
\t// 內部執行try_to_wake_up, 也就是將wq_entry的private(當前進程)添加到cpu的執行隊列中,讓cpu能夠調度task執行
\tint ret = default_wake_function(wq_entry, mode, sync, key);
\t
\t// 其他為當前喚醒函數私有邏輯
\tif (ret)
\t\tlist_del_init(&wq_entry->entry);
\treturn ret;
}
EXPORT_SYMBOL(autoremove_wake_function);/<code>
  • 對此,基於上述的喚醒邏輯可以總結如下:
    • 在等待隊列中循環遍歷所有的entry節點,並執行回調函數,直到當前entry為排他節點的時候退出循環遍歷
    • 執行的回調函數中,存在私有邏輯與公用邏輯,類似模板方法設計模式
    • 對於default_wake_function的喚醒回調函數主要是將entry的進程任務task添加到cpu就緒隊列中等待cpu調度執行任務task

至此,linux內核的休眠與喚醒機制有了上述認知之後,接下來揭開IO複用模型設計的本質就相對會比較容易理解


IO複用模型本質

深入分析select&poll&epoll技術原理

在講述IO複用模型之前,我們先簡單回顧下IO複用模型的思路,從上述的IO複用模型圖看出,一個進程可以處理N個socket描述符的操作,等待對應的socket為可讀的時候就會執行對應的read_process處理邏輯,也就是說這個時候我們站在read_process的角度去考慮,我只需要關注socket是不是可讀狀態,如果不可讀那麼我就休眠,如果可讀你要通知我,這個時候我再調用recvfrom去讀取數據就不會因內核沒有準備數據處於等待,這個時候只需要等待內核將數據複製到用戶空間的緩衝區中就可以了.那麼對於read_process而言,要實現複用該如何設計才能達到上述的效果呢?

複用本質

  • 摘錄電子通信工程中術語,“在一個通信頻道中傳遞多個信號的技術”, 可簡單理解: 為了提升設備使用效率,儘可能使用最少的設備資源傳遞更多信號的技術
  • 回到上述的IO複用模型,也就是說這裡複用是實現一個進程處理任務能夠接收N個socket並對這N個socket進行操作的技術

複用設計原理

在上述的IO複用模型中一個進程要處理N個scoket事件,也會對應著N個read_process,但是這裡的read_process都是向內核發起讀取操作的處理邏輯,它是屬於進程程序中的一段子程序,換言之這裡是實現read_process的複用,即N個socket中只要滿足有不少於一個socket事件是具備可讀狀態,read_process都能夠被觸發執行,聯想到Linux內核中的sleep & wakeup機制,read_process的複用是可以實現的,這裡的socket描述符可讀在Linux內核稱為事件,其設計實現的邏輯圖如下所示:

深入分析select&poll&epoll技術原理

  • 用戶進程向內核發起select函數的調用,並攜帶socket描述符集合從用戶空間複製到內核空間,由內核對socket集合進行可讀狀態的監控.
  • 其次當前內核沒有數據可達的時候,將註冊的socket集合分別以entry節點的方式添加到鏈表結構的等待隊列中等待數據報可達.
  • 這個時候網卡設備接收到網絡發起的數據請求數據,內核接收到數據報,就會通過輪詢喚醒的方式(內核並不知道是哪個socket可讀)逐個進行喚醒通知,直到當前socket描述符有可讀狀態的時候就退出輪詢然後從等待隊列移除對應的socket節點entry,並且這個時候內核將會更新fd集合中的描述符的狀態,以便於用戶進程知道是哪些socket是具備可讀性從而方便後續進行數據讀取操作
  • 同時在輪詢喚醒的過程中,如果有對應的socket描述符是可讀的,那麼此時會將read_process加入到cpu就緒隊列中,讓cpu能夠調度執行read_process任務
  • 最後是用戶進程調用select函數返回成功,此時用戶進程會在socket描述符結合中進行輪詢遍歷具備可讀的socket,此時也就意味著數據此時在內核已經準備就緒,用戶進程可以向內核發起數據讀取操作,也就是執行上述的read_process任務操作


IO複用模型實現技術

基於上述IO複用模型實現的認知,對於IO複用模型實現的技術select/poll/epoll也應具備上述兩個核心的邏輯,即等待邏輯以及喚醒邏輯,對此用偽代碼來還原select/poll/epoll的設計原理.注意這裡文章不過多關注使用細節,只關注偽代碼實現的邏輯思路.

select/poll/epoll的等待邏輯偽代碼

<code>for(;;){
res = 0;    
for(i=0; i<maxfds> ·// 檢測當前fd是否就緒       
if(fd[i].poll()){
          // 更新事件狀態,讓用戶進程知道當前socket狀態是可讀狀態
           fd_sock.event |= POLLIN;
\t\tres++;
       }

    }
    if(res | tiemout | expr){        
break;    
}
schdule();
}/<maxfds>/<code>

select/poll/epoll的喚醒邏輯偽代碼

<code>foreach(entry as waiter_queues){
    // 喚醒通知並將任務task加入cpu就緒隊列中
res = callback();

// 說明當前節點為獨佔節點,只能喚醒一次,因此需要退出循環
if(res && current == EXCLUSIVE){
break;
}

}/<code>

select技術分析

select函數定義

<code>int select(int maxfd1,\t\t\t// 最大文件描述符個數,傳輸的時候需要+1
\t\t fd_set *readset,\t// 讀描述符集合
\t\t fd_set *writeset,\t// 寫描述符集合
\t\t fd_set *exceptset,\t// 異常描述符集合
\t\t const struct timeval *timeout);// 超時時間

// 現在很多Liunx版本使用pselect函數,最新版本(5.6.2)的select已經棄用
// 其定義如下
int pselect(int maxfd1,\t\t        // 最大文件描述符個數,傳輸的時候需要+1
\t\t fd_set *readset,\t// 讀描述符集合
\t\t fd_set *writeset,\t// 寫描述符集合
\t\t fd_set *exceptset,\t// 異常描述符集合

\t\t const struct timespec *timeout,    // 超時時間
\t\t const struct sigset_t *sigmask); // 信號掩碼指針\t\t/<code>

select技術等待邏輯

<code>// 基於POSIX協議
// posix_type.h
#define __FD_SETSIZE\t1024 // 最大文件描述符為1024
// 這裡只關注socket可讀狀態,以下主要是休眠邏輯
static int do_select(int n, fd_set_bits *fds, struct timespec64 *end_time)
{
\tstruct poll_wqueues table;
\tpoll_table *wait;
\t
\t// ...
\t// 與上述休眠邏輯初始化等待節點操作類似(查看下面的喚醒邏輯)
\tpoll_initwait(&table);\t
\twait = &table.pt;// 獲取創建之後的等待節點
\t
\trcu_read_lock();
\tretval = max_select_fd(n, fds);
\trcu_read_unlock();
\tn = retval;
\t
\t// ...
\t// 操作返回值
\tretval = 0;
\tfor (;;) {
\t\t//...
\t\t// 監控可讀的描述符socket
\t\tinp = fds->in;
\t\tfor (i = 0; i < n; ++rinp, ++routp, ++rexp) {
\t\t\tbit = 1;
\t\t\t// BITS_PER_LONG若處理器為32bit則BITS_PER_LONG=32,否則BITS_PER_LONG=64;
\t\t\tfor (j = 0; j < BITS_PER_LONG; ++j, ++i, bit <<= 1) {
\t\t\t\tf = fdget(i);
\t\t\t\twait_key_set(wait, in, out, bit,
\t\t\t\t\t\t busy_flag);
\t\t\t\t// 檢測當前等待節點是否可讀

\t\t\t\tmask = vfs_poll(f.file, wait);
\t\t\t\tfdput(f);
\t\t\t\tif ((mask & POLLIN_SET) && (in & bit)) {
\t\t\t\t\tres_in |= bit;
\t\t\t\t\tretval++;
\t\t\t\t\twait->_qproc = NULL;
\t\t\t\t}
\t\t\t\t// ...
\t\t\t\t
\t\t\t}
\t\t}
\t\t// 說明有存在可讀節點退出節點遍歷
\t\tif (retval || timed_out || signal_pending(current))
\t\t\tbreak;
\t\t// ...
\t\t// 調度帶有超時事件的schedule
\t\tif (!poll_schedule_timeout(&table, TASK_INTERRUPTIBLE,
\t\t\t\t\t to, slack))
\t\t\ttimed_out = 1;
\t}
\t// 移除隊列中的等待節點
\tpoll_freewait(&table);
}/<code>

select技術喚醒邏輯

<code>// 在poll_initwait ->  __pollwait --> pollwake 的方法,主要關注pollwake方法
static int __pollwake(wait_queue_entry_t *wait, unsigned mode, int sync, void *key)
{
\tstruct poll_wqueues *pwq = wait->private;
\tDECLARE_WAITQUEUE(dummy_wait, pwq->polling_task);
\tsmp_wmb();\t// 內存屏障,保證數據可見性
\tpwq->triggered = 1;
\t
\t// 與linux內核中的喚醒機制一樣,下面的方法是內核執行的,不過多關心,有興趣可以看源碼core.c下面定義
\t// 就是polling_task也就是read_process添加到cpu就緒隊列中,讓cpu能夠進行調度
\treturn default_wake_function(&dummy_wait, mode, sync, key);
} /<code>

select技術小結

  • select技術的實現也是基於Linux內核的等待與喚醒機制實現,對於等待與喚醒邏輯主要細節也在上文中講述,這裡不再闡述
  • 其次可以通過源碼知道,在Linux中基於POSIX協議定義的select技術最大可支持的描述符個數為1024個,顯然對於互聯網的高併發連接應用是遠遠不夠的,雖然現代操作系統支持更多的描述符,但是對於select技術增加描述符的話,需要更改POSIX協議中描述符個數的定義,但是此時需要重新編譯內核,不切實際工作.
  • 另外一個是用戶進程調用select的時候需要將一整個fd集合的大塊內存從用戶空間拷貝到內核中,期間用戶空間與內核空間來回切換開銷非常大,再加上調用select的頻率本身非常頻繁,這樣導致高頻率調用且大內存數據的拷貝,嚴重影響性能
  • 最後喚醒邏輯的處理,select技術在等待過程如果監控到至少有一個socket事件是可讀的時候將會喚醒整個等待隊列,告知當前等待隊列中有存在就緒事件的socket,但是具體是哪個socket不知道,必須通過輪詢的方式逐個遍歷進行回調通知,也就是喚醒邏輯輪詢節點包含了就緒和等待通知的socket事件,如果每次只有一個socket事件可讀,那麼每次輪詢遍歷的事件複雜度是O(n),影響到性能

poll技術分析

poll技術與select技術實現邏輯基本一致,重要區別在於其使用鏈表的方式存儲描述符fd,不受數組大小影響,對此,現對poll技術進行分析如下:

poll函數定義

<code>// poll已經被棄用
int poll(struct pollfd *fds, \t        // fd的文件集合改成自定義結構體,不再是數組的方式,不受限於FD_SIZE
\t\t unsigned long nfds,     // 最大描述符個數
\t\t\t\tint timeout);// 超時時間
struct pollfd {
\tint fd;\t\t\t// fd索引值
\tshort events;\t\t// 輸入事件
\tshort revents;\t\t// 結果輸出事件
};
// 當前查看的linux版本(5.6.2)使用ppoll方式,與pselect差不多,其他細節不多關注
int ppoll(struct pollfd *fds, \t                // fd的文件集合改成自定義結構體,不再是數組的方式,不受限於FD_SIZE
\t\t unsigned long nfds, \t     // 最大描述符個數
\t\t struct timespec timeout,        // 超時時間,與pselect一樣
\t\t const struct sigset_t sigmask,\t // 信號指針掩碼
\t\t struct size_t sigsetsize);\t // 信號大小/<code>

poll部分源碼實現

<code>// 關於poll與select實現的機制差不多,因此不過多貼代碼,只簡單列出核心點即可
static int do_sys_poll(struct pollfd __user *ufds, unsigned int nfds,

\t\tstruct timespec64 *end_time)
{
\t// ...
\tfor (;;) {
\t\t// ...
\t\t// 從用戶空間將fdset拷貝到內核中
\t\tif (copy_from_user(walk->entries, ufds + nfds-todo,
\t\t\t\t\tsizeof(struct pollfd) * walk->len))
\t\t\tgoto out_fds;
\t\t// ...
\t\t// 和select一樣,初始化等待節點的操作
\t\tpoll_initwait(&table);
\t\t// do_poll的處理邏輯與do_select邏輯基本一致,只是這裡用鏈表的方式遍歷,do_select用數組的方式
\t\t// 鏈表可以無限增加節點,數組有指定大小,受到FD_SIZE的限制
\t\tfdcount = do_poll(head, &table, end_time);
\t\t// 從等待隊列移除等待節點
\t\tpoll_freewait(&table);
\t}
}/<code>

poll技術小結

poll技術使用鏈表結構的方式來存儲fdset的集合,相比select而言,鏈表不受限於FD_SIZE的個數限制,但是對於select存在的性能並沒有解決,即一個是存在大內存數據拷貝的問題,一個是輪詢遍歷整個等待隊列的每個節點並逐個通過回調函數來實現讀取任務的喚醒


epoll技術分析

為了解決select&poll技術存在的兩個性能問題,對於大內存數據拷貝問題,epoll通過epoll_create函數創建epoll空間(相當於一個容器管理),在內核中只存儲一份數據來維護N個socket事件的變化,通過epoll_ctl函數來實現對socket事件的增刪改操作,並且在內核底層通過利用虛擬內存的管理方式保證用戶空間與內核空間對該內存是具備可見性,直接通過指針引用的方式進行操作,避免了大內存數據的拷貝導致的空間切換性能問題,對於輪詢等待事件通過epoll_wait的方式來實現對socket事件的監聽,將不斷輪詢等待高頻事件wait與低頻socket註冊事件兩個操作分離開,同時會對監聽就緒的socket事件添加到就緒隊列中,也就保證喚醒輪詢的事件都是具備可讀的,現對epoll技術分析如下:

epoll技術定義

<code>// 創建保存epoll文件描述符的空間,該空間也稱為“epoll例程”
int epoll_create(int size);    // 使用鏈表,現在已經棄用
int epoll_create(int flag);    // 使用紅黑樹的數據結構

// epoll註冊/修改/刪除 fd的操作
long epoll_ctl(int epfd, // 上述epoll空間的fd索引值
int op, // 操作識別,EPOLL_CTL_ADD | EPOLL_CTL_MOD | EPOLL_CTL_DEL
int fd, // 註冊的fd
struct epoll_event *event); // epoll監聽事件的變化
struct epoll_event {
\t__poll_t events;
\t__u64 data;
} EPOLL_PACKED;
// epoll等待,與select/poll的邏輯一致
epoll_wait(int epfd, // epoll空間
           struct epoll_event *events, // epoll監聽事件的變化
           int maxevents, // epoll可以保存的最大事件數
        int timeout); // 超時時間/<code>

epoll技術實現細節

  • epoll_ctl函數處理socket描述符fd註冊問題,關注epoll_ctl的ADD方法
<code>// 摘取核心代碼
int do_epoll_ctl(int epfd, int op, int fd, struct epoll_event *epds,
\t\t bool nonblock)
{
\t// ...
\t// 在紅黑樹中查找存儲file對應的epitem,添加的時候會將epitem加到紅黑樹節點中

\tepi = ep_find(ep, tf.file, fd);
\t
\t// 對於EPOLL_CTL_ADD模式,使用mtx加鎖添加到wakeup隊列中
\tswitch (op) {
\tcase EPOLL_CTL_ADD:
\t // fd註冊操作
\t\t// epds->events |= EPOLLERR | EPOLLHUP;
\t\t// error = ep_insert(ep, epds, tf.file, fd, full_check);
\t\tbreak;
\tcase EPOLL_CTL_DEL:
\t // // 刪除操作:存儲epitem容器移除epitem信息
\t\tbreak;
\t// 對註冊的fd進行修改,但epoll的模式為EPOLLEXCLUSIVE是無法進行操作的
\tcase EPOLL_CTL_MOD:
\t // 修改操作,內核監聽到事件變化執行修改
            //error = ep_modify(ep, epi, epds);\t\t\t
\t\tbreak;
\t}
\t
\t// 釋放資源邏輯
}/<code>
  • EPOLL_CTL_ADD核心代碼邏輯
<code>// 添加邏輯
static int ep_insert(struct eventpoll *ep, const struct epoll_event *event,
\t\t struct file *tfile, int fd, int full_check)
{
// ...
\tstruct epitem *epi;
\tstruct ep_pqueue epq;
\t
\t// 將fd包裝在epitem的epollfile中
\tepi->ep = ep;
\tep_set_ffd(&epi->ffd, tfile, fd);
\tepi->event = *event;
\tepi->nwait = 0;
\tepi->next = EP_UNACTIVE_PTR;
\t

\t// 如果當前監聽到事件變化,那麼創建wakeup執行的source
\tif (epi->event.events & EPOLLWAKEUP) {
\t\terror = ep_create_wakeup_source(epi);
\t\tif (error)
\t\t\tgoto error_create_wakeup_source;
\t} else {
\t\tRCU_INIT_POINTER(epi->ws, NULL);
\t}
\t// 添加回調函數
\tepq.epi = epi;
\tinit_poll_funcptr(&epq.pt, ep_ptable_queue_proc);
// 輪詢檢測epitem中的事件
\trevents = ep_item_poll(epi, &epq.pt, 1);
// 將epitem插入到紅黑樹中\t
\tep_rbtree_insert(ep, epi);
\t// 如果有ready_list 則執行喚醒邏輯wakeup,這個是linux內核的喚醒機制,會將read_process添加到就緒隊列中讓cpu調度執行
\tif (revents && !ep_is_linked(epi)) {
\t\tlist_add_tail(&epi->rdllink, &ep->rdllist);
\t\tep_pm_stay_awake(epi);
\t\t/* Notify waiting tasks that events are available */
\t\tif (waitqueue_active(&ep->wq))
\t\t\twake_up(&ep->wq);
\t\tif (waitqueue_active(&ep->poll_wait))
\t\t\tpwake++;
\t}
// ....\t
\t// 存在預喚醒,則喚醒輪詢等待節點
\tif (pwake)
\t    ep_poll_safewake(&ep->poll_wait);
\treturn 0;
// goto statement code ...
}/<code>

上述代碼中存在註冊和喚醒邏輯,即對應處理邏輯都在這兩個方法ep_ptable_queue_proc & ep_item_poll,現通過流程圖如下所示:

深入分析select&poll&epoll技術原理

在上述的epoll_ctl技術代碼實現的細節中存在著兩個邏輯,即socket描述符註冊與喚醒邏輯,主要體現在兩個核心方法上,即ep_ptable_queue_proc & ep_item_poll對此分析如下:

  • 註冊邏輯:
  • 1) 在epoll空間中創建一個epitem的中間層,初始化一系列epitem的屬性,將新增加的socket描述符包裝到epitem下的epoll_filefd中,同時添加喚醒任務wakeup,並將epitem的內部ep容器指向epoll空間2) 其次在進行item事件的輪詢中,通過隊列回調的方式將epitem綁定到隊列節點entry上,同時將entry節點添加到epoll空間的等待隊列中,並在entry節點上綁定epoll的回調函數來喚醒業務處理3) 最後是將epitem插入以epoll空間為根節點的紅黑數中,後續內核可以通過fd查找到對應的epitem,通過epitem也就可以找到其容器epoll空間的引用
  • 喚醒邏輯:
  • 1) 在item事件輪詢中,通過輪詢檢測epoll空間中的等待隊列是否有對應的節點entry可讀,如果有退出循環,並且從當前socket描述符對應的中間層epitem開始輪詢遍歷查詢就緒的entry節點並將就緒entry節點的socket描述符添加到ready_list上2) 其次在上述註冊的邏輯之後,會檢查當前的epitem的ready list節點是否存在,如果存在ready_list,會將epoll空間的等待隊列喚醒,讓執行處理的read_process添加到就緒隊列中,讓cpu能夠進行調度
  • epoll_wait等待邏輯
<code>// epoll_wait -> do_epoll_wait -> ep_poll, 我們關注核心方法ep_poll
static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events,
\t\t int maxevents, long timeout)
{
\t// ...
\tfetch_events: // 檢測epoll是否有事件就緒
\t\t// ...
\t\tfor (;;) {
\t\t// ...
\t\t// 檢測當前ep空間是否有fd事件就緒
\t\teavail = ep_events_available(ep);
\t\tif (eavail)
\t\t\t// 是的跳出循環
\t\t\tbreak;
\t\tif (signal_pending(current)) {
\t\t\tres = -EINTR;
\t\t\tbreak;
\t\t}
\t\t// 執行休眠方法 schedule()
\t\tif (!schedule_hrtimeout_range(to, slack, HRTIMER_MODE_ABS)) {
\t\t\ttimed_out = 1;
\t\t\tbreak;
\t\t}
\t}
\t// ...
\tsend_events: // ep有事件就緒,將event_poll轉換到用戶空間中
\t\t//...
\t\tep_send_events(ep, events, maxevents);
\t
}/<code>
  • 從上述可以看出等待處理邏輯主要有fetch_event以及send_events,現分析如下:
  • 1) 循環檢查當前epoll空間是否有就緒事件,如果有將跳出循環,如果沒有將執行schedule的方法進入休眠等待再次輪詢,原理與select/poll一致2) 其次當有就緒事件的時候,循環遍歷將監聽變化的事件拷貝到用戶空間中,並且會將就緒事件socket添加到epitem的就緒隊列ready_list上

epoll技術解決的問題

  • 解決大內存且頻繁copy問題
  • 1) 首先,epoll通過epoll_create創建epoll空間,在創建epoll空間的同時將其從用戶空間拷貝到內核中,此後epoll對socket描述的註冊監聽通過epoll空間來進行操作,僅一次拷貝2) 其次,epoll註冊將拆分為ADD/MOD/DEL三個操作,分別只對相應的操作進行處理,大大降低頻繁調用的次數,相比select/poll機制,由原先高頻率的註冊等待轉換為高頻等待,低頻註冊的處理邏輯3) 接著,就是每次註冊都通過建立一個epitem結構體對socket相關的fd以及file進行封裝,並且epitem的ep容器通過指針引用指向epoll空間,即每次新增加一個socket描述符的時候而是通過單個epitem進行操作,相比fdset較為輕量級4) 最後,epoll在內核中通過虛擬內存方式將內核空間與用戶空間的一塊地址同時映射到相同的物理內存地址中,這塊內存對用戶空間以及內核空間均為可見,因此可以減少用戶空間與內核空間之間的數據交換
  • 解決只對就緒隊列進行喚醒循環遍歷
  • 1) 首先,我們可以看到在註冊的過程中,epoll通過epitem將socket描述符存儲到epoll_file中,同時將喚醒邏輯read_process也綁定到epitem,這樣當處於喚醒狀態就會被觸發執行,然後將當前epitem存儲到隊列entry節點上,並且entry節點綁定回調函數,最後將entry節點添加到epoll空間的的等待隊列上,而對於select/poll技術實現,會對整個fdset設置對應的callback以及wakeup,epoll則是直接將綁定在wakeup綁定在epitem,並有對應監控socket事件的時候才會創建entry節點並綁定對應的callback函數2) 其次,在進行wiat等待過程中,內核在執行file.poll()後會將等待隊列上的節點添加到輪詢等待中poll wait,處於半喚醒狀態,也就是當前是就緒狀態但還沒喚醒,同時會將喚醒的socket描述符添加到epoll空間的ready list中3) 接著,每當有一個item被喚醒的時候就會退出上述的輪詢遍歷並保持當前的item處於喚醒狀態,然後epoll空間開始遍歷item(單鏈表存儲)並執行回調函數通知,如果item為就緒狀態,就將epoll空間的就緒隊列ready list拷貝到當前喚醒節點的epitem的ready list中4) 最後,會更新監聽變化的事件狀態,返回到用戶進程,用戶進程這個時候獲取到ready list中的描述符均為可就緒狀態
  • epoll其他技術
  • 1) epoll支持併發執行,上述的休眠與喚醒邏輯都有加鎖操作2) 其次對於就緒狀態的ready_list是屬於無鎖操作,因此為了保證執行併發的安全性在epoll對ready_list進行操作的時候會通過全局加鎖的方式完成

epoll技術的邊緣觸發與水平觸發

  • 水平觸發
  • 1) socket接收數據的緩衝區不為空的時候,則一直觸發讀事件,相當於"不斷地詢問是否有數據可讀"2) socket發送數據的緩衝區不全滿的時候,則一直觸發寫事件,相當於"不斷地詢問是否有空閒區域可以讓數據寫入" 本質上就是一個不斷進行交流的過程, 水平觸發如下圖所示:
  • 邊緣觸發
  • 1) socket接收數據的緩衝區發生變化,則觸發讀取事件,也就是當空的接收數據的socket緩衝區這個時候有數據傳送過來的時候觸發2) socket發送數據的緩衝區發生變化,則觸發寫入事件,也就是當滿的發送數據的socket緩衝區這個時候剛刷新數據初期的時候觸發 本質上就是socket緩衝區變化而觸發,邊緣觸發如下圖所示:
  • 上述的觸發事件會調用epoll_wait方法,也就是
  • 1) 水平觸發會多次調用epoll_wait2) 邊緣觸發在socket緩衝區中不發生改變那麼就不會調用epoll_wait的方式

水平觸發與邊緣觸發代碼實現方式

  • 水平觸發:遍歷epoll下的等待隊列的每個entry,喚醒entry節點之後從ready_list移除當前socket事件,然後再輪詢當前item收集可用的事件,最後添加到ready_list以便於調用epoll_wait的時候能夠檢查到socket事件可用
  • 邊緣觸發:遍歷epoll下的等待隊列的每個entry,喚醒entry節點之後從ready_list移除當前socket事件,再輪詢當前item收集可用的事件然後喚醒執行的業務處理read_process


你好,我是疾風先生,先後從事外企和互聯網大廠的java和python工作, 記錄並分享個人技術棧,歡迎關注我的公眾號,致力於做一個有深度,有廣度,有故事的工程師,歡迎成長的路上有你陪伴,關注後回覆greek可添加私人微信,歡迎技術互動和交流,謝謝!

深入分析select&poll&epoll技術原理

老鐵們關注走一走,不迷路


分享到:


相關文章: