Linux学习之旅(31)---I/O多路复用(select、poll)

I/O多路复用的引入

我们知道在C/S模型中,通常都是一个服务器和多个客户端的,那么这样就无可避免的产生了这样一个问题,就是服务器如何管理多个客户端。这里包括处理客户端的链接,处理客户机的数据传输等。每个客户端的链接和数据什么时候来是根本无法确定的,那该怎么办那?

这时就产生了三种方法:

(1)将read、accept等阻塞函数修改为非阻塞函数,即轮询模型。

(2)使用多进程或多线程,将每路I/O通过一个进程或线程处理,即并发模型。

(3)单个线程,通过记录跟踪每个I/O流(sock)的状态,来同时管理多个I/O流 ,即多路I/O复用模型。

由于轮询模型的效率比较低,所以这里就在进行讨论了。在上一篇文章中介绍了并发服务器(多进程和多线程),并发模型有很多的好处,但也存在一定的缺点,在一般的C/S模型中,客户端和服务器的交互并不是很多,比如QQ,经常就是挂上也不聊天,尽管聊天数据量也是非常小的。但在并发服务器中,处理的方式为,给每个服务器建立一个进程或线程来处理一个客户端,这样就会导致一个可能我们的服务器上开了很多的进程或线程,但他们中的绝大多数是处于阻塞状态的即不活跃,这样就会导致很大的浪费。而且一旦有很多的用户的同时发起通信,就会导致系统在各个进程或线程之间不停的切换,导致大部分的时间浪费在现场保存和进程切换的上,而导致服务器的服务性能下降。所以I/O复用循环模型服务器是为了降低系统的不必要开支,将主要的系统处理能力集中在核心业务上,需要降低并发服务器处理单元数量,从而提高服务器的吞吐量。

什么是I/O多路复用?

“I/O多路”其实就是“多路I/O”的意思,那么什么是I/O那,在这里我们可以简单将每一个客户单看成是一路I/O,那么多个客户端就多路I/O。

“复用”其实就是“共享”,“复用”是使用在通信的技术中的,像“时分复用、频分复用”,就是如何将一条信道能同时(宏观)很多的用户使用。放到我们这里就是如何将一个线程给很多客户端使用。

简单理解I/O多路复用的意思就是:多个客户端(网络I/O)同时使用一个(极少)线程。I/O 复用这里面的“复用”指的其实是在单个线程通过记录跟踪每一个Sock(I/O流)的状态来同时管理多个I/O流。这里就会让人产生疑惑,一个线程如何能管理多个I/O流状态,这里其实就是类似电路中开关一样(类似):

I/O复用的实现方式有三种:(1)select、(2)poll、(3)epoll。

I/O多路复用 --- select

select函数作用:在一段时间内,监听用户感兴趣的文件描述符上的可读、可写和异常等事件。

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

参数说明:

1、nfds:指定被监听的文件描述符的总数。它通常被设置位select的所有文件描述符中的最大值加1,因为文件描述符是从0开始计数的。

2、readfds、writefds、exceptfds:可读、可写、异常等事件对应的文件描述符集合。

这三个参数为传入传出参数:将用户设置的文件描述符传入,select调用返回时,内核传出那些文件描述符已经就绪。

这三个参数的类型为:fd_set的指针。fd_set的结构体内部就是一个整形数组,该数组的每个元素的每一位(bit)标记一个文件描述符。由于位操作过于麻烦,所以linux提供了一组宏函数,来方便用户使用。

#include <sys/select.h>
FD_ZERO(fd_set *fdset);             //清除fdset所有位
FD_SET(int fd,fd_set *fdset);       //设置fdset的位fd
FD_CLR(int fd,fd_set *fdset);       //清除fdset的位fd
int FD_ISSET(int fd,fd_set *fdset); //测试fdset的位fd是否被设置

3、timeout:指定select函数的阻塞时间。传入传出参数:传入用户指定的时间,内核传出select的等待时间。timeval结构体的定义:

struct timeval
{
    long tv_sec;  //秒数
    long tv_usec; //微秒数
};

两个特殊取值:

(1)NULL:select将一直阻塞,直到某个文件描述符就绪。

(2)tv_sec和tv_usec的值都为0,则select将立即返回。

返回值:成功返回就绪文件描述符的总数,如果在指定时间内没有任何文件描述符就绪,select将返回0.

失败返回-1,并设置errno。如果select在等待期间接收到信号,则select立即返回-1,并设置errno为EINTR。

文件描述符就绪条件

socket可读:

(1)socket内核接收缓冲区中的字节数大于或等于其低水位标记SO_RCVLOWAT。此时就可以无阻塞的读该socket,并且读操作返回字节数大于0。

(2)socket通信的对方关闭连接。此时对该socket的读操作将返回0。

(3)监听socket上有新的连接请求。

(4)socket上有未处理的错误。此时可以使用getsockopt来读取和清除该错误。

socket可写:

(1)socket内核发送缓冲区中可用字节数大于等于其低水位标记SO_SNOLOWAT。此时可无阻塞的写该socket,并且写操作的返回值大于0。

(2)socket的写操作被关闭。对写操作被关闭的socket执行写操作将出发SIGPIPE信号。

(3)socket使用非阻塞connent连接成功或者失败(超时)之后。

(4)socket上有未处理的错误。此时可以使用getsockopt来读取和清除该错误。

socket异常:

select可以处理的异常只有一种:socket接收到带外数据。带外数据也称为加速数据,就是优先级比较高的数据(紧急数据)。

程序1、简单select时间请求服务器

#include <time.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/select.h>
#include "sockErrHand.h"

#define PORT 5500
#define MAXLISTEN 32
#define MAXSIZE 512
//没有处理客户端断开连接
int main()
{
    int serSock=Socket(AF_INET,SOCK_STREAM,0);
    struct sockaddr_in serAddr;
    bzero(&serAddr,sizeof(serAddr));
    serAddr.sin_family=AF_INET;
    serAddr.sin_port=htons(PORT);
    serAddr.sin_addr.s_addr=htonl(INADDR_ANY);
    Bind(serSock,(struct sockaddr*)(&serAddr),sizeof(serAddr));
    Listen(serSock,MAXLISTEN);
    printf("SERVER LISTENING\n");
    int linkNum=0;//已连接的客户端数量
    int cliSockList[FD_SETSIZE]; //FD_SETSIZE默认值为1024
    int i=0;
    for(i=0;i<FD_SETSIZE;++i)
    {
        //初始化客户端列表socket
        cliSockList[i]=-1;
    }
    fd_set readfds,rfds;
    FD_ZERO(&rfds); //清空读
    FD_SET(serSock,&rfds);//设置serSock的读
    int maxfd=serSock;
    while(1)
    {
        readfds=rfds;
        int nReady=select(maxfd+1,&readfds,NULL,NULL,NULL);
        if(nReady<0)
        {
            Exit("select");
        }
        //检测serScok的读位,检测是否由新的客户端的连接
        if(FD_ISSET(serSock,&readfds))
        {
            struct sockaddr_in cliAddr;
            bzero(&cliAddr,sizeof(cliAddr));
            socklen_t cliAddrLen=sizeof(cliAddr);
            int cliSock=Accept(serSock,(struct sockaddr*)(&cliAddr),&cliAddrLen);
            cliSockList[linkNum]=cliSock;
            //将新的客户端加入,函数会修改readfds的值
            FD_SET(cliSock,&rfds);
            if(cliSock>maxfd)
            {
                maxfd=cliSock;
            }
            ++linkNum;
            --nReady;
        }
        //剩余就客户端的请求
        int i=0;
        char dataBuf[MAXSIZE];
        for(i=0;nReady>0&&i<linkNum;++i)
        {
            if(FD_ISSET(cliSockList[i],&readfds))
            {
                --nReady;
                memset((void*)dataBuf,0,MAXSIZE);
                Read(cliSockList[i],(void*)dataBuf,MAXSIZE);
                if(strcmp(dataBuf,"Time\n")==0)
                {
                    memset((void*)dataBuf,0,MAXSIZE);
                    time_t t;
                    time(&t);
                    sprintf(dataBuf,"进程号:%d\n当前服务器时间为:%s\n",getpid(),ctime(&t));
                }
                else
                {
                    memset((void*)dataBuf,0,MAXSIZE);
                    sprintf(dataBuf,"%s\n","无效命令");
                }
                Write(cliSockList[i],(void*)dataBuf,strlen(dataBuf));          
            }
        }
    }
    Close(serSock);
    return 0;
}

运行结果:

select函数是第一个实现的I/O复用的函数,所有它不可避免的存在很多问题

(1)select函数不友好。当使用select函数就会发现,select函数会修改传入的参数,但这个参数下次还需要继续的使用,所有需要我们额外再保存一份数据。

(2)select使用比较麻烦。这个从上面的列子中很容易看出来,当select函数返回后,如果有数据到达,select函数并不能直接告诉我们具体是那一路I/O上有数据到达,而是需要用户自己判断。一旦当链接的客户过多,那么判断就会浪费大量的时间。

(3)select只能监视1024个链接,FD_SETSIZE的值就是1024。(毕竟在1983年1024个链接已经很多了)

(4)select不是线程安全的。如果将一个sock加入到select,然后在另外一个线程中发现该sock不需要了,需要收回,这个select是不支持的。如果直接关闭sock,那么就会导致select产生不可预测行为。

(5)select会在用户态和内核态之间切换,会产生不小的开销。

(6)可以处理的事件类型较少。

select也是有优点的:

(1)select函数的可移植性好,在一些unix下不支持poll。

(2)select函数对于超时提供了更好的精度:微秒,而poll是毫秒。

I/O多路复用 --- poll

poll和select调用类似,也是在指定时间内轮询一定数据的文件描述符,已测试其中是否有就绪者。

#include <poll.h>
int poll(struct pollfd* fds,nfds_t nfds,int timeout);

参数说明:

(1)fds:是一个pollfd结构类型的数组,它指定所有我们感兴趣的文件描述符上发生的可读、可写、异常等事件。pollfd结构体的定义定义如下:

struct polled
{
    int fd;          //文件描述符
    short events;    //注册的事件,监听的事件
    short revents;   //实际发生的事件,由内核填充通知用户
};

poll支持的事件类型(监听多个事件需要将每一个数据按位“或”):

事件描述是否可作为输入是否可作为输出
POLLIN数据(包括普通数据和优先数据)可读
POLLRDNORM普通数据可读
POLLRDBAND优先级带数据可读(Liunx不支持)
POLLPRI高优先级数据可读,比如TCP的带外数据
POLLOUT数据(包括普通数据和优先数据)可写
POLLWRNORM普通数据可写
POLLWRBAND优先级带数据可写
POLLRDHUPTCP连接被对方关闭,或者对方关闭了写操作,它由GNU引入
POLLERR错误
POLLHUP挂起。比如管道写端关闭后,读端描述符上将收到POLLHUP事件
POLLNVAL文件描述符没有打开

需要注意的是:

POLLRDNORM、POLLRDBAND、POLLWRNORM、POLLWRBAND是由XOPEN规范定义的,linux并不支持。

POLLRDHUP:在socket上接收对方关闭连接请求之后触发的。使用时需要定义_GNU_SOURCE

(2)nfds:指定被监听事件集合的fds大小。其类型nfds_t的定义如下:

typedef unsigned long int nfds_t;  //无符号长整型

(3)timeout:指定poll的等待值。当timeout为-1时,poll调用将永远阻塞直到事件发生,当timeout为0时,poll将立即返回。

返回值:成功返回就绪文件描述符的总数,如果在指定时间内没有任何文件描述符就绪,poll将返回0.

失败返回-1,并设置errno。如果poll在等待期间接收到信号,则poll立即返回-1,并设置errno为EINTR。

程序2、简单poll时间请求服务器

#include <poll.h>
#include <time.h>
#include <stdio.h>
#include <string.h>
#include "sockErrHand.h"

#define PORT 5500
#define MAXLISTEN 32
//linux系统中没有定义这个宏
#define POLLTIME -1
#define MAXSIZE 512

int main()
{
    int serScok=Socket(AF_INET,SOCK_STREAM,0);
    struct sockaddr_in serAddr;
    bzero(&serAddr,sizeof(serAddr));
    serAddr.sin_family=AF_INET;
    serAddr.sin_port=htons(PORT);
    serAddr.sin_addr.s_addr=htonl(INADDR_ANY);
    Bind(serScok,(struct sockaddr*)(&serAddr),sizeof(serAddr));
    Listen(serScok,MAXLISTEN);
    printf("SERVER LISTENING\n");
    int linkCli=1;
    struct pollfd cliPollfd[MAXLISTEN];
    cliPollfd[0].fd=serScok;
    cliPollfd[0].events=POLLIN;//读取普通数据
    while(1)
    {
        int nReady=poll(cliPollfd,linkCli,POLLTIME);
        if(nReady<0)
        {
            Exit("poll");
        }
        //处理客户端连接
        if(cliPollfd[0].revents&POLLIN)
        {
            struct sockaddr_in cliAddr;
            bzero(&cliAddr,sizeof(cliAddr));
            socklen_t cliAddrLen=sizeof(cliAddr);
            int cliSock=Accept(serScok,(struct sockaddr*)(&cliAddr),&cliAddrLen);
            //将客户端加入poll
            cliPollfd[linkCli].fd=cliSock;
            cliPollfd[linkCli].events=POLLIN;
            ++linkCli;
            --nReady;
        }
        int i=0;
        char dataBuf[MAXSIZE];
        //轮询操作
        for(i=1;nReady>0&&i<linkCli;++i)
        {
            if(cliPollfd[i].revents&POLLIN)
            {
                --nReady;
                memset((void*)dataBuf,0,MAXSIZE);
                Read(cliPollfd[i].fd,(void*)dataBuf,MAXSIZE);
                if(strcmp(dataBuf,"Time\n")==0)
                {
                    memset((void*)dataBuf,0,MAXSIZE);
                    time_t t;
                    time(&t);
                    sprintf(dataBuf,"进程号:%d\n当前服务器时间为:%s\n",getpid(),ctime(&t));
                }
                else
                {
                    sprintf(dataBuf,"%s无效命令\n",dataBuf);
                }
                Write(cliPollfd[i].fd,(void*)dataBuf,strlen(dataBuf));     
            }
        }
    }
    Close(serScok);
    return 0;
}

由于select的存在不少的问题,所以在1977年,也就是select出现的14年后,又实现了poll函数,poll修复了select的很多问题。

poll的优点:

(1)poll去掉了select连接的1024个的限制。

(2)poll不要求开发者计算最大文件描述符加一,即select中的nfds。

(3)poll在处理大数目的文件描述符的时候比select的速度更快。

poll的缺点:

(1)大量的fds的数组被整体复制于用户态和内核态地址空间之间(fds数组必须提前创建出来,即使没有使用也必须传递给poll函数)。

(2)poll函数任然没有解决,select函数最麻烦的问题,就是返回后需要应用程序轮询处理,依此判断,而没有指出具体是那路I/O上有数据。

(3)poll任然是线程不安全的。

(4)一些平台对poll的支持并不好。

文章参考:知乎 罗志宁对多路I/O复用的回答

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值