天天看點

「運維」從Linux核心看socket底層的本質(IO)

一、I/O 模型

一個輸入操作通常包括兩個階段:

  • 等待資料準備好
  • 從核心向程序複制資料

對于一個套接字上的輸入操作,第一步通常涉及等待資料從網絡中到達。當所等待資料到達時,它被複制到核心中的某個緩沖區。第二步就是把資料從核心緩沖區複制到應用程序緩沖區。

Unix 有五種 I/O 模型:

  • 阻塞式 I/O
  • 非阻塞式 I/O
  • I/O 複用(select 和 poll)
  • 信号驅動式 I/O(SIGIO)
  • 異步 I/O(AIO)

阻塞式 I/O

應用程序被阻塞,直到資料從核心緩沖區複制到應用程序緩沖區中才傳回。

應該注意到,在阻塞的過程中,其它應用程序還可以執行,是以阻塞不意味着整個作業系統都被阻塞。因為其它應用程序還可以執行,是以不消耗 CPU 時間,這種模型的 CPU 使用率會比較高。

下圖中,recvfrom() 用于接收 Socket 傳來的資料,并複制到應用程序的緩沖區 buf 中。這裡把 recvfrom() 當成系統調用。

ssize_t recvfrom(
int sockfd, void *buf, 
size_t len, int flags, 
struct sockaddr *src_addr, 
socklen_t *addrlen);
           
「運維」從Linux核心看socket底層的本質(IO)

非阻塞式 I/O

應用程序執行系統調用之後,核心傳回一個錯誤碼。應用程序可以繼續執行,但是需要不斷的執行系統調用來獲知 I/O 是否完成,這種方式稱為輪詢(polling)。

由于 CPU 要處理更多的系統調用,是以這種模型的 CPU 使用率比較低。

「運維」從Linux核心看socket底層的本質(IO)

I/O 複用

使用 select 或者 poll 等待資料,并且可以等待多個套接字中的任何一個變為可讀。這一過程會被阻塞,當某一個套接字可讀時傳回,之後再使用 recvfrom 把資料從核心複制到程序中。

它可以讓單個程序具有處理多個 I/O 事件的能力。又被稱為 Event Driven I/O,即事件驅動 I/O。

如果一個 Web 伺服器沒有 I/O 複用,那麼每一個 Socket 連接配接都需要建立一個線程去處理。如果同時有幾萬個連接配接,那麼就需要建立相同數量的線程。相比于多程序和多線程技術,I/O 複用不需要程序線程建立和切換的開銷,系統開銷更小。

「運維」從Linux核心看socket底層的本質(IO)

信号驅動 I/O

應用程序使用 sigaction 系統調用,核心立即傳回,應用程序可以繼續執行,也就是說等待資料階段應用程序是非阻塞的。核心在資料到達時向應用程序發送 SIGIO 信号,應用程序收到之後在信号處理程式中調用 recvfrom 将資料從核心複制到應用程序中。

相比于非阻塞式 I/O 的輪詢方式,信号驅動 I/O 的 CPU 使用率更高。

「運維」從Linux核心看socket底層的本質(IO)

異步 I/O

應用程序執行 aio_read 系統調用會立即傳回,應用程序可以繼續執行,不會被阻塞,核心會在所有操作完成之後向應用程序發送信号。

異步 I/O 與信号驅動 I/O 的差別在于,異步 I/O 的信号是通知應用程序 I/O 完成,而信号驅動 I/O 的信号是通知應用程序可以開始 I/O。

「運維」從Linux核心看socket底層的本質(IO)

五大 I/O 模型比較

  • 同步 I/O:将資料從核心緩沖區複制到應用程序緩沖區的階段(第二階段),應用程序會阻塞。
  • 異步 I/O:第二階段應用程序不會阻塞。

同步 I/O 包括阻塞式 I/O、非阻塞式 I/O、I/O 複用和信号驅動 I/O ,它們的主要差別在第一個階段。

非阻塞式 I/O 、信号驅動 I/O 和異步 I/O 在第一階段不會阻塞。

「運維」從Linux核心看socket底層的本質(IO)

二、I/O 複用

select/poll/epoll 都是 I/O 多路複用的具體實作,select 出現的最早,之後是 poll,再是 epoll。

select

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

select 允許應用程式監視一組檔案描述符,等待一個或者多個描述符成為就緒狀态,進而完成 I/O 操作。

  • fd_set 使用數組實作,數組大小使用 FD_SETSIZE 定義,是以隻能監聽少于 FD_SETSIZE 數量的描述符。有三種類型的描述符類型:readset、writeset、exceptset,分别對應讀、寫、異常條件的描述符集合。
  • timeout 為逾時參數,調用 select 會一直阻塞直到有描述符的事件到達或者等待的時間超過 timeout。
  • 成功調用傳回結果大于 0,出錯傳回結果為 -1,逾時傳回結果為 0。
fd_set fd_in, fd_out;
struct timeval tv;

// Reset the sets
FD_ZERO( &fd_in );
FD_ZERO( &fd_out );

// Monitor sock1 for input events
FD_SET( sock1, &fd_in );

// Monitor sock2 for output events
FD_SET( sock2, &fd_out );

// Find out which socket has the largest numeric value as select requires it
int largest_sock = sock1 > sock2 ? sock1 : sock2;

// Wait up to 10 seconds
tv.tv_sec = 10;
tv.tv_usec = 0;

// Call the select
int ret = select( largest_sock + 1, &fd_in, &fd_out, NULL, &tv );

// Check if select actually succeed
if ( ret == -1 )
    // report error and abort
else if ( ret == 0 )
    // timeout; no event detected
else
{
    if ( FD_ISSET( sock1, &fd_in ) )
        // input event on sock1

    if ( FD_ISSET( sock2, &fd_out ) )
        // output event on sock2
}
           

poll

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

poll 的功能與 select 類似,也是等待一組描述符中的一個成為就緒狀态。

poll 中的描述符是 pollfd 類型的數組,pollfd 的定義如下:

struct pollfd {
               int   fd;         /* file descriptor */
               short events;     /* requested events */
               short revents;    /* returned events */
           };
           
// The structure for two events
struct pollfd fds[2];

// Monitor sock1 for input
fds[0].fd = sock1;
fds[0].events = POLLIN;

// Monitor sock2 for output
fds[1].fd = sock2;
fds[1].events = POLLOUT;

// Wait 10 seconds
int ret = poll( &fds, 2, 10000 );
// Check if poll actually succeed
if ( ret == -1 )
    // report error and abort
else if ( ret == 0 )
    // timeout; no event detected
else
{
    // If we detect the event, zero it out so we can reuse the structure
    if ( fds[0].revents & POLLIN )
        fds[0].revents = 0;
        // input event on sock1

    if ( fds[1].revents & POLLOUT )
        fds[1].revents = 0;
        // output event on sock2
}
           

比較

1. 功能

select 和 poll 的功能基本相同,不過在一些實作細節上有所不同。

  • select 會修改描述符,而 poll 不會;
  • select 的描述符類型使用數組實作,FD_SETSIZE 大小預設為 1024,是以預設隻能監聽少于 1024 個描述符。如果要監聽更多描述符的話,需要修改 FD_SETSIZE 之後重新編譯;而 poll 沒有描述符數量的限制;
  • poll 提供了更多的事件類型,并且對描述符的重複利用上比 select 高。
  • 如果一個線程對某個描述符調用了 select 或者 poll,另一個線程關閉了該描述符,會導緻調用結果不确定。

2. 速度

select 和 poll 速度都比較慢,每次調用都需要将全部描述符從應用程序緩沖區複制到核心緩沖區。

3. 可移植性

幾乎所有的系統都支援 select,但是隻有比較新的系統支援 poll。

epoll

int epoll_create(int size);
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);
           

epoll_ctl() 用于向核心注冊新的描述符或者是改變某個檔案描述符的狀态。已注冊的描述符在核心中會被維護在一棵紅黑樹上,通過回調函數核心會将 I/O 準備好的描述符加入到一個連結清單中管理,程序調用 epoll_wait() 便可以得到事件完成的描述符。

從上面的描述可以看出,epoll 隻需要将描述符從程序緩沖區向核心緩沖區拷貝一次,并且程序不需要通過輪詢來獲得事件完成的描述符。

epoll 僅适用于 Linux OS。

epoll 比 select 和 poll 更加靈活而且沒有描述符數量限制。

epoll 對多線程程式設計更有友好,一個線程調用了 epoll_wait() 另一個線程關閉了同一個描述符也不會産生像 select 和 poll 的不确定情況。

// Create the epoll descriptor. Only one is needed per app, and is used to monitor all sockets.
// The function argument is ignored (it was not before, but now it is), so put your favorite number here
int pollingfd = epoll_create( 0xCAFE );

if ( pollingfd < 0 )
 // report error

// Initialize the epoll structure in case more members are added in future
struct epoll_event ev = { 0 };

// Associate the connection class instance with the event. You can associate anything
// you want, epoll does not use this information. We store a connection class pointer, pConnection1
ev.data.ptr = pConnection1;

// Monitor for input, and do not automatically rearm the descriptor after the event
ev.events = EPOLLIN | EPOLLONESHOT;
// Add the descriptor into the monitoring list. We can do it even if another thread is
// waiting in epoll_wait - the descriptor will be properly added
if ( epoll_ctl( epollfd, EPOLL_CTL_ADD, pConnection1->getSocket(), &ev ) != 0 )
    // report error

// Wait for up to 20 events (assuming we have added maybe 200 sockets before that it may happen)
struct epoll_event pevents[ 20 ];

// Wait for 10 seconds, and retrieve less than 20 epoll_event and store them into epoll_event array
int ready = epoll_wait( pollingfd, pevents, 20, 10000 );
// Check if epoll actually succeed
if ( ret == -1 )
    // report error and abort
else if ( ret == 0 )
    // timeout; no event detected
else
{
    // Check if any events detected
    for ( int i = 0; i < ready; i++ )
    {
        if ( pevents[i].events & EPOLLIN )
        {
            // Get back our connection pointer
            Connection * c = (Connection*) pevents[i].data.ptr;
            c->handleReadEvent();
         }
    }
}
           

工作模式

epoll 的描述符事件有兩種觸發模式:LT(level trigger)和 ET(edge trigger)。

1. LT 模式

當 epoll_wait() 檢測到描述符事件到達時,将此事件通知程序,程序可以不立即處理該事件,下次調用 epoll_wait() 會再次通知程序。是預設的一種模式,并且同時支援 Blocking 和 No-Blocking。

2. ET 模式

和 LT 模式不同的是,通知之後程序必須立即處理事件,下次再調用 epoll_wait() 時不會再得到事件到達的通知。

很大程度上減少了 epoll 事件被重複觸發的次數,是以效率要比 LT 模式高。隻支援 No-Blocking,以避免由于一個檔案句柄的阻塞讀/阻塞寫操作把處理多個檔案描述符的任務餓死。

應用場景

很容易産生一種錯覺認為隻要用 epoll 就可以了,select 和 poll 都已經過時了,其實它們都有各自的使用場景。

select 應用場景

select 的 timeout 參數精度為微秒,而 poll 和 epoll 為毫秒,是以 select 更加适用于實時性要求比較高的場景,比如核反應堆的控制。

select 可移植性更好,幾乎被所有主流平台所支援。

poll 應用場景

poll 沒有最大描述符數量的限制,如果平台支援并且對實時性要求不高,應該使用 poll 而不是 select。

epoll 應用場景

隻需要運作在 Linux 平台上,有大量的描述符需要同時輪詢,并且這些連接配接最好是長連接配接。

需要同時監控小于 1000 個描述符,就沒有必要使用 epoll,因為這個應用場景下并不能展現 epoll 的優勢。

需要監控的描述符狀态變化多,而且都是非常短暫的,也沒有必要使用 epoll。因為 epoll 中的所有描述符都存儲在核心中,造成每次需要對描述符的狀态改變都需要通過 epoll_ctl() 進行系統調用,頻繁系統調用降低效率。并且 epoll 的描述符存儲在核心,不容易調試。

文章來源:https://zhuanlan.zhihu.com/p/477292559

繼續閱讀