https://www.cnblogs.com/Jack-Blog/p/11385686.html

  本片文章主要讲解同步 I/O 与异步 I/O 相关知识,希望通过编写本篇文章为起点,对 Windows 内核原理知识进行学习与梳理。发现并弥补遗漏的知识点并加以学习。同时通过理解 Windows 内核原理,设计出更好、更合理的应用程序。

I/O

  I/O 即输入输出。在现在操作系统,输入输出是计算机完整功能必不可少的一部分。处理器负责各种计算任务,然后通过各种输入输出设备与外界进行交互。常见的输入输出设备包括键盘、鼠标、显示器、硬盘、网络适配器接口等。有了硬件设备,在软件层面上,使得操作系统通过以一致的方式与设备驱动交互从而的操控硬件设备。而应用程序通过统一的接口与系统内核进行交互。
  Windows 从一开始就设计了可扩展的 I/O 接口。在应用层通过统一的 Win32 API,将 I/O 请求分配给正确的设备驱动程序。设备驱动程序调用设备控制器来操控硬件。而内核通过硬件抽象层与硬件进行交互。硬件抽象层提供了供内核和驱动调用的例程

  例程就是系统提供的 API 或服务。

  在 Windows 下分为内核模式和用户模式。应用程序运行在用户模式下,操作系统和驱动程序运行在内核模式下。应用程序通过调用 Win32 API 与 Windows 内核交互。

  Windows 内核则通过设备驱动程序与设备控制器进行通讯,而设备控制器则直接操控硬件设备。
  设备驱动程序分为即插即用驱动程序、内核扩展驱动程序和文件系统驱动程序。其中文件系统驱动程序用于接收 I/O 请求,然后将请求转换为真正的存储设备或网络设备的 I/O 请求。

  设备控制器可以通过内存映射 I/O 的方式将设备的内存与主存映射,通过内存映射 I/O 后,处理器访问的就不是主存而是设备控制器的寄存器内存。但是这种方式的访问效率并不高,不适合大数据量 I/O 读写。通常硬盘和网络驱动器采用直接访问内存(DMA)的方式进行大量数据的 I/O 操作。DMA 需要硬件支持,硬件会有 DMA 控制器,在硬件执行 I/O 操作的时候,不会占用 CPU 的指令周期,DMA 控制器会和设备进行 I/O 操作。当数据传输完成后,DMA 则会通知处理器 I/O 操作完成。

同步 I/O

  当我们要把文件从硬盘读取到内存时,硬盘的读取速度是远小于内存的写入速度的。因此当我们使用一个线程从硬盘读取文件到内存中时。通常需要等待硬盘将数据从硬盘读取到内存中,此时线程将被阻塞,但是不会消耗指令周期。当读取完毕时,线程继续执行后续操作。
  虽然 DMA 执行的时候当前线程被阻塞,此时处理器可以获取另一个线程内核执行其他操作,由于线程是非常昂贵的资源,因此使用同步 I/O 的方式若需要并发执行时,需要大量的创建线程资源,这就产生了大量的线程上下文切换。

  在大多数 x86 和 x64 的多处理器,线程上下文切换时间间隔大约为 15ms。
  CPU 每过大约 15ms 将 CPU 寄存器当前的线程上下文存回到该线程的上下文,然后该线程不在运行。然后系统检查剩下的可调度线程内核对象,选择一个线程的内核对象,将其上下文载入导 CPU 寄存器中。
  关于 Windows 线程相关内容可以查阅《Windows via C/C++ 第五版》的第七章

异步 I/O

  前面提到了当硬件进行 I/O 传输时,实际上通常使用 DMA 技术执行 I/O 操作,不会占用 CPU 的指令周期。因此只要操作系统支持异步 I/O,则可以极大的提升系统性能,最大程度的降低线程数量,减少线程上下文切换产生的性能损失。

  在 Windows 下的异步 I/O 我们也可以称之为重叠(overlapped)I/O。重叠的意思是执行 I/O 请求的时间与线程执行其他任务的时间是重叠的,即执行真正 I/O 请求的时候,我们的工作线程可以执行其他请求,而不会阻塞等待 I/O 请求执行完毕。

  当使用一个线程向设备发出一个异步 I/O 请求时,该请求被传给设备驱动程序,设备驱动程序处理 I/O 请求时并不会等待 I/O 请求完成,而是将 I/O 请求加入到设备驱动程序的队列中,然后返回一个 I/O 处理中的信号。而实际的 I/O 操作则由设备驱动程序将 I/O 请求传给指定的硬件设备执行 I/O 操作。应用程序的线程并不需要挂起等待 I/O 请求的完成,从而可以继续执行其他任务。当某一时刻设备驱动程序完成了该 I/O 请求处理,设备控制器通过中断指令通知 I/O 请求完成,处理器则将通知 I/O 请求已完成。

I/O 完成通知

  在 Windows 中一共支持四种接收完成通知的方式。分别为触发设备内核对象、触发时间内核对象、可提醒 I/O 以及 I/O 完成端口。

触发设备内核

  当设备驱动加载时会创建一个设备驱动对象,设备驱动程序还会为设备创建对应的设备对象。设备对象代表的是每一个物理设备或逻辑设备。设备对象描述了一个特定设备的状态信息,包括 I/O 请求的状态。在通过异步 I/O 将 I/O 请求添加到队列之前,会将设备内核对象设置为未触发,此时就可以使用该设备内核对象进行同步操作,当 I/O 请求完成后则会将设备内核对象设置为触发状态。使用设备内核对象进行线程同步时,无法区分当前完成通知的 I/O 是读操作还是写操作,因此无论是读还是写都会将其状态设置为触发状态。

事件内核对象

  通过设备内核对象进行 I/O 通知由于无法区分读写操作,因此并没有什么用。通过事件内核对象我们可以将读写事件分离。在调用读写操作的时候会返回对应的读写事件内核对象。这样我们就可以等待对应的事件内核对象知道是什么 I/O 操作完成。我们可以通过等待多个事件内核对象,但是一次性最多只能等待 64 个事件内核对象,即一个线程最多只能创建 64 个事件内核对象进行等待。若需要监控上万个连接,则需要创建上百个线程进行监控。

可提醒 I/O

  在系统创建线程的时候会创建一个与线程相关的队列,该队列被称为异步调用(APC)队列,当发出一个 I/O 请求时,我们可以告诉设备驱动程序在调用线程的 APC 队列中添加一项完成函数,在 I/O 完成通知时调用完成函数进行回调。I/O 完成通知最大的问题是,请求时哪个线程调用的,必须由哪个线程回调。它不支持负载均衡机制。

完成端口

  I/O 完成端口的设计理论依据是并发编程的线程数必须有一个上限,即最佳并发线程数为 CPU 的逻辑线程数。I/O 完成端口充分的发挥了并发编程的优势的同时又避免了线程上下文切换带来的性能损失。
  完成端口可能是最复杂的内核对现象,但是它又是 Windows 下性能最佳的 I/O 通知方式。
  首先我们需要创建一个 I/O 完成端口,创建完成端口的时候可以指定线程数量。通过将设备与 I/O 完成端口进行关联。此使我们发出的 I/O 请求时,系统内核返回 IO_PENDDING 状态,然后线程就可以继续处理其他事情。而 DMA 继续执行 I/O 操作,将数据从设备读取到设备控制器的缓冲区中,并对其进行必要的校验后,将数据通过系统总线传输到内存中。当数据传输完成后,DMA 发出中断指令通知数据传输完毕,系统则会通过前面创建的 I/O 线程将 I/O 完成请求加入到 I/O 完成队列中。
  然后我们通过调用 Win32 API 就可以获取到对应的设备 I/O 完成请求通知,通知会将 I/O 完成请求从完成队列移除。

总结

  1. 同步 I/O 会阻塞线程,想要提高执行速度必须增加线程,但是会由于线程上下文切换造成性能损失。
  2. Windows 下大约每 15ms 会进行一次线程调度。减少 Windows 线程能降低内存占用(默认线程栈大小为 1M),降低线程上下文切换造成的性能损失。
  3. Windows 支持原生的异步 I/O。异步 I/O 也可以称为重叠 I/O。使用异步 I/O 时线程不会阻塞,系统底层将每个 I/O 请求生成 I/O 请求包(IRP)加入到设备驱动程序的请求队列中,然后直接返回 IO_PENDDING 状态表示请求受理成功,当底层设备完成了真实的 I/O 请求后会通过中断控制器通过中断操作通知 CPU,CPU 会调度一个线程通知上层设备驱动程序,将完成通知加入到完成队列中。此时上层应用即可获取到完成通知。
  4. 完成端口是 Windows 下性能最佳的完成通知方式。它最大程度的减少线程上下文切换。
  5. 使用异步 I/O 和完成端口实现高性能 I/O 操作的主要原因有三点。一是减少 I/O 上下文切换;二是异步不阻塞线程,预先提供一个 socket 用于连接,而不是接受到时再创建 socket(socket 创建也是比较耗资源的);三是避免了内存复制。
  6. 如何减少线程,如何避免内存复制,如何提高线程利用率,避免线程阻塞。以上几点是所有高性能框架或高性能应用程序必备的条件。

参考文档

  1. cpu 内存访问速度,磁盘和网络速度
  2. 手把手教你玩转 SOCKET 模型:完成端口(Completion Port)详解
  3. Reactor 与 Proactor 的概念
  4. 如何深刻理解 reactor 和 proactor?
  5. I/O Completion Ports
  6. 《Windows via C/C++ 第五版》
  7. 《Windows内核原理与实现》
  8. WaitForMultipleObjects 用法详解,一看就懂