20分鐘瞭解Epoll + 聊天室實戰

咱們知道,計算機的硬件資源由操做系統管理、調度,咱們的應用程序運行在操做系統之上,咱們的程序運行須要訪問計算機上的資源(如讀取文件,接收網絡請求),操做系統有內核空間和用戶空間之分,因此數據讀取,先由內核讀取數據到內核緩衝區,而後纔會從操做系統的內核空間拷貝到用戶空間,這個就是緩存I/O,又被稱做標準I/O。html

幾種常見的IO模式:阻塞I/O、非阻塞I/O、I/O多路複用node

一、阻塞I/Olinux

用戶進程向內核發起I/O系統調用,內核去準備所需的數據,直到數據都準備好了(須要一段時間)返回給用戶進程,在這期間,用戶進程一直處於阻塞狀態,拿到所需數據,纔會繼續向下執行。git

二、非阻塞I/Ogithub

用戶進程向內核發起I/O系統調用,內核發現數據還沒準備好,當即返回error,用戶進程拿到error,能夠再次向內核發起請求,直到獲取所需數據web

三、I/O多路複用api

下面詳細介紹數組

1、I/O多路複用

定義:I/O multiplexing allows us to simultaneously monitor multiple file descriptors to see if I/O is possible on any of them.緩存

一、爲何要同時監視多個文件描述符?

傳統的阻塞I/O模型能夠知足大部分的應用程序使用場景,但有的時候,一些應用程序會同時須要以下特性:服務器

  • 檢查文件I/O是否已經ready,若是沒有,不阻塞,直接返回
  • 同時監視多個文件描述符,判斷他們中的任意一個的I/O是否已經ready

好比一個web服務器,可能同時打開了幾千個鏈接,每accept一個鏈接,操做系統產生一個fd(文件描述符),咱們的服務器須要監聽這些文件描述符,當客戶端發來新的數據的時候,咱們須要處理請求數據,並給出響應,正常的實現方式可能以下:

connections = [fd1, fd2, fdn];
for (c in connections) {
    if (hasNewInput(x)) {
        processInput(x);
    }
}

這樣實現的問題是,咱們本身維護着全部的鏈接,須要主動的去輪尋判斷I/O是否已經ready以便進行讀寫,但事實上並非全部鏈接都是活躍狀態(創建的鏈接並無數據交互),若是咱們輪尋的頻率很低,那用戶獲取響應的時間可能長的沒法忍受,若是輪尋的頻率很是的高,則會浪費CPU的時間。

因此若是能夠將主動遍歷全部鏈接,判斷每個的IO狀態,改成將這些文件描述符(fd)交給內核去監視,而後當一個或多個文件描述符IO ready的時候,內核來告訴咱們,這樣效率就大大提升了,所以咱們須要IO多路複用。

二、I/O多路複用的幾種實現

IO多路複用的實現比較廣泛的有兩個:select和poll,相比較poll,select更爲廣泛,最先與BSD socket api一塊兒出現,已被歸入SUSV3標準規範,epoll是隻屬於Linux的特性,最先的API出如今Linux2.6。

名字叫I/O多路複用,所謂的複用,複用的是同一個進程(線程),也就是在同一個進程中「併發「的完成多個文件描述符的I/O。

2.一、select

select系統調用會阻塞,直到一個或多個文件描述符I/O ready

1>定義:

#include <sys/time.h>         /* For portability */ 
#include <sys/select.h> 

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

2>參數:

  • nfds:應設置爲大於等於下面三個fds監聽的文件描述符的總和
  • readfds:須要監聽輸入事件的文件描述符集合
  • writefds:須要監聽輸出事件的文件描述符集合
  • exceptfds:須要被監聽是否有異常狀況發生的文件描述符集合
  • timeout:,阻塞;設置爲0,檢查一遍文件描述符狀態當即返回;設置爲NULL或者非0值,會一直阻塞直到:
    • 三種fds中有至少一個文件描述符進入ready狀態
    • 被信號處理中斷
    • 到達指定的超時時間

3>返回值:

  • 返回-1:產生錯誤
  • 返回0:超時返回了,這期間沒有文件描述符ready
  • 返回正數:返回的數值就是已經ready的文件描述符的數量

4>小示例

#include <stdio.h>
#include <string.h>
#include <sys/time.h> 
#include <sys/select.h>

int main(int argc, char *argv[])
{
    fd_set readfds, writefds;
    int ready, nfds, fd, numRead, j;
    struct timeval timeout;
    struct timeval *pto;
    char buf[10];
    
    //判斷參數是否包含短斜線,包含timeout設置NULL
    if (strcmp(argv[1], "-") == 0) {
        pto = NULL;
    } else {
        pto = &timeout;
        pto.tv_sec = atoi(argv[1]);
        pto.tv_usec = 0;
    }
    //準備監聽文件描述符集合
    nfds = 0;
    //用select 提供的宏初始化fd_set
    FD_ZERO(&readfds);
    FD_ZERO(&writefds);

    for (j = 2; j < argc; j++) {
        numRead = sscanf(argv[j], "%d%2[rw]", &fd, buf);
        if (numRead != 2) {
            printf("error");
            return 0;
        }
        if (fd >= nfds) {
            nfds += 1;
        }
        //判斷是監聽讀仍是寫
        if (strchr(buf, 'r') != NULL) {
            FD_SET(fd, &readfds);
        }
        if (strchr(buf, 'w') != NULL) {
            FD_SET(fd, &writefds);
        }
    }
    //調用select函數
    ready = select(nfds, &readfds, &writefds, NULL, pto);
    if (ready == -1) {
        printf("select error\n");
        return 0;
    }
    for (fd = 0; fd < nfds; fd++) {
        //打印ready 狀態
        printf("%d: %s%s\n", fd, FD_ISSET(fd, &readfds) ? "r" : "", FD_ISSET(fd, &writefds) ? "w" : "");
    }
    return 0;
}

使用文件描述符0 也就是標準輸入測試,首先編譯一下這段代碼gcc -o select select.c

運行./select 10 0r 表明select阻塞最多10秒超時返回,監聽標準輸入的 讀ready狀態,會發現程序處於阻塞狀態,直到10秒後select返回,打印 0:,也就是沒有ready

運行./select - 0r 表明select不會超時直到 標準輸入 讀ready,程序會一直掛起,直到咱們敲回車,返回0:r

運行./select 5 0r 1w 表明最多5秒超時,同時監聽標準輸入的讀狀態,和標準輸出的寫狀態,返回0: 1:w

2.二、poll

poll跟select機制基本同樣,不一樣點在於select將監聽的文件描述符分開到3個集合中,poll只需一個文件描述符列表

1>定義

#include <poll.h> 

int poll(struct pollfd fds[], nfds_t nfds, int timeout);

2>參數

  • struct pollfd fds:文件描述符數組
struct pollfd {     
    int   fd;               /* File descriptor */     
    short events;           /* Requested events bit mask */     
    short revents;          /* Returned events bit mask */ 
};
  • nfds:fds數組中文件描述符的總數
  • timeout:超時時間,傳-1阻塞直到有文件描述符ready,傳0不阻塞,傳>0的值,無論有沒有ready的文件描述符,到指定時間超時返回

3>返回值

  • 返回-1:有錯誤產生
  • 返回0:沒有任何文件描述符ready
  • 返回正數:fds元素revents不爲0的文件描述的個數

三、poll和select存在哪些問題?

一、每次調用poll()或select(),內核必須檢查傳過來的全部文件描述符,當文件描述符超過必定數量後,光是檢查這一步就已經很是耗時了

二、每次調用poll()或select(),須要從用戶態初始化文件描述符的數據結構,而後傳遞到內核態,在內核態檢查IO狀態,然以將狀態更新到文件描述符數據結構,再返回這個數據結構,數據需不斷的在用戶態和內核態間拷貝,一樣當文件描述符數量很大時,很是耗費CPU時間

三、每次調用完poll()或select(),還要遍歷返回的全部文件描述符,判斷狀態,浪費時間

解決以上問題的關鍵是:1)不要每次都在內核態和用戶態複製這些監聽的文件描述符數據 2)只返回IO ready 的文件描述符,不要只標識狀態,本身還須要再遍歷一遍,所以,Linux給了咱們epoll。

2、Epoll

Linux的epoll,也是I/O多路複用的一種實現方式,一樣是同時監聽多個文件描述符的I/O狀態,他有以下幾個優點:

  • 同時監聽的大量文件描述符,效率高於select和epoll
  • epoll api同時提供了水平觸發通知和邊緣觸發通知可選,select和epoll只有水平觸發通知的方式(兩種通知方式後面會講)
  • 對於監聽文件描述的具體事件(讀、寫等,經過bit mask的方式),更爲靈活

不像select和poll直接就是系統調用的函數,epoll由3個系統調用函數組成

一、epoll的3個函數

1.一、epoll_create

經過調用epoll_create,建立一個新的epoll內核數據結構實例,返回該實例的文件描述符,而後,調用進程可使用這個文件描述符向epoll實例添加、刪除或修改它想要監視的其餘文件描述符

#include <sys/epoll.h>
int epoll_create(int size);

1.二、epoll_ctl

進程能夠經過調用epoll_ctl向epoll實例添加它但願監視的文件描述符,全部這些文件描述符由epoll實例維護在interest list中。當被監視的文件描述符爲I/O作好準備時,他們就進入到ready list,ready list是interest list的一個子集

定義:

#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

參數:

  • epfd:epoll_create返回的文件描述符
  • fd:準備加入監視列表(interest list)的文件描述符
  • op:要對這個fd作什麼操做,有3種
    • 添加:EPOLL_CTL_ADD
    • 刪除:EPOLL_CTL_DEL
    • 修改:EPOLL_CTL_MOD
  • event:指向epoll_event結構體的指針,結構體中存儲着咱們實際想要監視fd的哪些事件

epoll_event結構:

struct epoll_event {
  __uint32_t events;  /* Epoll events */
  epoll_data_t data;  /* User data variable */
};

events:
事件的成員是位掩碼,好比fd是一個socket,咱們可能但願監視他,以獲取套接字緩衝區上的新數據(使用EPOLLIN),若是但願事件的通知機制使用邊緣觸發的方式,可使用EPOLLET。也就是讀操做就緒,使用邊緣觸發的通知方式,須要這樣指定events:EPOLLIN | EPOLLET

全部能夠指定的events見http://man7.org/linux/man-pages/man2/epoll_ctl.2.html

data:
epoll_data_t 是一個union,能夠存儲一些數據,當該文件描述符ready的時候,返回給調用監聽的進程

typedef union epoll_data {     
    void *ptr;          /* Pointer to user-defined data */     
    int fd;             /* File descriptor */     
    uint32_t u32;        /* 32-bit integer */     
    uint64_t u64;        /* 64-bit integer */ 
} epoll_data_t;

返回值:返回0執行成功,-1發生錯誤

1.三、epoll_wait

經過調用epoll_wait,能夠獲取以前監聽的全部事件(interest list)中I/O準備好的事件(ready list)

定義:

#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *evlist, int maxevents, int timeout);

參數:

  • epfd:epoll_create建立epoll實例,返回的文件描述符
  • evlist:epoll_event數組,epoll_wait調用會阻塞,直到有文件描述符ready,ready的文件描述符及發生的事件會保存到evlist
  • maxevents:evlist數組的長度
  • timeout:指定epoll_wait將阻塞多長時間
    • 設置爲0,非阻塞模式,檢查interest list是否有ready的文件描述符後當即返回
    • 設置爲-1,永遠的阻塞直到1>interest list中的一個或多個文件描述符就緒 2>被信號處理程序終斷
    • 設置爲正數:永遠的阻塞直到1>interest list中的一個或多個文件描述符就緒 2>被信號處理程序終斷 3> 指定的timeout毫秒到了,過時返回

二、深刻epoll實例

2.一、先弄清文件描述符和打開文件的關係

爲了弄清楚打開文件、文件描述符和epoll之間的關係,咱們先看一下Linux操做系統中文件描述符(file descriptors)、打開文件描述(open file description)、和系統級文件inode表之間的關係,如圖所示:

內核維護了3個數據結構:

  • 每一個進程打開的文件描述符(file descriptor table)
    • fd flags:文件的flag,就是隻讀、只寫等這些flag
    • file ptr:指向文件描述的指針
  • 系統中打開文件的描述(open file table)
    • file offset:當前文件的offset
    • status flags:對應open的flags參數
    • inode ptr:指向i-node對象的指針
  • 操做系統維護文件系統 (i-node table)
    • file type:文件類型
    • file locks:一個指向該文件鎖相關的列表的指針
    • 以及其它各類標識這個文件相關信息

圖中能夠看出:

  • A進程的fd1和fd20這兩個文件描述符的file ptr指向同一個同一個open file description(可能執行了dup系統調用複製了文件描述符)
  • A進程fd2和B進程的fd2的file ptr也指向同一個open file description(B多是A調用系統調用fork出來的子進程)
  • A進程的fd0和B進程的fd3指向不一樣的file description,可是指向的是同一個i-node,也就說打開的是同一個文件

2.二、再看epoll_create

當調用了epoll_create,事實上內核在inode-table建立了一個新的inode(也就是epoll實例),以及在file description table中添加一條打開文件描述記錄,而且返回給調用者一個文件描述符。

接下來咱們就可使用這個文件描述符,向epoll實例中添加須要監聽的文件描述符,當調用epoll_ctl添加一個文件描述符到epoll實例的監聽列表(interest list)的時候,事實上真正添加的不是文件描述符,而是其對應的文件描述(file description table)。基於這一點,結合上面文件描述符和打開文件關係的圖:

假如進程A調用epoll_create,返回文件描述符fd8。

  • 若是B是A fork出來的子進程,那麼會繼承A全部打開的文件描述符,包括fd8,因此進程A和進程B共享interest list,
  • A fork完 B 以後,又打開了一個文件,返回文件描述符fd3,而後調用epoll_ctl添加到interest list中,這時B中並不包括A新打開的文件描述符fd3,可是當fd3有I/O 事件的時候,A或B進程調用epoll_wait,都會收到fd3 IO ready的通知

2.三、epoll爲何性能高於poll和select?

回顧上述poll()和select()存在的問題,能夠看到epoll恰好解決了這些問題:

  • 相比較poll和select,每次調用循環檢查每個文件描述符I/O狀態,時間複雜度O(N),無論N大小,調用一次循環檢查一次;epoll得益於監聽的是文件描述符下一級的打開文件描述,而且每次調用epoll_wait的時候直接返回ready list就能夠了,不須要循環
  • 相比較poll和select,每次調用都須要傳遞監聽文件描述符數據到內核,epoll調用epoll_ctl一次性添加到epoll實例,接下來調用epoll_wait不須要再傳遞這些文件描述符信息過去了
  • 相比較poll()和select()返回全部的文件描述,epoll只返回ready的文件描述符

監聽不一樣數量文件描述符,執行100000次狀態檢查select、poll、epoll性能對比:

三、關於level-trigger 和 edge-trigger

3.一、文件描述符準備就緒通知的兩種模型:

  • 水平觸發:若是能夠在不阻塞的狀況下執行I/O系統調用,則認爲文件描述符已經準備好了,觸發通知
  • 邊緣觸發:文件描述符被監控以來,有新的I/O活動(例如 新的輸入),觸發通知

3.二、不一樣的通知模型如何影響咱們的程序設計?

水平觸發

  • 肯定文件描述符的I/O狀態已經ready
  • 已經ready,對這個文件描述符執行一些I/O操做(好比讀取幾個字節數據),而後繼續監視它的IO狀態
  • 仍然有未讀取的數據,還會繼續觸發通知,也就是說不用一次執行完全部的IO操做(好比一次讀取緩衝區的所有內容)

邊緣觸發:

  • 只有新的IO事件發生時,纔會觸發通知
  • 直到下次IO事件發生,不會再觸發通知

注:對於邊緣觸發,當觸發通知時,咱們並不知道有多少IO可用(例若有多少字節能夠讀),因此通常使用邊緣觸發通知方式要遵循以下規則:

  1. 接收到事件通知後,程序應該儘量多的執行IO(讀寫),由於僅僅通知這一次,不讀取完畢,數據可能就丟失了
  2. 爲了不IO阻塞,每一個被監視的文件描述符應該是非阻塞模式打開的,而後收到事件通知後,重複的執行IO直到返回錯誤信息

接下來的聊天室的實例,咱們使用邊緣觸發的方式

3、Epoll實戰:聊天室

瞭解完epoll的基礎和原理,使用一個精簡版的聊天室的小實例來鞏固學習。

主要思路:一個服務端,能夠接收多個客戶端的鏈接,客戶端發送的消息會同步到全部聊天室內的客戶端

一、服務端設計與實現

第一步:服務端建立socket服務

第二步:建立epoll實例,將socket服務fd加入interest list

第三步:循環調用epoll_wait看是否有ready的文件描述符,若是有而且是咱們的socket服務fd,說明是有新的客戶端鏈接加入,保存客戶端fd,並將fd加入interest list;若是非socket 服務fd,說明是客戶端有新消息發送至服務端,接收消息而後廣播給保存的全部客戶端fd

二、客戶端設計與實現

第一步:建立socket鏈接服務端

第二步:建立管道,以便獲取客戶端輸入

第三步:建立epoll實例,並把socket fd和管道fd加入interest list

第四步:fork子進程

第五步:父進程調用epoll_wait獲取ready list,若是fd是服務端sockt,則直接打印廣播消息;若是fd是管道則爲用戶輸入,讀取管道中的消息,發到服務端

三、涉及其餘知識點

  • socket
  • fork
  • pipe
  • linked list(保存鏈接的客戶端fd)

運行如圖:

源碼放到了Githubhttps://github.com/bigbignerd/epollchat

4、參考文獻

一、《The linux programming interface》

二、Medium: The method to epoll's madness

若有表述錯誤之處,歡迎指正!

原文地址http://www.javashuo.com/article/p-tidqbzea-n.html

相關文章
相關標籤/搜索