所谓的 I/O(Input/Output)操作实际上就是输入输出的数据传输行为。
程序员最关注的主要是磁盘 IO 和网络 IO,因为这两个 IO 操作和应用程序的关系最直接最紧密, IO 对系统性能和稳定性影响非常大。
磁盘 IO:磁盘的输入输出,比如磁盘和内存之间的数据传输;网络 IO:不同系统间跨网络的数据传输,比如两个系统间的远程接口调用。
下面这张图展示了应用程序中发生 IO 的具体场景:
通过上图,我们可以了解到 IO 操作发生的具体场景。一个请求过程可能会发生很多次的 IO 操作:
页面请求到服务器会发生网络 IO。
服务之间远程调用会发生网络 IO。
应用程序访问数据库会发生网络 IO。
数据库查询或者写入数据会发生磁盘 IO。
IO 和 CPU 的关系
不少攻城狮会这样理解,如果 CPU 空闲率是 0%,就代表 CPU 已经在满负荷工作,没精力再处理其他任务了。真是这样的吗?
我们先看一下计算机是怎么管理磁盘 IO 操作的。计算机发展早期,磁盘和内存的数据传输是由 CPU 控制的,也就是说从磁盘读取数据到内存中,是需要 CPU 存储和转发的,期间 CPU 一直会被占用。
我们知道磁盘的读写速度远远比不上 CPU 的运转速度。这样在传输数据时就会占用大量 CPU 资源,造成 CPU 资源严重浪费。
后来有人设计了一个 IO 控制器,专门控制磁盘 IO。当发生磁盘和内存间的数据传输前,CPU 会给 IO 控制器发送指令,让 IO 控制器负责数据传输操作,数据传输完 IO 控制器再通知 CPU。
因此,从磁盘读取数据到内存的过程就不再需要 CPU 参与了,CPU 可以空出来处理其他事情,大大提高了 CPU 利用率。
这个 IO 控制器就是“DMA”,即直接内存访问,Direct Memory Access,现在的计算机基本都采用这种 DMA 模式进行数据传输。
通过上面内容我们了解到,IO 数据传输时,是不占用 CPU 的。
当应用进程或线程发生 IO 等待时,CPU 会及时释放相应的时间片资源并把时间片分配给其他进程或线程使用,从而使 CPU 资源得到充分利用。
所以,假如 CPU 大部分消耗在 IO 等待(wa)上时,即便 CPU 空闲率(id)是 0%,也并不意味着 CPU 资源完全耗尽了,如果有新的任务来了,CPU 仍然有精力执行任务。
所以我们执行 top 命令时,除了要关注 CPU 空闲率,CPU 使用率(us,sy),还要关注 IO Wait(wa)。注意,wa 只代表磁盘 IO Wait,不包括网络 IO Wait。
Linux 网络 IO模型
同步阻塞 IO
同步非阻塞 IO
多路复用 IO
信号驱动 IO
5 .异步 IO
为了更好地理解网络 IO 模型,我们先了解几个基本概念:
① Socket(套接字):Socket 可以理解成,在两个应用程序进行网络通信时,分别在两个应用程序中的通信端点。
通信时,一个应用程序将数据写入 Socket,然后通过网卡把数据发送到另外一个应用程序的 Socket 中。
我们平常所说的 HTTP 和 TCP 协议的远程通信,底层都是基于 Socket 实现的。5 种网络 IO 模型也都要基于 Socket 实现网络通信。
② 阻塞与非阻塞:所谓阻塞,就是发出一个请求不能立刻返回响应,要等所有的逻辑全处理完才能返回响应。
非阻塞反之,发出一个请求立刻返回应答,不用等处理完所有逻辑。
③ 内核空间与用户空间:在 Linux 中,应用程序稳定性远远比不上操作系统程序,为了保证操作系统的稳定性,Linux 区分了内核空间和用户空间。
可以这样理解,内核空间运行操作系统程序和驱动程序,用户空间运行应用程序。
Linux 以这种方式隔离了操作系统程序和应用程序,避免了应用程序影响到操作系统自身的稳定性。
这也是 Linux 系统超级稳定的主要原因。所有的系统资源操作都在内核空间进行,比如读写磁盘文件,内存分配和回收,网络接口调用等。
所以在一次网络 IO 读取过程中,数据并不是直接从网卡读取到用户空间中的应用程序缓冲区,而是先从网卡拷贝到内核空间缓冲区,然后再从内核拷贝到用户空间中的应用程序缓冲区。
对于网络 IO 写入过程,过程则相反,先将数据从用户空间中的应用程序缓冲区拷贝到内核缓冲区,再从内核缓冲区把数据通过网卡发送出去。
1. 同步阻塞 IO
我们先看一下传统阻塞 IO。在 Linux 中,默认情况下所有 Socket 都是阻塞模式的。
当用户线程调用系统函数 read(),内核开始准备数据(从网络接收数据),内核准备数据完成后,数据从内核拷贝到用户空间的应用程序缓冲区,数据拷贝完成后,请求才返回。
从发起 Read 请求到最终完成内核到应用程序的拷贝,整个过程都是阻塞的。为了提高性能,可以为每个连接都分配一个线程。
因此,在大量连接的场景下就需要大量的线程,会造成巨大的性能损耗,这也是传统阻塞 IO 的最大缺陷。
2. 同步非阻塞 IO
用户线程在发起 Read 请求后立即返回,不用等待内核准备数据的过程。如果 Read 请求没读取到数据,用户线程会不断轮询发起 Read 请求,直到数据到达(内核准备好数据)后才停止轮询。
非阻塞 IO 模型虽然避免了由于线程阻塞问题带来的大量线程消耗,但是频繁的重复轮询大大增加了请求次数,对 CPU 消耗也比较明显。这种模型在实际应用中很少使用。
3. 多路复用 IO 模型
多路复用 IO 模型,建立在多路事件分离函数 Select,Poll,Epoll 之上。
在发起 Read 请求前,先更新 Select 的 Socket 监控列表,然后等待 Select 函数返回(此过程是阻塞的,所以说多路复用 IO 也是阻塞 IO 模型)。
当某个 Socket 有数据到达时,Select 函数返回。此时用户线程才正式发起 Read 请求,读取并处理数据。
这种模式用一个专门的监视线程去检查多个 Socket,如果某个 Socket 有数据到达就交给工作线程处理。
由于等待 Socket 数据到达过程非常耗时,所以这种方式解决了阻塞 IO 模型一个 Socket 连接就需要一个线程的问题,也不存在非阻塞 IO 模型忙轮询带来的 CPU 性能损耗的问题。
多路复用 IO 模型的实际应用场景很多,比如大家耳熟能详的 Java NIO,Redis 以及 Dubbo 采用的通信框架 Netty 都采用了这种模型。
4.信号驱动 IO 模型
信号驱动 IO 模型,应用进程使用 Sigaction 函数,内核会立即返回,也就是说内核准备数据的阶段应用进程是非阻塞的。
内核准备好数据后向应用进程发送 SIGIO 信号,接到信号后数据被复制到应用程序进程。
采用这种方式,CPU 的利用率很高。不过这种模式下,在大量 IO 操作的情况下可能造成信号队列溢出导致信号丢失,造成灾难性后果。
5. 异步 IO 模型
异步 IO 模型的基本机制是,应用进程告诉内核启动某个操作,内核操作完成后再通知应用进程。
在多路复用 IO 模型中,Socket 状态事件到达,得到通知后,应用进程才开始自行读取并处理数据。
在异步 IO 模型中,应用进程得到通知时,内核已经读取完数据并把数据放到了应用进程的缓冲区中,此时应用进程直接使用数据即可。
很明显,异步 IO 模型性能很高。不过到目前为止,异步 IO 和信号驱动 IO 模型应用并不多见,传统阻塞 IO 和多路复用 IO 模型还是目前应用的主流。
Linux 2.6 版本后才引入异步 IO 模型,目前很多系统对异步 IO 模型支持尚不成熟。很多应用场景采用多路复用 IO 替代异步 IO 模型。