IO多路复用的本质(select、poll、epoll)


前言

如果你是新入行的小弟,对阻塞IO、非阻塞IO、同步IO、异步IO还不太了解,那得先看看这篇文章了深层次详解同步IO、异步IO、阻塞IO、非阻塞IO。如果你是久经沙场的老兵,那当做没看到就行了。


一、I/O多路复用是什么?

I/O多路复用的本质是使用select,poll或者epoll函数,挂起进程,当一个或者多个I/O事件发生之后,将控制返回给用户进程。以服务器编程为例,传统的多进程(多线程)并发模型,在处理用户连接时都是开启一个新的线程或者进程去处理一个新的连接,而I/O多路复用则可以在一个进程(线程)当中同时监听多个网络I/O事件,也就是多个文件描述符。select、poll 和 epoll 都是 Linux API 提供的 IO 复用方式。

二、linux中的一些基础概念

用户空间/内核空间、进程切换、进程阻塞、文件描述符、缓存io

  • 用户空间 / 内核空间
    现在操作系统都是采用虚拟存储器,那么64位操作系统而言,它的寻址空间(虚拟存储空间)为17179869184G(2的64次方,32位的为4G) ,当然这只是理论值,实际中不可能用到这么大的内存,目前64位windows系统最大只支持128G。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核,保证内核的安全,操作系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。

  • 进程切换
    为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行,这种行为被称为进程切换。任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的,并且进程切换是非常耗费资源的。

  • 进程阻塞
    正在执行的进程,由于某些期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得了CPU资源),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的。

  • 文件描述符
    文件描述符(File descriptor简称fd)是一个用于指向文件的引用的抽象化概念。【Linux 的世界,一切皆文件
    文件描述符本质上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。

  • 缓存I/O
    缓存I/O又称为标准I/O,大多数文件系统的默认I/O操作都是缓存I/O。在Linux的缓存I/O机制中,操作系统会将I/O的数据缓存在文件系统的页缓存中,即数据会先被拷贝到操作系统内核的缓冲区pagecache中,然后再拷贝到应用程序的地址空间。

三、三种复用方式

1.select

简单来说就是将需要监听的文件描述符数组通过系统调用交给内核,由内核根据IO状态修改fd_set的内容,返回当前进程可读的Socket或文件。

select函数:

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

参数

fd_set 为一个集合,里面存放的是文件描述符。
maxfdp: 一个整数值,集合中所有文件描述符的范围,即最大的文件描述符+1。
fd_set * readfds: fd_set结构的指针,我们是要监视这些文件描述符的读变化的,我们关心是否从这些文件可以读取数据了。如果其中一个文件可读,返回一个正数值,表示文件可读;没有文件可读,再根据timeout是超市判断,超时了,返回0,若发送错误返回负值。集合可以传入null,表示不关心任何文件的读写。
fd_set * writefds: 同上,改为写即可。
fd_set * exceptset: 同上,改为报错即可,(一般为NULL)
timeout:超时时间,设置NULL:则表示select()没有timeout,select将一直被阻塞,直到某个文件描述符上发生了事件。
【返回值】 int 若有就绪描述符(有可读写或出错的文件)返回其数目,若超时则为0,若出错则为-1

fd_set理解
理解select模型的关键在于理解fd_set,为说明方便,取fd_set长度为1字节,即fd_set中的每一bit可以对应一个文件描述符fd。则1字节长的fd_set最大可以对应8个fd。
(1)执行fd_set set; FD_ZERO(&set),则set用位表示是0000,0000。
(2)若fd=5,执行FD_SET(fd,&set),后set变为0001,0000(第5位置为1)
(3)若再加入fd=2,fd=1,则set变为0001,0011
(4)执行select(6,&set,0,0,0)阻塞等待
(5)若fd=1,fd=2上都发生可读事件,则select返回,此时set变为0000,0011。注意:没有事件
发生的fd=5被清空

PS:readfds,writefds,exceptfds,timeout都是输入/输出型参数,输入时,是你自己设置的值,输出时是改变后的值。

下面的宏提供了处理这三种描述符集的方式:
FD_CLR(inr fd,fd_set* set):用来清除描述词组set中相关fd 的位
FD_ISSET(int fd,fd_set *set):用来测试描述词组set中相关fd 的位是否为真(也就是是否已经就绪)
FD_SET(int fd,fd_set*set):用来设置描述词组set中相关fd的位
FD_ZERO(fd_set *set):用来清除描述词组set的全部位

机制

select()的机制中提供一种fd_set的数据结构,实际上是一个long类型的数组,每一个数组元素都能与一打开的文件句柄(不管是Socket句柄,还是其他文件或命名管道或设备句柄)建立联系,当调用select()时,由内核根据IO状态修改fd_set的fd的状态,来通知当前进程哪些fd可读写。但是此方法只返回一个int值,如果是正数则代表有就绪的描述符,用户程序再去轮询所有的文件标识符,获取就绪的描述符进行读写,再调取read、write 读写数据。
简洁说法: 一批fd交给通过select交给内核,内核来轮询,根据io状态改变每个fd的状态,返回一个int 结果,jvm进程根据int是正数(fd状态改变的个数),代表有就绪的fd,jvm进程再轮询所有的fd,找到在fd集合中就绪的fd 再发起read、write。

简单的实例代码如下:

main()  
{  
    int sock;  
    FILE *fp;  
    struct fd_set fds;  
    struct timeval timeout={3,0}; //select等待3秒,3秒轮询,要非阻塞就置0  
    char buffer[256]={0}; //256字节的接收缓冲区  
    /* 假定已经建立UDP/TCp连接*/  
    while(1)  {  
        FD_ZERO(&fds); //每次循环都要清空集合,否则不能检测描述符变化  
        FD_SET(sock,&fds); //添加描述符  
        FD_SET(fp,&fds); //同上  
        maxfdp=sock>fp?sock+1:fp+1;    //描述符最大值加1
          
        switch(select(maxfdp,&fds,&fds,NULL,&timeout)) {    //select使用  
            case -1: exit(-1);break; //select错误,退出程序  
            case 0:break; //再次轮询  
            default:  
                  if(FD_ISSET(sock,&fds)){ //测试sock是否可读,即是否网络上有数据                      
                        recvfrom(sock,buffer,256,.....);//接受网络数据  
                        if(FD_ISSET(fp,&fds)) //测试文件是否可写  
                            fwrite(fp,buffer...);//写入文件  
                         //清空buffer;  
                   } 
          }  
     } 
}

select 缺点

  • 单个进程打开的fd有一定现在限制,有FD_SETSIZE 设置,默认1024
  • 每次调用select,都需要把fd_set集合从用户态拷贝到内核态,产生巨大的开销;
  • select返回的是含有整个句柄的数组,应用程序需要遍历整个数组才能发现哪些句柄发生了事件

小结
select用于I/O复用,通过监听文件描述符的状态,当与文件描述符相关的资源准备就绪就返回,从而提高性能。
reads,writes, timeout都是输入/输出型参数,所以要在while循环内设置它们的状态。

2.poll

poll的机制与select类似,与select在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,相比select模型,poll使用链表保存文件描述符,因此没有了监视文件数量的限制。

poll的函数原型:

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

typedef struct pollfd {
    int fd; // 需要被检测或选择的文件描述符
    short events; // 对文件描述符fd上感兴趣的事件
    short revents; // 文件描述符fd上当前实际发生的事件
} pollfd_t;

参数

*struct pollfd fds: 一个struct pollfd类型的数组,用于存放待检测的fd,并且poll函数之后fds数组不会被清空;
nfds_t nfds: 记录数组fds中描述符的总数量
【返回值】 int 函数返回fds集合中就绪的读、写,或出错的描述符数量,返回0表示超时,返回-1表示出错;

poll改变了文件描述符集合的描述方式,使用了pollfd结构而不是select的fd_set结构,使得poll支持的文件描述符集合限制远大于select的1024。

3.epoll-eventpoll

epoll是Linux内核为处理大批量文件描述符而作了改进的poll,是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中但只有少量活跃情况下的CPU利用率。原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒加入到Ready队列的描述符集合就行了,相当于epollwait()返回正数后,直接指定队列取fd集合就行。
机制
首先服务端执行epoll_create时,会创建一个epoll句柄,用于监控其他的fd,同时创建红黑树和就绪链表。执行epoll_ctl时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据(当我们执行epoll_ctl时,除了把socket放到epoll文件系统里file对象对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list链表里。所以,当一个socket上有数据到了,内核在把网卡上的数据copy到内核中后就来把socket插入到准备就绪链表里了)。当epoll_wait调用时,仅仅观察这个list链表(双向链表)里有没有数据即eptime项即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。返回的都是就绪fd,然后用户进程就可以调取read()了。

Linux中提供的epoll相关函数如下:

int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

epoll_create: 函数创建一个epoll句柄,参数size表明内核要监听的描述符数量。调用成功时返回一个epoll句柄描述符,失败时返回-1。
epoll_ctl: 函数注册要监听的事件类型。四个参数解释如下:

  • epfd 表示epoll句柄, epoll_create() 的返回值
  • op 表示fd操作类型,有如下3种
    • EPOLL_CTL_ADD 注册新的fd到epfd中
    • EPOLL_CTL_MOD 修改已注册的fd的监听事件
    • EPOLL_CTL_DEL 从epfd中删除一个fd
  • fd 是要监听的描述符
  • event 表示要监听的事件

epoll_event 结构体定义如下:

struct epoll_event {
    __uint32_t events;  /* Epoll 事件 */
    epoll_data_t data;  /* 用户数据变量 */
};

typedef union epoll_data {
    void *ptr;
    int fd;
    __uint32_t u32;
    __uint64_t u64;
} epoll_data_t;
//events 可以是以下几个宏的集合:
EPOLLIN     //表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT    //表示对应的文件描述符可以写;
EPOLLPRI    //表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR    //表示对应的文件描述符发生错误;EPOLLHUP    //表示对应的文件描述符被挂断;
EPOLLET     //将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT//只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里。

epoll_wait: 函数等待事件的就绪,成功时返回就绪的事件数目,调用失败时返回 -1,等待超时返回 0。

  • epfd 是epoll句柄
  • events 表示从内核得到的就绪事件集合
  • maxevents 告诉内核events的大小
  • timeout 表示等待的超时事件

epoll 服务器流程图:
服务端

  • 建立套接字
  • 绑定端口
  • 监听客户端请求状态
  • 设置epollfd
  • 调用epoll_wait
    • 如果是服务端套接字发生变化,说明有新的连接,调用accept 函数受理
    • 如果不是服务端套接字发生变化,则响应客户端
      关闭
      在这里插入图片描述

四、总结

selectpollepoll
操作方式遍历遍历回调
底层实现数组链表红黑树
IO效率每次调用都进行线性遍历,时间复杂度为O(n)每次调用都进行线性遍历,时间复杂度为O(n)事件通知方式,每当fd就绪,系统注册的回调函数就会被调用,将就绪fd放到readyList里面,时间复杂度O(1)
最大连接数1024(x86)或2048(x64)无上限无上限
fd拷贝遍历每次调用select,都需要把fd集合从用户态拷贝到内核态同select调用epoll_ctl时拷贝进内核并保存,之后每次epoll_wait不拷贝,用户进程与内核使用了共享共享空间mmap

epoll是Linux目前大规模网络并发程序开发的首选模型。在绝大多数情况下性能远超select和poll。流行的高性能web服务器Nginx正是依赖于epoll提供的高效socket轮询服务。但是,在并发连接不高的情况下,多线程+阻塞I/O方式可能性能更好。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值