淺談 non-blocking I/O Multiplexing + poll/epoll 的正確使用

s1mba發表於2013-10-09

在前面的文章中曾經粗略講過poll,那時是用阻塞IO實現,在傳送和接收資料量都較小情況下和網路狀況良好的情況下是基本沒有問題的,read 不會只接收部分資料,write 也不會一直阻塞。但實際上poll IO複用經常是跟非阻塞IO一起使用的,想想如果現在核心接收緩衝區一點資料沒有,read 阻塞了,或者核心傳送緩衝區不夠空間存放資料,write 阻塞了,那整個事件迴圈就會延遲響應,比如現在又有一個新連線connect上來了,也不能很快回到迴圈去accept 它。


在前面的文章中也曾粗略講過epoll,使用的是ET 邊沿觸發模式,每次accept 返回需要將conn 設定為非阻塞,ET模式可能存在的問題是有可能只讀取了部分資料,剩下的epoll_wait 就再也不會返回可讀事件了。


這篇文章來談談如何正確使用non-blocking I/O Multiplexing + poll/epoll。


1、首先來回顧下poll / epoll 函式的原型

#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

struct pollfd {
     int   fd;     /* file descriptor */
     short events;     /* requested events */
     short revents;     /* returned events */
};

#include <sys/epoll.h>
int epoll_create(int size); //size 並不代表能夠容納的事件個數
int epoll_create1(int flags); // EPOLL_CLOEXEC

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

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 */
};


具體的引數介紹參考以前的文章。

2、關於SIGPIPE 訊號的產生和處理

如果客戶端關閉套接字close,而伺服器呼叫一次write, 伺服器會接收一個RST segment(tcp傳輸層)
如果伺服器端再次呼叫了write,這個時候就會產生SIGPIPE訊號,預設終止程式。可以在程式中直接忽略掉,如 signal(SIGPIPE, SIG_IGN);

3、TIME_WAIT 狀態對 伺服器的影響

如果伺服器端 主動斷開連線(先於client 呼叫close),伺服器端就會進入TIME_WAIT 狀態。應儘可能在伺服器端避免TIME_WAIT 狀態,因為它會在一定時間內hold住一些核心資源。協議設計上,應該讓客戶端主動斷開連線,這樣就把TIME_WAIT狀態分散到大量的客戶端。如果客戶端不活躍了,一些不客戶端不斷開連線,這樣就會佔用伺服器端的連線資源。伺服器端也要踢掉不活躍的連線close。

4、使用 C++ erase 的注意點

即erase 返回的是下一個元素的iterator

5、新的accept4 系統呼叫

accept - accept a connection on a socket

      #include <sys/types.h>          /* See NOTES */
       #include <sys/socket.h>

       int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

       #define _GNU_SOURCE             /* See feature_test_macros(7) */
       #include <sys/socket.h>

       int accept4(int sockfd, struct sockaddr *addr, socklen_t *addrlen, int flags);

可以使用accept4 這個新的系統呼叫,多了一個flags 引數,可以設定以下兩個標誌:

SOCK_NONBLOCK   Set the O_NONBLOCK file status flag on the new open file description.  Using this flag saves  extra  calls to fcntl(2) to achieve the same result.

 SOCK_CLOEXEC    Set  the close-on-exec (FD_CLOEXEC) flag on the new file descriptor.  See the description of the O_CLOEXEC  flag in open(2) for reasons why this may be useful.

注意,這兩個標誌是設定accept 回來的conn 標誌的,當然也可以使用fcntl (F_SETFL / F_SETFD) 設定,但少了兩次系統呼叫,可以稍微提高點效能。


7、poll  的處理流程和存在的問題


存在的問題和解決辦法:

(1)、read 可能一次並沒有把connfd 所對應的接收緩衝區(核心)的資料都讀完(粘包問題),那麼connfd 下次仍然是活躍的
應該把讀到的資料儲存在connfd 的應用層接收緩衝區,每次都追加在末尾。需要處理協議以區分每條訊息的邊界

(2)、write 可能一次並不能把所有資料都寫到傳送緩衝區(核心),所以應該有一個應用層傳送緩衝區,將未傳送完的資料新增到應用層傳送緩衝區,關注connfd 的POLLOUT 事件。POLLOUT事件到來,則取出應用層傳送緩衝區資料傳送write,如果應用層傳送緩衝區資料傳送完畢,則取消關注POLLOUT事件。
POLLOUT 事件觸發條件:connfd的傳送緩衝區(核心)不滿(可以容納資料)

注:connfd 的接收緩衝區(核心)資料被接收後會被清空,當發出資料段後接收到對方的ACK段後,傳送緩衝區(核心)資料段會被清空。write只是將應用層傳送緩衝區資料拷貝到connfd 對應的核心傳送緩衝區就返回;read 只是從connfd對應的核心接收緩衝區資料拷貝到應用層接收緩衝區就返回。


9、epoll  的兩種模式處理流程和存在的問題

Level-Triggered //跟poll 基本類似


LT 電平觸發(高電平觸發):
EPOLLIN 事件
核心中的某個socket接收緩衝區     為空          低電平
核心中的某個socket接收緩衝區     不為空       高電平

EPOLLOUT 事件
核心中的某個socket傳送緩衝區     不滿          高電平
核心中的某個socket傳送緩衝區     滿             低電平

注:只要第一次write沒寫完整,則下次呼叫write直接把資料新增到應用層緩衝區OutBuffer,等待EPOLLOUT事件。

如果採用Level-Triggered,那什麼時候關注EPOLLOUT事件?會不會造成busy-loop(忙等待)?

Edge-Triggered:


ET 邊沿觸發:
低電平-》高電平      觸發

推薦epoll使用LT模式的原因:
與poll相容

LT模式不會發生漏掉事件的BUG,但POLLOUT事件不能一開始就關注,否則會出現busy loop(即暫時還沒有資料需要寫入,但一旦連線建立,核心傳送緩衝區為空會一直觸發POLLOUT事件),而應該在write無法完全寫入核心緩衝區的時候才關注,將未寫入核心緩衝區的資料新增到應用層output buffer,直到應用層output buffer寫完,停止關注POLLOUT事件。

讀寫的時候不必等候EAGAIN,可以節省系統呼叫次數,降低延遲。(注:如果用ET模式,讀的時候讀到EAGAIN,寫的時候直到output buffer寫完或者寫到EAGAIN)

注:在使用 ET 模式時,可以寫得更嚴謹,即將 listenfd 設定為非阻塞,如果accpet 呼叫有返回,除了建立當前這個連線外,不能馬上就回到 epoll_wait ,還需要繼續迴圈accpet,直到返回-1 且errno == EAGAIN 才退出。程式碼示例如下:


 C++ Code 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
if(ev.events & EPOLLIN)
{
    do
    {
        struct sockaddr_in stSockAddr;
        socklen_t iSockAddrSize = sizeof(sockaddr_in);
        
        int iRetCode = accept(listenfd, (struct sockaddr *) &stSockAddr, iSockAddrSize);
        if (iRetCode > 0)
        {
           // ...建立連線
           // 新增事件關注
        }
        else
        {
            //直到發生EAGAIN才不繼續accept
            if(errno == EAGAIN)
            {
                break;
            }
        }
    }
    while(true);
    
    // ... 其他 EPOLLIN 事件
}


10、accept(2)返回EMFILE的處理(檔案描述符已經用完)


(1)、調高程式檔案描述符數目
(2)、死等
(3)、退出程式
(4)、關閉監聽套接字。那什麼時候重新開啟呢?
(5)、如果是epoll模型,可以改用edge trigger。問題是如果漏掉了一次accept(2),程式再也不會收到新連線(沒有狀態變化)
(6)、準備一個空閒的檔案描述符。遇到這種情況,先關閉這個空閒檔案,獲得一個檔案描述符名額;再accept(2)拿到socket連線的檔案描述符;隨後立刻close(2),這樣就優雅地斷開了與客戶端的連線;最後重新開啟空閒檔案,把“坑”填上,以備再次出現這種情況時使用。

如下面的程式碼片段:
 C++ Code 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int idlefd = open("/dev/null", O_RDONLY | O_CLOEXEC);
connfd = accept4(listenfd, (struct sockaddr *)&peeraddr,
                 &peerlen, SOCK_NONBLOCK | SOCK_CLOEXEC);

/*          if (connfd == -1)
                ERR_EXIT("accept4");
*/


if (connfd == -1)
{
    if (errno == EMFILE)
    {
        close(idlefd);
        idlefd = accept(listenfd, NULLNULL);
        close(idlefd);
        idlefd = open("/dev/null", O_RDONLY | O_CLOEXEC);
        continue;
    }
    else
        ERR_EXIT("accept4");
}



參考:
muduo manual.pdf
《linux 多執行緒伺服器程式設計:使用muduo c++網路庫》
http://vincent.bernat.im/en/blog/2014-tcp-time-wait-state-linux.html

相關文章