要深入的理解各种IO模型,那么必须先了解下产生各种IO的原因是什么,要知道这其中的本质问题那么我们就必须要知道一条消息是如何从一个人发送到另外一个人的。
以两个应用程序通讯为例,我们来了解一下当“A”向"B" 发送一条消息,简单来说会经过如下流程:
- 应用A把消息发送到 TCP发送缓冲区;
- TCP发送缓冲区再把消息发送出去,经过网络传递后,消息会发送到B服务器的TCP接收缓冲区;
- B再从TCP接收缓冲区去读取属于自己的数据。
如图所示:
一、阻塞 IO和非阻塞IO
我们把视角切换到上面图中的第三步, 也就是应用B从TCP缓冲区中读取数据。
思考:
TCP缓冲区还没有接收到应用B该读取的消息时,此时应用B向TCP缓冲区发起读取申请,TCP接收缓冲区是应该马上告诉应用B 现在没有你的数据,还是说让应用B在这里等着,直到有数据再把数据交给应用B。
1.1 阻塞IO
什么是阻塞IO:
从上面的思考中,可以知道:阻塞IO就是当应用B发起读取数据申请时,在内核数据没有准备好之前,应用B会一直处于等待数据状态,直到内核把数据准备好了交给应用B才结束。
术语描述:
在应用调用recvfrom读取数据时,其系统调用直到数据包到达且被复制到应用缓冲区中或者发送错误时才返回,在此期间一直会等待,进程从调用到返回这段时间内都是被阻塞的称为阻塞IO。
流程:
- 应用进程向内核发起recfrom读取数据;
- 准备数据报(应用进程阻塞);
- 将数据从内核复制到应用空间;
- 复制完成后,返回成功提示。
1.2 非阻塞IO
什么是非阻塞IO:
非阻塞IO就是当应用B发起读取数据申请时,如果内核数据没有准备好会即刻告诉应用B,不会让B在这里等待。
术语描述:
非阻塞IO是在应用调用recvfrom读取数据时,如果该缓冲区没有数据的话,就会直接返回一个EWOULDBLOCK错误,不会让应用一直等待中。在没有数据的时候会即刻返回错误标识,那也意味着如果应用要读取数据就需要不断的调用recvfrom请求,直到读取到它数据要的数据为止。
流程:
- 应用进程向内核发起recvfrom读取数据;
- 没有数据报准备好,即刻返回EWOULDBLOCK错误码;
- 应用进程向内核发起recvfrom读取数据;
- 已有数据包准备好就进行一下 步骤,否则还是返回错误码;
- 将数据从内核拷贝到用户空间;
- 完成后,返回成功提示。
二、IO多路复用
继续把视角放到应用B从TCP缓冲区中读取数据这个环节。如果在并发的环境下,可能会N个人向应用B发送消息,这种情况下我们的应用就必须创建多个线程去读取数据,每个线程都会自己调用recvfrom 去读取数据。
上面服务端通过多线程的方式处理客户端请求实现了主线程的非阻塞,使用不同线程处理不同的连接请求,但是我们并没有那么多的线程资源,并且等待读就绪的过程是耗时最多的,那么有没有什么办法可以将连接保存起来,等读已就绪时我们再进行处理。
基于非阻塞式 IO ,可以这么写:
arr = new Arr[];
listenfd = socket(); // 打开一个网络通信套接字
bind(listenfd); // 绑定
listen(listenfd); // 监听
while(1) {connfd = accept(listenfd); // 阻塞 等待建立连接arr.add(connfd);
}// 异步线程检测 连接是否可读
new Tread(){for(connfd : arr){// 还有一个弊端:可读 connfd 只能串行处理// 获取直接开多线程处理连接 但线程资源有限int n = read(connfd, buf); // 检测 connfd 是否可读if(n != -1){newThreadDeal(buf); // 创建新线程处理close(connfd); // 关闭连接 arr.remove(connfd); // 移除已处理的连接}}
}newTheadDeal(buf){doSomeThing(buf); // 处理数据
}
上面的实现看着很不错,但是却存在一个很大的问题,我们需要不断的调用 read() 进行系统调用,这里的系统调用我们可以理解为分布式系统的 RPC 调用,性能损耗十分严重,因为这依然是在用户层的操作。
这时我们自然而然就会想到把上述循环检测连接(文件描述符)可读的过程交给操作系统去做,从而避免频繁的进行系统调用。当然操作系统给我们提供了这样的函数:select、poll、epoll。
2.1 select
select 是操作系统提供的系统函数,通过它我们可以将文件描述符发送给系统,让系统内核帮我们遍历检测是否可读,并告诉我们进行读取数据。
arr = new Arr[];
listenfd = socket(); // 打开一个网络通信套接字
bind(listenfd); // 绑定
listen(listenfd); // 监听
while(1) {connfd = accept(listenfd); // 阻塞 等待建立连接arr.add(connfd);
}// 异步线程检测 通过 select 判断是否有连接可读
new Tread(){while(select(arr) > 0){for(connfd : arr){if(connfd can read){// 如果套接字可读 创建新线程处理newTheadDeal(connfd);arr.remove(connfd); // 移除已处理的连接}}}
}newTheadDeal(connfd){int n = read(connfd, buf); // 阻塞读取数据doSomeThing(buf); // 处理数据close(connfd); // 关闭连接
}
从上面我们可以看出 select 运行的整个流程:
术语描述:
进程通过将一个或多个fd传递给select,阻塞在select操作上,select帮我们侦测多个fd是否准备就绪,当有fd准备就绪时,select返回数据可读状态,应用程序再调用recvfrom读取数据。
虽然减少了大量系统调用但也存在一些问题:
- 每次调用需要在用户态和内核态之间拷贝文件描述符数组,在高并发场景下这个拷贝的消耗是很大的。
- 内核检测文件描述符可读还是通过遍历实现,当文件描述符数组很长时,遍历操作耗时也很长。
- 内核检测完文件描述符数组后,当存在可读的文件描述符数组时,用户态需要再遍历检测一遍。
2.2 poll
poll 和 select 原理基本一致,最大的区别是去掉了最大 1024 个文件描述符的限制。
select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。
poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。
2.3 epoll
epoll 主要优化了上面三个问题实现。
方案1:内核中保存一份文件描述符,无需用户每次传入,而是仅同步修改部分。
方案2:通过事件唤醒机制唤醒替代遍历。
方案3:仅将可读部分文件描述符同步给用户态,不需要用户态再次遍历。
epoll 基于高效的红黑树结构,提供了三个核心操作,主要流程如下所示:
边缘触发和水平触发:
- select/poll 只有水平触发模式;
- epoll 支持两种事件触发模式,分别是边缘触发(ET)和水平触发(LT),epoll 默认的触发模式是水平触发。
边缘触发:使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完。
水平触发:使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取。
三、信号驱动IO模型
IO多路复用解决了一个线程可以监控多个fd的问题,但是select是采用轮询的方式来监控多个fd的,通过不断的轮询fd的可读状态来知道是否有可读的数据;
有人就想,能不能我发出请求后等你数据准备好了就通知我,所以就衍生了信号驱动IO模型。
于是信号驱动IO不是用循环请求询问的方式去监控数据就绪状态,而是在调用sigaction时候建立一个SIGIO的信号联系,当内核数据准备好之后再通过SIGIO信号通知线程数据准备好后的可读状态;
当线程收到可读状态的信号后,此时再向内核发起recvfrom读取数据的请求,因为信号驱动IO的模型下应用线程在发出信号监控后即可返回,不会阻塞,所以这样的方式下,一个应用线程也可以同时监控多个fd。
术语描述:
首先开启套接口信号驱动IO功能,并通过系统调用sigaction执行一个信号处理函数,此时请求即刻返回,当数据准备就绪时,就生成对应进程的SIGIO信号,通过信号回调通知应用线程调用recvfrom来读取数据。
四、异步IO
通过观察发现,不管是IO复用还是信号驱动,要读取一个数据总是要发起两阶段的请求,第一次发送select请求,询问数据状态是否准备好,第二次发送recevform请求读取数据。
思考:为什么明明是想读取数据,而却非得要先发起一个select询问数据状态的请求,然后再发起真正的读取数据请求,能不能有一种一劳永逸的方式,只要发送一个请求告诉内核要读取数据,然后内核去帮我去完成剩下的所有事情?
因此设计了一种方案,应用只需要向内核发送一个read 请求,告诉内核它要读取数据后即刻返回;内核收到请求后会建立一个信号联系,当数据准备就绪,内核会主动把数据从内核复制到用户空间,等所有操作都完成之后,内核会发起一个通知告诉应用,这种一劳永逸的模式为异步IO模型。
术语描述:
应用告知内核启动某个操作,并让内核在整个操作完成之后,通知应用,这种模型与信号驱动模型的主要区别在于,信号驱动IO只是由内核通知我们合适可以开始下一个IO操作,而异步IO模型是由内核通知我们操作什么时候完成。
IO模型里面的同步异步:
在IO模型里面如果请求方从发起请求到数据最后完成的这一段过程中都需要自己参与,那么这种我们就称为同步请求;反之,如果应用发送完指令后就不再参与过程了,只需要等待最终完成结果的通知,那么这就属于异步。
为什么只有异步非阻塞而没有异步阻塞呢,因为异步模型下请求指定发送完后就即刻返回了,没有任何后续流程了,所以它注定不会阻塞,所以也就只会有异步非阻塞模型了。