【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开发高手"!

相关推荐
草莓熊Lotso19 小时前
C++ 继承特殊场景解析:友元、静态成员与菱形继承的底层逻辑
服务器·开发语言·c++·人工智能·经验分享·笔记·1024程序员节
yuxb7319 小时前
Zabbix企业级分布式监控系统(下)
笔记·zabbix
im_AMBER19 小时前
算法笔记 10
笔记·学习·算法·leetcode
9ilk19 小时前
【基于one-loop-per-thread的高并发服务器】--- 前置技术
运维·服务器·c++·笔记·后端·中间件
charlie1145141911 天前
CSS笔记4:CSS:列表、边框、表格、背景、鼠标与常用长度单位
css·笔记·学习·css3·教程
tjsoft1 天前
汇通家具管理软件 1.0 试用笔记
笔记
卡提西亚1 天前
C++笔记-10-循环语句
c++·笔记·算法
Cathy Bryant1 天前
概率论直觉(一):大数定律
笔记·考研·数学建模
摇滚侠1 天前
Spring Boot3零基础教程,Reactive-Stream 四大核心组件,笔记106
java·spring boot·笔记
✎ ﹏梦醒͜ღ҉繁华落℘1 天前
FreeRTOS学习笔记(应用)-- 各种 信号量的应用场景
笔记·学习