理解异步通知 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 的工作原理
- 发起 I/O 请求:程序向操作系统发出一个异步 I/O 请求(例如读取数据或写入文件)。
- 立即返回:操作系统不阻塞程序,立即将控制权返回给程序,程序可以继续执行其他任务。
- 通知机制:当 I/O 操作完成时,操作系统通过通知机制告知程序操作已完成。程序随后处理 I/O 操作的结果。
- 处理结果:程序通过回调函数、信号处理函数或事件检测处理操作结果。
实现异步通知 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_TIMEOUT 或 WSA_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. 压缩套接字和事件数组
当客户端关闭连接后,套接字和事件数组中会留下空位,因此使用 CompressSockets 和 CompressEvents 函数来压缩数组,移除已关闭的套接字和事件。
void CompressSockets(SOCKET hSockArr[], int idx, int total) {
for (int i = idx; i < total; i++)
hSockArr[i] = hSockArr[i + 1];
}
问答
(一)说明 select 函数和 WSAEventSelect 函数在使用上的差异
| 特性 | select | WSAEventSelect |
|---|---|---|
| 平台支持 | 跨平台(Windows 和 Unix 系统) | 仅 Windows 平台 |
| 监控机制 | 轮询(遍历 fd_set) | 事件驱动机制(操作系统通过事件对象通知) |
| 效率 | 对大量套接字效率较低 | 对大量套接字更高效 |
| 使用的对象 | 通过 fd_set 来监控文件描述符 | 使用事件对象关联套接字 |
| 异步操作 | 通过非阻塞模式实现异步操作 | 原生支持异步操作,使用事件通知 |
| 套接字类型 | 支持文件描述符(跨平台) | 仅支持套接字 |
| 事件种类 | 通过 FD_SET 指定可读、可写、异常事件 | 支持更丰富的网络事件(如 FD_ACCEPT、FD_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(竞争条件)和线程同步问题。程序可以显式地控制何时重置事件对象,并且确保所有需要的线程都能正确地响应事件通知。