歡迎來到Linux教程網
Linux教程網
Linux教程網
Linux教程網
Linux教程網 >> Linux基礎 >> 關於Linux >> UNIX網絡編程筆記(5)—I/O復用select/poll

UNIX網絡編程筆記(5)—I/O復用select/poll

日期:2017/3/1 11:46:26   编辑:關於Linux

1. 概述

考慮一種情況,當客戶端阻塞於fgets調用時,服務器進程被殺死;此時服務器TCP雖然正確地給客戶TCP發送了一個FIN,但是由於客戶進程阻塞於標准輸入的過程,直到從套接字讀時為止。這樣的進程就需要一種機制,使得內核一旦發現進程指定的一個或多個I/O條件就緒,就通知進程。這個能力就叫做I/O復用。由select和poll函數支持的。

I/O復用典型使用在下列網絡應用場合:

(1)客戶處理多個描述符。
(2) 客戶通知處理多個套接字。
(3) 服務器既要處理TCP又要處理UDP。
(4) TCP服務器既要處理監聽套接字,又要處理已連接套接字。
(5) 服務器要處理多個服務或者多個協議。


2. I/O模型

五種I/O模型簡介:

(1)阻塞式I/O
(2)非阻塞式I/O
(3)I/O復用
(4)信號驅動式I/O(SIGIO)
(5)異步I/O

一個輸入操作一通常包括兩個不同的階段:
1.等待數據准備好。(通常涉及等待分組網絡到達,數據被復制到某個緩沖區)
2.從內核向進程拷貝數據。(把數據和內核緩沖區復制到應用進程的緩沖區)

2.1 阻塞式I/O

默認情況下所有套接字都是阻塞的。阻塞調用很好理解,以UDP數據報套接字為例:
當應用進程調用recvfrom時,會阻塞等待,這裡是等待網絡中的數據到達並復制到內核的某個緩沖區。
接著,將數據從內核空間復制到用戶空間,復制完成後,recvfrom調用才找能成功返回。

我們說recvfrom是阻塞的,因為它直到“數據報到達且被復制到應用進程的緩沖區”或者“發生錯誤”時才返回。

2.2 非阻塞式I/O

書中給出一個概念就是:當所請求的的I/O操作非得把本進程投入睡眠才能完成時,不要把本進程投入睡眠,而是返回一個錯誤。其實就是當數據還沒有到達的時候不阻塞等待,而是直接返回,這樣周而復始循環調用直到有數據到來時,開始從內核的緩沖拷貝到應用進程,這個過程就叫做輪詢(polling)

2.3 I/O復用模型

本質上也是一個阻塞式的調用,不過它不是阻塞在真正的I/O系統調用上,而是阻塞在select和poll的某一個之上。
大概過程是這樣的:

1.無數據報到達的時候,select阻塞。
2.數據報准到達並拷貝到內核的某一個緩沖,並准備好。
3.select返回該套接字的可讀條件。
4.recvfrom進行系統調用復制數據報到進程空間。

select的優勢就在於我們可以等待多個描述符就緒。

2.4 信號驅動式I/O模型

進程中裝載一個信號處理函數,例如sigal函數,進程繼續執行,當數據報准備好的時候,內核發送一個SIGIO,進程進入中斷中執行並調用recvfrom等I/O函數將數據從內核態拷貝到用戶態。
這種I/O模型的好處就是,裝載完信號處理函數以後,主進程不被阻塞,將繼續執行。

2.5 異步I/O

與信號驅動I/O類似,它的工作機制是:一開始告訴內核啟動某個操作,並讓內核在整個操作完成後告訴我們(而信號驅動I/O指的是在當可以啟動一個I/O操作的時候告訴我們),同樣在等待I/O的過程中進程不會被阻塞,直到I/O完成,數據被拷貝到進程空間後,它會通知我們(例如給進程發一個特定的信號)

2.6 各種I/O的區別

首先是關於同步I/O和異步I/O,POSIX(Portable Operating System Interface of UNIX)把這兩個術語定義如下:

同步I/O操作:導致請求進程阻塞,直到I/O操作完成。
異步I/O操作:不導致請求進程阻塞。

根據上述定義,前面4種:阻塞式I/O非阻塞式I/OI/O復用信號驅動式I/O都是同步I/O模型,不論是哪種形式,在“將數據從內核緩沖區拷貝到用戶進程緩沖區”這個過程(書中說是真正的I/O操作),將阻塞進程,只有異步I/O和POSIX定義的異步I/O相匹配。


3. select函數

select函數允許進程指示內核等待多個事件中的任何一個發生,並只在有一個或多個事件發生或經歷一段指定的時間後才喚醒它。我們調用select可以告訴內核我們感興趣的描述符(讀/寫)去監聽,也可以指定等待的時間,當然最後還有異常處理。這裡的描述符不僅僅是套接字描述符,任何描述符都可以:比如大四時有個項目是要對兩路串口的輸入進行管理,就用這個辦法對三個字符驅動的描述符寫操作進行監聽。

#include 
#include 

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

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

其參數依次是:
1.maxfdp1:指定待測試的描述符個數,它的值是待測試的最大描述符加1(描述符0,1,2,…,一直到maxfdp1-1)。一般我們可以使用FD_SETSIZE表示1024(書中是256,在我的ubuntu14.04中1024),不過很少有程序使用如此多的描述符。

2.readset、writeset、exceptset:指定我們要讓內核測試讀、寫和異常條件的描述符。對於一個整型數組來說,假設其每個元素為32位,那麼第一個元素對應描述符0~31,第二個元素對應32~63,不過系統為我們提供了fd_set數據類型以及4個宏:

void FD_CLR(int fd, fd_set *set);//關閉描述符fd
int  FD_ISSET(int fd, fd_set *set);//判斷該描述符是否就緒
void FD_SET(int fd, fd_set *set);//打開某個描述符
void FD_ZERO(fd_set *set);//初始化

在使用的時候,通過上述4個宏來操作fd_set類型的描述符集。
比如說,由於3個描述符集為值-結果參數,當select返回的時候,我們可以通過使用FD_ISSET宏來測試fd_set數據類型中的描述符,當select返回的時候,需要把我們關心的描述符重新FD_SET一下。

3.最後一個參數是關於時間的結構體:

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

有了時間的結構體,就可以:

1.永遠等下去:僅在有一個描述符准備好I/O時才返回。為此要把該參數設置為空指針。
2.等待一段固定時間:在有一個描述符准備好I/O時返回,但是不超過由該參數所指向的結構體中的時間。
3.根本不等待:檢查描述符後立刻返回,這稱之為輪詢(polling)。為此我們把結構體中等待時間設置為0就好。

之前說過,select函數可以監聽所有的描述符,不僅僅局限於套接字描述符,所以,我們可以寫一個簡單的I/O程序測試select函數的用法:

//selectexample.c

#include 
#include 
#include 
#include 
#include 
int main(void)
{
    fd_set rfds;//描述符集合
    struct timeval tv;
    int retval;

    /* Watch stdin (fd 0) to see when it has input. */
    //fd 0 標准輸入描述符
    FD_ZERO(&rfds);//初始化
    FD_SET(0, &rfds);//設置打開0描述符

    /* Wait up to five seconds. */
    //5 s超時等待
    tv.tv_sec = 5;
    tv.tv_usec = 0;

    //系統內核定義 #define __FD_SETSIZE 1024
    retval = select(FD_SETSIZE, &rfds, NULL, NULL, &tv);

    if (retval == -1)//異常
        perror("select()");
    else if (retval)//正常返回
    {
        printf("Data is available now.\n");
        /* FD_ISSET(0, &rfds) will be true. */
        getchar();
    }
    else//超時
        printf("No data within five seconds.\n");

    exit(EXIT_SUCCESS);
}

運行程序後,系統阻塞,5秒後,如果沒有鍵盤輸入,將打印“No data within five seconds”;5秒內,鍵盤輸入任何字符回車後將打印“Data is available now.”

3.1 描述符就緒條件

包括三種情況:讀、寫、異常,下面分別介紹:

(1)滿足下列四個條件的任何一個條件時,描述符准備讀:

a.套接字接收緩沖中的數據字節數大於等於套接字接收緩沖區低水位標記的當前大小。
b.該連接的讀半部關閉(接收了FIN的TCP連接)。
c.該套接字是一個監聽套接字且已完成的連接數不為0。
d.其上有一個錯誤套接字待處理。

(2)滿足下列四個條件的任何一個條件時,描述符准備寫:

a.套接字發送緩沖區中的可用空間字節數大於等於套接字發送緩沖區低水位標記的當前大小。
b.該連接的寫半部關閉。
c.使用非阻塞式connect的套接字已建立連接,或者connect已經以失敗告終。
d.其上有一個套接字錯誤待處理。

注:上述提到的接收低水位和發送低水位,指的是當緩沖區的數據超過某一大小時select才會返回的最低標准線,例如假設這個值是64字節,那麼當緩沖區數據小於64字節時,select不會喚醒我們。

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

3.2 select的最大描述符

在我的ubuntu14.04 終端依次執行如下命令:

cd /usr/include/x86_64-linux-gnu/
grep "FD_SETSIZE" * -rn

終端將顯示相關信息,這裡我們我們需要的是:

bits/typesizes.h:86:#define __FD_SETSIZE        1024
sys/select.h:78:#define FD_SETSIZE      __FD_SETSIZE

這個宏的大小時1024,不過書中有提到在4.4BSD的

#ifndef FD_SETSIZE
#define FD_SETSIZE 256
#endif

4. 使用select修改TCP回射客戶端

4.1 str_cli改進版本1

使用select調用,等待兩個描述符:1是標准輸入可讀;2是套接字可讀,其中套接字可讀又有3個方面:

對端發送數據
對端發送FIN
對端發送RST

select處理各種條件

這裡寫圖片描述

<喎?http://www.2cto.com/kf/ware/vc/" target="_blank" class="keylink">vcD4NCjxoNCBpZD0="代碼1">代碼1

void str_cli(FILE*fp,int sockfd)
{
    int maxfdp1;
    int nread;
    int nwrite;
    fd_set rset;
    char readbuff[MAXLEN];
    FD_ZERO(&rset);//初始化
    while(1)
    {
        FD_SET(fileno(fp),&rset);
        FD_SET(sockfd,&rset);
        maxfdp1=max(sockfd,fileno(fp))+1;

        select(maxfdp1,&rset,NULL,NULL,NULL);
        if(FD_ISSET(sockfd,&rset)) //sockfd
        {
            memset(readbuff,0x00,sizeof(readbuff));
            if(( nread= read(sockfd,readbuff,sizeof(readbuff)))<=0)
            {
                printf("read error \r\n");
                printf("str_cli:server terminated prematurely \r\n");
                return ;
            }
            fputs(readbuff,stdout);
        }
        if(FD_ISSET(fileno(fp),&rset))//標准輸入
        {
            if(fgets(readbuff,sizeof(readbuff),fp)==NULL)
                return;
            if( (nwrite= write(sockfd,readbuff,strlen(readbuff)))<0)
            {
                printf("write error \r\n");
                return ;
            }
        }

    }//end while
}

4.2 str_cli改進版本2

書中提到一種情況,如果按照上述代碼去執行,停-等的網絡數據交互,是沒有一點問題的,但是通道的利用率太低,因為當客戶發送一個請求分節到收到服務器應答這個RTT時間內,我們可以更有效的利用管道。
但是現在的問題就是,當客戶端這邊發送完所有的請求之後,由於我們標准輸入的EOF處理,str_cli返回到了main函數,這個時候可能仍然有請求在去服務器的路上,或者仍然有應答在返回客戶端的路上。
這個時候我們就要使用shutdown函數實現半關閉

#include 
int shutdown(int sockfd, int howto);

參數說明:
sockfd:套接字描述符。
howto:有兩個參數,SHUT_RD表示關閉連接的讀這一半,SHUT_WR表示關閉寫這一半(對於TCP套機字,這稱之為半關閉)。

代碼2

void str_cli(FILE*fp,int sockfd)
{
    int maxfdp1;
    int nread;
    int nwrite;
    int n;
    int stdineof=0;
    fd_set rset;
    char readbuff[MAXLEN];
    FD_ZERO(&rset);
    while(1)
    {
        FD_SET(fileno(fp),&rset);
        FD_SET(sockfd,&rset);
        maxfdp1=max(sockfd,fileno(fp))+1;
        select(maxfdp1,&rset,NULL,NULL,NULL);
        if(FD_ISSET(sockfd,&rset)) //sockfd
        {
            memset(readbuff,0x00,sizeof(readbuff));
            if(( nread= read(sockfd,readbuff,sizeof(readbuff)))<=0)
            {
                if(stdineof ==1)
                    return;
                else
                {
                    printf("read error \r\n");
                    printf("str_cli:server terminated prematurely \r\n");
                    return ;
                }
            }
            write(fileno(stdout),readbuff,nread); 

            //fputs(readbuff,stdout);
        }
        if(FD_ISSET(fileno(fp),&rset))
        {

            memset(readbuff,0x00,sizeof(readbuff));
            if((n=read(fileno(fp),readbuff,MAXLEN))<=0)
            {
                stdineof = 1;
                if(shutdown(sockfd,SHUT_WR)<0)
                    return ;
                FD_CLR(fileno(fp),&rset);
                continue;
            }

            if( (nwrite= write(sockfd,readbuff,strlen(readbuff)))<0)
            {
                printf("write error \r\n");
                return ;
            }
        }
    }
}

5. 使用select修改TCP回射服務器

在之前的TCP回射服務器程序中,我們通過創建子進程來處理已連接套接字,用select可以改寫成處理多個客戶的單進程程序,這樣做的好處就是我們不需要為每個客戶連接都派生出子進程。

服務器只維護一個讀描述符集合,在這個集合中,假設服務器是在前台啟動,那麼在這之中,fd0、fd1、fd2分別被設置為標准輸入、標准輸出和標准錯誤輸出。這樣就意味著監聽套接字描述可用的fd是從3開始。此外,我們還維護一個client的整型數組,它包含每個客戶的已連接套接字描述符,初始情況下,該數組的所有元素都被初始化為-1。

這裡寫圖片描述

整程序的流程如下:
1.首先select對listenfd進行監聽,當第一個客戶與服務器建立連接時,listenfd可讀,select返回,服務器調用accept。

2.服務器需要記錄每個新的已連接描述符,並把其加入到描述符集合中去。

這裡寫圖片描述

3.同樣的流程更多的客戶與服務器建立連接。此時服務器中描述符集狀態:

這裡寫圖片描述

4.當第一個客戶終止連接時,客戶TCP發送一個FIN,fd4變成可讀,read返回0,我們關閉套接字,並更新數據結構:

這裡寫圖片描述

5.1 代碼

使用select的服務器程序
書中用了大量的包裹函數,這裡為了方便,我把他們都拆開了:

//testselect.c

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#define MAXLEN 1024
#define SERV_PORT 1024 // 1024~49151未被使用的端口
#define LISTENQ 1024
int main(int argc,char *argv[])
{
    struct sockaddr_in serveraddr; //服務器套接字結構
    struct sockaddr_in cliaddr;//客戶端套接字結構
    int listenfd;//監聽套接字描述符
    int connfd; //連接套接字字描述符
    int sockfd;
    int client[FD_SETSIZE];//管理已連接套接字描述符
    int ret;
    int i,maxi;
    int n;
    int nready;
    char buf[MAXLEN];
    fd_set rset,allset;

    //初始化
    memset(&serveraddr,0x00,sizeof(serveraddr));
    memset(&cliaddr,0x00,sizeof(cliaddr));

    serveraddr.sin_family=AF_INET;
    serveraddr.sin_addr.s_addr=htonl(INADDR_ANY);
    serveraddr.sin_port=htons(SERV_PORT);

    if((listenfd=socket(AF_INET,SOCK_STREAM,0))<0)//創建套接字
    {
        printf("socket error\r\n");
        return -1;
    }

    if(bind(listenfd,(struct sockaddr *)&serveraddr,sizeof(serveraddr))<0)//綁定套接字
    {
        printf("bind error\r\n");
        return -1;
    }

    if(listen(listenfd,LISTENQ)<0)
    {
        printf("listen error\r\n");
        return -1;
    }

    maxi = -1;
    for(i = 0;imaxi)//maxi:已連接描述符的個數
            {
                maxi=i;
                printf("maxi=%d\r\n",maxi);
            }
            if(--nready <= 0)
            {
                continue;
            }
        }//end for
        for(i=0;i<=maxi;i++)//檢查現有的連接
        {
            if((sockfd=client[i])<0)
                continue;
            if(FD_ISSET(sockfd,&rset))
            {
                if((n=read(sockfd,buf,MAXLEN))==0)
                {
                    close(sockfd);
                    FD_CLR(sockfd,&allset);
                    client[i]=-1;
                }
                else
                {
                    write(sockfd,buf,n);
                }
                if(--nready <= 0)
                {
                    break;
                }
            }
        }//end for
    }//end while
}//main 

代碼理解一下很簡單:
1.代碼進入死循環並阻塞在select調用。
2.有一個客戶連接後,listenfd可讀,select返回。
3.由於FD_ISSET返回true,accept調用並返回已連接套接字描述符。
4.對找到client數組第一個小於0的元素並賦值,保存描述符。
5.將已連接描述符connfd添加到描述符集。
6.程序返回到select,監聽listenfd和connfd。
7.當客戶端主動關閉連接,則關閉描述符,並將client清為-1。

5.2 拒絕服務攻擊

書中的所說的服務器需要等待換行符和EOF才返回已經不復存在(那是針對readline面向文本行),但是拒絕服務的概念還是有必要了解一蛤,其實也就是客戶端的某些行為讓服務器阻塞在某個位置(掛起)而不能對其他客戶端進行服務了。
可能的解決辦法有:1.非阻塞調用。2.多線程調度。3.超時返回操作等。


6. pselect函數

pselect前面的p應該就是POSIX的意思吧,他是由POSIX發明的。

#include 
#include 
#include 

int pselect (int maxfdp1,fd_set *readset,fd_set * writeset,fd_set *exceptset,
            const struct timespec *timeout,const sigset_t *sigmask);

6.1 pselect和select的區別

(1)pselect的時間參數使用timespec結構

struct timespec{
    time_t tv_sec;//second
    long tv_nsec;//nanosecond
}

(2)新增了一個參數sigmask表示指向信號掩碼的指針。


7. poll函數

#include 
int poll (struct pollfd *fdarray,unsigned long nfds,int timeout);

參數:
fdarray:一看就是指向某個結構的指針,這個結構如下:

struct pollfd
{
    int fd; //descriptor to check
    short events;//events of interest on fd
    short revents;//events that occurred on fd
};

對於eventsrevents而言,每個描述符都有兩個變量,一個是調用值,一個是返回結果。從而避免使用一個值-結果變量參數。所以events對應調用值,revents對應返回結果。因此系統也給定了一些常值處理輸入、輸出和異常。

常值 能作為events輸入麼? 能作為revents輸出麼? 說明 POLLIN
POLLRDNORM
POLLRDBAND
POLLPRI yes
yes
yes
yes yes
yes
yes
yes 普通或優先級帶數據可讀
普通數據可讀
優先級帶數據可讀
高優先級數據可讀 POLLOUT
POLLWRNORM
POLLWRBAND yes
yes
yes yes
yes
yes 普通數據可寫
普通數據可寫
優先級帶數據可寫 POLLERR
POLLHUP
POLLNVAL -
-
- yes
yes
yes 發生錯誤
發生掛起
描述符不是一個打開的文件

表格中的三個部分分別對應處理輸入,輸出和異常,其中第三部分異常只能在revents中返回,這也在情理之中,因為不可能上來就告訴別人異常吧。

timeout:該參數指定poll函數返回前等待多長時間:

timeout值 說明 INFTIM 永遠等待 0 立即返回,不阻塞進程 大於0 等待指定數目的時間,單位毫秒

7.1 poll調用example

書中給出了TCP回射服務器程序poll調用的示例,跟select差不多,代碼量有點多,簡單起見,寫了個簡單的example,監聽標准輸入(stdin)和FIFO文件描述符的poll示例。

代碼

//polltest.c

#include 
#include 
#include 
#include 
#include 
int main(int argc, char ** argv) 
{
    //描述符0表示標准輸入
    //描述符1表示標准輸出
    int fd;
    char buf[1024];
    int i;
    struct pollfd pfds[2];
    fd = open(argv[1], O_RDONLY|O_NONBLOCK);
    if(fd < 0)
    {
        printf("open file error");
    }


    while (1) 
    {
        pfds[0].fd = 0;//stdin
        pfds[0].events = POLLIN;
        pfds[1].fd = fd;//FIFO fd
        pfds[1].events = POLLIN;
        poll(pfds, 2, 0);

        if (pfds[0].revents&POLLIN)
        {
            printf("stdin\r\n");
            i = read(0, buf, 1024);
            if (!i) 
            {
                printf("stdin closed\r\n");
                return 0;
            }

            write(1, buf, i);//output
        }


        if (pfds[1].revents&POLLIN ) 
        {
            printf("FIFO in\r\n");
            i = read(fd, buf, 1024);
            if (!i)
            {
                printf("file closed\r\n");
                return 0;
            }

            write(1, buf, i);//output

        }

    }

}

在終端輸入:

mknod mypipe p  //創建一個FIFO
make polltest   //編譯
./polltest mypipe  //運行後進程阻塞在poll調用並監聽兩個描述符
//直接輸入文本
//也可以在新的終端同一文件目錄下輸入
echo test >> mypipe //重定向

8. 總結

主要介紹了I/O復用的兩種方式,select和poll,這兩個函數用法很相似,知道其一般形式即可。

Copyright © Linux教程網 All Rights Reserved