UNIX网络编程笔记—I/O复用select/poll

Posted NearXDU

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了UNIX网络编程笔记—I/O复用select/poll相关的知识,希望对你有一定的参考价值。

I/O复用:select和poll函数

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 <sys/select.h>
#include <sys/time.h>

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 <stdio.h>
#include <stdlib.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
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处理各种条件

这里写图片描述

代码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 <sys/socket.h>
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 <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <stdio.h>
#include <netinet/in.h>
#include <unistd.h>
#include <string.h>
#include <signal.h>
#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;i<FD_SETSIZE;i++)
    {
        client[i]=-1;
    }
    FD_ZERO(&allset);//初始化
    FD_SET(listenfd,&allset);//打开listenfd
    while(1)
    {
        rset=allset;//fd_set可以直接赋值
        nready = select(FD_SETSIZE,&rset,NULL,NULL,NULL);//nready返回就绪描述符个数
        if(FD_ISSET(listenfd,&rset))//新的客户连接,listenfd可读
        {
            connfd = accept(listenfd,NULL,NULL); //调用accept
            for(i=0;i<FD_SETSIZE;i++)
            {
                if(client[i]<0)
                {
                    client[i]=connfd;
                    break;
                }
            }
            if(i==FD_SETSIZE)//太多描述符
                printf("too many clients");
            FD_SET(connfd,&allset);//设置描述符集
            if(i>maxi)//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 <sys/select.h>
#include <signal.h>
#include <time.h>

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 <poll.h>
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 <fcntl.h>
#include <stdio.h>
#include <sys/poll.h>
#include <sys/time.h>
#include <unistd.h>
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,这两个函数用法很相似,知道其一般形式即可。

以上是关于UNIX网络编程笔记—I/O复用select/poll的主要内容,如果未能解决你的问题,请参考以下文章

《Unix 网络编程》06:IO复用之select/poll

unix下网络编程之I/O复用

unix下网络编程之I/O复用

UNIX网络编程入门——I/O复用

unix下网络编程之I/O复用

unix下网络编程之I/O复用