有些东西,开发的时候你都懂。等你开发完了,你就记忆模糊了,哈哈。不知道是不是只有我这样。额
在介绍三者之前,先聊一聊IO读写的基础原理
。
《Netty、Redis、ZooKeeper高并发实战》图书
相信大家都听说过缓冲区
这个词,其实只要进行IO操作,就离不开它。
就Linux而言,程序并非直接和底层物理内存进行数据的交互,中间还要经过缓冲区
。
缓冲区
->物理设备内存缓冲区
->上层设备 上层设备调用操作系统的read
和write
都会涉及缓冲区。
内核缓冲区
复制到进程缓冲区
进程缓冲区
复制到内核缓冲区
read
和write
两个系统调用,都不会负责数据在内核缓冲区
和实际物理设备(如磁盘、内存)之间的交换,这项底层的读写交换,实际是由操作系统的内核(Kernel)完成的。
在用户程序中,无论是Socket的IO、还是文件的IO操作,都是上层应用的开发,其输入Input和输出Output的处理,在编程流程上都是一致的。
[实际上我们说的Linux指的就是其内核Kernel,而具体的Debian、CentOS等Linux发行版本,才是基于Linux内核的完整操作系统,包括GUI组件和许多其他工具。通常可以理解操作系统是系统内核上的一套软件,我们的应用至多和操作系统层面打交道,而物理设备的具体操作还是操作系统调用内核的接口,由内核处理的]
关于read和write系统调度,感兴趣的可以看看这几篇网文。
linux中的read和write系统调用
read和write函数
《Netty、Redis、ZooKeeper高并发实战》图书
Linux用户空间与内核空间(理解高端内存)
什么是内核缓冲区,用户缓冲区
众所皆知,CPU处理指令很快,如果获取数据每次都需要从内存临时读取,那么太慢了。所以CPU和内存之间,还有一个CPU的寄存器。内存会自己将获取到的需要处理的数据即时传输到寄存器,这样CPU就不需要每次都去操作内存看是否需要处理数据了。
类似CPU和内存之间需要寄存器来暂存数据一样。我们的应用和物理设备的实际IO之间,隔着一对缓冲区(内核缓冲区
、进程缓冲区
)。实际上,内核缓冲区
和进程缓冲区
就是对内存资源的一种划分形式。
有了缓冲区后,上层应用使用read
系统调用时,仅仅把数据从内核缓冲区区
复制到上层应用的缓冲区(进程缓冲区
)。[内核缓冲区的数据,由系统内核从物理设备中读取,这个过程是透明的,用户无法感知]
上层应用调用write
系统调用时,仅把数据从进程缓冲区
复制到内核缓冲区
。底层操作会对内核缓冲区
进行监控,等待缓冲区的数据达到一定数量级时,请求IO设备的中断处理(操作系统级别),集中执行物理设备的实际IO操作。这种机制提高了系统的性能。至于什么时候中断(读中断、写中断),由操作系统的内核来决定,用户程序无需关心。
(我理解就是,内核缓冲区
获取数据,并且申请系统中断,此时CPU会处理对应的物理设备的IO事件。内存将数据交给CPU寄存器,CPU读取数据和指令,进行处理。实际应该更加复杂,希望看官们补充下自己的见解)
《Netty、Redis、ZooKeeper高并发实战》图书
如果是read一个socket(套接字),上述两阶段具体处理流程如下:
内核中的缓冲区
。这个工作由操作系统自动完成,用户无感知。内核缓冲区
复制到对应的进程缓冲区
。如果是具体的Java服务端,完成一次socket请求和响应,完整流程如下:
内核缓冲区
。read
系统调用,从Linux内核缓冲区
读取数据,再送入进程缓冲区。用户空间
(后面IO的讲解也会提到这个词)中处理客户端的请求。write
系统调用,将这些数据从用户缓冲区
(进程缓冲区
)写入内核缓冲区
。内核缓冲区
中的数据写入网卡,网卡通过底层的通信协议,将数据发送给目标客户端。(内核缓冲区
本身就是内存,只不过是对指定区块的内存地址命名为内存缓冲区
)这里补充一下网卡接收数据的流程。这个比较复杂,感兴趣的可以自己深入了解。
Linux网络 - 数据包的接收过程
这个我在自己的github笔记也有过文字描述了。这里直接摘过来了。
网卡接收数据:
网卡网络服务中,网卡NIC通过硬件中断IRQ通知CPU有数据要处理时,CPU在查询已注册的中断函数,请求调用网卡驱动NIC Driver的相对应函数,这个过程所有其他硬件不能请求硬中断,也就是没法和CPU交互;然后网卡驱动NIC Driver收到请求后,先禁用网卡的中断IQR,这样CPU就能再响应其他硬件的中断了。此时NIC Driver启用软中断,经过内核读取网卡写到内存的数据包,调用相应软中断处理函数,然后网卡把数据交给CPU让其软中断上下文中处理网络数据,接着调用协议栈响应的函数将数据给协议栈处理,待内存中所有数据包处理完后(即poll函数执行完后),才启用网卡的硬中断,这样网卡下次就能再收到数据通知CPU。
大致就是:网卡NIC-- 请求硬中断–> CPU — 请求网卡驱动调用函数 --> NIC Driver – 禁用网卡硬中断,此时别的硬件能够对CPU请求硬中断操作 --> NIC Driver – 启用软中断 --> 和内核、内存、CPU在软中断过程交互处理网卡写到内存的数据 – > 交给协议栈处理 --内存所有数据包处理完后–> 启用网卡硬中断,能够再收到数据并通知CPU
BIO、NIO、AIO三者无非就“是否同步”、“是否阻塞”的区别。在讲解三者之前,先聊聊“同步
”和“异步
(非同步)”;“阻塞
”和“非阻塞
”。
参考资料
java BIO NIO AIO讲解
《Netty、Redis、ZooKeeper高并发实战》图书
首先,同步
和异步
,区别在于用户空间
与内核空间
的IO发起方式。
同步IO和异步IO:
同步IO
中,用户进程的具体某一线程作为主动发起方
,发起IO请求,而内核空间作为被动接受方
。异步IO
则相反,系统内核作为主动发起方
发起IO请求,而用户空间的线程是被动接收方
。
听上去很绕口、抽象。同步IO即用户请求IO操作时,应用程序内部会自动轮询查看操作系统对IO事件的处理,当处理成功或者出现异常时,再将结果返回给用户。而异步IO,在用户请求IO操作后,用户可接下去继续其他操作,这时候由操作系统来监听查看IO事件的处理,如果完成或者出现异常,再将信息返回给应用程序(一般应用程序有预设的回调函数、钩子函数,操作系统将信息返回给这些预设的函数,然后应用程序会在收到通知后自动调用这些函数)。
简单讲,同步和异步的区别,就是监听IO事件的执行过程,是由谁完成的。如果是同步IO,应用程序会执行内部循环监听IO事件的函数等操作,所以用户没法继续别的操作;而异步IO,操作系统也将监听IO事件的处理的任务包揽了,会自己将处理结果(成功、异常)返回给应用程序预设的函数,由于不需要监听IO事件的执行情况,这时候用户就可以继续别的操作了。
参考资料
java BIO NIO AIO讲解
《Netty、Redis、ZooKeeper高并发实战》图书
阻塞
和非阻塞
,区别在于用户空间
是否需要等待内核空间
的IO处理彻底完成。
非阻塞IO
,即用户空间
的程序不需要等待内核IO彻底完成,可以立即返回用户空间
执行用户的操作,用户空间处于非阻塞的状态,于此同时,内核空间
会立即返回给用户一个状态值。
简单说就是,用户空间
(调用线程)拿到内核空间
返回的状态值,就立刻将其返回给自己的空间,IO操作可以干就干,不可以干就继续干别的事情。
非阻塞IO要求socket设置为NONBLOCK。
这里的NIO(同步非阻塞)模型,并非Java的NIO(New IO)库,Java的NIO更偏向IO多路复用理念
可能有人就有疑惑了,我IO操作,不都希望得到IO处理结果吗,那我为啥用非阻塞IO。其实这个确实用的很少,如果需要即时处理数据,往往需要用while循环等反复判断非阻塞IO
是否处理完IO事件,其实也约等于用成了阻塞IO
。不过如果偏底层的程序,如果不需要实时处理IO事件,但是又需定时判断某一IO事件是否处理完成,那就用得到非阻塞IO
了。(暂时没想到比较贴近生活的应用场景,阅历高的看官们可以给点建议。)
《Netty、Redis、ZooKeeper高并发实战》图书
epoll原理详解及epoll反应堆模型
根据前面的学习,知道了IO读写原理、同步与异步、阻塞和非阻塞,那么BIO、NIO、AIO、IO多路复用就很好理解了。
用户空间
)作为IO事件的主动发起方
,向内核空间
发起IO事件,并且阻塞线程,直到IO事件彻底完成后,用户程序重新获取到CPU的执行权,能够往下运行程序。用户空间
)主动发起IO事件,但是立即获取到IO事件的状态值,无需等待IO事件完成,可继续进行别的操作。异步
,但是可能是非阻塞IO
,也可能是阻塞IO
。也就是用户空间
作为IO事件的被动接收方
,在请求IO事件处理后,监听IO事件处理的过程全权交由操作系统处理,内核空间
之后再将结果返回给用户程序指定的回调函数。如果是阻塞IO
,那么即时是异步
,用户仍然将CPU执行交由操作系统处理IO事件,必须等待IO事件完成,才能继续向下执行代码;如果是非阻塞IO
,那么用户直接得知当时那一刻的IO事件处理状态(这往往没什么太大用)。同步IO
,且为阻塞IO
,但是和BIO不同,其算是NIO的改良版本。这个要具体展开的话,可以说很多(建议看看上面多次提到的《Netty、Redis、ZooKeeper高并发实战》)。非阻塞IO
操作,导致需要我们在用户空间
手动while循环判断IO事件的处理状态。而IO多路复用,通过select
、epoll
等系统调用,完成阻塞IO
的IO状态监听。IO多路复用
模型中,引入了一种新的系统调用,查询IO的就绪状态。在Linux系统中,对应的系统调用为select/epoll
系统调用。通过该系统调用,一个进程可以监视多个文件描述符,一旦某个描述符就绪(一般是内核缓冲区可读/可写),内核就能够将就绪的状态返回给应用程序,随后,应用程序根据就绪的状态,进行相应的IO系统调用。select/epoll选择器
中,Java中对应的是Selector类。然后开启整个IO多路复用模型的轮询流程。select/epoll
系统调用,其会收集所有当前就绪的socket,返回列表,这一选择过程,需要阻塞线程,直到至少一个socket就绪返回【当然也可能执行选择操作时,返回含有多个就绪socket的列表】)。read
系统调用,用户线程阻塞,内核复制数据,将数据从内核缓冲区
复制到用户缓冲区
。select
和read
都完成后,执行其他操作,处理数据什么的。 和NIO模型相似,多路复用IO也需要轮询。负责select/epoll
状态查询调用的线程,需要不断地进行select/epoll
轮询,查找出达到IO操作就绪的socket连接。
IO多路复用模型与同步非阻塞IO模型有密切关系。对于注册在选择器上的每一个可以查询的socket连接,一般都设置成为NIO同步非阻塞模型。(对于用户程序而言是无感知的)
IO多路复用模型的优点:与一个线程维护一个连接的阻塞IO模式相比,使用select/epoll
的最大优势在于,一个选择器查询线程可以同时处理成千上万个连接(Connection)。
IO多路复用模型的缺点:本质上,select/epoll系统调用是阻塞式的,属于同步IO。
在实际开发中,比如在Netty中运用IO多路复用时,TCP通讯的话,需要一个连接对应一个Channel通道,往往可以开启一个线程(或多个线程=CPU内核数)当作Boss线程组,专门处理连接请求(就绪状态轮询);然后另外开一个线程组(CPU内核数*2)的Worker工作线程用于处理数据的读取、处理。这样,Boss线程组中1个线程就可以监听处理N个Socket连接事件(Channel通道的连接),而对应连接就绪的Channel通道获取数据、处理数据时,由Worker线程组的空闲线程分担处理。
(个人理解,IO多路复用在实际开发中,类似事件驱动模式。)
最后自己宣传下自己最近和队友制作的Android安卓小程序,采用核心技术为:SpringCloud、Netty(UDP通讯)、Flutter移动端开发。
有排面app-介绍视频(原长版)