TCP/IP 网络编程(二十)---异步通知 I/O 模型

2 阅读16分钟

理解异步通知 I/O 模型

(一)同步与异步

(1)同步(Synchronous)

同步指的是任务按照顺序执行,一个任务必须等待前一个任务完成后才能继续。换句话说,在进行同步操作时,程序会阻塞在某个操作上,直到该操作完成后才会继续往下执行。

  • 特点

    • 任务按顺序执行。
    • 操作必须等待完成。
    • 执行过程阻塞当前线程或进程。
  • 优点

    • 逻辑简单,容易理解和实现。
    • 操作结果可以立即得到,适合需要一步步按序进行的操作。
  • 缺点

    • 效率低,尤其是涉及到耗时的 I/O 操作(如文件读写、网络请求等)时,程序在等待期间无法处理其他任务。

(2)异步(Asynchronous)

异步是指任务并行执行,程序发起一个操作后不等待其完成,而是继续执行其他任务。程序可以在等待操作完成的同时执行其他操作,最终通过回调、事件、信号等方式获取操作结果。

  • 特点

    • 操作立即返回,不阻塞。
    • 通过回调函数、事件或通知机制在操作完成时获取结果。
    • 程序可以同时进行其他任务,提高并发性。
  • 优点

    • 提高了程序的效率,特别是在 I/O 密集型任务中(如网络请求、数据库操作)。
    • 可以并行处理多个任务,最大化利用 CPU 资源。
  • 缺点

    • 逻辑更加复杂,通常需要处理回调、事件、或多线程同步等问题。
    • 对于结果的获取可能需要等待较长时间。

(3)同步与异步的对比

特点同步异步
执行顺序按顺序执行,后一个任务等待前一个完成可以并发执行,不需要等待操作完成
阻塞操作会阻塞当前线程或进程操作不阻塞,立即返回
效率效率较低,尤其是涉及到 I/O 操作时效率较高,适合高并发场景
复杂度逻辑简单,容易实现逻辑较复杂,需处理回调、事件或多线程
适用场景适用于执行顺序明确、无需并发的场景适用于并发处理和 I/O 密集型任务的场景

(二)异步通知 I/O

异步通知 I/O 是一种高效的 I/O 处理模型,其中程序发起 I/O 操作后不等待操作完成,而是让操作系统在操作完成时通过某种机制(如信号、回调函数或事件)通知程序。程序不需要持续检查 I/O 状态(轮询),也不必在 I/O 完成前被阻塞。

异步通知 I/O 的工作原理

  1. 发起 I/O 请求:程序向操作系统发出一个异步 I/O 请求(例如读取数据或写入文件)。
  2. 立即返回:操作系统不阻塞程序,立即将控制权返回给程序,程序可以继续执行其他任务。
  3. 通知机制:当 I/O 操作完成时,操作系统通过通知机制告知程序操作已完成。程序随后处理 I/O 操作的结果。
  4. 处理结果:程序通过回调函数、信号处理函数或事件检测处理操作结果。

实现异步通知 I/O 模型

异步通知 I/O 模型的实现方法有2种:一种是稍后介绍的 WSAEventSelect 函数,第二种是使用 WSAAsyncSelect 函数,第二种方法是 UI 相关内容,不进行介绍,需要了解可自行查阅资料。

(一)WSAEventSelect 函数

WSAEventSelect 是 Windows Sockets API(Winsock)中的一个函数,用于将一个 Windows 事件对象与一个套接字(socket)关联,以便在套接字状态发生变化时获得通知。这个函数常用于异步网络编程,特别是在处理多个套接字时。

int WSAEventSelect(
    SOCKET s,                   // 套接字句柄
    HANDLE hEvent,              // 事件对象句柄
    long lNetworkEvents         // 要监视的事件类型信息
);
  • s:套接字句柄。要将事件对象与之关联的套接字。

  • hEvent事件对象的句柄。用于通知套接字状态的变化。你可以使用 CreateEvent 创建一个事件对象。

  • lNetworkEvents:一个位掩码,指定要监视的网络事件。可以是以下常量的组合:

    • FD_READ:套接字可读。
    • FD_WRITE:套接字可写。
    • FD_OOB:套接字有紧急数据。
    • FD_ACCEPT:套接字已接受连接请求(用于监听套接字)。
    • FD_CONNECT:套接字已连接(用于非监听套接字)。
    • FD_CLOSE:套接字已关闭。

传入参数 S 的套接字内只要发生 lNetworkEvents 中指定的事件之一,WSAEventSelect 函数就将 hEvent 句柄所指内核对象改为 signaled 状态。

无论事件发生与否,WSAEventSelect 函数调用后都会直接返回,所以可以执行其他任务。

从函数说明中可以看出,我们还需要知道以下内容:

WSAEventSelect 函数的第二个参数中用到的事件对象的创建方法。

② 调用 WSAEventSelect 函数后发生事件的验证方法。

③ 验证事件发生后事件类型的查看方法。

(二)manual-reset 模式事件的其他创建方法

之前创建事件对象是利用 CreateEvent 函数。 CreateEvent 函数在创建事件对象时,可以在 auto-reset 模式和 manual-reset 模式中任选其一。但是我们只需要 manual-reset 模式 non-signaled 状态的事件对象,所以利用下面的函数创建比较方便。

#include <winsock2.h>

// 成功时返回事件对象句柄,失败时返回 WSA_INVALID_EVENT
WSAEVENT WSACreateEvent(void);
  • WSAEVENT#define WSAEVENT HANDLE

另外,可使用如下函数销毁上述函数创建的事件对象:

#include <winsock2.h>

// 成功时返回 TRUE, 失败时返回 FALSE
BOOL WSACloseEvent(WSAEVENT hEvent);

(三)验证是否发生事件

为了验证是否发生事件,需要查看事件对象,完成该任务的函数如下:

#include <winsock2.h>

DWORD WSAWaitForMultipleEvents(
    DWORD cEvents,                // 要等待的事件对象数量
    const WSAEVENT* lphEvents,    // 指向事件对象句柄数组的指针
    BOOL fWaitAll,                // 指定是等待所有事件还是任何一个事件
    DWORD dwTimeout,              // 超时时间(毫秒)
    BOOL fAlertable               // 指定等待期间是否允许进入警报状态(APC)
);

  • cEvents:要等待的事件对象的数量。这个值不能大于 WSA_MAXIMUM_WAIT_EVENTS,通常为 64

  • lphEvents:指向事件对象句柄数组的指针,这些句柄由 Winsock API 返回,比如通过 WSACreateEvent

  • fWaitAll:指定是否要等待所有事件对象都进入有信号状态。如果为 TRUE,则必须所有事件对象进入有信号状态,函数才会返回;如果为 FALSE,则任何一个事件对象进入有信号状态时,函数就会返回。

  • dwTimeout:超时值,单位是毫秒。如果为 INFINITE,函数将无限期地等待。

  • fAlertable:指定是否允许该函数在等待期间进入警报状态(如执行异步过程调用APC)。如果为 TRUE,则允许函数在等待期间进入警报状态。

  • 返回值:返回值减去常量 WSA_WAIT_EVENT_0 时,可以得到转变为 signaled 状态的事件对象句柄对应的索引,可以通过该索引在第二个参数指定的数组中查找句柄。如果有多个事件对象变为 signaled 状态,则会得到其中较小的值。发生超时将返回 WSA_WAIT_TIMEOUT

只通过1次函数调用无法得到转为 signaled 状态的所有事件对象句柄的信息。通过该函数可以得到转为 signaled 状态的事件对象中的第一个(按数组中的保存顺序)索引值。但可以利用 “事件对象为 manual-reset 模式” 的特点,通过如下方式获得所有 signaled 状态的事件对象。

int posInfo, startIdx, i;
...
posInfo = WSAWaitForMultipleEvents(numOfSock, hEventarray, FALSE, WSA_INFINITE, FALSE);
startIdx = posInfo - WSA_WAIT_EVENT_0;
...
for(i = startIdx; i < numOfSock; i++)
{
    int sigEventIdx = WSAWaitForMultipleEvents(1, &hEventArray[i], TRUE, 0, FALSE);
    ...
}

上述代码解析:

posInfo = WSAWaitForMultipleEvents(numOfSock, hEventArray, FALSE, WSA_INFINITE, FALSE);
  • 这行代码使用 WSAWaitForMultipleEvents 函数等待 hEventArray 中的任意一个事件对象变为 signaled 状态。numOfSock 表示等待的事件数量。
  • FALSE 表示只需要任意一个事件进入 signaled 状态即可,不需要等待所有事件。
  • WSA_INFINITE 表示函数将无限期等待,直到某个事件被触发。
  • posInfo 会返回 WSA_WAIT_EVENT_0 加上事件数组中进入 signaled 状态的事件索引。
startIdx = posInfo - WSA_WAIT_EVENT_0;
  • startIdx 用于保存当前进入 signaled 状态的事件在 hEventArray 数组中的索引。WSA_WAIT_EVENT_0 是常量,代表第一个事件对象的基值,因此 posInfo - WSA_WAIT_EVENT_0 计算出实际事件在数组中的索引。
for(i = startIdx; i < numOfSock; i++)
{
    int sigEventIdx = WSAWaitForMultipleEvents(1, &hEventArray[i], TRUE, 0, FALSE);
    ...
}
  • 遍历事件数组:从第一个 signaled 的事件开始,遍历剩余的事件,检查它们是否已经被触发。

  • WSAWaitForMultipleEvents(1, &hEventArray[i], TRUE, 0, FALSE) 仅检查 hEventArray[i] 这个单个事件对象是否被触发。由于超时时间为 0,它不会等待,只会立即返回结果。

    • 1 表示等待的事件数量。
    • TRUE 表示等待所有传入的事件对象被触发,但由于这里只传了一个事件,所以它实际只是检查这个事件的状态。
    • 0 表示超时为 0 毫秒,即不等待立即返回。
    • FALSE 表示不允许进入可警报状态。

如果这个事件已经 signaled,则 sigEventIdx 返回 WSA_WAIT_EVENT_0,否则返回 WSA_WAIT_TIMEOUTWSA_WAIT_FAILED

(四)区分事件类型

下面的函数用于确定相应对象进入 signaled 状态的原因:

#include <winsock2.h>

// 成功时返回0,失败时返回 SOCKET_ERROR
int WSAEnumNetworkEvents(
    SOCKET s,                           // 套接字句柄
    WSAEVENT hEventObject,              // 可选的事件对象句柄
    LPWSANETWORKEVENTS lpNetworkEvents  // 指向 WSANETWORKEVENTS 结构的指针
);
  • s:表示套接字的句柄。该套接字应该与某个事件对象通过 WSAEventSelect 函数关联。

  • hEventObject:一个可选的事件对象句柄。如果这个参数非空,WSAEnumNetworkEvents 函数会自动将这个事件对象的状态重置(改为 non-signaled 状态)。 如果传递的是 NULL,那么该函数仅仅返回网络事件信息,而不重置任何事件。

  • lpNetworkEvents:指向 WSANETWORKEVENTS 结构的指针,用于接收套接字的事件信息。此结构包含了发生的网络事件及其对应的错误码。

  • WSANETWORKEVENTS 结构体

    typedef struct _WSANETWORKEVENTS {
        long lNetworkEvents;  // 事件的掩码(可以是多个事件的组合)
        int iErrorCode[FD_MAX_EVENTS];  // 每个事件对应的错误码
    } WSANETWORKEVENTS, *LPWSANETWORKEVENTS;
    
    

    上述结构体的 lNetworkEvents 成员将保存发生的事件信息,与 WSAEventSelect 函数的第三个参数相同,需要接收数据时,该成员为 FD_READ;有连接请求时,该成员为 FD_ACCEPT。因此,可通过如下方式查看发生的事件类型:

    WSANETWORKEVENTS netEvents;
    ...
    WSAEnumNetworkEvents(hSock, hEvent, &netEvents);
    if(netEvents.lNetworkEvents & FD_ACCEPT)
    {
        // FD_ACCEPT 事件的处理
    }
    
    if(netEvents.lNetworkEvents & FD_READ)
    {
        // FD_READ 事件的处理
    }
    

    另外,错误信息将保存在成员 iErrorCode 数组中:

    “如果发生 FD_XXX 相关错误,则在 iErrorCode[FD_XXX_BIT] 中保存除 0 以外的值。”

(五)利用异步通知 I/O 模型实现回声服务器端

#include <stdio.h>
#include <string.h>
#include <winsock2.h>

#define BUF_SIZE 100

void CompressSockets(SOCKET hSockArr[], int idx, int total);
void CompressEvents(WSAEVENT hEventArr[], int idx, int total);
void ErrorHandling(char *msg);

int main(int argc, char *argv[])
{
	WSADATA wsaData;
	SOCKET hServSock, hClntSock;
	SOCKADDR_IN servAdr, clntAdr;

	SOCKET hSockArr[WSA_MAXIMUM_WAIT_EVENTS]; 
	WSAEVENT hEventArr[WSA_MAXIMUM_WAIT_EVENTS];
	WSAEVENT newEvent;
	WSANETWORKEVENTS netEvents;

	int numOfClntSock=0;
	int strLen, i;
	int posInfo, startIdx;
	int clntAdrLen;
	char msg[BUF_SIZE];
	
	if(argc!=2) {
		printf("Usage: %s <port>\n", argv[0]);
		exit(1);
	}
	if(WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
		ErrorHandling("WSAStartup() error!");

	hServSock=socket(PF_INET, SOCK_STREAM, 0);
	memset(&servAdr, 0, sizeof(servAdr));
	servAdr.sin_family=AF_INET;
	servAdr.sin_addr.s_addr=htonl(INADDR_ANY);
	servAdr.sin_port=htons(atoi(argv[1]));

	if(bind(hServSock, (SOCKADDR*) &servAdr, sizeof(servAdr))==SOCKET_ERROR)
		ErrorHandling("bind() error");

	if(listen(hServSock, 5)==SOCKET_ERROR)
		ErrorHandling("listen() error");

	newEvent=WSACreateEvent();
	if(WSAEventSelect(hServSock, newEvent, FD_ACCEPT)==SOCKET_ERROR)
		ErrorHandling("WSAEventSelect() error");

	hSockArr[numOfClntSock]=hServSock;
	hEventArr[numOfClntSock]=newEvent;
	numOfClntSock++;

	while(1)
	{
		posInfo=WSAWaitForMultipleEvents(
			numOfClntSock, hEventArr, FALSE, WSA_INFINITE, FALSE);
		startIdx=posInfo-WSA_WAIT_EVENT_0;

		for(i=startIdx; i<numOfClntSock; i++)
		{
			int sigEventIdx=
				WSAWaitForMultipleEvents(1, &hEventArr[i], TRUE, 0, FALSE);
			if((sigEventIdx==WSA_WAIT_FAILED || sigEventIdx==WSA_WAIT_TIMEOUT))
			{
				continue;
			}
			else
			{
				sigEventIdx=i;
				WSAEnumNetworkEvents(
					hSockArr[sigEventIdx], hEventArr[sigEventIdx], &netEvents);
				if(netEvents.lNetworkEvents & FD_ACCEPT)
				{
					if(netEvents.iErrorCode[FD_ACCEPT_BIT]!=0)
					{
						puts("Accept Error");
						break;
					}
					clntAdrLen=sizeof(clntAdr);
					hClntSock=accept(
						hSockArr[sigEventIdx], (SOCKADDR*)&clntAdr, &clntAdrLen);
					newEvent=WSACreateEvent();
					WSAEventSelect(hClntSock, newEvent, FD_READ|FD_CLOSE);

					hEventArr[numOfClntSock]=newEvent;
					hSockArr[numOfClntSock]=hClntSock;
					numOfClntSock++;
					puts("connected new client...");
				}

				if(netEvents.lNetworkEvents & FD_READ)
				{
					if(netEvents.iErrorCode[FD_READ_BIT]!=0)
					{
						puts("Read Error");
						break;
					}
					strLen=recv(hSockArr[sigEventIdx], msg, sizeof(msg), 0);
					send(hSockArr[sigEventIdx], msg, strLen, 0);
				}

				if(netEvents.lNetworkEvents & FD_CLOSE)
				{
					if(netEvents.iErrorCode[FD_CLOSE_BIT]!=0)	
					{
						puts("Close Error");
						break;
					}
					WSACloseEvent(hEventArr[sigEventIdx]);
					closesocket(hSockArr[sigEventIdx]);
					
					numOfClntSock--;
					CompressSockets(hSockArr, sigEventIdx, numOfClntSock);
					CompressEvents(hEventArr, sigEventIdx, numOfClntSock);
				}
			}
		}
	}
	WSACleanup();
	return 0;
}

void CompressSockets(SOCKET hSockArr[], int idx, int total)
{
	int i;
	for(i=idx; i<total; i++)
		hSockArr[i]=hSockArr[i+1];
}
void CompressEvents(WSAEVENT hEventArr[], int idx, int total)
{
	int i;
	for(i=idx; i<total; i++)
		hEventArr[i]=hEventArr[i+1];
}
void ErrorHandling(char *msg)
{	
	fputs(msg, stderr);
	fputc('\n', stderr);
	exit(1);
}

代码解析

1. 初始化 Winsock 库

if(WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
    ErrorHandling("WSAStartup() error!");

WSAStartup 初始化 Winsock 库,程序需要在使用任何 Winsock API 之前进行此操作。

2. 创建服务器套接字并绑定

hServSock = socket(PF_INET, SOCK_STREAM, 0);
memset(&servAdr, 0, sizeof(servAdr));
servAdr.sin_family = AF_INET;
servAdr.sin_addr.s_addr = htonl(INADDR_ANY);
servAdr.sin_port = htons(atoi(argv[1]));
  • 创建服务器套接字 hServSock,使用 IPv4 和 TCP 协议。
  • 配置服务器地址为本地 IP 和命令行参数中指定的端口号。

3. 监听客户端连接

if (listen(hServSock, 5) == SOCKET_ERROR)
    ErrorHandling("listen() error");

开始监听客户端连接,允许最多 5 个连接请求等待队列。

4. 事件驱动机制

newEvent = WSACreateEvent();
if (WSAEventSelect(hServSock, newEvent, FD_ACCEPT) == SOCKET_ERROR)
    ErrorHandling("WSAEventSelect() error");

为服务器套接字 hServSock 创建一个事件对象 newEvent,并设置它监听 FD_ACCEPT 事件,表示有客户端连接到来时会触发事件。

5. 等待多个事件

posInfo = WSAWaitForMultipleEvents(numOfClntSock, hEventArr, FALSE, WSA_INFINITE, FALSE);
startIdx = posInfo - WSA_WAIT_EVENT_0;
  • 使用 WSAWaitForMultipleEvents 等待多个套接字事件。numOfClntSock 表示当前监听的套接字数量,hEventArr 是事件数组。
  • WSAWaitForMultipleEvents 返回触发事件的位置,然后计算出事件的索引 startIdx

6. 处理网络事件

通过 WSAEnumNetworkEvents 来枚举并处理套接字上发生的具体网络事件。

  • FD_ACCEPT:当有新的客户端连接时,通过 accept() 接受连接,并为新连接创建一个新的事件对象。

    if (netEvents.lNetworkEvents & FD_ACCEPT) {
        hClntSock = accept(hSockArr[sigEventIdx], (SOCKADDR*)&clntAdr, &clntAdrLen);
        newEvent = WSACreateEvent();
        WSAEventSelect(hClntSock, newEvent, FD_READ | FD_CLOSE);
    }
    
  • FD_READ:当套接字上有数据可读时,使用 recv() 读取数据,然后通过 send() 将数据发送回客户端。

    if (netEvents.lNetworkEvents & FD_READ) {
        strLen = recv(hSockArr[sigEventIdx], msg, sizeof(msg), 0);
        send(hSockArr[sigEventIdx], msg, strLen, 0);
    }
    
  • FD_CLOSE:当客户端关闭连接时,关闭相应的套接字并移除事件对象。

    if (netEvents.lNetworkEvents & FD_CLOSE) {
        WSACloseEvent(hEventArr[sigEventIdx]);
        closesocket(hSockArr[sigEventIdx]);
        numOfClntSock--;
        CompressSockets(hSockArr, sigEventIdx, numOfClntSock);
        CompressEvents(hEventArr, sigEventIdx, numOfClntSock);
    }
    

7. 压缩套接字和事件数组

当客户端关闭连接后,套接字和事件数组中会留下空位,因此使用 CompressSocketsCompressEvents 函数来压缩数组,移除已关闭的套接字和事件。

void CompressSockets(SOCKET hSockArr[], int idx, int total) {
    for (int i = idx; i < total; i++)
        hSockArr[i] = hSockArr[i + 1];
}

问答

(一)说明 select 函数和 WSAEventSelect 函数在使用上的差异

特性selectWSAEventSelect
平台支持跨平台(Windows 和 Unix 系统)仅 Windows 平台
监控机制轮询(遍历 fd_set事件驱动机制(操作系统通过事件对象通知)
效率对大量套接字效率较低对大量套接字更高效
使用的对象通过 fd_set 来监控文件描述符使用事件对象关联套接字
异步操作通过非阻塞模式实现异步操作原生支持异步操作,使用事件通知
套接字类型支持文件描述符(跨平台)仅支持套接字
事件种类通过 FD_SET 指定可读、可写、异常事件支持更丰富的网络事件(如 FD_ACCEPTFD_READ 等)
  • select 是一种较为基础的多路复用 I/O 监控机制,适用于较小规模的网络编程,并且具有跨平台兼容性,但随着监控套接字数量的增加,性能会显著下降。

  • WSAEventSelect 采用事件驱动的异步 I/O 机制,特别适合大量并发连接的网络编程,在 Windows 平台上使用更为高效,但只能在 Windows 上使用。

(二)epoll 可以在条件触发和边缘触发这2种方式下工作。那种更适合异步 I/O 模型?

对于需要处理大量并发 I/O 操作的异步 I/O 模型,边缘触发(ET) 通常是更合适的选择,原因如下:

  • 减少系统调用次数:边缘触发模式减少了重复的事件通知,这意味着你可以减少 epoll_wait 调用次数,从而减少系统调用的开销。

  • 提高效率:在高并发的网络应用中,边缘触发模式能够有效地减少不必要的事件通知,提高系统的整体处理效率。

  • 适配异步处理:异步 I/O 模型通常依赖于高效的事件通知机制,以避免轮询或过度的同步等待。边缘触发模式更符合这一需求,因为它允许程序在事件到达时进行处理,而不会因为事件未完全处理完而重复收到通知。

(三)为什么异步通知 I/O 模型中的事件对象必须是 manual-reset 模式?

  • 确保所有线程都能接收到事件

    • 在异步通知 I/O 模型中,一个事件对象通常用于通知多个线程或处理多个 I/O 操作。当事件对象处于信号状态时,所有等待这个事件的线程都会被唤醒。如果事件对象是 auto-reset(自动重置)模式,则一旦一个线程被唤醒,事件对象会被自动重置为非信号状态,这会导致其他线程无法再次接收到相同的事件通知。
    • Manual-reset 事件对象允许事件状态保持为信号状态,直到显式重置,这确保了所有等待该事件的线程都能被通知到,避免遗漏事件通知。
  • 适合处理高并发

    • 在高并发的异步 I/O 模型中,可能有大量的线程或 I/O 操作需要处理相同的事件。使用 manual-reset 事件对象可以减少对事件对象状态的频繁修改,提高系统效率,避免因事件状态自动重置导致的复杂同步问题。
  • 减少事件通知的复杂性

    • 使用 manual-reset 事件对象简化了事件通知机制,因为不需要担心事件对象状态会被自动重置,从而避免了可能的 race condition(竞争条件)和线程同步问题。程序可以显式地控制何时重置事件对象,并且确保所有需要的线程都能正确地响应事件通知。