一、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 函数(read
或 write
等)从用户态去操作内核资源,所以这个过程仍然是同步的 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 协议栈)来处理的。 -
我们也不知道客户端什么时候会主动断开连接。
上面这四个“未知问题”,构成了网络编程中需要解决的关键问题:
- 连接的建立(何时建立连接)
- 连接的断开(何时断开连接)
- 数据的接收(何时有数据可读)
- 数据的发送(是否可以写,是否写全)
接下来我们学习阻塞 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:就是从用户态区域操作内核态的数据,比如 accept
、read
、write
、connect
,这些都属于典型的 IO 操作。
Reactor 网络模型将 IO 操作分成两步:
- IO 检测(是否就绪)
- 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:
- 把 socket 绑定到 iocp 当中
- 投递具体操作(异步请求),比如:
AcceptEx
:投递连接请求(不需要调用 accept)WSARecv
:投递接收数据请求WSASend
:投递发送数据请求ConnectEx
:投递连接请求
这些 API 都是异步的,IO 操作并不会立即完成,而是由系统内核在底层完成。用户线程只需要等待完成通知。
- 完成通知:当 IO 操作完成时,iocp 会把完成信息发送给用户线程,通知某个操作完成了。
- 比如
AcceptEx
成功后,就可以拿到clientfd
和客户端地址addr
。 WSARecv
成功后,数据已经从内核缓冲区拷贝到了用户态缓冲区,用户可以直接使用。- 整个过程不需要再主动调用
read
、write
或accept
,只需要等待完成通知结果即可。
- 比如
总结:Proactor 模型下,IO 检测 + IO 操作都由内核完成,我们只需要提前注册事件,并等待完成通知。程序逻辑更加简洁,效率也很高,适合 Windows 平台上的高并发网络编程。
2.4 问题总结
-
reactor 与 select / poll / epoll 的关系
select
/poll
/epoll
是 IO 多路复用技术,属于 reactor 网络模型 的实现方式之一。
IO 多路复用的作用是负责检测多个 IO 是否就绪(IO 检测),一旦有某个 IO 就绪,就会触发事件进行就绪通知,reactor 框架再去执行对应的 IO 操作 —— 本质上是一种事件驱动模型(事件回调)。 -
阻塞 IO 与 非阻塞 IO
在 IO 检测阶段,如果 IO 未就绪,阻塞 IO 会阻塞线程,直到 IO 就绪。
非阻塞 IO 不会阻塞线程,系统调用会立刻返回一个错误码(比如EAGAIN
),告诉你暂时没有数据。
两者本质上都属于 同步 IO —— IO 操作还是由用户线程自己完成。 -
同步 IO 与 异步 IO
同步 IO:用户线程在发起 IO 操作后,必须自己完成 IO 操作(包括数据拷贝等)。
异步 IO(如 iocp):事件触发后,用户线程只是投递一个操作(比如AcceptEx
、WSARecv
、WSASend
、ConnectEx
),具体的 IO 检测和 IO 操作都在内核中完成,完成之后再通过完成通知告诉用户线程 —— 所以,异步 IO 不在接口函数内部完成 IO 操作,也不阻塞用户线程。
三、同步IO与异步IO
3.1 同步io
图中上半部分是同步io,整体流程:线程在等 IO,什么也干不了
时间点 | 动作 |
---|---|
T0 | 线程发起 I/O 请求 |
T0 → T1 | 线程什么也不干,一直等 |
T1 | 内核开始执行 I/O 操作(如读取文件) |
T2 | I/O 结束,内核通知线程 |
T2 → T3 | 线程继续执行,处理数据 |
-
T0:线程发起一个 I/O 请求,比如想读一个文件。
-
T0→T1:线程什么也不干,就在等。
-
T1:内核收到请求后,开始真正去读文件。
-
T2:I/O 读完了,内核通知线程:“搞定了”。
-
T2→T3:线程被唤醒,继续跑,处理刚刚读到的数据。
✅ 结论:
- 同步 I/O 的线程会“等着”,性能浪费。
- 多线程环境里,容易出现“线程都卡住了”的问题。
3.2 异步io
图中下半部分是同步io,整体流程:线程发完请求就走开干别的事,等系统通知
时间点 | 动作 |
---|---|
T0 | 线程发起异步 I/O 请求 |
T0→T2 | 线程去干别的事(不会阻塞) |
T1 | 内核开始执行 I/O 操作 |
T2 | I/O 结束,内核发通知 |
T2→T3 | 线程处理通知、开始处理数据 |
-
T0:线程发出一个“异步读文件”的请求。
-
T0→T2:线程没在等,而是继续去干别的任务了(比如处理别的网络请求)。
-
T1:内核去这个 I/O 操作。
-
T2:内核完成 I/O 后,发个通知(比如信号、回调)。
-
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
的作用:
-
创建 IOCP 对象(如果你传入的是 NULL 句柄)
-
将一个已有的文件句柄绑定到一个现有的 IOCP 上
-
投递异步操作(AcceptEx、WSARecv、WSASend、ConnectEx)
每当你对某个句柄(如 socket)执行一个异步 IO 操作(如 ReadFileEx
、WSARecv
),操作完成时,系统不会立刻通知你,而是将一个完成通知(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 模型的核心机制。流程如下:
-
应用程序通过某个异步 API 向绑定了 IOCP 的文件句柄发起 IO 操作(比如
WSARecv
)。 -
操作系统开始在内核中执行这个 IO。
-
IO 完成后,不会立刻通知调用者,而是:
- 系统把一个完成通知(称为“完成数据包”)放入 IOCP 队列。
- 这个通知中包括:操作状态、传输的字节数、关联的句柄等信息。
-
此时,如果有线程正在调用
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 没人用了,可以释放资源。
✔️ 正确关闭步骤:
-
关闭所有与 IOCP 绑定的文件句柄(比如关闭 socket)
- 调用
CloseHandle(socket)
- 或者
closesocket()
关闭网络句柄
- 调用
-
然后调用
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 只有在所有引用都被关闭后才能被释放
-
正确做法是:
- 关闭所有绑定的文件句柄
- 再关闭 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 流程
-
主线程发起多个异步 IO 请求(带不同的
OVERLAPPED
指针) -
系统异步处理这些请求,挂起执行
-
某个请求完成后,系统把结果和
OVERLAPPED
指针一起放入 IOCP(或调用回调函数) -
线程从 IOCP 取出这个完成事件
-
通过
OVERLAPPED
找出是哪次请求,然后处理结果
5.3 总结
(1) 什么是重叠 I/O
-
是 Windows 提供的异步 IO 模型
-
不阻塞线程,可同时发起多个 IO 请求
-
每个请求使用独立的
OVERLAPPED
结构体来区分
(2) 主要优势
-
提高 IO 并发能力
-
避免线程阻塞
-
与 IOCP 机制完美配合,构建高性能服务器模型
(3) 关键特性
-
IO 请求可堆叠,不必等待上一个完成再发起下一个
-
IO 完成通知是无序的,必须用
OVERLAPPED
区分 -
适合和线程池、IOCP、事件对象等组合使用
(4) 编程时注意:
-
每个异步操作要用独立的
OVERLAPPED
对象 -
完成时一定要确认是哪次操作,用上下文参数或结构标识
-
合理管理 OVERLAPPED 生命周期,避免内存泄漏