1. 程式人生 > >轉BIO,NIO和AIO講的很明白的文章

轉BIO,NIO和AIO講的很明白的文章

到底什麼是“IO Block”

很多人說BIO不好,會“block”,但到底什麼是IO的Block呢?考慮下面兩種情況:

  • 用系統呼叫read從socket裡讀取一段資料
  • 用系統呼叫read從一個磁碟檔案讀取一段資料到記憶體

如果你的直覺告訴你,這兩種都算“Block”,那麼很遺憾,你的理解與Linux不同。Linux認為:

  • 對於第一種情況,算作block,因為Linux無法知道網路上對方是否會發資料。如果沒資料發過來,對於呼叫read的程式來說,就只能“等”。

  • 對於第二種情況,不算做block

是的,對於磁碟檔案IO,Linux總是不視作Block。

你可能會說,這不科學啊,磁碟讀寫偶爾也會因為硬體而卡殼啊,怎麼能不算Block呢?但實際就是不算。

一個解釋是,所謂“Block”是指作業系統可以預見這個Block會發生才會主動Block。例如當讀取TCP連線的資料時,如果發現Socket buffer裡沒有資料就可以確定定對方還沒有發過來,於是Block;而對於普通磁碟檔案的讀寫,也許磁碟運作期間會抖動,會短暫暫停,但是作業系統無法預見這種情況,只能視作不會Block,照樣執行。

基於這個基本的設定,在討論IO時,一定要嚴格區分網路IO和磁碟檔案IO。NIO和後文講到的IO多路複用只對網路IO有意義。

嚴格的說,O_NONBLOCK和IO多路複用,對標準輸入輸出描述符、管道和FIFO也都是有效的。但本文側重於討論高效能網路伺服器下各種IO的含義和關係,所以本文做了簡化,只提及網路IO和磁碟檔案IO兩種情況。

本文先著重講一下網路IO。

BIO

有了Block的定義,就可以討論BIO和NIO了。BIO是Blocking IO的意思。在類似於網路中進行read, write, connect一類的系統呼叫時會被卡住。

舉個例子,當用read去讀取網路的資料時,是無法預知對方是否已經發送資料的。因此在收到資料之前,能做的只有等待,直到對方把資料發過來,或者等到網路超時。

對於單執行緒的網路服務,這樣做就會有卡死的問題。因為當等待時,整個執行緒會被掛起,無法執行,也無法做其他的工作。

順便說一句,這種Block是不會影響同時執行的其他程式(程序)的,因為現代作業系統都是多工的,任務之間的切換是搶佔式的。這裡Block只是指Block當前的程序。

於是,網路服務為了同時響應多個併發的網路請求,必須實現為多執行緒的。每個執行緒處理一個網路請求。執行緒數隨著併發連線數線性增長。這的確能奏效。實際上2000年之前很多網路伺服器就是這麼實現的。但這帶來兩個問題:

  • 執行緒越多,Context Switch就越多,而Context Switch是一個比較重的操作,會無謂浪費大量的CPU。
  • 每個執行緒會佔用一定的記憶體作為執行緒的棧。比如有1000個執行緒同時執行,每個佔用1MB記憶體,就佔用了1個G的記憶體。

也許現在看來1GB記憶體不算什麼,現在伺服器上百G記憶體的配置現在司空見慣了。但是倒退20年,1G記憶體是很金貴的。並且,儘管現在通過使用大記憶體,可以輕易實現併發1萬甚至10萬的連線。但是水漲船高,如果是要單機撐1千萬的連線呢?

問題的關鍵在於,當呼叫read接受網路請求時,有資料到了就用,沒資料到時,實際上是可以幹別的。使用大量執行緒,僅僅是因為Block發生,沒有其他辦法。

當然你可能會說,是不是可以弄個執行緒池呢?這樣既能併發的處理請求,又不會產生大量執行緒。但這樣會限制最大併發的連線數。比如你弄4個執行緒,那麼最大4個執行緒都Block了就沒法響應更多請求了。

要是操作IO介面時,作業系統能夠總是直接告訴有沒有資料,而不是Block去等就好了。於是,NIO登場。

NIO

NIO是指將IO模式設為“Non-Blocking”模式。在Linux下,一般是這樣:

void setnonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

再強調一下,以上操作只對socket對應的檔案描述符有意義;對磁碟檔案的檔案描述符做此設定總會成功,但是會直接被忽略。

這時,BIO和NIO的區別是什麼呢?

在BIO模式下,呼叫read,如果發現沒資料已經到達,就會Block住。

在NIO模式下,呼叫read,如果發現沒資料已經到達,就會立刻返回-1, 並且errno被設為EAGAIN

在有些文件中寫的是會返回EWOULDBLOCK。實際上,在Linux下EAGAINEWOULDBLOCK是一樣的,即#define EWOULDBLOCK EAGAIN

於是,一段NIO的程式碼,大概就可以寫成這個樣子。

struct timespec sleep_interval{.tv_sec = 0, .tv_nsec = 1000};
ssize_t nbytes;
while (1) {
    /* 嘗試讀取 */
    if ((nbytes = read(fd, buf, sizeof(buf))) < 0) {
        if (errno == EAGAIN) { // 沒資料到
            perror("nothing can be read");
        } else {
            perror("fatal error");
            exit(EXIT_FAILURE);
        }
    } else { // 有資料
        process_data(buf, nbytes);
    }
    // 處理其他事情,做完了就等一會,再嘗試
    nanosleep(sleep_interval, NULL);
}

這段程式碼很容易理解,就是輪詢,不斷的嘗試有沒有資料到達,有了就處理,沒有(得到EWOULDBLOCK或者EAGAIN)就等一小會再試。這比之前BIO好多了,起碼程式不會被卡死了。

但這樣會帶來兩個新問題:

  • 如果有大量檔案描述符都要等,那麼就得一個一個的read。這會帶來大量的Context Switch(read是系統呼叫,每呼叫一次就得在使用者態和核心態切換一次)
  • 休息一會的時間不好把握。這裡是要猜多久之後資料才能到。等待時間設的太長,程式響應延遲就過大;設的太短,就會造成過於頻繁的重試,乾耗CPU而已。

要是作業系統能一口氣告訴程式,哪些資料到了就好了。

於是IO多路複用被搞出來解決這個問題。

IO多路複用

IO多路複用(IO Multiplexing) 是這麼一種機制:程式註冊一組socket檔案描述符給作業系統,表示“我要監視這些fd是否有IO事件發生,有了就告訴程式處理”。

IO多路複用是要和NIO一起使用的。儘管在作業系統級別,NIO和IO多路複用是兩個相對獨立的事情。NIO僅僅是指IO API總是能立刻返回,不會被Blocking;而IO多路複用僅僅是作業系統提供的一種便利的通知機制。作業系統並不會強制這倆必須得一起用——你可以用NIO,但不用IO多路複用,就像上一節中的程式碼;也可以只用IO多路複用 + BIO,這時效果還是當前執行緒被卡住。但是,IO多路複用和NIO是要配合一起使用才有實際意義。因此,在使用IO多路複用之前,請總是先把fd設為O_NONBLOCK

對IO多路複用,還存在一些常見的誤解,比如:

  • ❌IO多路複用是指多個數據流共享同一個Socket。其實IO多路複用說的是多個Socket,只不過作業系統是一起監聽他們的事件而已。

    多個數據流共享同一個TCP連線的場景的確是有,比如Http2 Multiplexing就是指Http2通訊中中多個邏輯的資料流共享同一個TCP連線。但這與IO多路複用是完全不同的問題。

  • ❌IO多路複用是NIO,所以總是不Block的。其實IO多路複用的關鍵API呼叫(selectpollepoll_wait)總是Block的,正如下文的例子所講。

  • IO多路複用和NIO一起減少了IO。實際上,IO本身(網路資料的收發)無論用不用IO多路複用和NIO,都沒有變化。請求的資料該是多少還是多少;網路上該傳輸多少資料還是多少資料。IO多路複用和NIO一起僅僅是解決了排程的問題,避免CPU在這個過程中的浪費,使系統的瓶頸更容易觸達到網路頻寬,而非CPU或者記憶體。要提高IO吞吐,還是提高硬體的容量(例如,用支援更大頻寬的網線、網絡卡和交換機)和依靠併發傳輸(例如HDFS的資料多副本併發傳輸)。

作業系統級別提供了一些介面來支援IO多路複用,最老掉牙的是selectpoll

select

select長這樣:

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

它接受3個檔案描述符的陣列,分別監聽讀取(readfds),寫入(writefds)和異常(expectfds)事件。那麼一個 IO多路複用的程式碼大概是這樣:

struct timeval tv = {.tv_sec = 1, .tv_usec = 0};

ssize_t nbytes;
while(1) {
    FD_ZERO(&read_fds);
    setnonblocking(fd1);
    setnonblocking(fd2);
    FD_SET(fd1, &read_fds);
    FD_SET(fd2, &read_fds);
    // 把要監聽的fd拼到一個數組裡,而且每次迴圈都得重來一次...
    if (select(FD_SETSIZE, &read_fds, NULL, NULL, &tv) < 0) { // block住,直到有事件到達
        perror("select出錯了");
        exit(EXIT_FAILURE);
    }
    for (int i = 0; i < FD_SETSIZE; i++) {
        if (FD_ISSET(i, &read_fds)) {
            /* 檢測到第[i]個讀取fd已經收到了,這裡假設buf總是大於到達的資料,所以可以一次read完 */
            if ((nbytes = read(i, buf, sizeof(buf))) >= 0) {
                process_data(nbytes, buf);
            } else {
                perror("讀取出錯了");
                exit(EXIT_FAILURE);
            }
        }
    }
}

首先,為了select需要構造一個fd陣列(這裡為了簡化,沒有構造要監聽寫入和異常事件的fd陣列)。之後,用select監聽了read_fds中的多個socket的讀取時間。呼叫select後,程式會Block住,直到一個事件發生了,或者等到最大1秒鐘(tv定義了這個時間長度)就返回。之後,需要遍歷所有註冊的fd,挨個檢查哪個fd有事件到達(FD_ISSET返回true)。如果是,就說明資料已經到達了,可以讀取fd了。讀取後就可以進行資料的處理。

select有一些髮指的缺點:

  • select能夠支援的最大的fd陣列的長度是1024。這對要處理高併發的web伺服器是不可接受的。
  • fd陣列按照監聽的事件分為了3個數組,為了這3個數組要分配3段記憶體去構造,而且每次呼叫select前都要重設它們(因為select會改這3個數組);呼叫select後,這3陣列要從使用者態複製一份到核心態;事件到達後,要遍歷這3陣列。很不爽。
  • select返回後要挨個遍歷fd,找到被“SET”的那些進行處理。這樣比較低效。
  • select是無狀態的,即每次呼叫select,核心都要重新檢查所有被註冊的fd的狀態。select返回後,這些狀態就被返回了,核心不會記住它們;到了下一次呼叫,核心依然要重新檢查一遍。於是查詢的效率很低。

poll

pollselect類似於。它大概長這樣:

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

poll的程式碼例子和select差不多,因此也就不贅述了。有意思的是poll這個單詞的意思是“輪詢”,所以很多中文資料都會提到對IO進行“輪詢”。

上面說的select和下文說的epoll本質上都是輪詢。

poll優化了select的一些問題。比如不再有3個數組,而是1個polldfd結構的陣列了,並且也不需要每次重設了。陣列的個數也沒有了1024的限制。但其他的問題依舊:

  • 依然是無狀態的,效能的問題與select差不多一樣;
  • 應用程式仍然無法很方便的拿到那些“有事件發生的fd“,還是需要遍歷所有註冊的fd。

目前來看,高效能的web伺服器都不會使用selectpoll。他們倆存在的意義僅僅是“相容性”,因為很多作業系統都實現了這兩個系統呼叫。

如果是追求效能的話,在BSD/macOS上提供了kqueue api;在Salorias中提供了/dev/poll(可惜該作業系統已經涼涼);而在Linux上提供了epoll api。它們的出現徹底解決了selectpoll的問題。Java NIO,nginx等在對應的平臺的上都是使用這些api實現。

因為大部分情況下我會用Linux做伺服器,所以下文以Linux epoll為例子來解釋多路複用是怎麼工作的。

用epoll實現的IO多路複用

epoll是Linux下的IO多路複用的實現。這裡單開一章是因為它非常有代表性,並且Linux也是目前最廣泛被作為伺服器的作業系統。細緻的瞭解epoll對整個IO多路複用的工作原理非常有幫助。

selectpoll不同,要使用epoll是需要先建立一下的。

int epfd = epoll_create(10);

epoll_create在核心層建立了一個數據表,介面會返回一個“epoll的檔案描述符”指向這個表。注意,介面引數是一個表達要監聽事件列表的長度的數值。但不用太在意,因為epoll內部隨後會根據事件註冊和事件登出動態調整epoll中表格的大小。

epoll建立

為什麼epoll要建立一個用檔案描述符來指向的表呢?這裡有兩個好處:

  • epoll是有狀態的,不像selectpoll那樣每次都要重新傳入所有要監聽的fd,這避免了很多無謂的資料複製。epoll的資料是用介面epoll_ctl來管理的(增、刪、改)。
  • epoll檔案描述符在程序被fork時,子程序是可以繼承的。這可以給對多程序共享一份epoll資料,實現並行監聽網路請求帶來便利。但這超過了本文的討論範圍,就此打住。

epoll建立後,第二步是使用epoll_ctl介面來註冊要監聽的事件。

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

其中第一個引數就是上面建立的epfd。第二個引數op表示如何對檔名進行操作,共有3種。

  • EPOLL_CTL_ADD - 註冊一個事件
  • EPOLL_CTL_DEL - 取消一個事件的註冊
  • EPOLL_CTL_MOD - 修改一個事件的註冊

第三個引數是要操作的fd,這裡必須是支援NIO的fd(比如socket)。

第四個引數是一個epoll_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 */
};

比方說,想關注一個fd1的讀取事件事件,並採用邊緣觸發(下文會解釋什麼是邊緣觸發),大概要這麼寫:

struct epoll_data ev;
ev.events = EPOLLIN | EPOLLET; // EPOLLIN表示讀事件;EPOLLET表示邊緣觸發
ev.data.fd = fd1;

通過epoll_ctl就可以靈活的註冊/取消註冊/修改註冊某個fd的某些事件。

管理fd事件註冊

第三步,使用epoll_wait來等待事件的發生。

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

特別留意,這一步是"block"的。只有當註冊的事件至少有一個發生,或者timeout達到時,該呼叫才會返回。這與selectpoll幾乎一致。但不一樣的地方是evlist,它是epoll_wait的返回陣列,裡面只包含那些被觸發的事件對應的fd,而不是像selectpoll那樣返回所有註冊的fd。

監聽fd事件

綜合起來,一段比較完整的epoll程式碼大概是這樣的。

#define MAX_EVENTS 10
struct epoll_event ev, events[MAX_EVENTS];
int nfds, epfd, fd1, fd2;

// 假設這裡有兩個socket,fd1和fd2,被初始化好。
// 設定為non blocking
setnonblocking(fd1);
setnonblocking(fd2);

// 建立epoll
epfd = epoll_create(MAX_EVENTS);
if (epollfd == -1) {
    perror("epoll_create1");
    exit(EXIT_FAILURE);
}

//註冊事件
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = fd1;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, fd1, &ev) == -1) {
    perror("epoll_ctl: error register fd1");
    exit(EXIT_FAILURE);
}
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, fd2, &ev) == -1) {
    perror("epoll_ctl: error register fd2");
    exit(EXIT_FAILURE);
}

// 監聽事件
for (;;) {
    nfds = epoll_wait(epdf, events, MAX_EVENTS, -1);
    if (nfds == -1) {
        perror("epoll_wait");
        exit(EXIT_FAILURE);
    }

    for (n = 0; n < nfds; ++n) { // 處理所有發生IO事件的fd
        process_event(events[n].data.fd);
        // 如果有必要,可以利用epoll_ctl繼續對本fd註冊下一次監聽,然後重新epoll_wait
    }
}

此外,epoll的手冊 中也有一個簡單的例子。

所有的基於IO多路複用的程式碼都會遵循這樣的寫法:註冊——監聽事件——處理——再註冊,無限迴圈下去。

epoll的優勢

為什麼epoll的效能比selectpoll要強呢? selectpoll每次都需要把完成的fd列表傳入到核心,迫使核心每次必須從頭掃描到尾。而epoll完全是反過來的。epoll在核心的資料被建立好了之後,每次某個被監聽的fd一旦有事件發生,核心就直接標記之。epoll_wait呼叫時,會嘗試直接讀取到當時已經標記好的fd列表,如果沒有就會進入等待狀態。

同時,epoll_wait直接只返回了被觸發的fd列表,這樣上層應用寫起來也輕鬆愉快,再也不用從大量註冊的fd中篩選出有事件的fd了。

簡單說就是selectpoll的代價是"O(所有註冊事件fd的數量)",而epoll的代價是"O(發生事件fd的數量)"。於是,高效能網路伺服器的場景特別適合用epoll來實現——因為大多數網路伺服器都有這樣的模式:同時要監聽大量(幾千,幾萬,幾十萬甚至更多)的網路連線,但是短時間內發生的事件非常少。

但是,假設發生事件的fd的數量接近所有註冊事件fd的數量,那麼epoll的優勢就沒有了,其效能表現會和pollselect差不多。

epoll除了效能優勢,還有一個優點——同時支援水平觸發(Level Trigger)和邊沿觸發(Edge Trigger)。

水平觸發和邊沿觸發

預設情況下,epoll使用水平觸發,這與selectpoll的行為完全一致。在水平觸發下,epoll頂多算是一個“跑得更快的poll”。

而一旦在註冊事件時使用了EPOLLET標記(如上文中的例子),那麼將其視為邊沿觸發(或者有地方叫邊緣觸發,一個意思)。那麼到底什麼水平觸發和邊沿觸發呢?

考慮下圖中的例子。有兩個socket的fd——fd1和fd2。我們設定監聽f1的“水平觸發讀事件“,監聽fd2的”邊沿觸發讀事件“。我們使用在時刻t1,使用epoll_wait監聽他們的事件。在時刻t2時,兩個fd都到了100bytes資料,於是在時刻t3, epoll_wait返回了兩個fd進行處理。在t4,我們故意不讀取所有的資料出來,只各自讀50bytes。然後在t5重新註冊兩個事件並監聽。在t6時,只有fd1會返回,因為fd1裡的資料沒有讀完,仍然處於“被觸發”狀態;而fd2不會被返回,因為沒有新資料到達。

水平觸發和邊沿觸發

這個例子很明確的顯示了水平觸發和邊沿觸發的區別。

  • 水平觸發只關心檔案描述符中是否還有沒完成處理的資料,如果有,不管怎樣epoll_wait,總是會被返回。簡單說——水平觸發代表了一種“狀態”。

  • 邊沿觸發只關心檔案描述符是否有的事件產生,如果有,則返回;如果返回過一次,不管程式是否處理了,只要沒有新的事件產生,epoll_wait不會再認為這個fd被“觸發”了。簡單說——邊沿觸發代表了一個“事件”。

    那麼邊沿觸發怎麼才能迫使新事件產生呢?一般需要反覆呼叫read/write這樣的IO介面,直到得到了EAGAIN錯誤碼,再去嘗試epoll_wait才有可能得到下次事件。

那麼為什麼需要邊沿觸發呢?

邊沿觸發把如何處理資料的控制權完全交給了開發者,提供了巨大的靈活性。比如,讀取一個http的請求,開發者可以決定只讀取http中的headers資料就停下來,然後根據業務邏輯判斷是否要繼續讀(比如需要呼叫另外一個服務來決定是否繼續讀)。而不是次次被socket尚有資料的狀態煩擾;寫入資料時也是如此。比如希望將一個資源A寫入到socket。當socket的buffer充足時,epoll_wait會返回這個fd是準備好的。但是資源A此時不一定準備好。如果使用水平觸發,每次經過epoll_wait也總會被打擾。在邊沿觸發下,開發者有機會更精細的定製這裡的控制邏輯。

但不好的一面時,邊沿觸發也大大的提高了程式設計的難度。一不留神,可能就會miss掉處理部分socket資料的機會。如果沒有很好的根據EAGAIN來“重置”一個fd,就會造成此fd永遠沒有新事件產生,進而導致餓死相關的處理程式碼。

再來思考一下什麼是“Block”

上面的所有介紹都在圍繞如何讓網路IO不會被Block。但是網路IO處理僅僅是整個資料處理中的一部分。如果你留意到上文例子中的“處理事件”程式碼,就會發現這裡可能是有問題的。

  • 處理程式碼有可能需要讀寫檔案,可能會很慢,從而干擾整個程式的效率;
  • 處理程式碼有可能是一段複雜的資料計算,計算量很大的話,就會卡住整個執行流程;
  • 處理程式碼有bug,可能直接進入了一段死迴圈……

這時你會發現,這裡的Block和本文之初講的O_NONBLOCK是不同的事情。在一個網路服務中,如果處理程式的延遲遠遠小於網路IO,那麼這完全不成問題。但是如果處理程式的延遲已經大到無法忽略了,就會對整個程式產生很大的影響。這時IO多路複用已經不是問題的關鍵。

試分析和比較下面兩個場景:

  • web proxy。程式通過IO多路複用接收到了請求之後,直接轉發給另外一個網路服務。
  • web server。程式通過IO多路複用接收到了請求之後,需要讀取一個檔案,並返回其內容。

它們有什麼不同?它們的瓶頸可能出在哪裡?

總結

小結一下本文:

  • 對於socket的檔案描述符才有所謂BIO和NIO。
  • 多執行緒+BIO模式會帶來大量的資源浪費,而NIO+IO多路複用可以解決這個問題。
  • 在Linux下,基於epoll的IO多路複用是解決這個問題的最佳方案;epoll相比selectpoll有很大的效能優勢和功能優勢,適合實現高效能網路服務。

但是IO多路複用僅僅是解決了一部分問題,另外一部分問題如何解決呢?且聽下回分解。

作者:大寬寬 連結:https://www.jianshu.com/p/ef418ccf2f7d 來源:簡書 簡書著作權歸作者所有,任何形式的轉載都請聯絡作者獲得授權並註明出處。