IO
计算机 IO
IO,英文全称是 Input/Output,翻译过来就是输入/输出。比如磁盘 IO,网络 IO。
输入设备是向计算机输入数据和信息的设备,键盘,鼠标都属于输入设备;输出设备是计算机硬件系统的终端设备,用于接收计算机数据的输出显示,一般显示器、打印机属于输出设备。
鼠标、显示器这只是直观表面的输入输出,回到计算机架构来说,涉及计算机核心与其他设备间数据迁移的过程,就是 IO。如磁盘 IO,就是从磁盘读取数据到内存,这算一次输入,对应的,将内存中的数据写入磁盘,就算输出。这就是 IO 的本质。
操作系统 IO
将内存中的数据写入到磁盘的话,主体会可能是一个应用程序,比如一个 Java 进程或者 Go 进程。
以 32 位操作系统为例,它为每一个进程都分配了 4G(2 的 32 次方)的内存空间。这 4G 可访问的内存空间分为二部分,一部分是用户空间,一部分是内核空间。
内核空间是操作系统内核访问的区域,是受保护的内存空间,而用户空间是用户应用程序访问的内存区域。
应用程序是跑在用户空间的,它不存在实质的 IO 过程,真正的 IO 是在操作系统执行的。即应用程序的 IO 操作分为两种动作:IO 调用和 IO 执行。
- IO 调用:应用程序进程向操作系统内核发起调用。
- IO 执行:操作系统内核完成 IO 操作。
IO 调用是由进程(应用程序的运行态)发起,而 IO 执行是操作系统内核的工作。此时所说的 IO 是应用程序对操作系统 IO 功能的一次触发,即 IO 调用。
操作系统内核完成 IO 操作还包括两个过程:
- 准备数据阶段:内核等待 I/O 设备准备好数据。
- 拷贝数据阶段:将数据从内核缓冲区拷贝到用户进程缓冲区。
IO 就是把进程的内部数据转移到外部设备,或者把外部设备的数据迁移到进程内部。外部设备一般指硬盘、socket 通讯的网卡。一个完整的 IO 过程包括以下几个步骤:
- 应用程序进程向操作系统发起 IO 调用请求
- 操作系统准备数据,把 IO 外部设备的数据,加载到内核缓冲区
- 操作系统拷贝数据,即将内核缓冲区的数据,拷贝到用户进程缓冲区
阻塞 IO 模型
用程序的进程发起 IO 调用,但是如果内核的数据还没准备好的话,那应用程序进程就一直在阻塞等待,一直等到内核数据准备好了,从内核拷贝到用户空间,才返回成功提示,此次 IO 操作,称之为阻塞 IO。
阻塞 IO 的缺点就是:如果内核数据一直没准备好,那用户进程将一直阻塞,浪费性能,可以使用非阻塞 IO 优化。
非阻塞 IO 模型
如果内核数据还没准备好,可以先返回错误信息给用户进程,让它不需要等待,而是通过轮询的方式再来请求。这就是非阻塞 IO。
相对于阻塞 IO,虽然大幅提升了性能,但是它依然存在性能问题,即频繁的轮询,导致频繁的系统调用,同样会消耗大量的 CPU 资源。可以考虑 IO 复用模型,去解决这个问题。
IO 多路复用模型
IO 复用模型核心思路:操作系统提供了一类函数(比如 select、poll、epoll 函数),它们可以同时监控多个 fd(socket 或者文件)的操作,任何一个返回内核数据就绪,应用进程再发起 recvfrom 系统调用。
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。如果同时连接的大量客户端,在一时刻可能只有极少处于就绪状态,伴随着监视的描述符数量的增长,效率也会线性下降。
epoll
epoll 先通过 epoll_ctl()来注册一个 fd(文件描述符),一旦基于某个 fd 就绪时,内核会采用回调机制,迅速激活这个 fd,当进程调用 epoll_wait()时便得到通知。这里去掉了遍历文件描述符的操作,而是采用监听事件回调的机制。这就是 epoll 的亮点。
select、poll、epoll 的区别
select | poll | epoll | |
---|---|---|---|
底层数据结构 | 数组 | 链表 | 红黑树和双链表 |
获取就绪的 fd | 遍历 | 遍历 | 事件回调 |
时间复杂度 | O(n) | O(n) | O(1) |
最大连接数 | 1024 | 无限制 | 无限制 |
fd 数据拷贝 | 每次调用 select,需要将 fd 数据从用户空间拷贝到内核空间 | 每次调用 poll,需要将 fd 数据从用户空间拷贝到内核空间 | 使用内存映射(mmap),不需要从用户空间频繁拷贝 fd 数据到内核空间 |
epoll 明显优化了 IO 的执行效率,但在进程调用 epoll_wait()时,仍然可能被阻塞。能不能不用老是去问你数据是否准备就绪,等发出请求后,数据准备好了通知程序就行了,这就诞生了信号驱动 IO 模型。
信号驱动 IO 模型
信号驱动 IO 不再用主动询问的方式去确认数据是否就绪,而是向内核发送一个信号(调用 sigaction 的时候建立一个 SIGIO 的信号),然后应用用户进程可以去做别的事,不用阻塞。当内核数据准备好后,再通过 SIGIO 信号通知应用进程,数据准备好后的可读状态。应用用户进程收到信号之后,立即调用 recvfrom,去读取数据。
信号驱动 IO 模型,在应用进程发出信号后,是立即返回的,不会阻塞进程。它已经有异步操作的感觉了。但是你细看上面的流程图,发现数据复制到应用缓冲的时候,应用进程还是阻塞的。回过头来看下,不管是 BIO,还是 NIO,还是信号驱动,在数据从内核复制到应用缓冲的时候,都是阻塞的。
异步 IO 模型(AIO)
AIO 实现了 IO 全流程的非阻塞,就是应用进程发出系统调用后,是立即返回的,但是立即返回的不是处理结果,而是表示提交成功类似的意思。等内核数据准备好,将数据拷贝到用户进程缓冲区,发送信号通知用户进程 IO 操作执行完毕。
异步 IO 的优化思路很简单,只需要向内核发送一次请求,就可以完成数据状态询问和数据拷贝的所有操作,并且不用阻塞等待结果。日常开发中,有类似思想的业务场景:
比如发起一笔批量转账,但是批量转账处理比较耗时,这时候后端可以先告知前端转账提交成功,等到结果处理完,再通知前端结果即可。
阻塞、非阻塞、同步、异步 IO 划分
IO 模型 | |
---|---|
阻塞 I/O 模型 | 同步阻塞 |
非阻塞 I/O 模型 | 同步非阻塞 |
I/O 多路复用模型 | 同步阻塞 |
信号驱动 I/O 模型 | 同步非阻塞 |
异步 IO(AIO)模型 | 异步非阻塞 |