【Win32 多线程程序设计基础第六章笔记】

🌹 作者: 云小逸

🤟 个人主页: 云小逸的主页

🤟 motto : 要敢于一个人默默的面对自己,强大自己才是核心 。不要等到什么都没有了,才下定决心去做。种一颗树,最好的时间是十年前,其次就是现在!学会自己和解,与过去和解,努力爱自己。希望春天来之前,我们一起面朝大海,春暖花开!

🥇 专栏:

文章目录

    • [📚 前言](#📚 前言)
    • [一. Overlapped I/O基础概念](#一. Overlapped I/O基础概念)
      • [1. 什么是Overlapped I/O(FAQ21)](#1. 什么是Overlapped I/O(FAQ21))
      • [2. 为什么需要Overlapped I/O?](#2. 为什么需要Overlapped I/O?)
      • [3. Overlapped I/O与多线程的关系](#3. Overlapped I/O与多线程的关系)
    • [二. Win32文件操作核心函数](#二. Win32文件操作核心函数)
    • [二. 激发的File Handles:最简单的Overlapped I/O](#二. 激发的File Handles:最简单的Overlapped I/O)
      • [1. 原理:用文件句柄做"同步信号"](#1. 原理:用文件句柄做“同步信号”)
      • [2. GetOverlappedResult:获取I/O结果](#2. GetOverlappedResult:获取I/O结果)
      • [3. 示例:读文件的Overlapped I/O](#3. 示例:读文件的Overlapped I/O)
      • [4. 关键注意点](#4. 关键注意点)
    • [三. 激发的Event对象:区分多个异步I/O](#三. 激发的Event对象:区分多个异步I/O)
      • [1. 为什么需要Event对象?](#1. 为什么需要Event对象?)
      • [2. 必须用"手动重置"的Event](#2. 必须用“手动重置”的Event)
      • [3. 示例:多I/O操作的管理(节选自文档列表6-2)](#3. 示例:多I/O操作的管理(节选自文档列表6-2))
      • [4. 执行结果分析](#4. 执行结果分析)
    • [四. 异步过程调用(APCs):用回调函数处理I/O结果](#四. 异步过程调用(APCs):用回调函数处理I/O结果)
      • [1. APC解决的问题](#1. APC解决的问题)
      • [2. 核心函数:ReadFileEx与WriteFileEx(FAQ26)](#2. 核心函数:ReadFileEx与WriteFileEx(FAQ26))
      • [3. 线程的"Alertable"状态(FAQ27)](#3. 线程的“Alertable”状态(FAQ27))
      • [4. 传递用户自定义数据(FAQ28)](#4. 传递用户自定义数据(FAQ28))
      • [5. C++成员函数作为回调(FAQ29)](#5. C++成员函数作为回调(FAQ29))
    • [五. 对文件进行Overlapped I/O的缺点](#五. 对文件进行Overlapped I/O的缺点)
      • [1. 小数据I/O可能"同步执行"](#1. 小数据I/O可能“同步执行”)
      • [2. 两种"必然同步"的情况](#2. 两种“必然同步”的情况)
      • [3. 效率对比](#3. 效率对比)
    • [六. I/O Completion Ports:高并发服务器的核心](#六. I/O Completion Ports:高并发服务器的核心)
    • [七. 对Sockets使用Overlapped I/O](#七. 对Sockets使用Overlapped I/O)
      • [1. Socket默认支持Overlapped I/O](#1. Socket默认支持Overlapped I/O)
      • [2. 为什么不用select()?(FAQ33)](#2. 为什么不用select()?(FAQ33))
      • [3. ECHO范例的执行流程](#3. ECHO范例的执行流程)
    • [八. 提要](#八. 提要)
    • [📣 结语](#📣 结语)

📚 前言

在Win32多线程程序设计中,我们之前学过用线程处理后台任务(比如后台打印),但有些场景下,直接用"异步I/O"(即Overlapped I/O)能更高效地解决问题。比如当程序需要同时处理多个文件读写、网络通信等I/O操作时,Overlapped I/O可以让操作系统在后台完成I/O,操作结束后再通知程序,避免线程等待I/O时浪费CPU资源。

举个直观的例子:假设你通过拨号网络(RAS)从服务器读取100KB数据,在9600bps的网速下要近两分钟。如果程序用传统同步I/O,会卡在读取操作上,窗口完全没反应;但用Overlapped I/O,程序可以继续响应用户操作,直到数据读取完成再处理结果。

需要特别注意的是,Windows 95对Overlapped I/O有严格限制:仅支持named pipes(命名管道)、mailslots(邮槽)、serial I/O(串行I/O)和Sockets,不支持磁盘或光盘文件的Overlapped I/O。本章所有例子仅在Windows NT下有效(FAQ22)。

本章会从基础到进阶,逐步讲解Overlapped I/O的4种实现方式:激发的File Handles、激发的Event对象、异步过程调用(APCs)、I/O Completion Ports,最后还会介绍如何对Sockets使用Overlapped I/O。

一. Overlapped I/O基础概念

1. 什么是Overlapped I/O(FAQ21)

Overlapped I/O(重叠I/O)是Win32的一项技术,简单说就是"让操作系统帮你执行I/O操作,操作完成后通知你"。它的核心是"异步"------程序发起I/O请求后,不需要等待I/O完成,能继续做其他事;当I/O结束(比如数据读完、写完),操作系统会通过特定方式(如激发对象、回调函数)告知程序。

操作系统内部其实是用线程实现Overlapped I/O的,但对我们来说,不用手动管理线程,就能享受"并行I/O"的好处。

2. 为什么需要Overlapped I/O?

I/O设备(打印机、磁盘、网络)比CPU慢得多。比如磁盘传输率约5MB/秒,移动读写头平均要10ms,而CPU每秒能执行数千万条指令。如果程序"傻等"I/O完成,就是在浪费CPU资源。

举个租车店的例子:租车店有几位代理人,窗外有3辆敞篷车。如果4个客户同时来租敞篷车,每个代理人都先看"有车",再写派车单,最后发现车不够------这就是没做好同步的问题。Overlapped I/O就像一个"车辆计数器",能准确记录可用车辆,避免多个操作冲突。

3. Overlapped I/O与多线程的关系

Overlapped I/O可以替代部分线程的功能(比如处理多个I/O),但高阶场景下(如高并发服务器),通常会把"Overlapped I/O + I/O Completion Ports"和多线程结合,让线程池处理完成的I/O请求,实现高效的"可扩展(scalable)"架构。

二. Win32文件操作核心函数

要使用Overlapped I/O,必须先掌握3个核心Win32文件函数:CreateFile(打开文件/设备)、ReadFile(读数据)、WriteFile(写数据),以及关键的OVERLAPPED结构。

1. 核心I/O函数解析

(1)CreateFile:打开文件/设备

CreateFile不仅能打开普通文件,还能打开串行口、Sockets、named pipes等设备。要启用Overlapped I/O,必须在第6个参数dwFlagsAndAttributes中指定FILE_FLAG_OVERLAPPED

函数原型关键部分:

c 复制代码
HANDLE CreateFile(
    LPCTSTR lpFileName,       // 文件名/设备名
    DWORD dwDesiredAccess,    // 读写权限(如GENERIC_READ)
    DWORD dwShareMode,        // 共享模式(如FILE_SHARE_READ)
    LPSECURITY_ATTRIBUTES lpSecurityAttributes, // 安全属性(NULL为默认)
    DWORD dwCreationDisposition, // 文件创建方式(如OPEN_EXISTING)
    DWORD dwFlagsAndAttributes,  // 关键!指定FILE_FLAG_OVERLAPPED启用异步I/O
    HANDLE hTemplateFile        // 模板文件(NULL)
);

一旦用FILE_FLAG_OVERLAPPED打开文件,后续对该文件的所有读写操作都必须是Overlapped I/O。

(2)ReadFile与WriteFile:异步读写

ReadFileWriteFile是执行异步读写的函数,它们的最后一个参数是LPOVERLAPPED(指向OVERLAPPED结构的指针),这是Overlapped I/O的核心参数。

ReadFile为例,函数原型关键部分:

c 复制代码
BOOL ReadFile(
    HANDLE hFile,                  // 要读的文件/设备句柄(CreateFile返回)
    LPVOID lpBuffer,               // 接收数据的缓冲区
    DWORD nNumberOfBytesToRead,    // 要读取的字节数
    LPDWORD lpNumberOfBytesRead,   // 实际读取的字节数(异步时可能为NULL)
    LPOVERLAPPED lpOverlapped      // 关键!Overlapped结构指针
);

WriteFile的参数类似,只是把"读"换成"写"。

2. OVERLAPPED结构:异步I/O的"身份证"

OVERLAPPED结构有两个核心作用:一是标识每个异步I/O请求(比如区分"读文件A"和"读文件B");二是传递I/O相关参数(如文件读取的起始位置)。

结构定义如下:

c 复制代码
typedef struct _OVERLAPPED {
    DWORD Internal;        // 系统保留,用于存储状态
    DWORD InternalHigh;    // 系统保留,用于存储实际传输的字节数
    DWORD Offset;          // 文件读写的起始偏移(低32位)
    DWORD OffsetHigh;      // 文件读写的起始偏移(高32位,支持大文件)
    HANDLE hEvent;        // 事件对象句柄(I/O完成时激发)
} OVERLAPPED, *LPOVERLAPPED;

各成员的详细说明如下表(表格6-1):

成员名称 说明
Internal 系统保留。当GetOverlappedResult返回FALSE且GetLastError不是ERROR_IO_PENDING时,存储系统状态
InternalHigh 系统保留。当GetOverlappedResult返回TRUE时,存储实际传输的字节数
Offset 文件读写的起始偏移(从文件头开始算),单位字节。不支持文件位置的设备(如管道)会忽略此值
OffsetHigh 64位文件偏移的高32位,用于处理超过4GB的大文件。不支持文件位置的设备会忽略此值
hEvent 手动重置的Event对象句柄(I/O完成时会被激发)。ReadFileEx/WriteFileEx会忽略此值,可用于传递用户数据

注意OVERLAPPED结构的生命周期要超过ReadFile/WriteFile的调用------不能定义为局部变量(函数结束后会被销毁),最好在堆(heap)上分配。

3. 不能用C Runtime的stdio函数(FAQ23)

C语言标准库的stdio.h函数(如fopenfreadprintf)不支持Overlapped I/O。比如fgets虽然能读一行文字,但无法异步执行。如果要做异步文本I/O,只能用Win32 API手动处理(如用ReadFile读字节,再自己解析换行符)。

二. 激发的File Handles:最简单的Overlapped I/O

1. 原理:用文件句柄做"同步信号"

打开支持Overlapped I/O的文件后,文件句柄(HANDLE)本身就是一个核心对象 ------当异步I/O操作完成时,文件句柄会自动变成"激发状态"。我们可以用WaitForSingleObjectWaitForMultipleObjects等待这个句柄,就能知道I/O是否完成。

流程如下:

  1. FILE_FLAG_OVERLAPPED打开文件,得到文件句柄hFile
  2. 初始化OVERLAPPED结构(指定读写偏移等);
  3. 调用ReadFile/WriteFile发起异步I/O;
  4. WaitForSingleObject(hFile, INFINITE)等待I/O完成;
  5. 调用GetOverlappedResult获取I/O结果(如实际读写的字节数、是否成功)。

2. GetOverlappedResult:获取I/O结果

GetOverlappedResult是关键函数,它能获取异步I/O的最终结果,功能类似"WaitForSingleObject + 结果查询"的结合。

函数原型:

c 复制代码
BOOL GetOverlappedResult(
    HANDLE hFile,                  // 异步I/O对应的文件句柄
    LPOVERLAPPED lpOverlapped,     // 发起I/O时用的OVERLAPPED结构
    LPDWORD lpNumberOfBytesTransferred, // 实际传输的字节数
    BOOL bWait                     // 是否等待I/O完成(TRUE=等待,FALSE=不等待)
);
  • 返回值:TRUE表示I/O成功,FALSE表示失败(如读文件出错);
  • bWait=TRUE,函数会等待I/O完成后再返回;若bWait=FALSE且I/O未完成,GetLastError会返回ERROR_IO_INCOMPLETE

3. 示例:读文件的Overlapped I/O

以下是从C:\WINDOWS\WINFILE.EXE的第1500字节开始,异步读取300字节的核心代码(节选自文档列表6-1):

c 复制代码
int ReadSomething() {
    BOOL rc;
    HANDLE hFile;
    DWORD numread;
    OVERLAPPED overlap;
    char buf[512];

    // 1. 用Overlapped模式打开文件
    hFile = CreateFile(
        "C:\\WINDOWS\\WINFILE.EXE",
        GENERIC_READ,
        FILE_SHARE_READ|FILE_SHARE_WRITE,
        NULL,
        OPEN_EXISTING,
        FILE_FLAG_OVERLAPPED,  // 启用Overlapped I/O
        NULL
    );
    if (hFile == INVALID_HANDLE_VALUE) return -1;

    // 2. 初始化OVERLAPPED结构(从第1500字节开始读)
    memset(&overlap, 0, sizeof(overlap));
    overlap.Offset = 1500;  // 读取起始偏移

    // 3. 发起异步读请求
    rc = ReadFile(hFile, buf, 300, &numread, &overlap);

    if (rc) {
        // 情况1:I/O立即完成(如数据在缓存中)
        printf("数据已立即读取完成\n");
    } else {
        // 情况2:I/O排队等待(需要等操作系统完成)
        if (GetLastError() == ERROR_IO_PENDING) {
            // 等待I/O完成(文件句柄激发)
            WaitForSingleObject(hFile, INFINITE);
            // 获取I/O结果
            rc = GetOverlappedResult(hFile, &overlap, &numread, FALSE);
        } else {
            // 真的出错了(如文件不存在)
            printf("ReadFile失败\n");
        }
    }

    // 4. 关闭文件句柄
    CloseHandle(hFile);
    return TRUE;
}

4. 关键注意点

  • I/O可能立即完成 :即使指定了FILE_FLAG_OVERLAPPED,如果数据已在系统缓存中,ReadFile/WriteFile会立即返回TRUE,文件句柄也会处于激发状态;
  • 区分"排队"和"错误"ReadFile返回FALSE时,必须用GetLastError()判断是"I/O排队(ERROR_IO_PENDING)"还是真的出错(如ERROR_FILE_NOT_FOUND);
  • 无"当前文件位置" :用Overlapped I/O时,文件没有"当前位置"的概念------每次读写都必须在OVERLAPPED结构中指定OffsetOffsetHigh(起始偏移)。

三. 激发的Event对象:区分多个异步I/O

1. 为什么需要Event对象?

用"激发的File Handles"有个明显缺点:如果对同一个文件发起多个异步I/O(比如同时读文件的不同部分),所有操作共用一个文件句柄------当句柄激发时,无法区分是哪个I/O操作完成了。

比如对一个文件同时发起"读偏移100字节"和"读偏移200字节"两个请求,文件句柄激发后,不知道该调用哪个GetOverlappedResult

解决方案是:给每个异步I/O分配一个独立的Event对象 ,通过OVERLAPPED结构的hEvent成员关联Event------I/O完成时,对应的Event会被激发,这样就能通过等待Event来区分不同I/O。

2. 必须用"手动重置"的Event

Event对象有两种类型:自动重置(Auto Reset)和手动重置(Manual Reset)。在Overlapped I/O中,必须用手动重置的EventCreateEvent的第二个参数bManualReset=TRUE),原因如下:

如果用自动重置Event,系统在激发Event后会立即将其重置为"未激发状态"。如果I/O完成时,程序还没来得及等待Event,Event的"激发状态"就会丢失,导致WaitFor...函数永远等待。而手动重置Event被激发后,会一直保持激发状态,直到程序调用ResetEvent手动重置。

3. 示例:多I/O操作的管理(节选自文档列表6-2)

以下代码演示如何用Event对象管理多个异步读请求(如同时读一个文件的5个不同部分):

c 复制代码
// 全局变量:存储每个I/O的Event、OVERLAPPED、缓冲区
#define MAX_REQUESTS 5  // 最多5个异步请求
HANDLE ghEvents[MAX_REQUESTS];       // 每个I/O对应一个Event
OVERLAPPED gOverlapped[MAX_REQUESTS];// 每个I/O的OVERLAPPED结构
HANDLE ghFile;                       // 目标文件句柄
char gBuffers[MAX_REQUESTS][512];    // 每个I/O的缓冲区

// 函数:发起一个异步读请求
int QueueRequest(int nIndex, DWORD dwLocation, DWORD dwAmount) {
    BOOL rc;
    DWORD dwNumread;

    // 1. 创建手动重置的Event
    ghEvents[nIndex] = CreateEvent(
        NULL,       // 安全属性(默认)
        TRUE,       // 手动重置(关键!)
        FALSE,      // 初始状态:未激发
        NULL        // 无名称
    );
    if (ghEvents[nIndex] == NULL) return -1;

    // 2. 关联Event到OVERLAPPED结构
    gOverlapped[nIndex].hEvent = ghEvents[nIndex];
    gOverlapped[nIndex].Offset = dwLocation;  // 读起始偏移

    // 3. 发起异步读
    rc = ReadFile(
        ghFile,
        gBuffers[nIndex],  // 缓冲区
        dwAmount,         // 要读的字节数
        &dwNumread,
        &gOverlapped[nIndex]
    );

    if (rc) {
        // I/O立即完成
        printf("读请求%d:立即完成\n", nIndex);
        return TRUE;
    } else {
        if (GetLastError() == ERROR_IO_PENDING) {
            // I/O排队等待
            printf("读请求%d:已排队\n", nIndex);
            return TRUE;
        } else {
            // 出错(如内存不足)
            printf("ReadFile失败\n");
            return -1;
        }
    }
}

4. 执行结果分析

当我们发起5个读请求时,可能出现部分请求"立即完成"、部分"排队"的情况。比如Windows NT会对已缓存的数据直接返回结果,未缓存的数据则排队异步读取。以下是一个实际执行结果:

复制代码
读请求0:已排队
读请求1:立即完成
读请求2:立即完成
读请求3:立即完成
读请求4:立即完成
// 等待所有I/O完成后
读请求0:返回1,读取512字节
读请求1:返回1,读取512字节
...

四. 异步过程调用(APCs):用回调函数处理I/O结果

1. APC解决的问题

用Event对象虽然能区分多个I/O,但有两个局限:

  1. WaitForMultipleObjects最多只能等待64个对象(MAXIMUM_WAIT_OBJECTS),无法支持更多I/O;
  2. 需要手动维护"Event数组"和"OVERLAPPED数组"的对应关系,代码繁琐。

异步过程调用(APCs,Asynchronous Procedure Calls)能解决这些问题:通过ReadFileEx/WriteFileEx指定一个"回调函数"(I/O Completion Routine),当I/O完成且线程处于"可提醒(Alertable)"状态时,系统会自动调用这个回调函数,直接在回调中处理I/O结果。

2. 核心函数:ReadFileEx与WriteFileEx(FAQ26)

ReadFileExWriteFileEx是支持APCs的异步I/O函数,它们比ReadFile/WriteFile多一个参数------回调函数地址。以ReadFileEx为例:

c 复制代码
BOOL ReadFileEx(
    HANDLE hFile,
    LPVOID lpBuffer,
    DWORD nNumberOfBytesToRead,
    LPOVERLAPPED lpOverlapped,
    LPOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine  // 回调函数
);

其中lpCompletionRoutine是回调函数指针,函数原型固定为:

c 复制代码
VOID WINAPI FileIOCompletionRoutine(
    DWORD dwErrorCode,          // I/O结果(0=成功,ERROR_HANDLE_EOF=文件尾)
    DWORD dwNumberOfBytesTransferred,  // 实际传输的字节数
    LPOVERLAPPED lpOverlapped   // 关联的OVERLAPPED结构
);

3. 线程的"Alertable"状态(FAQ27)

系统不会"强行"调用回调函数,只有当线程处于"可提醒(Alertable)"状态时,才会执行等待中的APC回调。线程进入Alertable状态的方式是:调用以下5个函数时,将"可提醒标记"设为TRUE

  • SleepEx():带可提醒的睡眠;
  • WaitForSingleObjectEx():带可提醒的等待单个对象;
  • WaitForMultipleObjectsEx():带可提醒的等待多个对象;
  • MsgWaitForMultipleObjectsEx():带可提醒的等待消息和对象;
  • SignalObjectAndWait():带可提醒的信号和等待。

比如调用WaitForSingleObjectEx(ghEvent, INFINITE, TRUE),线程会等待ghEvent激发,同时处于Alertable状态------如果此时有I/O完成,系统会先调用回调函数,再回到等待。

4. 传递用户自定义数据(FAQ28)

回调函数只有3个参数,要传递自定义数据(如"这个I/O对应的业务标识"),可以利用OVERLAPPED结构的hEvent成员------因为ReadFileEx/WriteFileEx会忽略hEvent,我们可以用它存储自定义数据的指针。

示例:在OVERLAPPEDhEvent中存储请求索引,回调中通过lpOverlapped->hEvent获取:

c 复制代码
// 发起I/O时:将请求索引存入hEvent
gOverlapped[nIndex].hEvent = (HANDLE)nIndex;

// 回调函数中:获取索引
VOID WINAPI FileIOCompletionRoutine(
    DWORD dwErrorCode,
    DWORD dwNumberOfBytesTransferred,
    LPOVERLAPPED lpOverlapped
) {
    int nIndex = (int)(lpOverlapped->hEvent);  // 取出自定义索引
    printf("请求%d:%d字节已读取,结果:%d\n", 
           nIndex, dwNumberOfBytesTransferred, dwErrorCode);
}

5. C++成员函数作为回调(FAQ29)

C++的非静态成员函数有this指针,不能直接作为APC回调(回调函数要求无this指针)。解决方案是:

  1. 定义一个静态成员函数作为回调;
  2. OVERLAPPEDhEvent中存储this指针(对象地址);
  3. 在静态回调中,通过hEvent取出this,再调用非静态成员函数。

示例代码逻辑:

c 复制代码
class MyClass {
public:
    // 静态回调函数(符合APC回调原型)
    static VOID WINAPI StaticCompletionRoutine(
        DWORD dwErrorCode,
        DWORD dwBytesTransferred,
        LPOVERLAPPED lpOverlapped
    ) {
        // 从hEvent取出this指针
        MyClass* pThis = (MyClass*)(lpOverlapped->hEvent);
        // 调用非静态成员函数处理结果
        pThis->OnIOComplete(dwErrorCode, dwBytesTransferred);
    }

    // 非静态函数:实际处理I/O结果
    void OnIOComplete(DWORD dwErrorCode, DWORD dwBytesTransferred) {
        // 处理逻辑...
    }

    // 发起异步I/O
    void StartAsyncRead(HANDLE hFile) {
        OVERLAPPED overlap;
        memset(&overlap, 0, sizeof(overlap));
        overlap.hEvent = this;  // 存储this指针

        ReadFileEx(hFile, m_buf, 512, &overlap, StaticCompletionRoutine);
    }

private:
    char m_buf[512];
};

五. 对文件进行Overlapped I/O的缺点

虽然Overlapped I/O很强大,但对文件操作时存在一些反直觉的缺点:

1. 小数据I/O可能"同步执行"

Windows NT会根据I/O请求的大小决定是否"真异步":如果请求的数据很小(比如小于32KB),系统可能会"同步执行"I/O,即ReadFile/ReadFileEx会卡住直到I/O完成,失去异步的意义。

原因是:小数据的I/O时间(如读8KB数据约1.8ms)远小于线程切换、系统通知的额外开销(约12ms),同步执行反而更高效。但这会导致程序在小数据I/O时依然卡顿。

2. 两种"必然同步"的情况

即使指定了FILE_FLAG_OVERLAPPED,以下两种情况的I/O必然同步执行:

  1. 文件扩展:写入操作导致文件大小增加(如在文件末尾追加数据);
  2. 压缩文件:读写NTFS压缩文件时,系统需要实时压缩/解压数据,只能同步执行。

3. 效率对比

测试表明:对一系列小于32KB的文件传输,用Overlapped I/O比传统同步I/O慢15%。因此,在Web服务器(大部分请求是小数据)等场景,用"线程池+同步I/O"可能比Overlapped I/O更高效。

六. I/O Completion Ports:高并发服务器的核心

I/O Completion Ports(I/O完成端口)是Windows NT 3.5引入的高阶Overlapped I/O机制,专门解决"高并发I/O"场景(如Web服务器、数据库服务器),是本章最重要的内容(FAQ30、31)。

1. 为什么I/O Completion Ports特殊?(FAQ31)

它解决了之前所有方案的局限:

  • 无handle数量限制 :突破WaitForMultipleObjects的64个对象限制,支持成千上万的并发I/O;
  • 跨线程服务:发起I/O的线程和处理I/O结果的线程可以是不同的,适合线程池架构;
  • 自动负载均衡:根据CPU数量调整并发线程数,避免过多线程导致的上下文切换开销,支持SMP(对称多处理器)系统。

2. 服务器的3种线程模型

在了解Completion Ports前,先明确服务器的线程模型,Completion Ports对应"每CPU一线程"的最优模型:

模型 特点 缺点
单一线程 用Overlapped I/O处理所有I/O 若线程需处理计算(如解析数据),会阻塞所有I/O
每Client一线程 为每个客户端连接创建一个线程 线程过多(如2000个),系统资源耗尽
每CPU一线程(最优) 线程池大小=CPU数量,处理所有I/O结果 需Completion Ports支持负载均衡

3. I/O Completion Ports的核心流程

使用Completion Ports需要5个步骤,流程清晰且可复用:

步骤1:创建I/O Completion Port

CreateIoCompletionPort创建Completion Port(核心对象):

c 复制代码
HANDLE CreateIoCompletionPort(
    HANDLE FileHandle,          // 文件/设备句柄(INVALID_HANDLE_VALUE=仅创建Port)
    HANDLE ExistingCompletionPort,  // 已有的Completion Port(NULL=新建)
    DWORD CompletionKey,        // 用户自定义Key(关联到文件句柄,如业务标识)
    DWORD NumberOfConcurrentThreads  // 并发线程数(0=默认=CPU数量)
);
  • 若要"仅创建Port",将FileHandle设为INVALID_HANDLE_VALUEExistingCompletionPort设为NULL
  • NumberOfConcurrentThreads设为0时,系统会根据CPU数量自动调整并发线程数(如4核CPU允许4个线程同时处理I/O结果)。
步骤2:关联文件/设备句柄到Port

对每个需要异步I/O的文件/设备(如Sockets、named pipes),调用CreateIoCompletionPort将其与Completion Port关联:

c 复制代码
// 假设hPort是已创建的Completion Port,hFile是用FILE_FLAG_OVERLAPPED打开的文件句柄
CreateIoCompletionPort(
    hFile,          // 要关联的文件句柄
    hPort,          // 目标Completion Port
    (DWORD)pKey,    // 自定义Key(如指向业务数据的指针)
    0              // 并发线程数(用Port的默认值)
);

关联后,该文件的所有异步I/O完成时,系统会自动向Completion Port发送一个"完成包(Completion Packet)"。

步骤3:创建线程池

创建一组线程(数量通常=CPU数量×2+2),每个线程的唯一工作是:调用GetQueuedCompletionStatus等待Completion Port的完成包,收到后处理I/O结果。

线程函数核心逻辑:

c 复制代码
DWORD WINAPI WorkerThread(LPVOID pParam) {
    HANDLE hPort = (HANDLE)pParam;
    DWORD dwBytesTransferred;
    DWORD dwCompletionKey;  // 步骤2关联的自定义Key
    LPOVERLAPPED lpOverlapped;

    while (TRUE) {
        // 等待Completion Port的完成包
        BOOL bResult = GetQueuedCompletionStatus(
            hPort,
            &dwBytesTransferred,  // 实际传输的字节数
            &dwCompletionKey,     // 取出自定义Key
            &lpOverlapped,        // 关联的OVERLAPPED结构
            INFINITE              // 无限等待
        );

        if (bResult) {
            // 情况1:收到正常完成包,处理I/O结果
            // 从dwCompletionKey和lpOverlapped获取业务数据,处理逻辑...
        } else {
            if (lpOverlapped == NULL) {
                // 情况2:函数调用失败(如Port无效)
                break;
            } else {
                // 情况3:I/O操作失败(如读文件出错)
                // 处理错误逻辑...
            }
        }
    }
    return 0;
}
步骤4:发起异步I/O请求

ReadFile/WriteFile(或ReadFileEx/WriteFileEx)发起异步I/O,不需要手动等待------I/O完成后,系统会自动向Completion Port发送完成包,线程池中的线程会被唤醒处理。

步骤5:处理完成包

线程池中的线程通过GetQueuedCompletionStatus获取完成包后,根据dwCompletionKey(自定义业务Key)和lpOverlapped(I/O参数)处理结果,比如解析读取的数据、回写响应等。

4. 线程池数量建议(FAQ32)

Completion Port的线程池数量不是越多越好,建议设为"CPU数量×2+2"。比如4核CPU设10个线程,原因是:

  • 当部分线程因I/O(如等待网络响应)阻塞时,其他线程能继续处理完成包;
  • 避免线程过多导致的上下文切换开销,保持CPU利用率最大化。

5. 示例:ECHO服务器(基于Sockets和Completion Ports)

文档中的ECHO范例是Completion Ports的经典应用:服务器监听TCP端口5554,收到客户端数据后原样回显(类似标准TCP的7号端口)。核心逻辑如下:

(1)服务器初始化(节选自列表6-4)
c 复制代码
int main() {
    SOCKET listener;  // 监听Socket
    HANDLE hPort;     // Completion Port

    // 1. 创建Completion Port
    hPort = CreateIoCompletionPort(
        INVALID_HANDLE_VALUE,
        NULL,
        0,
        0  // 并发线程数=CPU数量
    );

    // 2. 创建线程池(数量=CPU×2+2)
    CreateWorkerThreads(hPort);

    // 3. 初始化监听Socket,开始接受客户端连接
    listener = socket(AF_INET, SOCK_STREAM, 0);
    bind(listener, ...);  // 绑定端口
    listen(listener, 5);

    // 4. 循环接受连接,关联Socket到Completion Port
    while (TRUE) {
        SOCKET clientSock = accept(listener, ...);  // 接受客户端连接
        // 创建业务数据结构(存储Socket、缓冲区等)
        ContextKey* pKey = (ContextKey*)calloc(1, sizeof(ContextKey));
        pKey->sock = clientSock;

        // 关联客户端Socket到Completion Port
        CreateIoCompletionPort(
            (HANDLE)clientSock,
            hPort,
            (DWORD)pKey,  // 业务Key=业务数据指针
            0
        );

        // 5. 发起第一个异步读请求(读客户端数据)
        IssueRead(pKey);
    }
    return 0;
}
(2)线程池创建(节选自列表6-5)
c 复制代码
void CreateWorkerThreads(HANDLE hPort) {
    SYSTEM_INFO sysInfo;
    GetSystemInfo(&sysInfo);  // 获取CPU数量
    DWORD dwThreads = sysInfo.dwNumberOfProcessors * 2 + 2;  // 线程数

    for (DWORD i=0; i<dwThreads; i++) {
        HANDLE hThread = CreateThread(
            NULL,
            0,
            WorkerThread,  // 线程函数
            hPort,         // 参数=Completion Port句柄
            0,
            &dwThreadId
        );
        CloseHandle(hThread);
    }
}
(3)线程函数处理完成包(节选自列表6-6)
c 复制代码
DWORD WINAPI WorkerThread(LPVOID pParam) {
    HANDLE hPort = (HANDLE)pParam;
    DWORD dwBytesTransferred;
    ContextKey* pKey;  // 业务数据
    LPOVERLAPPED lpOverlapped;

    while (TRUE) {
        // 等待完成包
        BOOL bResult = GetQueuedCompletionStatus(
            hPort, &dwBytesTransferred, (DWORD*)&pKey, &lpOverlapped, INFINITE
        );

        if (bResult && dwBytesTransferred > 0) {
            // 处理客户端数据:将收到的字节回显给客户端
            char* pData = pKey->InBuffer;
            WriteFile(
                (HANDLE)pKey->sock, pData, dwBytesTransferred, 
                &pKey->dwWritten, &pKey->ovOut
            );

            // 发起下一次异步读,继续接收客户端数据
            IssueRead(pKey);
        } else {
            // 客户端断开连接,清理资源
            closesocket(pKey->sock);
            free(pKey);
        }
    }
    return 0;
}

七. 对Sockets使用Overlapped I/O

Sockets是Overlapped I/O的重要应用场景,比如Web服务器、即时通讯软件等。以下是关键要点:

1. Socket默认支持Overlapped I/O

创建Socket时,不需要额外标记,默认就支持Overlapped I/O。但要将Socket与I/O Completion Port关联(通过CreateIoCompletionPort),才能用Completion Port管理Socket的异步I/O。

2. 为什么不用select()?(FAQ33)

select()是传统的"多路复用I/O"方法,能同时监听多个Socket的读写事件,但存在明显缺点:

  • 效率低 :每次调用select()都要传递所有Socket的集合,系统需要遍历检查,Socket越多越慢;
  • 不支持SMPselect()无法利用多CPU的优势,线程只能单进程处理所有事件;
  • 有句柄限制select()支持的Socket数量有限(通常是1024)。

而I/O Completion Port没有这些问题,是高并发Socket服务器的首选方案。

3. ECHO范例的执行流程

文档中的ECHO范例包含服务器(ECHOSRV)和客户端(ECHOCLI):

  1. 服务器启动后,创建Completion Port和线程池,监听TCP端口5554;
  2. 客户端连接服务器,发送输入的字符串;
  3. 服务器的Worker线程收到"读完成包",将数据原样回写给客户端;
  4. 客户端收到回显数据,打印结果。

该范例支持同时处理多个客户端连接,且能充分利用多CPU资源。

八. 提要

本章我们系统学习了Overlapped I/O的4种实现方式,从简单到复杂:

  1. 激发的File Handles:最简单的异步I/O,用文件句柄作为同步信号,适合单个I/O操作;
  2. 激发的Event对象:用独立Event区分多个I/O,适合少量并发I/O;
  3. APCs:用回调函数处理I/O结果,避免手动管理Event数组,适合中等并发;
  4. I/O Completion Ports:最强大的方案,支持高并发、跨线程服务、负载均衡,是高并发服务器(如Web、数据库)的核心。

其中,I/O Completion Ports是重点,它解决了Overlapped I/O的所有局限,能在SMP系统上实现"可扩展"的高并发I/O处理。

📣 结语

恭喜你完成了Win32 Overlapped I/O的学习!从基础的File Handles到高阶的I/O Completion Ports,你已经掌握了Win32异步I/O的核心技术,这些知识在网络编程、文件处理等场景中能大幅提升程序效率。

如果你觉得本章内容有帮助,记得点赞、收藏和关注,后续会继续深入Win32多线程的其他高级主题(如MFC中的线程、DLL与线程等)。

学习编程没有捷径,多敲代码、多调试范例(比如本章的ECHO服务器),才能真正掌握这些技术。让我们一起加油,从"新手"逐步成长为"Win32开发高手"!

相关推荐
Yawesh_best16 小时前
告别系统壁垒!WSL+cpolar 让跨平台开发效率翻倍
运维·服务器·数据库·笔记·web安全
Ccjf酷儿19 小时前
操作系统 蒋炎岩 3.硬件视角的操作系统
笔记
习习.y19 小时前
python笔记梳理以及一些题目整理
开发语言·笔记·python
在逃热干面19 小时前
(笔记)自定义 systemd 服务
笔记
DKPT21 小时前
ZGC和G1收集器相比哪个更好?
java·jvm·笔记·学习·spring
QT 小鲜肉1 天前
【孙子兵法之上篇】001. 孙子兵法·计篇
笔记·读书·孙子兵法
星轨初途1 天前
数据结构排序算法详解(5)——非比较函数:计数排序(鸽巢原理)及排序算法复杂度和稳定性分析
c语言·开发语言·数据结构·经验分享·笔记·算法·排序算法
QT 小鲜肉1 天前
【孙子兵法之上篇】001. 孙子兵法·计篇深度解析与现代应用
笔记·读书·孙子兵法
love530love1 天前
【笔记】ComfUI RIFEInterpolation 节点缺失问题(cupy CUDA 安装)解决方案
人工智能·windows·笔记·python·插件·comfyui
愚戏师1 天前
MySQL 数据导出
数据库·笔记·mysql