什么是IO多路复用呢?
IO多路复用的实现有哪些呢?
它们的区别是什么呢?
为了回答上面三个问题,我总结得到了这篇文章。
什么是IO多路复用呢?
IO多路复用是一种可以监视多个描述符,一旦某个描述符就绪(读、写就绪),能够通知进程进行相应的读写操作的机制。
描述符:内核(kernel)利用文件描述符(file descriptor)来访问文件。 文件描述符是非负整数。 打开现存文件或新建文件时,内核会返回一个文件描述符。 读写文件也需要使用文件描述符来指定待读写的文件。
IO多路复用是同步非阻塞方式,由于同步非阻塞方式需要不断主动轮询,轮询占据了很大一部分过程,轮询会消耗大量的CPU时间,而 “后台” 可能有多个任务在同时进行,人们就想到了循环查询多个任务的完成状态,只要有任何一个任务完成,就去处理它。如果轮询不是进程的用户态,而是有人帮忙就好了。那么这就是所谓的 “IO 多路复用”
。UNIX/Linux 下的 select、poll、epoll 就是干这个的(epoll 比 poll、select 效率高,做的事情是一样的)。
其最大优势就系统开销小,系统不必要创建过多的进程/线程,减少了上下文切换的消耗和系统资源。
主要适用如下场合:
如果一个服务器即要处理TCP,又要处理UDP,一般要使用I/O复用;
如果一个服务器要处理多个服务或多个协议,一般要使用I/O复用;
如果一个TCP服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到I/O复用;
当客户端需要处理多个描述符时(一般是交互式输入和网络套接口),必须使用I/O复用。
IO多路复用的实现有哪些呢?
在内核中的实现有 select、poll、epoll ,其出现顺序也是如此。
select
用户线程会启动一个监视Socket调用select函数以后会阻塞,直到有描述符就绪,或超时,函数才返回。当函数返回以后,再遍历fdset来寻找就绪的描述符。select本质上是通过设置或者检查存放fd标志位的数据结构来进行下一步处理。
有以下缺点:
每次调用都需要把fd集合从用户态拷贝到内核态,这样会使得用户空间和内核空间在传递该结构时复制开销大;
每次扫描时采取线性扫描内核中的所有fd,效率很低;
单个线程处理的IO数量由fd限制,默认值为1024。
然而,它有良好的跨平台性,几乎所有平台都可以支持。
poll
poll和select的实现是类似的,区别在于它是基于链表存储的,没有最大连接的限制。
epoll
epoll是在2.6内核中提出的,是之前的select和poll的增强版本。相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。
epoll的调用分为三个部分:调用epoll_create创建一个epoll对象、调用epoll_ctl向epoll对象中添加连接的套接字、调用epoll_wait收集发生了I/O事件的连接,epoll_wait 的效率会非常高,因为调用的时候只是传递了发生 IO 事件的连接。
当某一个进程调用 epoll_create 方法的时候,Linux 内核会创建一个 eventpoll 结构体,这个结构体中有两个重要的成员:
- rb_root rbr:这是红黑树的根节点,存储着所有添加到 epoll 中的事件,也就是这个 epoll 监控的事件。
- list_head rdllist: 这是一个双向链表,保存着将要通过 epoll_wait 返回给用户的、满足条件的事件。
每一个 epoll 对象都有一个独立的 eventpoll
结构体,这个结构体会在内核空间中创造独立的内存,用于存储使用 epoll_ctl 方法向 epoll 对象中添加进来的事件。这些事件都会挂到 rbr 红黑树中,这样就能够高效的识别重复添加的节点。
所有添加到 epoll 中的事件都会与设备(如网卡等)驱动程序建立回调关系,也就是说,相应的事件发生时会调用这里的方法。这个回调方法在内核中叫做 ep_poll_callback,它把这样的事件放到 rdllist 双向链表中。在 epoll 中,对于每一个事件都会建立一个 epitem 结构体。
当调用 epoll_wait 检查是否有发生事件的连接时,只需要检查 eventpoll
对象中的 rdllist 双向链表中是否有 epitem
元素,如果 rdllist 链表不为空,则把这里的事件复制到用户态内存中的同时,将事件数量返回给用户。通过这种方法,epoll_wait 的效率非常高。epoll-ctl 在向 epoll 对象中添加、修改、删除事件时,都是从 rbr这样的 红黑树中查找事件也非常快。这样,epoll 就能够轻易的处理百万级的并发连接。
针对于select的三个缺点,epoll有了更好的解决方法:
对于第一个缺点,epoll的解决方案在epoll_ctl函数中。每次注册新的事件到epoll句柄中时(在epoll_ctl中指定EPOLL_CTL_ADD),会把所有的fd直接拷贝进内核,这样避免了用户态和内核态之间的重复拷贝。epoll保证了每个fd在整个过程中只会拷贝一次。
对于第二个缺点,epoll的解决方案不像select或poll一样每次都把current轮流加入fd对应的设备等待队列中,而只在epoll_ctl时把current挂一遍(这一遍必不可少)并为每个fd指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的fd加入一个就绪链表)。epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd(利用schedule_timeout()实现睡一会,判断一会的效果,和select实现中的第7步是类似的)。
对于第三个缺点,epoll没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。
如果没有大量的空闲连接或者“死”连接,epoll的效率并不会比select/poll高很多,但是当有大量空闲连接的时候,就会发现epoll的效率会大大提高。
它们的区别是什么呢?
三者的区别体现在三个方面:
类型 | 支持的最大连接数 | FD剧增后带来的IO效率问题 | 消息传递方式 |
---|---|---|---|
select | 最大连接数由FD_SIZE决定,默认值为1024 | 每次调用都会对连接进行线性遍历,随着FD的增加使得遍历效率降低 | 通过拷贝的方式将内核的消息传递给用户空间 |
poll | 基于链表存储,没有最大连接数的限制 | 同上 | 同上 |
epoll | 最大连接数由内存决定,1G的内存可以打开约10万个连接 | fd上是利用回调函数,只有活跃的socket才会主动调用callback,在活跃socket较少的情况下,不存在前者的线性下降的性能问题,当所有socket都很活跃的时候,可能会出现性能问题 | 通过共享内存的方式,实现内核态和用户态的通信 |