理解异步通知 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(竞争条件)和线程同步问题。程序可以显式地控制何时重置事件对象,并且确保所有需要的线程都能正确地响应事件通知。