I/O多路复用(select、poll、epoll)详解

多线程或者多进程服务器端程序工作具体流程是:服务器端应用程序监听到一个客户端连接,就fork一个子进程,由子进程处理事件,原服务器端应用程序继续监听来自其他客户端的连接后做相同的操作,当有客户端退出时,和该客户端通信的子进程就成了僵尸进程,由父进程利用信号注册等机制将其回收。

由于多线程或者多进程服务器是由服务器端应用程序监听客户端请求,并且阻塞等待事件发生,这样会降低应用程序执行效率并且极大消耗CPU资源。因此可以采用多路IO转接服务器(也叫多任务IO服务器)。该类服务器实现的主旨思想是:不再由应用程序自己监视客户端连接,而是由内核替代应用程序监听来自客户端的连接、读写数据等,然后通知应用程序,因此应用程序就不用阻塞等待连接和读数据,而是调用accept后立刻就能执行得到结果。

阻塞:就是进程或是线程执行到这些函数(如accept,read函数)时必须等待某个事件的发生,如果事件没有发生,进程或线程就被阻塞,函数不能立即返回。
非阻塞:就是进程或线程执行此函数时不必非要等待事件的发生,一旦执行结果返回,以返回值的不同来反映函数的执行情况,如果事件发生则与阻塞方式发生事件后返回值一样,若事件没有发生则返回一个代码来告知事件未发生,而进程或线程继续执行,所以效率较高。

与多进程和多线程技术相比,I/O多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。

使用内核监听的实现方式主要有三种:select,poll,epoll。select,poll,epoll都是IO多路复用的机制。** I/O多路复用是就通过一种机制,让一个线程可以监视多个描述符,内核一旦发现进程指定的一个或者多个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。 ** 但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的。

I/O多路复用形成原因:

如果一个I/O流进来,我们就开启一个进程处理这个I/O流。那么假设现在有一百万个I/O流进来,那我们就需要开启一百万个进程一一对应处理这些I/O流(——这就是传统意义下的多进程并发处理)。思考一下,一百万个进程,你的CPU占有率会多高,这个实现方式及其的不合理。所以人们提出了I/O多路复用这个模型,一个线程/进程,通过记录I/O流的状态来同时管理多个I/O,降低CPU占用率
.
就像下面这张图的前半部分一样,中间的那条线就是我们的单个线程/进程,它通过记录传入的每一个I/O流的状态来同时管理多个IO。
在这里插入图片描述

select

该函数用于监视文件描述符的变化情况,调用后select函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以通过遍历fd_set(一个存放文件描述符的信息的结构体),来找到就绪的描述符。

select函数头文件和原型为:

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

(1)第一个参数nfds:所监听的所有文件描述符中,最大的文件描述符+1。即如果服务器监听了4个文件文件描述符,分别存放在文件描述符表的3~6号位置,
(2)第二个、第三个、第四个参数:分别表示所监听的文件描述符“可读”事件、“可写”事件、“异常”事件。可通过以下四个宏进行设置:

void FD_ZERO(fd_set *set); //将set清空,fd_set 是位图类型
void FD_SET(int fd, fd_set *set); //将fd设置到set集合中
void FD_CLR(int fd, fd_set *set);  //将fd从set中清除出去
int  FD_ISSET(int fd, fd_set *set); //判断fd是否在集合中

(3)第五个参数:一个指向timeval结构的指针,用于决定select等待I/o的最长时间。如果为空将一直等待。

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

返回值:成功返回所监听的所有集合中满足条件的总数。即假如在文件描述符表的3 ~ 6 号位置存放fd1 ~ fd4的文件描述符,第一个参数监听fd1和fd3,第二个参数监听fd2,fd3,第三个参数监听fd4,其中fd1可读,fd3可写,fd4异常,一共监听了5个事件,但满足条件的就3个事件,则返回3。失败返回-1。超时返回0。
select函数的调用过程:
假设服务器需要支持100万的并发连接,则在__FD_SETSIZE 为1024的情况下,则我们至少需要创建1k个进程才能实现100万的并发连接。除了进程间上下文切换的时间消耗外,从内核/用户空间大量的无脑内存拷贝、数组轮询等,是系统难以承受的。因此,基于select模型的服务器程序,要达到10万级别的并发访问,是一个很难完成的任务。

缺点
1.单个进程能够监视的文件描述符的数量存在最大限制,通常是1024,当然可以更改数量,但由于select采用轮询的方式扫描文件描述符,文件描述符数量越多,性能越差。
2.解决1024个以下客户端时使用select是很合适的,但是如果连接客户端过多,select采用的是轮询模型,会大大降低服务器响应效率。
每次select()都要通过遍历FD_SETSIZE个Socket来完成调度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间。
3.需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。

poll

poll和select类似,通过轮询方式管理多个描述符,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制。poll和select同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。
poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。

poll函数头文件和原型为:

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

epoll

相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关心的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。
epoll支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些fd刚刚变为就绪态,并且只会通知一次。还有一个特点是,epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。
epoll的优点:
1、没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口)。
2、效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。
  只有活跃可用的FD才会调用callback函数;即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。
3、内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。

设想一下如下场景:有100万个客户端同时与一个服务器进程保持着TCP连接。而每一时刻,通常只有几百上千个TCP连接是活跃的(事实上大部分场景都是这种情况)。如何实现这样的高并发?
在select/poll时代,服务器进程每次都把这100万个连接告诉操作系统(从用户态复制句柄数据结构到内核态),让操作系统内核去查询这些套接字上是否有事件发生,轮询完后,再将句柄数据复制到用户态,让服务器应用程序轮询处理已发生的网络事件,这一过程资源消耗较大,因此,select/poll一般只能处理几千的并发连接。
epoll通过在Linux内核中申请一个简易的文件系统(文件系统一般用什么数据结构实现?B+树)。把原先的select/poll调用分成了3个部分:
1)调用epoll_create()建立一个epoll对象(在epoll文件系统中为这个句柄对象分配资源)
2)调用epoll_ctl向epoll对象中添加这100万个连接的套接字
3)调用epoll_wait收集发生的事件的连接

#include <sys/epoll.h>
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对象,然后在需要的时候向这个epoll对象中添加或者删除连接。同时,epoll_wait的效率也非常高,因为调用epoll_wait时,并没有一股脑的向操作系统复制这100万个连接的句柄数据,内核也不需要去遍历全部的连接
下面来看看Linux内核具体的epoll机制实现思路。
当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关。eventpoll结构体如下所示:

struct eventpoll{
    ....
    /*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
    struct rb_root  rbr;
    /*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
    struct list_head rdlist;
    ....
};

每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是lgn,其中n为树的高度)。

而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中。

在epoll中,对于每一个事件,都会建立一个epitem结构体,如下所示:

struct epitem{
    struct rb_node  rbn;//红黑树节点
    struct list_head    rdllink;//双向链表节点
    struct epoll_filefd  ffd;  //事件句柄信息
    struct eventpoll *ep;    //指向其所属的eventpoll对象
    struct epoll_event event; //期待发生的事件类型
}

当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可。如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。
在这里插入图片描述

从上面的讲解可知:通过红黑树和双链表数据结构,并结合回调机制,造就了epoll的高效。
总结epoll的用法:
第一步:epoll_create()系统调用。此调用返回一个句柄,之后所有的使用都依靠这个句柄来标识。
第二步:epoll_ctl()系统调用。通过此调用向epoll对象中添加、删除、修改感兴趣的事件,返回0标识成功,返回-1表示失败。
第三部:epoll_wait()系统调用。通过此调用收集收集在epoll监控中已经发生的事件。

总结

1.支持一个进程所能打开的最大连接数
select:有限制
poll:无限制,因为基于链表来存储fd
epoll:虽然有限制,但上限很大,1G内存的机器可以打开10万左右的连接,2G内存的机器可以打开20万左右的连接
2.FD数量增加带来的IO效率问题
select和poll:每次调用select时都会对连接进行线性遍历所以随着FD的增加会造成遍历速度线性下降
epoll:因为epoll内核中实现是根据每个fd上的callback函数来实现的,只有活跃的socket才会主动调用callback,所以在活跃socket较少的情况下,即使连接数很大,使用epoll没有select和poll的效率线性下降问题,但是socket都很活跃的情况下可能会有性能问题。
3.消息传递方式
select和poll:内核需要将消息传递到用户空间,都需要内核拷贝动作
epoll:通过内核和用户空间共享一块内存来实现

因此,在连接数少且活跃的客户端多的情况下(低于1024个),select和poll的性能会好点,毕竟epoll的通知机制需要很多函数回调。但是连接数多的情况下,只能选择epoll机制。

参考:https://blog.csdn.net/davidsguo008/article/details/73556811
https://www.cnblogs.com/Anker/p/3265058.html

检测客户端是否断开连接的三种方法
心跳包和乒乓包
1.心跳包:检测服务器和客户端的连接状态,服务器向客户端发送信号,客户端响应信号给服务器,当服务器发出好几个信号都得不到客户端响应时,认定客户端断开连接。
2.乒乓包:检测数据状态
都是应用层协议
3.设置TCP属性
线程池
由于多线程/多进程服务器或者多路I/O转接服务器中,父进程会对每一个连接的客户端创建一个子进程或子线程去处理事件,这样每次一有连接就临时创建线程,断开时销毁,当并发量大的时候效率就很低。因此可以用线程池存放一堆线程,线程池不是真实存在的,只是一堆线程而已,server端维护一个任务队列,线程去任务队列中取数据。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值