select、poll、epoll的原理區別

前言

本文討論的開發環境是 Linux 網絡io

同步I/O

在操作系統中,程序運行的空間分爲內核空間和用戶空間,用戶空間所有對io操作的代碼(如文件的讀寫、socket的收發等)都會通過系統調用進入內核空間完成實際的操作。

而且我們都知道CPU的速度遠遠快於硬盤、網絡等I/O。在一個線程中,CPU執行代碼的速度極快,然而,一旦遇到I/O操作,如讀寫文件、發送網絡數據時,就需要等待 I/O 操作完成,才能繼續進行下一步操作,這種情況稱爲同步 I/O。

在某個應用程序運行時,假設需要讀寫某個文件,此時就發生了 I/O 操作,在I/O操作的過程中,系統會將當前線程掛起,而其他需要CPU執行的代碼就無法被當前線程執行了,這就是同步I/O操作,因爲一個IO操作就阻塞了當前線程,導致其他代碼無法執行,所以我們可以使用多線程或者多進程來併發執行代碼,當某個線程/進程被掛起後,不會影響其他線程或進程。

多線程和多進程雖然解決了這種併發的問題,但是系統不能無上限地增加線程/進程。由於系統切換線程/進程的開銷也很大,所以,一旦線程/進程數量過多,CPU的時間就花在線程/進程切換上了,真正運行代碼的時間就少了,這樣子的結果也導致系統性能嚴重下降。

多線程和多進程只是解決這一問題的一種方法,另一種解決I/O問題的方法是異步I/O,當然還有其他解決的方法。

異步I/O

當程序需要對I/O進行操作時,它只發出I/O操作的指令,並不等待I/O操作的結果,然後就去執行其他代碼了。一段時間後,當I/O返回結果時,再通知CPU進行處理。這樣子用戶空間中的程序不需要等待內核空間中的 I/O 完成實際操作,就可執行其他任務,提高CPU的利用率。

簡單來說就是,用戶不需要等待內核完成實際對io的讀寫操作就直接返回了。

阻塞I/O

在linux中,默認情況下所有的socket都是阻塞的,一個典型的讀操作流程大概是這樣:

用戶空間內核空間調用read()/readform()等函數進程進入阻塞狀態如果當前沒有數據, 則進入阻塞狀態, 直到有數據才返回返回讀取到的數據用戶空間內核空間

當用戶進程調用了read()/recvfrom()等系統調用函數,它會進入內核空間中,當這個網絡I/O沒有數據的時候,內核就要等待數據的到來,而在用戶進程這邊,整個進程會被阻塞,直到內核空間返回數據。當內核空間的數據準備好了,它就會將數據從內核空間中拷貝到用戶空間,此時用戶進程才解除阻塞的的狀態,重新運行起來。

所以,阻塞I/O的特點就是在IO執行的兩個階段(用戶空間與內核空間)都被阻塞了。

非阻塞I/O

linux下,可以通過設置socket使其變爲非阻塞模式,這種情況下,當內核空間並無數據的時候,它會馬上返回結果而不會阻塞,此時用戶進程可以根據這個結果自由配置,比如繼續請求數據,或者不再繼續請求。當對一個非阻塞socket執行讀操作時,流程是這個樣子:

用戶空間內核空間調用read()/recvfrom()等函數進程不阻塞沒有數據,立即返回調用read()/recvfrom()等函數進程不阻塞沒有數據,立即返回調用read()/recvfrom()等函數進程不阻塞沒有數據,立即返回調用read()/recvfrom()等函數將數據從內核空間 拷貝到用戶空間返回讀取到的數據用戶空間內核空間

當用戶進程調用read()/recvfrom()等系統調用函數時,如果內核空間中的數據還沒有準備好,那麼它並不會阻塞用戶進程,而是立刻返回一個error。

對於應用進程來說,它發起一個read()操作後,並不需要等待,而是馬上就得到了一個結果。用戶進程判斷結果是一個error時,它就知道內核中的數據還沒有準備好,那麼它可以再次調用read()/recvfrom()等函數。

當內核空間的數據準備好了,它就會將數據從內核空間中拷貝到用戶空間,此時用戶進程也就得到了數據。

所以,非阻塞I/O的特點是用戶進程需要不斷的主動詢問內核空間的數據準備好了沒有。

多路複用I/O

多路複用I/O就是我們說的select,poll,epoll等操作,複用的好處就在於單個進程就可以同時處理多個網絡連接的I/O,能實現這種功能的原理就是select、poll、epoll等函數會不斷的輪詢它們所負責的所有socket,當某個socket有數據到達了,就通知用戶進程。

一般來說I/O複用多用於以下情況:

  1. 當客戶處理多個描述符時。

  2. 服務器在高併發處理網絡連接的時候。

  3. 服務器既要處理監聽套接口,又要處理已連接套接口,一般也要用到I/O複用。

  4. 如果一個服務器即要處理TCP,又要處理UDP,一般要使用I/O複用。

  5. 如果一個服務器要處理多個服務或多個協議,一般要使用I/O複用。

與多進程和多線程技術相比,I/O多路複用技術的最大優勢是系統開銷小,系統不必創建進程/線程,也不必維護這些進程/線程,從而大大減小了系統的開銷。但select,poll,epoll本質上都是同步I/O,因爲他們都需要在讀寫事件就緒後自己負責進行讀寫,也就是說這個讀寫過程是阻塞的,而異步I/O則無需自己負責進行讀寫,異步I/O的實現會負責把數據從內核拷貝到用戶空間

select

用直白的話來介紹select:select機制會監聽它所負責的所有socket,當其中一個socket或者多個socket可讀或者可寫的時候,它就會返回,而如果所有的socket都是不可讀或者不可寫的時候,這個進程就會被阻塞,直到超時或者socket可讀寫,當select函數返回後,可以通過遍歷fdset,來找到就緒的描述符。

select整個處理過程如下

用戶空間內核空間FD_ZERO()FD_SET()調用select()函數進程進入阻塞狀態,可能等到一個或者多個socket描述符就緒。loop[ 遍歷一遍fd ]如果沒有滿足條件的fd, 將進行休眠, 在socket可讀寫時喚醒, 或者在超時後喚醒返回當select()函數返回後, 可以通過遍歷fdset, 來找到就緒的描述符, 再操作socket調用read()/recvfrom()等函數將數據從內核空間 拷貝到用戶空間返回讀取到的數據用戶空間內核空間
  1. 用戶進程調用select()函數,如果當前沒有可讀寫的socket,則用戶進程進入阻塞狀態。
  2. 對於內核空間來說,它會從用戶空間拷貝fd_set到內核空間,然後在內核中遍歷一遍所有的socket描述符,如果沒有滿足條件的socket描述符,內核將進行休眠,當設備驅動發生自身資源可讀寫後,會喚醒其等待隊列上睡眠的內核進程,即在socket可讀寫時喚醒,或者在超時後喚醒。
  3. 返回select()函數的調用結果給用戶進程,返回就緒socket描述符的數目,超時返回0,出錯返回-1。
  4. 注意,在select()函數返回後還是需要輪詢去找到就緒的socket描述符的,此時用戶進程纔可以去操作socket

select目前幾乎在所有的平臺上支持,其良好跨平臺支持也是它的一個優點。

當然select也有很多缺點:

  1. 單個進程能夠監視的文件描述符的數量存在最大限制,在Linux上一般爲1024,可以通過修改宏定義甚至重新編譯內核的方式提升這一限制,但是這樣也會造成效率的降低。
  2. 需要維護一個用來存放大量fd的數據結構,這樣會使得用戶空間和內核空間在傳遞該結構時複製開銷大。
  3. 每次在有socket描述符活躍的時候,都需要遍歷一遍所有的fd找到該描述符,這會帶來大量的時間消耗(時間複雜度是O(n),並且伴隨着描述符越多,這開銷呈線性增長)

對應內核來說,整個處理的流程如下:

超時
返回
監聽類型的socket
活躍連接的socket
設置socket的fd
可以返回
數據接收成功
數據接收失敗
清除socket標誌位
返回
返回
select
FD_ISSET
close
accept
recv
FD_SET
return
close
FD_CLR

select函數原型:

int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout)

參數說明:

  • maxfdp1指定感興趣的socket描述符個數,它的值是套接字最大socket描述符加1,socket描述符0、1、2 … maxfdp1-1均將被設置爲感興趣(即會查看他們是否可讀、可寫)。

  • readset:指定這個socket描述符是可讀的時候才返回。

  • writeset:指定這個socket描述符是可寫的時候才返回。

  • exceptset:指定這個socket描述符是異常條件時候才返回。

  • timeout:指定了超時的時間,當超時了也會返回。

如果對某一個的條件不感興趣,就可以把它設爲空指針。

返回值:就緒socket描述符的數目,超時返回0,出錯返回-1。

select的缺點

  1. 每次調用select,都需要把fd集合從用戶態拷貝到內核態,這個開銷在fd很多時會很大。

  2. 同時每次調用select都需要在內核遍歷傳遞進來的所有fd,這個開銷在fd很多時也很大。

  3. 每次在select()函數返回後,都要通過遍歷文件描述符來獲取已經就緒的socket

  4. select支持的文件描述符數量太小了,默認是1024

poll

poll的實現和select非常相似,只是描述fd集合的方式不同,poll使用pollfd結構而不是selectfd_set結構,poll不限制socket描述符的個數,因爲它是使用鏈表維護這些socket描述符的,其他的都差不多和select()函數一樣,poll()函數返回後,需要輪詢pollfd來獲取就緒的描述符,根據描述符的狀態進行處理,但是poll沒有最大文件描述符數量的限制。pollselect同樣存在一個缺點就是,包含大量文件描述符的數組被整體複製於用戶態和內核的地址空間之間,而不論這些文件描述符是否就緒,它的開銷隨着文件描述符數量的增加而線性增大。

函數原型:

int poll (struct pollfd *fds, unsigned int nfds, int timeout);

epoll

epoll的原理

其實相對於selectpoll來說,epoll更加靈活,但是核心的原理都是當socket描述符就緒(可讀、可寫、出現異常),就會通知應用進程,告訴他哪個socket描述符就緒,只是通知處理的方式不同而已。

epoll使用一個epfd(epoll文件描述符)管理多個socket描述符,epoll不限制socket描述符的個數,將用戶空間的socket描述符的事件存放到內核的一個事件表中,這樣在用戶空間和內核空間的copy只需一次。當epoll記錄的socket產生就緒的時候,epoll會通過callback的方式來激活這個fd,這樣子在epoll_wait便可以收到通知,告知應用層哪個socket就緒了,這種通知的方式是可以直接得到那個socket就緒的,因此相比於selectpoll,它不需要遍歷socket列表,時間複雜度是O(1),不會因爲記錄的socket增多而導致開銷變大。

epoll的操作模式

epoll對socket描述符的操作有兩種模式:LT(level trigger)和ET(edge trigger)。LT模式是默認模式,LT模式與ET模式的區別如下:

  • LT模式:即水平出發模式,當epoll_wait檢測到socket描述符處於就緒時就通知應用程序,應用程序可以不立即處理它。下次調用epoll_wait時,還會再次產生通知。

  • ET模式:即邊緣觸發模式,當epoll_wait檢測到socket描述符處於就緒時就通知應用程序,應用程序必須立即處理它。如果不處理,下次調用epoll_wait時,不會再次產生通知。

ET模式在很大程度上減少了epoll事件被重複觸發的次數,因此效率要比LT模式高。epoll工作在ET模式的時候,必須使用非阻塞套接口,以避免由於一個文件句柄的阻塞讀/阻塞寫操作把處理多個文件描述符的任務餓死。

epoll的函數

epoll只有epoll_create()、epoll_ctl()、epoll_wait() 3個系統調用函數。

epoll_create()

int epoll_create(int size);

創建一個epoll的epfd(epoll文件描述符,或者稱之爲句柄),當創建好epoll句柄後,它就是會佔用一個fd值,必須調用close()關閉,否則可能導致fd被耗盡,這也是爲什麼我們前面所講的是:epoll使用一個epfd管理多個socket描述符

size參數用來告訴內核這個監聽的數目一共有多大,它其實是在內核申請一空間,用來存放用戶想監聽的socket fd上是否可讀可行或者其他異常,只要有足夠的內存空間,size可以隨意設置大小,1G的內存上能監聽約10萬個端口。

epoll_ctl()

該函數用於控制某個epoll文件描述符上的事件,可以註冊事件,修改事件,以及刪除事件。

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

參數:

  • epdf:由epoll_create()函數返回的epoll文件描述符(句柄)。

  • op:op是操作的選項,目前有以下三個選項:

    • EPOLL_CTL_ADD:註冊要監聽的目標socket描述符fd到epoll句柄中。

    • EPOLL_CTL_MOD:修改epoll句柄已經註冊的fd的監聽事件。

    • EPOLL_CTL_DEL:從epoll句柄刪除已經註冊的socket描述符。

  • fd:指定監聽的socket描述符。

  • event:event結構如下:

    typedef union epoll_data {
        void        *ptr;
        int          fd;
        uint32_t     u32;
        uint64_t     u64;
    } epoll_data_t;
    
    struct epoll_event {
        uint32_t     events;      /* Epoll events */
        epoll_data_t data;        /* User data variable */
    };
    
    • events可以是以下幾個宏的集合:

      • EPOLLIN:表示對應的文件描述符可以讀(包括對端SOCKET正常關閉)。

      • EPOLLOUT:表示對應的文件描述符可以寫。

      • EPOLLPRI:表示對應的文件描述符有緊急的數據可讀(這裏應該表示有帶外數據到來)。

      • EPOLLERR:表示對應的文件描述符發生錯誤。

      • EPOLLHUP:表示對應的文件描述符被掛斷。

      • EPOLLET: 將EPOLL設爲邊緣觸發(Edge Triggered)模式,這是相對於水平觸發(Level Triggered)來說的。

      • EPOLLONESHOT:只監聽一次事件,當監聽完這次事件之後,如果還需要繼續監聽這個socket的話,需要再次把這個socket加入到EPOLL隊列裏。

epoll_wait()

int epoll_wait(int epfd, struct epoll_event *events,
               int maxevents, int timeout);

epoll_wait()函數的作用就是等待監聽的事件的發生,類似於調用select()函數。

參數:

  • events:用來從內核得到事件的集合。

  • maxevents告之內核這個events有多大,這個 maxevents的值不能大於創建epoll_create()時的指定的size。

  • timeout是超時時間。

  • 函數的返回值表示需要處理的事件數目,如返回0表示已超時。

epoll爲什麼更高效

  1. 當我們調用epoll_wait()函數返回的不是實際的描述符,而是一個代表就緒描述符數量的值,這個時候需要去epoll指定的一個數組中依次取得相應數量的socket描述符即可,而不需要遍歷掃描所有的socket描述符,因此這裏的時間複雜度是O(1)。

  2. 此外還使用了內存映射(mmap)技術,這樣便徹底省掉了這些socket描述符在系統調用時拷貝的開銷(因爲從用戶空間到內核空間需要拷貝操作)。mmap將用戶空間的一塊地址和內核空間的一塊地址同時映射到相同的一塊物理內存地址(不管是用戶空間還是內核空間都是虛擬地址,最終要通過地址映射映射到物理地址),使得這塊物理內存對內核和對用戶均可見,減少用戶態和內核態之間的數據交換,不需要依賴拷貝,這樣子內核可以直接看到epoll監聽的socket描述符,效率極高。

  3. 另一個本質的改進在於epoll採用基於事件的就緒通知方式。在select/poll中,進程只有在調用一定的方法後,內核纔對所有監視的socket描述符進行掃描,而epoll事先通過epoll_ctl()來註冊一個socket描述符,一旦檢測到epoll管理的socket描述符就緒時,內核會採用類似callback的回調機制,迅速激活這個socket描述符,當進程調用epoll_wait()時便可以得到通知,也就是說epoll最大的優點就在於它只管就緒的socket描述符,而跟socket描述符的總數無關

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