讲IO多路复用之前我们先理解什么是IO?
一、 什么是IO?
我们都知道unix世界里,一切皆文件,而文件是什么呢?文件就是一串二进制流,不管socket,还是FIFO、管道、终端,对我们来说,一切都是文件,一切都是流。在信息 交换的过程中,我们都是对这些流进行数据的收发操作,简称为I/O操作(input and output),往流中读出数据,系统调用read,写入数据,系统调用write。
二、 文件描述符
那么计算机中这么多流,我们是如何知道要操作哪个流呢?这时候我们需要有一个能够对文件进行定位的标识符,那么文件描述符就应运而生。文件描述符是内核为了高效管理已经被打开的文件所创建的索引,他是一个从0开始的整数,程序所有执行的I/O操作都是通过文件描述符进行的。其中,在程序刚刚启动时,0,1,2三个文件描述符已经被占用了,0代表标准输入设备stdin(比如键盘),1代表标准输出设备stdout(显示器),2代表标准错误stderr.POSIX标准要求每次打开文件时(含socket)必须使用当前进程中最小可用的文件描述符号码,因此再打开一个文件,它的文件描述符会是3。
既然文件描述符是文件的索引,那么它有没有最大限制呢?对的,文件描述符是系统的一个重要的资源,实际中最大打开的文件数是系统内存的10%,这个是系统级限制,有系统就会有用户,用户级限制是单个进程最大打开的文件数,一般是1024,可以使用ulimit -n命令查看。
系统是如何通过文件描述符定位到文件的呢?
系统为每一个进程维护了一个文件描述符表,这是一个进程级的文件描述符表,它通过文件描述符所对应的文件指针指向系统级的打开文件描述符表中的一个打开文件句柄,句柄中存储了打开文件相应的全部信息,包括文件偏移量、状态标示、访问模式文件类型以及文件属性等等,其中有一个inode指针,它指向了i-node表 中该文件的表项,具体想要了解i-node表的可以查看Linux的inode的理解.此博客写的很详细。
三、 阻塞与非阻塞
为了让大家能顺利掌握IO复用,请允许我再唠叨一下阻塞与非阻塞。
什么是程序的阻塞呢?想象这种情形,比如你等快递,但快递一直没来,你会怎么做?有两种方式:
快递没来,我可以先去睡觉,然后快递来了给我打电话叫我去取就行了。
快递没来,我就不停的给快递打电话说:擦,怎么还没来,给老子快点,直到快递来。
很显然,你无法忍受第二种方式,不仅耽搁自己的时间,也会让快递很想打你。
而在计算机世界,这两种情形就对应阻塞和非阻塞忙轮询。
非阻塞忙轮询:数据没来,进程就不停的去检测数据,直到数据来。
阻塞:数据没来,啥都不做,直到数据来了,才进行下一步的处理。
先说说阻塞,为了了解阻塞是如何进行的,我们来讨论缓冲区,以及内核缓冲区,最终把I/O事件解释清楚。缓冲区的引入是为了减少频繁I/O操作而引起频繁的系统调用(你知道它很慢的),当你操作一个流时,更多的是以缓冲区为单位进行操作,这是相对于用户空间而言。对于内核来说,也需要缓冲区。
因为一个线程只能处理一个套接字的I/O事件,如果想同时处理多个,可以利用非阻塞忙轮询的方式,伪代码如下:
while true { for i in stream[]
{ if i has data read until unavailable } }
我们只要把所有流从头到尾查询一遍,就可以处理多个流了,但这样做很不好,因为如果所有的流都没有I/O事件,白白浪费CPU时间片。正如有一位科学家所说,计算机所有的问题都可以增加一个中间层来解决,同样,为了避免这里cpu的空转,我们不让这个线程亲自去检查流中是否有事件,而是引进了一个代理(一开始是select,后来是poll),这个代理很牛,它可以同时观察许多流的I/O事件,如果没有事件,代理就阻塞,线程就不会挨个挨个去轮询了,伪代码如下:
while true { select(streams[]) //这一步死在这里,知道有一个流有I/O事件时,才往下执行
for i in streams[] { if i has data read until unavailable } }
四、 IO多路复用
好了,我们讲了这么多,那么,到底什么是I/O多路复用呢?
我们知道阻塞IO是阻塞一个IO,IO多路复用就是同时阻塞多个IO,官方点的解释是指内核一旦发现进程指定的一个或者多个IO条件准备读取,它就通知该进程。下面有一幅图形象的体现出这一点:
IO多路复用适用如下场合:
1. 当客户处理多个描述符时(一般是交互式输入和网络套接口),必须使用I/O复用。
2. 当一个客户同时处理多个套接口时,而这种情况是可能的,但很少出现。
3. 如果一个TCP服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到I/O复用。
4. 如果一个服务器即要处理TCP,又要处理UDP,一般要使用I/O复用。
5. 如果一个服务器要处理多个服务或多个协议,一般要使用I/O复用。
与多进程和多线程技术相比,I/O多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。
上面说了那么多,好像这里并没有用到什么,别着急,下面来说下IO复用的具体实现。
五、 IO多路复用的实现
select, poll, epoll 都是I/O多路复用的具体的实现,之所以有它们三个存在,其实是他们出现是有先后顺序的。
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(因此把该参数命名为maxfdp1),描述字0、1、2…maxfdp1-1均将被测试。
因为文件描述符是从0开始的。
(2)中间的三个参数readset、writeset和exceptset指定我们要让内核测试读、写和异常条件的描述字。如果对某一个的条件不感兴趣,就可以把它设为空指针。struct fd_set可以理解为一个集合,这个集合中存放的是文件描述符,可通过以下四个宏进行设置:
- void FD_ZERO(fd_set *fdset); //清空集合
- void FD_SET(int fd, fd_set *fdset); //将一个给定的文件描述符加入集合之中
- void FD_CLR(int fd, fd_set *fdset); //将一个给定的文件描述符从集合中删除
- int FD_ISSET(int fd, fd_set *fdset); // 检查集合中指定的文件描述符是否可以读写
(3)timeout告知内核等待所指定描述字中的任何一个就绪可花多少时间。其timeval结构用于指定这段时间的秒数和微秒数。 -
struct timeval{ long tv_sec; //seconds long tv_usec; //microseconds };
这个参数有三种可能:
(1)永远等待下去:仅在有一个描述字准备好I/O时才返回。为此,把该参数设置为空指针NULL。
(2)等待一段固定时间:在有一个描述字准备好I/O时返回,但是不超过由该参数所指向的timeval结构中指定的秒数和微秒数。
(3)根本不等待:检查描述字后立即返回,这称为轮询。为此,该参数必须指向一个timeval结构,而且其中的定时器值必须为0。
原理图:
函数说明:
I/O多路复用这个概念被提出来以后, select是第一个实现 (1983 左右在BSD里面实现的)。select 被实现以后,很快就暴露出了很多问题。
1) 对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低。
当套接字比较多的时候,每次select()都要通过遍历FD_SETSIZE个Socket来完成调度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间。
2) select 如果任何一个sock(I/O stream)出现了数据,select 仅仅会返回,但是并不会告诉你是那个sock上有数据,于是你只能自己一个一个的找。
3) select 只能监视1024个链接, linux 定义在头文件中的,参见FD_SETSIZE。
4) 需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。
5) select 不是线程安全的,如果你把一个sock加入到select, 然后突然另外一个线程发现,这个sock不用,要收回。对不起,这个select 不支持的,如果你丧心病狂的竟然关掉这个sock, select的标准行为是。。呃。。不可预测的, 这个可是写在文档中的哦.
“If a file descriptor being monitored by select() is closed in another thread, the result is unspecified”poll
于是14年以后(1997年)一帮人又实现了poll, poll 修复了select的很多问题,比如
1) poll 去掉了1024个链接的限制,于是要多少链接呢, 主人你开心就好。
2) poll 从设计上来说,不再修改传入数组,不过这个要看你的平台了。
但是poll仍然不是线程安全的, 这就意味着,不管服务器有多强悍,你也只能在一个线程里面处理一组I/O流。你当然可以那多进程来配合了,不过然后你就有了多进程的各种问题。
poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。
它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有一个缺点:
1) 大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。
2) poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。epoll
于是5年以后, 在2002, 大神 Davide Libenzi 实现了epoll.
epoll 可以说是I/O 多路复用最新的一个实现,epoll 修复了poll 和select绝大部分问题, 比如:
1. 没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口)。
2. 效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。
3. 内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。(共享内存的方式实现)epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。
原理:epoll支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些fd刚刚变为就绪态,并且只会通知一次。还有一个特点是,epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦某个文件描述符就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。(此处去掉了遍历文件描述符,而是通过监听回调的的机制。这正是epoll的魅力所在。)三种方式总结:
从select那里仅仅知道了,有I/O事件发生了,却并不知道是哪那几个流(可能有一个,多个,甚至全部),我们只能无差 别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。所以select具有O(n)的无差别轮询复杂度,同时处理的流越多,无差别轮询时间 就越长。
epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll会把哪个流发生了怎样的I/O事件通知我们。所以我们说epoll实际上是事件驱动(每个事件关联上fd)的,此时我们对这些流的操作都是有意义的。(复杂度降低到了O(1))伪代码如下:while true { active_stream[] = epoll_wait(epollfd) for i in active_stream[] { read or write till } }
可以看到,select和epoll最大的区别就是:select只是告诉你一定数目的流有事件了,至于哪个流有事件,还得你一个一个地去轮询,而 epoll会把发生的事件告诉你,通过发生的事件,就自然而然定位到哪个流了。不能不说epoll跟select相比,是质的飞跃,这也是一种牺牲空间,换取时间的思想,毕竟现在硬件越来越便宜了。
表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。