网络编程:IOCP与异步IO模型深度解析

一、IOCP 简介

I/O 完成端口提供了一个高效的线程模型,用于处理多处理器系统上的多个异步 I/O 请求。 当进程创建 I/O 完成端口时,系统会为线程创建关联的队列对象,该对象的唯一目的是为这些请求提供服务。 与在收到 I/O 请求时创建线程相比,处理多个并发异步 I/O 请求的进程可以通过将 I/O 完成端口与预分配的线程 池结合使用来更快、更高效地执行此操作。

完成端口不是指物理上的端口,也不是指网络中的端口,而是指操作系统所提供的一种机制。这种机制是 windows 操作系统提供一种高效处理 IO 操作结果的通知机制。

Windows 网络编程中使用 IOCP(I/O Completion Port)机制,它是一种基于 异步 I/O + 完成事件通知 的高效模型;而在 Linux 中常见的 Reactor 模型则基于 同步 I/O + 就绪事件通知

iocp 与 reactor 的差异

我们通常进行网络编程都是在用户层进行(用户态),IO的处理是在内核当中完成的。

Reactor 模型:
我们注册事件到 epoll、select、poll 等 io 多路复用接口上,当某个 socket 可读或可写时,内核告诉我们这个 io “准备好了”,给我们一个就绪通知,但这时数据还没被处理,接下来我们就需要去调用 io 函数(readwrite 等)从用户态去操作内核资源,所以这个过程仍然是同步的 I/O

IOCP 模型:
我们先向系统提交一个 I/O 操作,比如想要建立连接,先投递一个异步请求AcceptEx到内核,这个 io 操作会直接在内核里面完成,完成之后内核直接给我们一个完成通知。

总的来说,reactor 当中,我们通过epoll_wait,select,poll拿到的是仅仅是就绪通知,就绪通知给我们时候,IO没有完成,仍然需要我们从用户态调用 IO 函数去操作内核资源。iocp当中,完成通知给我们时候,IO已经完成。


二、网络编程需要解决哪些问题

在这里插入图片描述
所有的网络编程蓝色部分外面的内容是固定流程。不同的网络模型,如reactor,proctor,阻塞IO网络模型,这三种不同的网络模型差异在于是蓝色部分的实现机制不一样。

蓝色部分:

  • accept:在网络编程中,服务端调用 accept,接收客户端跟服务端建立的连接,但是作为服务端,我们并不知道客户端什么时候需要跟我们建立连接。

  • read:在已经建立连接后,我们需要解决数据读取的问题,但我们并不知道客户端什么时候给我们发送数据。

  • write:用于向对端发送数据,它只是将用户态的数据拷贝到内核态的发送缓冲区(即协议栈的发送缓冲区)。每个 socket 都对应一个接收缓冲区和一个发送缓冲区。一旦 write 操作完成,网络编程的职责就结束了。至于数据什么时候传送到对端能不能成功送达能不能一次性完全写入发送缓冲区,是由内核的网络协议栈(如 TCP/IP 协议栈)来处理的。

  • 我们也不知道客户端什么时候会主动断开连接。

上面这四个“未知问题”,构成了网络编程中需要解决的关键问题:

  1. 连接的建立(何时建立连接)
  2. 连接的断开(何时断开连接)
  3. 数据的接收(何时有数据可读)
  4. 数据的发送(是否可以写,是否写全)

接下来我们学习阻塞 IO 网络模型, linux 下 reactor 网络模型,windows下 proactor (iocp属于proactor) 网络模型分别是怎么解决上面四个问题。

2.1 阻塞 io 网络模型

  • accept:在不知道客户端什么时候建立连接的情况下,通过阻塞线程来等待连接事件的发生。accept 会一直阻塞,直到有客户端发起连接请求,线程才会继续往下执行。

  • read:我们不知道客户端什么时候会发送数据,因此调用 read 时线程会被阻塞,一直等待客户端发送数据。数据会先到达内核态的接收缓冲区,然后再通过 read 系统调用从内核缓冲区拷贝到用户态缓冲区。

  • write:发送数据同样是通过阻塞线程来完成的。write 的作用是把用户态的数据拷贝到内核态的发送缓冲区。如果缓冲区有足够空间,那么数据就会直接写入;如果缓冲区空间不足,比如只剩下 50 个字节,而我们此时想写入 100 个字节,那么就只能写入 50 个字节;如果发送缓冲区已满,write 就会一直阻塞,直到缓冲区有空闲空间,才能继续拷贝剩余的数据进去。

  • 断开连接:判断连接是否断开可以通过下面的系统调用:

    int n = read(fd, buf, sz);
    

    如果返回值 n = 0,说明对端已经关闭了连接,此时我们可以认为这条连接已经断开。

2.2 Reactor 网络模型

操作 IO:就是从用户态区域操作内核态的数据,比如 acceptreadwriteconnect,这些都属于典型的 IO 操作。

Reactor 网络模型将 IO 操作分成两步:

  1. IO 检测(是否就绪)
  2. IO 操作(实际执行拷贝)
  • accept:当我们调用 accept,第一步会先进行 IO 检测,查看内核中的全连接队列是否有新连接数据(是否有客户端完成三次握手)。如果队列中没有数据,说明 IO 未就绪,就会阻塞线程等待。当有客户端需要与我们建立连接时,它会进入全连接队列,内核检测到有数据,就会唤醒线程,接着把这个连接从全连接队列中拷贝出来,构造一个新的 clientfd,并带上客户端的 IP 地址和端口信息。

  • read:我们调用 read 的时候,首先进行 IO 检测,检测对应 socket 的接收缓冲区是否有数据。如果没有,就说明数据还没准备好,线程会被阻塞。等客户端发送数据过来,内核会把数据写入接收缓冲区,然后唤醒线程,再去执行 IO 操作,把内核缓冲区的数据拷贝到用户态。

  • write:我们想把用户态缓冲区的数据写到某个 socket 对应的发送缓冲区,但我们不知道当前缓冲区的可写状态,首先要检测发送缓冲区是否还有空间可写。如果没有,就阻塞线程,直到协议栈把之前的数据发送出去、释放出空间,线程被唤醒。然后再执行 IO 操作,把用户态的数据拷贝进发送缓冲区。

在这里插入图片描述

在 Reactor 模型中,检测 IO 是否就绪和执行 IO 操作是由用户程序主导完成的。也就是说,操作是“事件驱动 + 用户处理”,内核只负责通知 IO 就绪,不参与真正的数据传输操作。

总的来说,reactor 把 IO 操作分成两个部分,分别进行处理:

Reactor 模式 = IO 多路复用(负责检测 IO 事件) + 非阻塞 IO 函数(负责执行真正的读写)

  • 采用IO多路复用,专门做IO检测,同时可以检测多路IO,只需要使用一个线程可以同时检测多路连接是否就绪。
  • IO操作通过IO函数处理(accept、read、write、connect)

2.3 Proactor 网络模型

Proactor 网络模型的代表是 Windows 下的 IOCP(I/O Completion Port)。核心特点:IO 检测和 IO 操作都在内核中完成,用户线程只关心完成通知。

IOCP:

  1. 把 socket 绑定到 iocp 当中
  2. 投递具体操作(异步请求),比如:
    • AcceptEx:投递连接请求(不需要调用 accept)
    • WSARecv:投递接收数据请求
    • WSASend:投递发送数据请求
    • ConnectEx:投递连接请求

在这里插入图片描述
这些 API 都是异步的,IO 操作并不会立即完成,而是由系统内核在底层完成。用户线程只需要等待完成通知。

  1. 完成通知:当 IO 操作完成时,iocp 会把完成信息发送给用户线程,通知某个操作完成了。
    • 比如 AcceptEx 成功后,就可以拿到 clientfd 和客户端地址 addr
    • WSARecv 成功后,数据已经从内核缓冲区拷贝到了用户态缓冲区,用户可以直接使用。
    • 整个过程不需要再主动调用 readwriteaccept,只需要等待完成通知结果即可。

在这里插入图片描述
总结:Proactor 模型下,IO 检测 + IO 操作都由内核完成,我们只需要提前注册事件,并等待完成通知。程序逻辑更加简洁,效率也很高,适合 Windows 平台上的高并发网络编程。

2.4 问题总结

  1. reactor 与 select / poll / epoll 的关系
    select / poll / epoll 是 IO 多路复用技术,属于 reactor 网络模型 的实现方式之一。
    IO 多路复用的作用是负责检测多个 IO 是否就绪(IO 检测),一旦有某个 IO 就绪,就会触发事件进行就绪通知,reactor 框架再去执行对应的 IO 操作 —— 本质上是一种事件驱动模型(事件回调)。

  2. 阻塞 IO 与 非阻塞 IO
    在 IO 检测阶段,如果 IO 未就绪,阻塞 IO 会阻塞线程,直到 IO 就绪。
    非阻塞 IO 不会阻塞线程,系统调用会立刻返回一个错误码(比如 EAGAIN),告诉你暂时没有数据。
    两者本质上都属于 同步 IO —— IO 操作还是由用户线程自己完成。

  3. 同步 IO 与 异步 IO
    同步 IO:用户线程在发起 IO 操作后,必须自己完成 IO 操作(包括数据拷贝等)。
    异步 IO(如 iocp):事件触发后,用户线程只是投递一个操作(比如 AcceptExWSARecvWSASendConnectEx),具体的 IO 检测和 IO 操作都在内核中完成,完成之后再通过完成通知告诉用户线程 —— 所以,异步 IO 不在接口函数内部完成 IO 操作,也不阻塞用户线程。


三、同步IO与异步IO

在这里插入图片描述

3.1 同步io

图中上半部分是同步io,整体流程:线程在等 IO,什么也干不了

时间点动作
T0线程发起 I/O 请求
T0 → T1线程什么也不干,一直等
T1内核开始执行 I/O 操作(如读取文件)
T2I/O 结束,内核通知线程
T2 → T3线程继续执行,处理数据
  1. T0:线程发起一个 I/O 请求,比如想读一个文件。

  2. T0→T1:线程什么也不干,就在等。

  3. T1:内核收到请求后,开始真正去读文件。

  4. T2:I/O 读完了,内核通知线程:“搞定了”。

  5. T2→T3:线程被唤醒,继续跑,处理刚刚读到的数据。

✅ 结论:

  • 同步 I/O 的线程会“等着”,性能浪费。
  • 多线程环境里,容易出现“线程都卡住了”的问题。

3.2 异步io

图中下半部分是同步io,整体流程:线程发完请求就走开干别的事,等系统通知

时间点动作
T0线程发起异步 I/O 请求
T0→T2线程去干别的事(不会阻塞)
T1内核开始执行 I/O 操作
T2I/O 结束,内核发通知
T2→T3线程处理通知、开始处理数据
  1. T0:线程发出一个“异步读文件”的请求。

  2. T0→T2:线程没在等,而是继续去干别的任务了(比如处理别的网络请求)。

  3. T1:内核去这个 I/O 操作。

  4. T2:内核完成 I/O 后,发个通知(比如信号、回调)。

  5. T2→T3:线程收到通知,停下当前工作,来处理数据。

✅ 结论:

  • 异步 I/O 不会阻塞线程。
  • 非常适合高并发场景,比如服务器程序(比如 Nginx 就是异步 IO)。

3.3 io 操作(T1 → T2)

不管是同步 IO 还是异步 IO,所有涉及网络通信或磁盘读写的操作,最终都必须通过系统调用从用户态进入内核态。这是因为网络、磁盘、键盘、显示器等都属于“外设资源”,它们是计算机的硬件,用户程序(也叫用户态程序)出于安全和稳定的考虑,是不允许直接操作这些硬件的。比如我们不能直接发指令控制网卡收发数据,也不能直接去读写磁盘的物理位置。

操作系统提供了一套“系统调用”的接口作为中介,比如 read()write()recv()send()connect() 等,这些函数就是用户程序“请求”内核去帮它做 IO 的方式。只要程序调用了这些函数,系统就会从用户态“陷入”到内核态,在内核态中由操作系统去完成真正的硬件访问,比如让网卡收数据、向磁盘写入文件。

同步 IO 和异步 IO 的区别在于线程是否会阻塞(也就是“傻等”):同步 IO 是线程一边发起 IO,一边等着 IO 完成,什么也不干,等完了再继续处理;而异步 IO 则是线程发出 IO 请求后就可以干别的事情,等 IO 真正完成了,系统会通知线程过来处理结果。但无论是哪一种方式,底层的 IO 操作始终是由内核完成的,用户态程序无法绕过操作系统自己直接操作外设。


四、IOCP 原理

在这里插入图片描述

4.1 CreateIoCompletionPort(创建和绑定)

createIoCompletionPort 函数 (ioapiset.h) CreateIoCompletionPort 函数创建 I/O 完成端口,并将一个或多个文件句柄与该端口相关联。当其中一个文件句柄上的异步 I/O 操作完成时,I/O 完成数据包将排入先进先出 (FIFO) 相关 I/O 完成端口的队列。

CreateIoCompletionPort 是 Windows 系统提供的一个函数,用来创建 I/O 完成端口对象(IOCP 对象),并将一个或多个文件句柄(file handle) 绑定到这个 IOCP 上。

📌什么是文件句柄?
在 Windows 中,文件句柄不仅仅指普通文件,还包括套接字(Socket)、管道、串口等支持异步 IO 的对象。每一个支持 IO 的对象,系统都会为它分配一个句柄。我们可以把“文件句柄”简单理解为系统内核中对某个 IO 对象的引用或指针

📌CreateIoCompletionPort 的作用

  1. 创建 IOCP 对象(如果你传入的是 NULL 句柄)

  2. 将一个已有的文件句柄绑定到一个现有的 IOCP 上

  3. 投递异步操作(AcceptEx、WSARecv、WSASend、ConnectEx)

在这里插入图片描述

每当你对某个句柄(如 socket)执行一个异步 IO 操作(如 ReadFileExWSARecv),操作完成时,系统不会立刻通知你,而是将一个完成通知(I/O completion packet) 放入 IOCP 的队列中。这个队列是先进先出(FIFO)的结构。

在这里插入图片描述

4.2 GetQueuedCompletionStatus(关联线程)

当线程调用 GetQueuedCompletionStatus 时,线程将完成与 IOCP 完成关联,当线程退出、指定其他 IOCP、或关闭 IOCP 时,解除关联。一个线程最多与一个 IOCP 关联,但是可多个线程关联同一个 IOCP。

线程并不是一创建就和 IOCP 绑定,而是在调用某个关键函数时才与 IOCP 建立关联。

GetQueuedCompletionStatus 是 IOCP 的“接收通知”的接口。线程在调用这个函数时:

  • 会进入阻塞等待状态(除非有完成事件)。
  • 线程此时就被“绑定”到了这个 IOCP。
  • 每次 IO 完成时,系统会唤醒一个等待的线程,把 IO 完成信息交给它处理。

📌 注意绑定关系规则:

  • 一个线程只能绑定一个 IOCP
  • 但一个 IOCP 可以绑定多个线程,形成一个线程池。
  • 如果线程退出了、调用了另一个 IOCP、或者 IOCP 被关闭,那么绑定关系就被解除。

🌰 举个例子:
假设你有 4 个线程都调用了 GetQueuedCompletionStatus 等待一个 IOCP,当某个 IO 操作完成,系统会选择其中一个线程来唤醒,让它处理这个 IO 事件。
在这里插入图片描述

4.3 IO 完成通知是怎么被应用程序“捕获”的?

当 io 事件完成,将在 IOCP 中的完成队列排队,通过正在调用 GetQueuedCompletionStatus 的线程取出完成通知,或者系统通过唤醒与该 IOCP 关联并在阻塞等待的线程取出完成通知。

这是 IOCP 模型的核心机制。流程如下:

  1. 应用程序通过某个异步 API 向绑定了 IOCP 的文件句柄发起 IO 操作(比如 WSARecv)。

  2. 操作系统开始在内核中执行这个 IO。

  3. IO 完成后,不会立刻通知调用者,而是:

    • 系统把一个完成通知(称为“完成数据包”)放入 IOCP 队列。
    • 这个通知中包括:操作状态、传输的字节数、关联的句柄等信息。
  4. 此时,如果有线程正在调用 GetQueuedCompletionStatus(),系统就会:

    • 唤醒线程
    • 把刚才完成的 IO 通知返回给这个线程处理

✅ 这个机制保证了线程不用盯着某个句柄死等,而是“事件完成才来通知我”。这样可以显著提高效率、降低线程资源浪费。

4.4 如何正确关闭与 IOCP 关联的文件句柄?

I/O 完成端口句柄以及与该特定 I/O 完成端口关联的每个文件句柄称为对 I/O 完成端口的引用。 当不再引用 I/O 完成端口时, 将释放该端口。 因此,必须正确关闭所有这些句柄才能释放 I/O 完成端口及其关联的系统资源。 满足这些条件后,应用程序应通过调用 CloseHandle 函数关闭 I/O 完成端口句柄。

这是 IOCP 使用中一个很容易出错的环节。如果做得不对,可能会导致系统资源无法释放,甚至内存泄漏或句柄泄漏。

✔️ 理解“引用”的含义:

在 Windows 系统中,每个 I/O 完成端口是一个内核对象,它有一个引用计数,即:它知道有多少“文件句柄”绑定到了自己身上。

  • 每绑定一个句柄,引用计数 +1
  • 每关闭一个绑定句柄,引用计数 -1

当引用计数变成 0,系统才会认为这个 IOCP 没人用了,可以释放资源。

✔️ 正确关闭步骤:

  1. 关闭所有与 IOCP 绑定的文件句柄(比如关闭 socket)

    • 调用 CloseHandle(socket)
    • 或者 closesocket() 关闭网络句柄
  2. 然后调用 CloseHandle(iocp) 关闭 IOCP 对象本身

    • 这个操作会释放整个 IOCP 的内核资源
    • 如果此时还有未关闭的绑定句柄,会导致资源泄露

注意:

  • 线程阻塞在 GetQueuedCompletionStatus() 上时,需要先用退出信号“退出事件” 唤醒线程,避免“僵尸线程”。
  • 可以通过向 IOCP 投递一个自定义“退出通知”,来唤醒线程并安全退出。

4.5 要点总结

(1) CreateIoCompletionPort 函数作用

  • 创建一个 IO 完成端口对象(IOCP)
  • 将文件句柄(如 socket)绑定到 IOCP 上
  • 多个文件句柄可以绑定到一个 IOCP

(2) 线程与 IOCP 的关联方式

  • 线程通过调用 GetQueuedCompletionStatus 与 IOCP 建立绑定关系
  • 一个线程只能关联一个 IOCP
  • 一个 IOCP 可同时关联多个线程(形成线程池)
  • 线程退出、绑定其他 IOCP 或关闭 IOCP 时,会自动解除关联

(3) IO 完成通知捕获机制

  • 异步 IO 操作完成后,系统将完成通知打包进 IOCP 队列中
  • 等待 IOCP 的线程(调用了 GetQueuedCompletionStatus)被系统唤醒
  • 唤醒线程获得 IO 完成信息,并继续处理业务逻辑

(4) 正确释放 IOCP 与句柄资源

  • 每个绑定的文件句柄是 IOCP 的一个引用

  • IOCP 只有在所有引用都被关闭后才能被释放

  • 正确做法是:

    1. 关闭所有绑定的文件句柄
    2. 再关闭 IOCP 本身(CloseHandle(iocp)

(5) IOCP 的优势

  • 线程复用,减少线程数量
  • 高效异步处理 IO
  • 适用于高并发服务器模型
  • 保证系统资源合理使用

五、重叠 IO

无需等待上一个 IO 操作完成就可以提交下一个 IO 操作请求。 也就是这些 IO 操作可以堆叠在一起。 注意:尽管 IO 操作是按顺序投递的,但是 IO 操作完成通知可以是随机无序的(在多线程等待 IO 完成通知时)。

GetQueuedCompletionStatus 每次只能拿到一个完成通知,如果要接收多个用户端的连接,服务端每次只能投递一个请求后取出一个完成通知,那么我们的接收效率就会很低。这时候我们就需要用到重叠 io,起始的时候投递多个请求,服务端启动后并发接收多条连接。

重叠 I/O 是 Windows 提供的一种异步 I/O 模型,让我们可以在不阻塞当前线程的情况下发起 I/O 请求。操作系统会在 I/O 操作完成之后再通知我们(比如通过 IOCP),而不是“干等”它做完。

5.1 OVERLAPPED

在使用重叠 io 这个模型时,Windows 要求你传入一个叫 OVERLAPPED 的结构体,它记录了这个 I/O 操作的“上下文”(比如偏移量、缓冲区信息等),方便系统在多个并发 I/O 操作之间区分谁是谁。我们可以把它理解为“操作的身份证”:

  • 多个 I/O 操作可能同时在执行(堆叠)

  • 每个 OVERLAPPED 实例都能让系统知道:这个 I/O 请求是干嘛的、在哪处理、什么时候完成

typedef struct _OVERLAPPED {
    ULONG_PTR Internal;     // 系统内部状态码 (I/O 操作状态)
    ULONG_PTR InternalHigh; // 实际传输的字节数 (操作完成时有效)
    union {
        struct {
            DWORD Offset;    // 文件操作的起始偏移低32位
            DWORD OffsetHigh;// 文件操作的起始偏移高32位
        } DUMMYSTRUCTNAME;
        PVOID Pointer;       // 保留字段 (非文件I/O时可能使用)
    } DUMMYUNIONNAME;
    HANDLE hEvent;          // 事件句柄 (I/O完成时触发信号)
} OVERLAPPED, *LPOVERLAPPED;
/*注意:在函数调用中使用结构之前,应始终将此结构的任何未使用成员初始化为零。*/

OVERLAPPED是内核操作的资源,不需要我们去修改操作。

IO 完成通知可能是无序的。虽然我们按顺序发出了 IO 操作(先发 A,再发 B,再发 C),但 A、B、C 的完成顺序可能是 B、C、A,因为底层的网络或磁盘处理时间不一样,完成时间也就不同。系统不会按我们发起的顺序返回完成通知,而是哪个操作先完成,哪个就先通知

所以我们必须通过 OVERLAPPED 结构体或者额外的上下文数据来区分这些完成事件属于哪次操作!

5.2 重叠 I/O 流程

  1. 主线程发起多个异步 IO 请求(带不同的 OVERLAPPED 指针)

  2. 系统异步处理这些请求,挂起执行

  3. 某个请求完成后,系统把结果和 OVERLAPPED 指针一起放入 IOCP(或调用回调函数)

  4. 线程从 IOCP 取出这个完成事件

  5. 通过 OVERLAPPED 找出是哪次请求,然后处理结果

5.3 总结

(1) 什么是重叠 I/O

  • 是 Windows 提供的异步 IO 模型

  • 不阻塞线程,可同时发起多个 IO 请求

  • 每个请求使用独立的 OVERLAPPED 结构体来区分

(2) 主要优势

  • 提高 IO 并发能力

  • 避免线程阻塞

  • 与 IOCP 机制完美配合,构建高性能服务器模型

(3) 关键特性

  1. IO 请求可堆叠,不必等待上一个完成再发起下一个

  2. IO 完成通知是无序的,必须用 OVERLAPPED 区分

  3. 适合和线程池、IOCP、事件对象等组合使用

(4) 编程时注意:

  • 每个异步操作要用独立的 OVERLAPPED 对象

  • 完成时一定要确认是哪次操作,用上下文参数或结构标识

  • 合理管理 OVERLAPPED 生命周期,避免内存泄漏

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值