第6章 I/O 複用:select 和 poll 函式

概述

I/O 複用:程序指定了一個或多個 I/O ,當程序具有這樣的能力,就是預先告知核心,使得核心一旦發現程序指定的一個或多個 I/O 條件就緒時,核心就通知程序。這個能力就是 I/O 複用。是由 select 和 poll 這兩個函式支援的。

網路應用場合:

(1)當客戶處理多個描述符(通常是互動式輸入和網路套接字)時,必須使用 I/O 複用。

(2)一個客戶同時處理多個套接字是可能的,不過比較少見。

(3)如果一個 TCP 伺服器既要處理監聽套接字,又要處理已連線套接字,一般就要使用 I/O 複用。

(4)如果一個伺服器既要處理 TCP,又要處理 UDP,一般就要使用 I/O 複用。

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

I/O 複用並非只限於網路程式設計,許多重要的應用程式也需要使用這項技術。


I/O 模型

  • 阻塞式 I/O

  • 非阻塞式 I/O

  • I/O 複用(select 和 poll)

  • 訊號驅動式 I/O(SIGO)

  • 非同步 I/O(POSIX 的 aio_ 系列函式)

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

1. 阻塞式 I/O 模型

程序在從呼叫 recvfrom 開始到它返回的整段時間內是被阻塞的。

2. 非阻塞式 I/O 模型

程序把一個套接字設定成非阻塞是在通知核心:當所請求的 I/O 操作非得把本程序投入睡眠才能完成時,不要把本程序投入睡眠,而是返回一個錯誤。

3. I/O 複用模型

有了 I/O 複用,我們就可以呼叫 select 或 poll 系統呼叫,讓程序阻塞在這兩個系統呼叫中的某個之上,而不是阻塞在真正的 I/O 系統呼叫上。

我們阻塞於 select 呼叫,等待資料包套接字變為可讀。當 select 返回套接字可讀這一條件時,我們呼叫 recvfrom 把所讀資料包復制到應用程序緩衝區。

與 I/O 複用密切相關的另一種 I/O 模型是在多執行緒中使用阻塞式 I/O。這種模型與上述模型極為相似,但它沒有使用 select 阻塞在多個檔案描述符上,而是使用多個執行緒,這樣每個執行緒都可以自由地呼叫諸如 recvfrom 之類的阻塞式 I/O 系統呼叫了。

4. 訊號驅動式 I/O 模型

我們也可以使用訊號,讓核心在描述符就緒時傳送 SIGIO 訊號通知我們。我們稱這種模型為訊號驅動式 I/O。

我們首先開啟套接字的訊號驅動式 I/O 功能,並通過 sigaction 系統呼叫安裝一個訊號處理函式。該系統呼叫立即返回,我們的程序繼續工作,也就是說它沒有被阻塞。當資料包準備好讀取時,核心就為該程序產生一個 SIGIO 訊號。我們隨後既可以在訊號處理函式中呼叫 recvfrom 讀取資料包,並通知主迴圈資料已準備好待處理,也可以立即通知主迴圈,讓它讀取資料包。

無論如何處理 SIGIO 訊號,這種模型的優勢在於等待資料包到達期間程序不被阻塞。主迴圈可以繼續執行,只要等待來自訊號處理函式的通知:既可以是資料已準備好處理,也可以是資料包已準備好被讀取。

5. 非同步 I/O 模型

非同步 I/O 由 POSIX 規範定義。這些函式的工作機制是:告知核心啟動某個操作。並讓核心在整個操作(包括將資料從核心複製到我們自己的緩衝區)完成後通知我們。這種模型與前一節介紹的訊號驅動模型的主要區別在於:訊號驅動式 I/O 是由核心通知我們何時可以啟動一個 I/O 操作,而非同步 I/O 模型是由核心通知我們 I/O 操作何時完成。

5 種 I/O 模式的比較


select 函式

該函式允許程序指示核心等待多個事件中的任何一個發生,並只在有一個或多個事件發生或經歷一段指定的時間後才喚醒它。

#include <sys/select.h>
#include <sys/time.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

timeout 告知核心等待所指定描述符中的任何一個就緒可花多長時間。該引數有以下可能:

(1)永遠等待下去:僅在有一個描述符準備好 I/O 時才返回。為此我們把該引數設定為空指標。

(2)等待一段固定時間:在有一個描述符準備好 I/O 時返回,但是不超過由該引數所指向的時間。

(3)根本不等待:檢查描述符後立即返回,這稱為輪詢(polling)。該引數必須指向一個 timeval 結構,而且其中的秒數和微秒數數必須為 0 。

前兩種情形的等待通常會被程序在等待期間捕獲的訊號中斷,並從訊號處理函式返回。這導致我們可能需要重啟 select,所以我們必須做好 select 返回 EINTR 錯誤的準備。

timeout 引數在 Unix 中被定義為 const,表示不被修改。舉例來說,如果我們指定一個 10s 的超時值,不過在定時器到時之前 select 就返回了(結果可能是有一個或多個描述符就緒,也可能是得到 EINTR 錯誤),那麼 timeout 引數所指向的 timeval 結構不會被更新成該函式返回時剩餘的秒數。如果我們需要知道這個值,那麼必須在呼叫 select 之前取得系統時間,它返回後再取得系統時間,兩者相減就是改值(任何健壯的程式都得考慮到系統時間可能在這段時間內偶爾會被管理員或 ntpd 之類守護程序調整)。

timeout 在有些 Linux 版本會修改這個 timeval 結構。如我這是 Ubuntu,上面的確沒有使用 const 做限定。

中間三個引數 readfds、writefds、exceptfds 指定我們要讓核心測試讀、寫和異常條件的描述符。如果我們對某一個的條件不感興趣,就可以把它設為空指標。事實上,如果這三個指標均為空,我們就有了一個比 Unix 的 sleep 函式更為精確的定時器。

select 使用的描述符集通常是一個整數陣列,其中每個整數中的每一位對應一個描述符。比如一個 32 整數,那麼該陣列的第一個元素對應於描述符 0~31,第二個元素對應於描述符32~63,依此類推。如下:

0

12……30313233……626364……

第一個整數

第二個整數

      ……

所有這些實現細節都與應用程式無關,它們隱藏在名為 fd_set 的資料型別和以下四個巨集中:

    void FD_CLR(int fd, fd_set *set);    /*關閉 set 的 fd 位*/
int  FD_ISSET(int fd, fd_set *set);    /*fd 是否在 set 中*/
void FD_SET(int fd, fd_set *set);    /*開啟 set 的 fd 位*/
void FD_ZERO(fd_set *set);    /*初始化,清空 set 的所有位*/

我們開啟 <bits/typesizes.h>、<sys/select.h> 可以看到:

    /* Number of descriptors that can fit in an `fd_set'.  */
#define __FD_SETSIZE		1024
/* Maximum number of file descriptors in `fd_set'.  */
#define	FD_SETSIZE		__FD_SETSIZE

nfds 引數指定待測試的描述符個數,它的值是待測試的最大描述符加 1,描述符 0,1,2 …… 一直到 nfds – 1 均將被測試。如開啟描述符 1、4、5,其 nfds 的值就是 6 。這是為效率考慮而設計的,表示程序與核心之間不復制描述符集中不必要的部分,只複製前 6 個,從而不測試總為 0 的那些位。

返回值:若有就緒描述符則為其數目,若超時則為 0,若出錯則為 -1。

該函式返回後,我們使用 FD_ISSET 巨集來測試 fd_set 資料型別中的描述符。描述符集內任何與未就緒描述符對應的位返回時均清成 0。為此,每次重新呼叫 select 函式時,我們都得再次把所有描述符集內所關心的位均置為 1。

描述符就緒條件

(1)滿足下列四個條件之一,一個套接字準備好讀:

  • 該套接字接受緩衝區中的資料位元組數大於等於套接字接收緩衝區低水位標記(SO_RCVLOWAT 套接字選項來設定)的當前大小。對於 TCP 和 UDP 套接字而言,低水位標記其預設值為 1。
  • 該連線的讀半部關閉(也就是接收了 FIN 的 TCP 連線)。對這樣的套接字的讀操作將不阻塞並返回 0(也就是 EOF)。
  • 該套接字是一個監聽套接字且已完成的連線數不為 0。對這樣的套接字的 accept 通常不會阻塞。
  • 其上有一個套接字錯誤待處理。對這樣的套接字讀操作立即返回 -1,同時把 errno 置成錯誤條件。這些待處理的錯誤也可以通過指定 SO_ERROR 套接字選項呼叫 getsockopt 獲取並清除。

(2)下列四條件之一滿足,套接字準備好寫:

  • 該套接字傳送緩衝區中的可用空間位元組數大於等於套接字傳送緩衝區低水位標記的當前大小,並且或者該套接字已連線,或者該套接字不需要連線(如 UDP)。我們可以使用 SO_SNDLOWAT 選項來設定該套接字的低水位標記,對於 TCP、UDP 而言,其預設值為 2048。
  • 該連線的寫半部關閉。對這樣的套接字寫操作將產生 SIGPIPE 訊號。
  • 使用非阻塞式 connect 的套接字已建立連線,或 connect 已經以失敗告終。
  • 其上有一個套接字錯誤待處理。對這樣的套接字寫操作立即返回 -1,同時把 errno 置成錯誤條件。這些待處理的錯誤也可以通過指定 SO_ERROR 套接字選項呼叫 getsockopt 獲取並清除。

(3)如果一個套接字存在帶外資料或者仍處於帶外標記,那麼它有異常條件待處理。

注意:接收低水位標記和傳送低水位標記的目的在於允許程序控制在 select 返回之前,有多少資料可讀或者有多大空間可寫。比如,如果我們知道除非至少存在 64 個位元組的資料,否則我們的應用程式沒有任何有效工作可做,那麼可以把接收低水位標記設定為 64,以防少於 64 個位元組的資料準備好讀時 select 喚醒我們。


使用 select 的 str_cli 用例

功能:接受標準 I/O 的輸入,並通過套接字傳送接受的輸入

void str_cli(FILE *fp, int sockfd)
{
int maxfdp1;
fd_set rset;
char sendline[MAXLINE], recvline[MAXLINE];
FD_ZERO(&rset);
for(;;){
FD_SET(fileno(fp), &rset);
FD_SET(sockfd, &rset);
maxfdp1 = max(fileno(fp), sockfd)   1;    /* max fd   1*/
select(maxfdp1, &rset, NULL, NULL, NULL);
if(FD_ISSET(sockfd, &rset)){    /* socket is readable */
if(read(sockfd, recvline, MAXLINE) == 0)
{
err_quit("str_cli: server terminated prematurely");
}
}
if(FD_ISSET(fileno(fd), &rset)) {    /* input is readable */
if(fgets(sendline, MAXLINE, fd) == NULL)
return;
write(sockfd, sendline, strlen(sendline));
}
}
}

shutdown 函式

該函式用來終止網路連線。

(1)終止網路連線的通常做法是呼叫 close,然而 close 是把描述符引用計數變為 0 時才關閉套接字。使用 shutdown 可以不管引用計數就激發 TCP 的正常連線終止序列。

(2)close 終止讀和寫兩個方向的資料傳送。然而我們有時並不希望這樣。比如 TCP 連線是全雙工的,有時候我們需要告知對端我們已經完成資料的傳送,即使對端仍有資料要傳送給我們,這時我們就不能把讀也關閉掉。

客戶呼叫 shutdown 關閉寫,表示完成寫操作,傳送 FIN。伺服器接收到 FIN,返回資料和 FIN 確認,這時伺服器可能還有資料要傳送給客戶,等所有資料傳送完畢後,服務端應呼叫 close 關閉連線(會引發服務端的讀寫關閉,以及客戶端的讀關閉)。

       #include <sys/socket.h>
int shutdown(int sockfd, int how);

該函式的行為依賴 how 引數值:

SHUT_RD:關閉連線的讀這一半,套接字中不再有資料可接收,而且接受緩衝區中現有的資料都被丟棄。對一個 TCP 套接字這樣呼叫 shutdown 後,該套接字接收的來自對端的任何資料都被確認,然後悄然丟棄。

SHUT_WR:關閉連線的寫這一半,當前留在套接字傳送緩衝區中的資料將被髮送掉,後跟 TCP 的正常連線終止序列。程序不能再對這樣的套接字呼叫任何寫函式。

SHUT_RDWR:連線的讀和寫都關閉。


pselect 函式

該函式是 POSIX 發明的,如今有許多 Unix 變種支援它。

       #include <sys/select.h>
int pselect(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, const struct timespec *timeout,
const sigset_t *sigmask);

返回:若有就緒描述符則為其數目,若超時則為 0,若出錯則為 -1。

相較於 select 有兩個變化:

(1)使用 timespec 結構,它能指定納秒級。

(2)增加了第六個引數:一個指向訊號掩碼的指標。該引數允許程式先禁止遞交某些訊號,再測試由這些當前被禁止訊號的訊號處理函式設定的全域性變數,然後呼叫 pselect,告訴它重新設定訊號掩碼。

我們先來思考一下這種情況:一般情況,我們為了避免 select 呼叫阻塞時被訊號中斷,往往會寫成這樣

void sig_intr(int signo)
{
intr_flag |= signo;
}
void main()
{
...
if( intr_flag )
handle_intr(); --------------------------------- 1
if( (nready = select(...)) < 0 ) ------------------ 2
{
if( errno == EINTR )
{
if( intr_flag )
handle_intr();
}
...
}
...
}

如果在 1 和 2 之間訊號到達,接著執行 select ,這時 select 並不會出錯,如果 select 一直阻塞著,那麼雖然 intr_flag 已改變,但這個 handle_intr() 卻得不到處理。也就是這個訊號將丟失。

所以正確的寫法是使用 sigprocmask 來阻塞訊號:

void sig_intr(int signo)
{
intr_flag |= signo;
}
void main()
{
...
sigset_t newmask, oldmask;
sigemptyset(&newmask);
sigaddset(&newmask, SIGINT);
sigprocmask(SIG_BLOCK, &newmask, &oldmask);
if( intr_flag )
handle_intr(); --------------------------------- 1
//sigprocmask(SIG_BLOCK, &oldmask, NULL);
if( (nready = select(...)) < 0 ) ------------------ 2
{
if( errno == EINTR )
{
if( intr_flag )
handle_intr();
}
...
}
...
}

但是這樣有一個問題,select 也不會因為這個訊號而返回錯誤,因為訊號被阻塞了。如果在呼叫 select 之前恢復遮蔽呢,這又變成了上面一種情況。使用 pselect 可以解決這樣的問題。

void sig_intr(int signo)
{
intr_flag |= signo;
}
void main()
{
...
sigset_t zeromask, newmask, oldmask;
sigemptyset(&zeromask);
sigemptyset(&newmask);
sigaddset(&newmask, SIGINT);
sigprocmask(SIG_BLOCK, &newmask, &oldmask);
if( intr_flag )
handle_intr(); --------------------------------- 1
if( (nready = pselect(..., &zeromask)) < 0 ) ------------------ 2
{
if( errno == EINTR )
{
if( intr_flag )
handle_intr();
}
...
}
...
}

pselect(…,&zeromask) 與 pselect(…,NULl) 意義完全不同,前者是在呼叫期間不阻塞任何新訊號,後者和呼叫 select 相同,保持原來的訊號 mask。這時,pselect 使用 zeromask 替代原來的 mask,等返回時又還原為原來的 mask。所以,當阻塞過程中有 INT 訊號到來時,就會引起錯誤。當然,只要我們在 pselect 的 mask 中新增要遮蔽的訊號,那麼 pselect 也會遮蔽這些訊號。


poll 函式