IO模型
很多优秀的框架(比如Netty、HSF、Dubbo、Thrift等)已经把底层网络IO给封装了,通过提供的API能力或者配置就能完成想要的服务能力开发; 作为服务端的开发工程师来说,都会进行一系列服务设计、开发以及能力开放,而服务能力开放也是需要通过网络来完成的;
- 在互联网的时代下,绝大部分数据都是通过网络来进行获取的。
- 在服务端的架构中,绝大部分数据也是通过网络来进行交互的。
什么是IO
IO(Input/Output)是指计算机与外部设备(如磁盘、键盘、屏幕等)之间进行数据交换的过程。在计算机系统中,程序通过IO来获取输入并输出结果。
IO可以分为输入(Input)和输出(Output)两种形式:
- 输入(Input):从外部设备读取数据到计算机系统中。例如,从键盘接收用户的输入、从文件中读取数据等。
- 输出(Output):将计算机系统中的数据发送到外部设备。例如,将数据写入文件、将结果显示在屏幕上等。
Java中的IO操作有很多种方式,主要分为字节流(Byte Stream)和字符流(Character Stream)。字节流以字节(8位)为单位进行读写操作,适用于处理二进制数据或者字节流数据。而字符流以字符(16位)为单位进行读写操作,适用于处理文本数据。
常见的IO类包括 InputStream
、OutputStream
(字节流)、Reader
、Writer
(字符流),它们提供了一系列方法来进行读取和写入操作。
Java还提供了更高级的IO类,如 File
、BufferedReader
、FileWriter
等,以简化IO操作并提供更高效的数据处理能力。
IO在编程中起着重要的作用,使得程序能够与外部设备进行数据交互,实现数据的输入和输出。
IO分类
IO分为磁盘IO和网络IO,网络IO用于计算机之间的通信,磁盘IO用于访问永久存储的数据。了解不同类型的IO操作有助于优化程序的性能,并有效地进行数据交换。
- 网络IO(Network IO):网络IO是指计算机与其他计算机或设备之间进行数据交换的操作。它涉及从网络接口卡(NIC)发送数据以及接收来自网络的数据。通过网络IO,计算机可以连接到互联网、局域网或其他计算机,并进行数据的传输和通信。典型的网络IO操作包括建立网络连接、发送和接收数据包等。
- 磁盘IO(Disk IO):磁盘IO是指计算机与硬盘驱动器进行数据交换的操作。磁盘IO涉及将数据从磁盘读取到内存或将数据从内存写入到磁盘。通过磁盘IO,计算机可以访问存储在磁盘上的文件和数据。典型的磁盘IO操作包括打开、读取、写入和关闭文件,以及对文件进行增删改查等。
在计算机系统中,通常将内存操作称为内存访问或内存读写,而不是IO。内存操作是指程序对计算机内存的读取和写入操作,用于处理程序内部的数据传输和计算。这些操作是在CPU和内存之间进行的,无需通过外部设备进行数据交换。例如,将数据从一个内存位置复制到另一个内存位置,或者通过CPU和寄存器之间的数据传输。
内存操作通常不被归类为IO。IO更专注于程序与外部设备之间的数据交互,例如从键盘获取输入、将数据写入磁盘或发送数据到网络等。
通常用户进程的一个完整的IO分为两个阶段:
磁盘IO:
网络IO:
操作系统和驱动程序运行在内核空间,应用程序运行在用户空间,两者不能使用指针传递数据,因为Linux使用的虚拟内存机制,必须通过系统调用请求内核来完成IO动作。
程序读写操作,本质上都是在调用OS
内核提供的函数:read()、 write()
。往流中读取数据,系统调用Read,写入数据,系统调用Write。
- 操作系统和驱动程序运行在内核空间:操作系统是计算机系统的核心部分,负责管理计算机资源和提供服务。驱动程序是操作系统中的一种软件模块,用于控制和管理硬件设备。它们运行在称为内核空间的特权模式下,有更高的访问权限和更大的系统资源访问能力。
- 应用程序运行在用户空间:应用程序是用户编写的软件,常规的应用程序代码都运行在用户空间。用户空间对于资源的访问有限,并且具有较低的安全权限。
- 两者不能使用指针传递数据:由于内核空间和用户空间之间存在权限隔离,用户空间的应用程序无法直接访问和操作内核空间的数据,包括使用指针传递数据。这是为了保护系统的稳定性和安全性。
- Linux使用虚拟内存机制:Linux操作系统采用了虚拟内存机制,将物理内存抽象成虚拟地址空间。每个进程在用户空间都有自己独立的虚拟地址空间。虚拟内存机制使得每个进程能够以独立的方式访问内存,提高了系统的安全性和稳定性。
- 必须通过系统调用请求内核来完成IO动作:由于应用程序无法直接访问内核空间,如果需要进行IO操作(如读写文件、发送网络数据等),应用程序必须通过系统调用向内核发起IO请求。系统调用是一种用户空间与内核空间之间的接口,允许应用程序请求内核执行特定的操作,包括进行IO操作。内核会根据系统调用的请求进行相应的处理。
操作系统和驱动程序运行在内核空间,应用程序运行在用户空间,它们之间通过系统调用来完成IO操作。这种设计保护了系统的稳定性和安全性,并允许应用程序以受控的方式访问硬件和其他资源。
为什么需要IO模型
如果使用同步的方式来通信的话,所有的操作都在一个线程内顺序执行完成,这么做缺点是很明显的:
- 因为同步的通信操作会阻塞同一个线程的其他任何操作,只有这个操作完成了之后,后续的操作才可以完成,所以出现了同步阻塞+多线程(每个Socket都创建一个线程对应),但是系统内线程数量是有限制的,同时线程切换很浪费时间,适合Socket少的情况。
因该需要出现IO模型。
Linux的IO模型
在描述Linux IO模型之前,我们先来了解一下Linux系统数据读取的过程:
以用户请求index.html文件为例子说明
基本概念
用户空间和内核空间
操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。
- 为了保证内核的安全,用户进程不能直接操作内核,操作系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。
进程切换
为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。
这种行为被称为进程切换。
因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。
进程的阻塞
正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。
可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。
当进程进入阻塞状态,是不占用CPU资源的。
文件描述符
文件描述符(File Descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。
文件描述符在形式上是一个非负整数,实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。
- 当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。
缓存IO
大多数文件系统的默认 IO 操作都是缓存 IO。
其读写过程如下:
- 读操作:操作系统检查内核的缓冲区有没有需要的数据,如果已经缓存了,那么就直接从缓存中返回;否则从磁盘、网卡等中读取,然后缓存在操作系统的缓存中;
- 写操作:将数据从用户空间复制到内核空间的缓存中。这时对用户程序来说写操作就已经完成,至于什么时候再写到磁盘、网卡等中由操作系统决定,除非显示地调用了 sync 同步命令。
假设内核空间缓存无需要的数据,用户进程从磁盘或网络读数据分两个阶段:
- **阶段一:**内核程序从磁盘、网卡等读取数据到内核空间缓存区;
- **阶段二:**用户程序从内核空间缓存拷贝数据到用户空间。
缓存 IO 的缺点:
数据在传输过程中需要在应用程序地址空间和内核空间进行多次数据拷贝操作,这些数据拷贝操作所带来的CPU以及内存开销非常大。
Linux
系统中共计提供了五种IO
模型,它们分别为BIO、NIO
、多路复用、信号驱动、AIO
,从性能上来说,它们属于依次递进的关系,但越靠后的IO
模型实现也越为复杂。
同步阻塞式IO-BIO
BIO(Blocking-IO)
即同步阻塞模型,这也是最初的IO
模型,也就是当调用内核的read()
函数后,内核在执行数据准备、复制阶段的IO
操作时,应用线程都是阻塞的,所以本次IO
操作则被称为同步阻塞式IO
,如下:
当程序中需要进行IO
操作时,会先调用内核提供的read()
函数,IO
会经过"设备→内核缓冲区→程序缓冲区"这个过程,该过程必然是耗时的,在同步阻塞模型中,程序中的线程发起IO
调用后,会一直挂起等待,直至数据成功拷贝至程序缓冲区才会继续往下执行。
用户空间的应用程序执行一个系统调用,这会导致应用程序阻塞,什么也不干,直到数据准备好,并且将数据从内核复制到用户进程,最后进程再处理数据,在等待数据到处理数据的两个阶段,整个进程都被阻塞,不能处理别的网络IO。
当本次IO
操作还在执行时,又出现多个IO
调用,比如多个网络数据到来,采用多线程实现,包括最初的IO
模型也的确是这样实现的,也就是当出现一个新的IO
调用时,服务器就会多一条线程去处理,因此会出现如下情况:
在BIO
这种模型中,为了支持并发请求,通常情况下会采用"请求:线程"1:1
的模型,那此时会带来很大的弊端:
- ①并发过高时会导致创建大量线程,而线程资源是有限的,超出后会导致系统崩溃。
- ②并发过高时,就算创建的线程数未达系统瓶颈,但由于线程数过多也会造成频繁的上下文切换。
但在Java
常用的Tomcat
服务器中,Tomcat7.x
版本以下默认的IO
类型也是BIO
,但似乎并未碰到过:并发请求创建大量线程导致系统崩溃的情况出现呢?这是由于Tomcat
中对BIO
模型稍微进行了优化,通过线程池做了限制:
在Tomcat
中,存在一个处理请求的线程池,该线程池声明了核心线程数以及最大线程数,当并发请求数超出配置的最大线程数时,会将客户端的请求加入请求队列中等待,防止并发过高造成创建大量线程,从而引发系统崩溃。
同步非阻塞式IO-NIO
NIO(Non-Blocking-IO)
同步非阻塞模型,从字面意思上来说就是:调用read()
函数的线程并不会阻塞,而是可以正常运行,如下:
当应用程序中发起IO
调用后,内核并不阻塞当前线程,而是立马返回一个"数据未就绪"的信息给应用程序,而应用程序这边则一直反复轮询去问内核:数据有没有准备好?直到最终数据准备好了之后,内核返回"数据已就绪"状态,紧接着再由进程去处理数据.....
其实相对来说,这个过程虽然没有阻塞发起IO
调用的线程,但实际上也会让调用方不断去轮询发起"数据是否准备好"的信号,这也并非真正意义上的非阻塞,这种所谓的NIO
相对来说较为鸡肋,因此目前大多数的NIO
技术并非采用这种多线程的模型,而是基于单线程的多路复用模型 实现的,Java
中支持的NIO
模型亦是如此。
IO多路复用模型
由于线程在不断的轮询查看数据是否准备就绪,造成CPU
开销较大。既然说是由于大量无效的轮询造成CPU
占用过高,那么等内核中的数据准备好了之后,再去询问数据是否就绪是不是就可以了? IO多路复用,这是一种进程预先告知内核的能力,让内核发现进程指定的一个或多个IO条件就绪了,就通知进程。
多路复用模型 基于文件描述符File Descriptor
实现的,在Linux
中提供了select、poll、epoll
等一系列函数实现该模型,结构如下:
IO复用的实现方式目前主要有Select、Poll和Epoll。
在多路复用模型中,内核仅有一条线程负责处理所有连接,所有网络请求/连接(Socket
)都会利用通道Channel
注册到选择器上,然后监听器负责监听所有的连接,过程如下:
当出现一个IO
操作时,会通过调用内核提供的多路复用函数,将当前连接注册到监听器上,当监听器发现该连接的数据准备就绪后,会返回一个可读条件给用户进程,然后用户进程拷贝内核准备好的数据进行处理(这里实际是读取Socket
缓冲区中的数据)。
这里面涉及到一个概念:系统调用,本意是指调用内核所提供的API接口函数。
recvfrom
函数则是指经Socket
套接字接收数据,主要用于网络IO
操作。
read
函数则是指从本地读取数据,主要用于本地的文件IO
操作。
伪代码描述IO多路复用:
java
while(status == OK) { // 不断轮询
ready_fd_list = io_wait(fd_list); //内核缓冲区是否有准备好的数据
for(fd in ready_fd_list) {
data = read(fd) // 有准备好的数据读取到用户缓冲区
process(data)
}
}
信号驱动模型
信号驱动IO
模型(Signal-Driven-IO
)是一种偏异步IO
的模型,在该模型中引入了信号驱动的概念,在用户进程中首先会创建一个SIGIO
信号处理程序,然后基于信号的模型进行处理,如下:
在该模型中,首先用户进程中会创建一个Sigio
信号处理程序,然后会系统调用sigaction
信号处理函数,紧接着内核会直接让用户进程中的线程返回,用户进程可在这期间干别的工作,当内核中的数据准备好之后,内核会生成一个Sigio
信号,通知对应的用户进程数据已准备就绪,然后由用户进程在触发一个recvfrom
的系统调用,从内核中将数据拷贝出来进行处理。
信号驱动模型相较于之前的模型而言,从一定意义上实现了异步,也就是数据的准备阶段是异步非阻塞执行的,但数据的复制阶段却依旧是同步阻塞执行的。
纵观上述的所有IO
模型:BIO、NIO
、多路复用、信号驱动,本质上从内核缓冲区拷贝数据到程序缓冲区的过程都是阻塞的,如果想要做到真正意义上的异步非阻塞IO
,那么就牵扯到了AIO
模型。
流程如下:
- 开启套接字信号驱动IO功能
- 系统调用Sigaction执行信号处理函数(非阻塞,立刻返回)
- 数据就绪,生成Sigio信号,通过信号回调通知应用来读取数据
此种IO方式存在的一个很大的问题:Linux中信号队列是有限制的,如果超过这个数字问题就无法读取数据
异步非阻塞式IO-AIO
AIO(Asynchronous-Non-Blocking-IO)
异步非阻塞模型,该模型是真正意义上的异步非阻塞式IO
,代表数据准备与复制阶段都是异步非阻塞的:
在AIO
模型中,同样会基于信号驱动实现,在最开始会先调用aio_read、sigaction
函数,然后用户进程中会创建出一个信号处理程序,同时用户进程可立马返回执行其他操作,在数据写入到内核、且从内核拷贝到用户缓冲区后,内核会通知对应的用户进程对数据进行处理。
在
AIO
模型中,真正意义上的实现了异步非阻塞,从始至终用户进程只需要发起一次系统调用,后续的所有IO
操作由内核完成,最后在数据拷贝至程序缓冲区后,通知用户进程处理即可。
异步IO流程如下所示:
- 当用户线程调用了
aio_read
系统调用,立刻就可以开始去做其它的事,用户线程不阻塞 - 内核就开始了IO的第一个阶段:准备数据。当内核一直等到数据准备好了,它就会将数据从内核内核缓冲区,拷贝到用户缓冲区
- 内核会给用户线程发送一个信号,或者回调用户线程注册的回调接口,告诉用户线程Read操作完成了
- 用户线程读取用户缓冲区的数据,完成后续的业务操作
相对于同步IO,异步IO不是顺序执行。
用户进程进行aio_read
系统调用之后,无论内核数据是否准备好,都会直接返回给用户进程,然后用户态进程可以去做别的事情。
等到数据准备好了,内核直接复制数据给进程,然后从内核向进程发送通知。
对比信号驱动IO,异步IO的主要区别在于:
- 信号驱动由内核告诉我们何时可以开始一个IO操作(数据在内核缓冲区中),而异步IO则由内核通知IO操作何时已经完成(数据已经在用户空间中)。
异步IO又叫做事件驱动IO,在Unix中,为异步方式访问文件定义了一套库函数,定义了AIO的一系列接口。
- 使用
aio_read
或者aio_write
发起异步IO操作,使用aio_error
检查正在运行的IO操作的状态。
目前Linux中AIO的内核实现只对文件IO有效,如果要实现真正的AIO,需要用户自己来实现。
目前有很多开源的异步IO库,例如libevent、libev、libuv。