Linux網絡編程 - select

接下來幾篇文章,我們將把注意力放在高併發高性能的網絡服務器程序上。

I/O 多路複用

前面文章設計的程序,都是從標準輸入接收數據輸入,然後通過套接字發送出去,同時,該程序也通過套接字接收對方發送的數據流。我們可以使用 fgets 方法等待標準輸入,但是這樣就沒有辦法在套接字有數據的時候讀出數據。我們也可以使用 read 方法等待套接字有數據返回,但是這樣做也沒有辦法在標準輸入有數據的情況下,讀入數據併發送給對方。

I/O 多路複用的設計初衷就是解決這樣的場景。我們可以把標準輸入、套接字等都看做 I/O 的一路,多路複用的意思,就是在任何一路 I/O 有“事件”發生的情況下,通知應用程序去處理相應的 I/O 事件,這樣我們的程序就變成了“多面手”,在同一時刻彷彿可以處理多個 I/O 事件。

select 函數就是這樣一種常見的 I/O 多路複用技術,我們將在後面繼續講解其他的多路複用技術。使用 select 函數,通知內核掛起進程,當一個或多個 I/O 事件發生後,控制權返還給應用程序,由應用程序進行 I/O 事件的處理。

這些 I/O 事件的類型非常多,比如:

  • 標準輸入文件描述符準備好可以讀。
  • 監聽套接字準備好,新的連接已經建立成功。
  • 已連接套接字準備好可以寫。
  • 如果一個 I/O 事件等待超過了 10 秒,發生了超時事件。

select 函數的使用方法

int select(int maxfd, fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout);

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

在這個函數中,maxfd 表示的是待測試的描述符基數,它的值是待測試的最大描述符加 1。Linux系統中select支持的maxfd的最大值爲1024,於是之前的數組大小就可以計算出來是32。比如現在的 select 待測試的描述符集合是{0,1,4},那麼 maxfd 就是 5。緊接着的是三個描述符集合,分別是讀描述符集合 readset、寫描述符集合 writeset 和異常描述符集合 exceptset,這三個分別通知內核,在哪些描述符上檢測數據可以讀,可以寫和有異常發生。三個描述符集合中的每一個都可以設置成空,這樣就表示不需要內核進行相關的檢測。

void FD_ZERO(fd_set *fdset);      
void FD_SET(int fd, fd_set *fdset);  
void FD_CLR(int fd, fd_set *fdset);   
int  FD_ISSET(int fd, fd_set *fdset);

利用上面這些宏,可以設置描述符集合。怎麼去理解他們?下面有一個向量,代表了一個描述符集合,其中的每個元素都是二進制中的0或者1:

       a[maxfd-1], ..., a[1], a[0]

FD_ZERO 用來將這個向量的所有元素都設置成 0;

FD_SET 用來把對應套接字 fd 的元素,a[fd]設置成 1;

FD_CLR 用來把對應套接字 fd 的元素,a[fd]設置成 0;

FD_ISSET 對這個向量進行檢測,判斷出對應套接字的元素 a[fd]是 0 還是 1。

實際上,很多系統是用一個整型數組來表示一個描述字集合的,一個 32 位的整型數可以表示 32 個描述字,例如第一個整型數表示 0-31 描述字,第二個整型數可以表示 32-63 描述字,以此類推。像下面這樣:

00000000 00000000 00000000 10010010

這個32bit分別表示了描述字7,4和1設置爲1,其他的設置爲0。

最後一個參數是 timeval 結構體時間:

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

這個參數設置成不同的值,會有不同的可能:第一個可能是設置成空 (NULL),表示如果沒有 I/O 事件發生,則 select 一直等待下去。第二個可能是設置一個非零的值,這個表示等待固定的一段時間後從 select 阻塞調用中返回,這在前面的例子裏使用過。第三個可能是將 tv_sec 和 tv_usec 都設置成 0,表示根本不等待,檢測完畢立即返回。這種情況使用得比較少。

看下面的程序,來深入理解一下select函數的使用:

int main(int argc, char **argv) {
    if (argc != 2) {
        printf("usage: select01 <IPaddress>\n");
        return 0;
    }
    int sockfd;
    int connect_rt;
    struct sockaddr_in serv_addr;

    sockfd = socket(PF_INET, SOCK_STREAM, 0);
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(7878);
    inet_pton(AF_INET, "192.168.133.131", &serv_addr.sin_addr);
    connect_rt = connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
    if (connect_rt < 0)
    {
        fprintf(stderr, "Connect failed !\n");
        exit(0);
    }

    char recv_line[MAXLINE], send_line[MAXLINE];
    int n;
    fd_set readmask;
    fd_set allreads;
    FD_ZERO(&allreads);
    FD_SET(0, &allreads);
    FD_SET(socket_fd, &allreads);

    for (;;) {
        readmask = allreads;
        int rc = select(socket_fd + 1, &readmask, NULL, NULL, NULL);
        if (rc <= 0) {
            printf("select failed");
            exit(0);
        }

        if (FD_ISSET(socket_fd, &readmask)) {
            n = read(socket_fd, recv_line, MAXLINE);
            if (n < 0) {
                printf("read error\n");
                continue;
            } else if (n == 0) {
                printf("server terminated \n");
                continue;
            }
            recv_line[n] = 0;
            fputs(recv_line, stdout);
            fputs("\n", stdout);
        }
        if (FD_ISSET(stdin, &readmask)) {
            if (fgets(send_line, MAXLINE, stdin) != NULL) {
                int i = strlen(send_line);
                if (send_line[i - 1] == '\n') {
                    send_line[i - 1] = 0;
                }

                printf("now sending %s\n", send_line);
                size_t rt = write(socket_fd, send_line, strlen(send_line));
                if (rt < 0) {
                    printf("write failed \n");
                    continue;
                }
                printf("send bytes: %zu \n", rt);
            }
        }
    }
}

程序中通過 FD_ZERO 初始化了一個描述符集合,這個描述符讀集合是空的:

                             

接下來,分別使用 FD_SET 將描述符 0,即標準輸入,以及連接套接字描述符 3 設置爲待檢測:

                             

接下來是循環檢測,這裏我們沒有阻塞在 fgets 或 read 調用,而是通過 select 來檢測套接字描述字有數據可讀,或者標準輸入有數據可讀。比如,當用戶通過標準輸入使得標準輸入描述符可讀時,返回的 readmask 的值爲:

                            

這個時候 select 調用返回,可以使用 FD_ISSET 來判斷哪個描述符準備好可讀了。如上圖所示,這個時候是標準輸入可讀,程序讀入後發送給對端。如果是連接描述字準備好可讀了,判斷爲真,使用 read 將套接字數據讀出。

我們需要注意的是,每次測試完之後,重新設置待測試的描述符集合。你可以看到上面的例子,在 select 測試之前的數據是{0,3},select 測試之後就變成了{0}。

這是因爲 select 調用每次完成測試之後,內核都會修改描述符集合,通過修改完的描述符集合來和應用程序交互,應用程序使用 FD_ISSET 來對每個描述符進行判斷,從而知道什麼樣的事件發生。

套接字描述符就緒條件

當我們說 select 測試返回,某個套接字準備好可讀,表示什麼樣的事件發生呢?

第一種情況是套接字接收緩衝區有數據可以讀,如果我們使用 read 函數去執行讀操作,肯定不會被阻塞,而是會直接讀到這部分數據。

第二種情況是對方發送了 FIN,使用 read 函數執行讀操作,不會被阻塞,直接返回 0。

第三種情況是針對一個監聽套接字而言的,有已經完成的連接建立,此時使用 accept 函數去執行不會阻塞,直接返回已經完成的連接。

第四種情況是套接字有錯誤待處理,使用 read 函數去執行讀操作,不阻塞,且返回 -1。

總結成一句話就是,內核通知我們套接字有數據可以讀了,使用 read 函數不會阻塞。

select 檢測套接字可寫,完全是基於套接字本身的特性來說的,具體來說有以下幾種情況。

第一種是套接字發送緩衝區足夠大,如果我們使用非阻塞套接字進行 write 操作,將不會被阻塞,直接返回。

第二種是連接的寫半邊已經關閉,如果繼續進行寫操作將會產生 SIGPIPE 信號。

第三種是套接字上有錯誤待處理,使用 write 函數去執行讀操作,不阻塞,且返回 -1。

總結成一句話就是,內核通知我們套接字可以往裏寫了,使用 write 函數就不會阻塞

最後,select 函數提供了最基本的 I/O 多路複用方法,在使用 select 時,我們需要建立兩個重要的認識:

描述符基數是當前最大描述符 +1;

每次 select 調用完成之後,記得要重置待測試集合。

 

溫故而知新 !

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章