Blog.7 IO多路複用

引言

結合文章我讀過的最好的epoll講解,認識selectepoll的基本工做原理。html

假設:啓動一個WEB服務,服務端每accept一個鏈接,在內核中就會生成一個相應的文件描述符。如今服務器成功創建了10個鏈接,咱們須要知道其中哪些鏈接發送過來了新的數據,而後對其進行處理和響應。linux

經過一個基本的循環,咱們就能夠實現:git

while true: 
    for x in open_connections:
        if has_new_input(x):
            process_input(x)

這也是咱們經常使用的「輪詢」模式,不停的詢問服務器「數據是否已經準備就緒」,而這很是浪費CPU的時間。github

爲了不CPU的空轉(無限的for循環),系統引入了一個select的代理。這個代理比較厲害,能夠同時觀察許多流的I/O事件。在空閒的時候,會把當前線程阻塞掉。當有一個或多個流有I/O事件時,就從阻塞態中醒來。因而,代碼調整成這樣:golang

while true: 
    select(open_connections)
    for x in open_connections:
        if has_new_input(x):
            process_input(x)

調整以後,若是沒有I/O事件產生,咱們的程序就會阻塞在select處。但這樣依然有個問題:咱們從select那裏僅僅知道,有I/O事件發生了,但卻並不知道是那幾個流(可能有一個,多個,甚至所有),咱們只能無差異進行輪詢,找出能讀出或寫入數據的流,對他們進行操做。使用select,咱們有O(n)的無差異輪詢複雜度,同時處理的流越多,每一次無差異輪詢時間就越長。redis

epoll被用來優化select的問題,它會將哪一個流發生了怎樣的I/O事件通知咱們。此時咱們對這些流的操做都是有意義的(複雜度下降到了O(k),k爲產生I/O事件流的個數)。最後,代碼調整了這樣:編程

while true: 
    active_conns = epoll(open_connections)
    for x in active_conns:
        process_input(x)

I/O多路複用

多路複用的本質是同步非阻塞I/O,多路複用的優點並非單個鏈接處理的更快,而是在於能處理更多的鏈接。相似服務對外提供了一個批量接口。服務器

I/O編程過程當中,須要同時處理多個客戶端接入請求時,能夠利用多線程或者I/O多路複用技術進行處理。 I/O多路複用技術經過把多個I/O的阻塞複用到同一個select阻塞上,一個進程監視多個描述符,一旦某個描述符就位, 可以通知程序進行讀寫操做。由於多路複用本質上是同步I/O,都須要應用程序在讀寫事件就緒後本身負責讀寫。 最大的優點是系統開銷小,不須要建立和維護額外線程或進程。多線程

圖片描述

結合多路複用,來看一下異步非阻塞I/O:併發

對比異步非阻塞I/O,讀請求會當即返回,說明請求已經成功發起,應用程序不被阻塞,繼續執行其它處理操做。當read響應到達,將數據拷貝到用戶空間,產生信號或者執行一個基於線程回調函數完成I/O處理。應用程序不用在多個任務之間切換。

圖片描述

能夠看出,阻塞I/Owait for datacopy data from kernel to user兩個階段都是阻塞的。而只有異步I/Oreceivefrom是不阻塞的。

epoll

epoll的系統調用方法包括:epoll_createepoll_ctlepoll_wait

  • epoll_create建立一個epoll對象:
epollfd = epoll_create()
  • epoll_ctl往epoll對象中增長/刪除某一個流的某一個事件:
// 有緩衝區內有數據時epoll_wait返回
epoll_ctl(epollfd, EPOLL_CTL_ADD, socket, EPOLLIN);

//緩衝區可寫入時epoll_wait返回
epoll_ctl(epollfd, EPOLL_CTL_DEL, socket, EPOLLOUT);
  • epoll_wait等待直到註冊的事件發生。

Go語言

go語法上提供了select語句,來實現多路複用。select語句中能夠監聽多個channel,只要其中任意一個channel有事件返回,select就會返回。不然,程序會一直阻塞在select上。經過結合default,還能夠實現反覆輪詢的效果。

select {
case <-tick:
    // Do nothing.
case <-abort:
    fmt.Println("Launch aborted!")
    return
}

netpoll_epoll.go中實現的epoll方法,依次經過調用netpollinitnetpollopennetpoll來實現。多是調用太清晰了,整個文件除了下面的註釋外,再也沒有別的有效註釋了。

// polls for ready network connections
// returns list of goroutines that become runnable
func netpoll(block bool) *g {}

epollLTET模式

epoll的兩種觸發模式:Level triggeredEdge triggered

二者的差別在於level-trigger模式下只要某個socket處於readable/writable狀態,不管何時進行epoll_wait都會返回該socket。而edge-trigger模式下只有某個socketunreadable變爲readable或從unwritable變爲writable時,epoll_wait纔會返回該socket

圖片描述

因此, 在epollET模式下, 正確的讀寫方式爲:

  1. 讀: 只要可讀, 就一直讀, 直到返回0, 或者 errno = EAGAIN
  2. 寫: 只要可寫, 就一直寫, 直到數據發送完, 或者 errno = EAGAIN

關於這兩種模式,博客Epoll is fundamentally broken 1/2也作了解釋,它經過內核負載均衡accept()的例子來進行說明。這裏也嘗試簡單介紹一下,由於例子讀起來確實有趣,也方便咱們加深理解。

在開發一個高吞吐量的HTTP Server(服務大量的短鏈接)時,由於請求量很是大,咱們但願充分利用計算機多核資源,將accept操做分配到不一樣的核來併發處理。但想要實現鏈接的負載均衡,直到內核4.5版本才變成可能。

水平觸發 - 不須要的喚醒

一個天真的解決辦法是:咱們全局建立一個epoll對象,多個工做線程來同時wait它。可是level triggere模式存在「驚羣現象」(前提:沒有給epoll指定具體的flag),對於每個到來的新鏈接,全部的工做線程都會被喚醒。

Kernel: 接收到一個新鏈接
   Kernel: 通知正在等待的線程`Thread A`和`Thread B`
 Thread A: epoll_wait()返回.
 Thread B: epoll_wait()返回.
 Thread A: 執行`accept()`, 操做成功.
 Thread B: 執行`accept()`, 操做失敗,返回`EAGAIN`.

在這個過程當中,喚醒Thread B是徹底沒有必要的,而且浪費了系統資源。因此,level-triggered模式在水平擴展上很是差。

邊緣觸發 - 不須要的喚醒和飢餓

咱們已經介紹了level-triggered模式的問題,那麼edge-triggered模式會不會作到更好呢?

並非,下面是可能的運行狀況:

Kernel: 收到一個新鏈接,此時線程`A`和`B`都在等待。由於如今是"edge-triggered"模式,因此僅僅會有一個線程被通知,假設是`A`.
 Thread A: `epoll_wait()`返回.
 Thread A: 執行`accept()`, 操做成功.
   Kernel: accpet隊列變空, `event-triggered socket`狀態由"readable"變爲"non readable"
   Kernel: 接收到第二個鏈接.
   Kernel: 如今只剩下線程`B`在執行`epoll_wait()`. 因而喚醒`B`.
 Thread A: 繼續執行`accept()`,本來但願返回`EAGAIN`,可是返回了第二個鏈接
 Thread B: 執行`accept()`, 返回`EAGAIN`. 
 Thread A: 繼續執行`accept()`, 返回`EAGAIN`.

上述過程當中,B的喚醒是徹底不須要的。並且,B會感到很是困惑。此外,edge-triggered模式也很難避免線程飢餓的狀況

Kernel: 接收到兩個鏈接,此時`A`和`B`都在等待. 假設`A`收到通知.
 Thread A: `epoll_wait()`返回.
 Thread A: 執行`accept()`, 操做成功
   Kernel: 接收到第3個鏈接.`event-triggered socket`是"readable"狀態, 如今依然是"readable". 
 Thread A: 必須執行`accept()`, 但願返回`EGAIN`, 可是返回了第三個鏈接.
   Kernel: 接收到第4個鏈接.
 Thread A: 繼續執行`accept()`, 但願返回`EGAIN`, 可是返回了第四個鏈接.

在這個例子中,event-triggered socket狀態只是從non-readable變爲了readable。由於在edge-trigger模式下,內核只會喚醒其中一個線程。因此,例子中全部的鏈接都會被線程A處理,負載均衡沒法實現。

SELECT

selectpoll相似,主要操做包括兩步:

  1. 傳遞給它們一個文件描述符集合
  2. 它們返回集合中的哪些能夠進行讀/寫操做

程序調用select將會被阻塞,直到存在可用的文件描述符,或者執行超時。當返回成功時,各個fd_set集合會被修改成僅包含可用的文件描述符。因此,每次調用select還須要重置它的參數列表。

函數解釋:

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

nfds被設置爲三個集合中最高的文件描述符數值+1,這代表每一個集合中的文件描述符都會被檢查,直到達到這個限制。

三個獨立的文件描述符集合會被監控:readfds中的文件描述符是否可讀,writefds是否有空間可寫,exceptfds是否異常的狀況。在函數退出時,文件描述符集合會被修改,來標識被改變狀態的文件標識符。若是沒有文件描述符須要被監控,這三個集合均可以指定爲NULL

當這三個集合都爲NULL,而timeval不爲空時,等價於系統執行sleep的效果。若是timeval結構體的兩個字段都爲0,就相似於輪詢的效果了。如下是timeval的結構體:

struct timeval {
   long    tv_sec;         /* seconds */
   long    tv_usec;        /* microseconds */
};

select中有4個宏函數被提供:FD_ZERO()用於清除一個集合,FD_SET()FD_CLR()用來增長和刪除一個給定的描述符,FD_ISSET()用來檢查文件描述符是不是集合的一部分。

爲何咱們在io操做中不使用select,而選擇使用epoll

節選自Async IO on Linux: select, poll, and epoll的描述:

On each call to select() or poll(), the kernel must check all of the specified file descriptors to see if they are ready. When monitoring a large number of file descriptors that are in a densely packed range, the timed required for this operation greatly outweights [the rest of the stuff they have to do]

參考文章:

  1. Async IO on Linux: select, poll, and epoll
  2. LINUX – IO MULTIPLEXING – SELECT VS POLL VS EPOLL
  3. 我讀過的最好的epoll講解
  4. Epoll在LT和ET模式下的讀寫方式
  5. Epoll is fundamentally broken 1/2
  6. I/O模型與多路複用
  7. Redis 和 I/O 多路複用
  8. SELECT(2)