IO模型详解
什么是IO呢?什么是阻塞非阻塞IO?什么是同步异步IO?什么是IO多路复用?select/epoll跟IO模型有什么关系?有几种经典IO模型呢?BIO、NIO、AIO到底有什么区别的?
1. 什么是IO呢?
IO,英文全称是Input/Output,翻译过来就是输入/输出。平时我们听得挺多,就是什么磁盘IO,网络IO。那IO到底是什么呢?是不是有种懵懵懂懂的感觉呀,好像大概知道它是什么,又好像说不清楚。
IO,即输入/输出,到底谁是输入?谁是输出呢?IO如果脱离了主体,就会让人疑惑。
1.1 计算机角度的IO
我们常说的输入输出,比较直观的意思就是计算机的输入输出,计算机就是主体。大家是否还记得,大学学计算机组成原理的时候,有个冯.诺依曼结构,它将计算机分成分为5个部分:运算器、控制器、存储器、输入设备、输出设备。
输入设备是向计算机输入数据和信息的设备,键盘,鼠标都属于输入设备;输出设备是计算机硬件系统的终端设备,用于接收计算机数据的输出显示,一般显示器、打印机属于输出设备。
例如你在鼠标键盘敲几下,它就会把你的指令数据,传给主机,主机通过运算后,把返回的数据信息,输出到显示器。
鼠标、显示器这只是直观表面的输入输出,回到计算机架构来说,涉及计算机核心与其他设备间数据迁移的过程,就是IO。如磁盘IO,就是从磁盘读取数据到内存,这算一次输入,对应的,将内存中的数据写入磁盘,就算输出。这就是IO的本质。
1.2 操作系统的IO
我们要将内存中的数据写入到磁盘的话,主体会是什么呢?主体可能是一个应用程序,比如一个Java进程(假设网络传来二进制流,一个Java进程可以把它写入到磁盘)。
操作系统负责计算机的资源管理和进程的调度。我们电脑上跑着的应用程序,其实是需要经过操作系统,才能做一些特殊操作,如磁盘文件读写、内存的读写等等。因为这些都是比较危险的操作,不可以由应用程序乱来,只能交给底层操作系统来。也就是说,你的应用程序要把数据写入磁盘,只能通过调用操作系统开放出来的API来操作。
- 什么是用户空间?什么是内核空间?
- 以32位操作系统为例,它为每一个进程都分配了4G(2的32次方)的内存空间。这4G可访问的内存空间分为二部分,一部分是用户空间,一部分是内核空间。内核空间是操作系统内核访问的区域,是受保护的内存空间,而用户空间是用户应用程序访问的内存区域。
我们应用程序是跑在用户空间的,它不存在实质的IO过程,真正的IO是在操作系统执行的。即应用程序的IO操作分为两种动作:IO调用和IO执行。IO调用是由进程(应用程序的运行态)发起,而IO执行是操作系统内核的工作。此时所说的IO是应用程序对操作系统IO功能的一次触发,即IO调用。
2. 操作系统的一次IO过程
应用程序发起的一次IO操作包含两个阶段:
- IO调用:应用程序进程向操作系统内核发起调用。
- IO执行:操作系统内核完成IO操作。
操作系统内核完成IO操作还包括两个过程:
- 准备数据阶段:内核等待I/O设备准备好数据
- 拷贝数据阶段:将数据从内核缓冲区拷贝到用户进程缓冲区
其实IO就是把进程的内部数据转移到外部设备,或者把外部设备的数据迁移到进程内部。外部设备一般指硬盘、socket通讯的网卡。一个完整的IO过程包括以下几个步骤:
- 应用程序进程向操作系统发起IO调用请求
- 操作系统准备数据,把IO外部设备的数据,加载到内核缓冲区
- 操作系统拷贝数据,即将内核缓冲区的数据,拷贝到用户进程缓冲区
在 Linux 系统中,传统的访问方式是通过 write() 和 read() 两个系统调用实现的,通过 read() 函数读取文件到到缓存区中,然后通过 write() 方法把缓存中的数据输出到网络端口。
下图分别对应传统 I/O 操作的数据读写流程,整个过程涉及 2 次 CPU 拷贝、2 次 DMA 拷贝,总共 4 次拷贝,以及 4 次上下文切换。
- CPU 拷贝: 由 CPU 直接处理数据的传送,数据拷贝时会一直占用 CPU 的资源。
- DMA 拷贝: 由 CPU 向DMA磁盘控制器下达指令,让 DMA 控制器来处理数据的传送,数据传送完毕再把信息反馈给 CPU,从而减轻了 CPU 资源的占有率。
- 上下文切换: 当用户程序向内核发起系统调用时,CPU 将用户进程从用户态切换到内核态; 当系统调用返回时,CPU 将用户进程从内核态切换回用户态。
文件描述符fd
文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。
2.1 读操作
当应用程序执行 read 系统调用读取一块数据的时候,如果这块数据已经存在于用户进程的页内存中,就直接从内存中读取数据。
如果数据不存在,则先将数据从磁盘加载数据到内核空间的读缓存(Read Buffer)中,再从读缓存拷贝到用户进程的页内存中。
read(file_fd, tmp_buf, len);
基于传统的 I/O 读取方式,read 系统调用会触发 2 次上下文切换,1 次 DMA 拷贝和 1 次 CPU 拷贝。
发起数据读取的流程如下:
- 用户进程通过 read() 函数向 Kernel 发起 System Call,上下文从 user space 切换为 kernel space。
- CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到 kernel space 的读缓冲区(Read Buffer)。
- CPU 将读缓冲区(Read Buffer)中的数据拷贝到 user space 的用户缓冲区(User Buffer)。
- 上下文从 kernel space 切换回用户态(User Space),read 调用执行返回。
2.2 写操作
当应用程序准备好数据,执行 write 系统调用发送网络数据时,先将数据从用户空间的页缓存拷贝到内核空间的网络缓冲区(Socket Buffer)中,然后再将写缓存中的数据拷贝到网卡设备完成数据发送。
基于传统的 I/O 写入方式,write() 系统调用会触发 2 次上下文切换,1 次 CPU 拷贝和 1 次 DMA 拷贝。
用户程序发送网络数据的流程如下:
- 用户进程通过 write() 函数向 kernel 发起 System Call,上下文从 user space 切换为 kernel space。
- CPU 将用户缓冲区(User Buffer)中的数据拷贝到 kernel space 的网络缓冲区(Socket Buffer)。
- CPU 利用 DMA 控制器将数据从网络缓冲区(Socket Buffer)拷贝到 NIC 进行数据传输。
- 上下文从 kernel space 切换回 user space,write 系统调用执行返回。
2.3 网络 I/O
2.4 磁盘 I/O
3. 阻塞IO模型
我们已经知道IO是什么啦,那什么是阻塞IO呢?
假设应用程序的进程发起IO调用,但是如果内核的数据还没准备好的话,那应用程序进程就一直在阻塞等待,一直等到内核数据准备好了,从内核拷贝到用户空间,才返回成功提示,此次IO操作,称之为阻塞IO。
- 阻塞IO比较经典的应用就是阻塞socket、Java BIO。
- 阻塞IO的缺点就是:如果内核数据一直没准备好,那用户进程将一直阻塞,浪费性能,可以使用非阻塞IO优化。
4. 非阻塞IO模型
如果内核数据还没准备好,可以先返回错误信息给用户进程,让它不需要等待,而是通过轮询的方式再来请求。这就是非阻塞IO,流程图如下:
非阻塞IO的流程如下:
- 应用进程向操作系统内核,发起
recvfrom
读取数据。 - 操作系统内核数据没有准备好,立即返回
EWOULDBLOCK
错误码。 - 应用程序进程轮询调用,继续向操作系统内核发起
recvfrom
读取数据。 - 操作系统内核数据准备好了,从内核缓冲区拷贝到用户空间。
- 完成调用,返回成功提示。
非阻塞IO模型,简称NIO,Non-Blocking IO
。它相对于阻塞IO,虽然大幅提升了性能,但是它依然存在性能问题,即频繁的轮询,导致频繁的系统调用,同样会消耗大量的CPU资源。可以考虑IO复用模型,去解决这个问题。
5. IO多路复用模型
既然NIO无效的轮询会导致CPU资源消耗,我们等到内核数据准备好了,主动通知应用进程再去进行系统调用,那不就好了嘛?
在这之前,我们先来复习下,什么是文件描述符fd(File Descriptor),它是计算机科学中的一个术语,形式上是一个非负整数。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。
IO复用模型核心思路:系统给我们提供一类函数(如我们耳濡目染的select、poll、epoll函数),它们可以同时监控多个fd
的操作,任何一个返回内核数据就绪,应用进程再发起recvfrom
系统调用。
5.1 什么是IO多路复用
「定义」
- IO多路复用是一种同步IO模型,实现一个线程可以监视多个文件句柄;一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作;没有文件句柄就绪时会阻塞应用程序,交出cpu。多路是指网络连接,复用指的是同一个线程。
5.2 为什么有IO多路复用机制?
没有IO多路复用机制时,有BIO、NIO两种实现方式,但有一些问题
同步阻塞(BIO)
- 服务端采用单线程,当accept一个请求后,在recv或send调用阻塞时,将无法accept其他请求(必须等上一个请求处recv或send完),
无法处理并发
// 伪代码描述 |
- 服务器端采用多线程,当accept一个请求后,开启线程进行recv,可以完成并发处理,但随着请求数增加需要增加系统线程,
大量的线程占用很大的内存空间,并且线程切换会带来很大的开销,10000个线程真正发生读写事件的线程数不会超过20%,每次accept都开一个线程也是一种资源浪费
// 伪代码描述 |
同步非阻塞(NIO)
- 服务器端当accept一个请求后,加入fds集合,每次轮询一遍fds集合recv(非阻塞)数据,没有数据则立即返回错误,
每次轮询所有fd(包括没有发生读写事件的fd)会很浪费cpu
setNonblocking(listen_fd) |
IO多路复用(现在的做法)
- 服务器端采用单线程通过select/epoll等系统调用获取fd列表,遍历有事件的fd进行accept/recv/send,使其能
支持更多的并发连接请求
fds = [listen_fd] |
5.3 IO多路复用之select
基本原理
应用进程通过调用select函数,可以同时监控多个fd
,在select
函数监控的fd
中,只要有任何一个数据状态准备就绪了,select
函数就会返回可读状态,这时应用进程再发起recvfrom
请求去读取数据。
非阻塞IO模型(NIO)中,需要N
(N>=1)次轮询系统调用,然而借助select
的IO多路复用模型,只需要发起一次询问就够了,大大优化了性能。
但是呢,select
有几个缺点:
- 监听的IO最大连接数有限,在Linux系统上一般为1024。
可以通过修改宏定义甚至重新编译内核的方式提升这一限制
,但是这样也会造成效率的降低。 - select函数返回后,是通过遍历
fdset
,找到就绪的描述符fd
。(仅知道有I/O事件发生,却不知是哪几个流,所以遍历所有流)
因为存在连接数限制,所以后来又提出了poll。与select相比,poll解决了连接数限制问题。但是呢,select和poll一样,还是需要通过遍历文件描述符来获取已经就绪的socket
。如果同时连接的大量客户端,在一时刻可能只有极少处于就绪状态,伴随着监视的描述符数量的增长,效率也会线性下降。
select函数接口
|
select使用示例
int main() { |
select缺点
- select最大的缺陷就是单个进程所打开的FD是有一定限制的,它由FD_SETSIZE设置,默认值是1024。
一般来说这个数目和系统内存关系很大,
具体数目可以cat /proc/sys/fs/file-max察看
。32位机默认是1024个。64位机默认是2048.
- select最大的缺陷就是单个进程所打开的FD是有一定限制的,它由FD_SETSIZE设置,默认值是1024。
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大。
需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。
- 对socket扫描时是线性扫描,采用轮询的方法,效率较低(高并发时)
当套接字比较多的时候,每次select()都要通过遍历FD_SETSIZE个Socket来完成调度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间。
如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询
,这正是epoll与kqueue做的。
5.4 IO多路复用之poll
基本原理
poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间
,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历
它没有最大连接数的限制,原因是它是基于链表来存储的
,但是同样有一个缺点:
大量的fd的数组被整体复制于用户态和内核地址空间之间
,而不管这样的复制是不是有意义。poll还有一个特点是“水平触发”
,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。
注意:
从上面看,select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket
。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态
,因此随着监视的描述符数量的增长,其效率也会线性下降。
poll函数接口
poll与select相比,只是没有fd的限制,其它基本一样
|
poll使用示例
// 先宏定义长度 |
poll缺点
- 每次调用poll,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
- 对socket扫描时是线性扫描,采用轮询的方法,效率较低(高并发时)
5.5 IO多路复用之epoll
为了解决select/poll
存在的问题,多路复用模型epoll
诞生,它采用事件驱动来实现,流程图如下:
epoll是在2.6内核中提出的,是之前的select和poll的增强版本。相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次
。
epoll先通过epoll_ctl()
来注册一个fd
(文件描述符),一旦基于某个fd
就绪时,内核会采用回调机制,迅速激活这个fd
,当进程调用epoll_wait()
时便得到通知。这里去掉了遍历文件描述符的坑爹操作,而是采用监听事件回调的机制。这就是epoll的亮点。
基本原理
epoll支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些fd刚刚变为就绪态,并且只会通知一次
。还有一个特点是,epoll使用“事件”的就绪通知方式
,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd
,epoll_wait便可以收到通知。
epoll优点
没有最大并发连接的限制
,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口)。效率提升,不是轮询的方式,不会随着FD数目的增加效率下降
。只有活跃可用的FD才会调用callback函数;即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关
,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。内存拷贝
,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销
。
epoll操作模式
epoll对文件描述符的操作有两种模式:LT(level trigger)和ET(edge trigger)
。LT模式是默认模式,LT模式与ET模式的区别如下:
LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件
。下次调用epoll_wait时,会再次响应应用程序并通知此事件。
ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件
。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。
- LT模式
LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket
。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的
。
- ET模式
ET(edge-triggered)是高速工作方式,只支持no-block socket
。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)
。
ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高
。epoll工作在ET模式的时候,必须使用非阻塞套接口
,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
在select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描
,而epoll事先通过epoll_ctl()来注册一个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制
,迅速激活这个文件描述符,当进程调用epoll_wait()时便得到通知。(此处去掉了遍历文件描述符,而是通过监听回调的的机制。这正是epoll的魅力所在。
)
epoll函数接口
|
epoll使用示例
int main(int argc, char* argv[]) |
5.6 select、poll、epoll区别
综上,在选择select,poll,epoll时要根据具体的使用场合以及这三种方式的自身特点:
- 表面上看epoll的性能最好,
但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好
,毕竟epoll的通知机制需要很多函数回调。select低效是因为每次它都需要轮询
。但低效也是相对的,视情况而定,也可通过良好的设计改善。
6. IO模型之信号驱动模型
信号驱动IO不再用主动询问的方式去确认数据是否就绪,而是向内核发送一个信号(调用sigaction
的时候建立一个SIGIO
的信号),然后应用用户进程可以去做别的事,不用阻塞。当内核数据准备好后,再通过SIGIO
信号通知应用进程,数据准备好后的可读状态。应用用户进程收到信号之后,立即调用recvfrom
,去读取数据。
信号驱动IO模型,在应用进程发出信号后,是立即返回的,不会阻塞进程。它已经有异步操作的感觉了。但是你细看上面的流程图,发现数据复制到应用缓冲的时候,应用进程还是阻塞的。回过头来看下,不管是BIO,还是NIO,还是信号驱动,在数据从内核复制到应用缓冲的时候,都是阻塞的。还有没有优化方案呢?AIO(真正的异步IO)!
7. IO 模型之异步IO(AIO)
前面讲的BIO,NIO和信号驱动
,在数据从内核复制到应用缓冲的时候,都是阻塞的,因此都不算是真正的异步。AIO
实现了IO全流程的非阻塞,就是应用进程发出系统调用后,是立即返回的,但是立即返回的不是处理结果,而是表示提交成功类似的意思。等内核数据准备好,将数据拷贝到用户进程缓冲区,发送信号通知用户进程IO操作执行完毕。
流程如下:
异步IO的优化思路很简单,只需要向内核发送一次请求,就可以完成数据状态询问和数据拷贝的所有操作,并且不用阻塞等待结果。日常开发中,有类似思想的业务场景:
比如发起一笔批量转账,但是批量转账处理比较耗时,这时候后端可以先告知前端转账提交成功,等到结果处理完,再通知前端结果即可。
8. 阻塞、非阻塞、同步、异步IO划分
参考感谢
看一遍就理解:IO模型详解 (qq.com)
https://mp.weixin.qq.com/s/qLGFLVYieO5NI6ay_8d7QA
【面试】彻底理解 IO多路复用? (qq.com)
聊聊IO多路复用之select、poll、epoll详解 - 简书 (jianshu.com)