基于WSAEventSelect模型的通信程序设计
WSAEventSelect 模型实战:从零实现一个水仙花数查询的 C/S 通信程序
实验环境:Windows 11 + Visual Studio 2022
技术栈:Winsock2、WSAEventSelect 异步 I/O 模型、C++ 控制台程序
实验目标:在同一台电脑上模拟服务器端与客户端双向通信,客户端发送“请输出从1到1000内所有的水仙花数”,服务器计算并回传结果。
一、为什么选 WSAEventSelect
Windows Sockets 提供了多种 I/O 模型:阻塞模型、select 模型、WSAEventSelect 模型、WSAAsyncSelect 模型、重叠 I/O 与 IOCP。阻塞模型简单但一个线程只能服务一个连接;select 模型需要每次轮询套接字集合,连接数多时效率下降;WSAEventSelect 则把“网络事件发生”转化为“内核事件对象有信号”,用 WSAWaitForMultipleEvents 统一等待,既避免了忙等,也让事件与套接字一一对应,结构清晰,非常适合中小规模连接的教学与实践。
本实验用两个控制台程序分别扮演服务器和客户端,通过本地回环地址 127.0.0.1:8888 通信,完整走一遍 WSAEventSelect 的五个环节:创建事件对象、绑定套接字、等待事件、枚举事件、分派处理。
二、WSAEventSelect 的工作流程
先上一张文字版流程,后面代码就围绕它展开:
WSACreateEvent创建手工重置事件对象;WSAEventSelect(s, hEvent, 事件掩码)把套接字与事件对象关联,并注册关心的事件(FD_ACCEPT、FD_READ、FD_CONNECT、FD_CLOSE等)。注意:这个调用会自动把套接字设为非阻塞模式;WSAWaitForMultipleEvents阻塞等待事件数组,返回首个有信号事件的下标;WSAEnumNetworkEvents枚举具体发生了哪种网络事件,并自动复位事件对象;- 按
lNetworkEvents的位标志分派处理,处理前先检查iErrorCode对应位是否为 0。
一个容易踩的坑:WSAEventSelect 之后,connect、recv、send 都可能立即返回 WSAEWOULDBLOCK,真正的结果要靠事件通知。客户端的 connect 尤其典型——调用只负责“发起”,连接成败由 FD_CONNECT 事件告知。
三、服务器端设计
服务器端要做四件事:监听端口、接受连接、接收请求、计算并回传水仙花数。
关键代码片段(完整代码见文末):
// 监听套接字绑定 FD_ACCEPT 事件
eventArray[eventCount] = WSACreateEvent();
sockArray[eventCount] = listenSock;
WSAEventSelect(listenSock, eventArray[eventCount], FD_ACCEPT);
eventCount++;
while (true) {
DWORD index = WSAWaitForMultipleEvents(eventCount, eventArray, FALSE, WSA_INFINITE, FALSE);
index -= WSA_WAIT_EVENT_0;
for (int i = index; i < eventCount; i++) {
if (WSAWaitForMultipleEvents(1, &eventArray[i], TRUE, 0, FALSE) == WSA_WAIT_TIMEOUT)
continue;
WSANETWORKEVENTS netEvents;
WSAEnumNetworkEvents(sockArray[i], eventArray[i], &netEvents);
if (netEvents.lNetworkEvents & FD_ACCEPT) {
// accept 新连接,为新套接字注册 FD_READ | FD_CLOSE,加入事件数组
}
if (netEvents.lNetworkEvents & FD_READ) {
// recv 请求,计算水仙花数,send 回传
}
if (netEvents.lNetworkEvents & FD_CLOSE) {
// 关闭套接字、释放事件、从数组移除
}
}
}
水仙花数判定逻辑:
bool isNarcissistic(int n) {
if (n < 100 || n > 999) return false;
int a = n / 100;
int b = (n / 10) % 10;
int c = n % 10;
return n == (a*a*a + b*b*b + c*c*c);
}
100 到 999 遍历后得到结果:153、370、371、407。
事件数组的管理是服务器端的难点:新连接到来时动态 WSACreateEvent 并注册事件、加入数组;连接关闭时 closesocket + WSACloseEvent 并从数组移除。移除时用“末尾元素覆盖被删元素”的方式并修正下标,避免数组空洞。
四、客户端设计
客户端流程更短:创建套接字 → 创建事件对象 → WSAEventSelect 注册 FD_CONNECT | FD_READ | FD_CLOSE → connect → 等待事件。
WSAEVENT event = WSACreateEvent();
WSAEventSelect(clientSock, event, FD_CONNECT | FD_READ | FD_CLOSE);
connect(clientSock, (sockaddr*)&serverAddr, sizeof(serverAddr));
while (true) {
DWORD index = WSAWaitForMultipleEvents(1, &event, FALSE, WSA_INFINITE, FALSE);
WSANETWORKEVENTS netEvents;
WSAEnumNetworkEvents(clientSock, event, &netEvents);
if (netEvents.lNetworkEvents & FD_CONNECT) {
if (netEvents.iErrorCode[FD_CONNECT_BIT] == 0) {
std::string request = "请输出从1到1000内所有的水仙花数";
send(clientSock, request.c_str(), (int)request.length(), 0);
}
}
if (netEvents.lNetworkEvents & FD_READ) {
char buffer[4096] = {0};
int ret = recv(clientSock, buffer, sizeof(buffer) - 1, 0);
if (ret > 0) {
std::cout << buffer << std::endl;
break;
}
}
if (netEvents.lNetworkEvents & FD_CLOSE) break;
}
recv 返回值要区分三种情况:大于 0 表示读到数据,等于 0 表示对端关闭,小于 0 表示出错(非阻塞下可能是 WSAEWOULDBLOCK,但在 FD_READ 分支里通常不会出现)。
五、运行结果
启动顺序很重要:先服务器,后客户端。
服务器启动后输出:
服务器已启动,监听端口 8888,等待客户端连接...
客户端运行后输出:
正在连接服务器...
连接成功,发送请求...
收到服务器回应:
1到1000内的水仙花数:153 370 371 407
服务器端同步输出:
客户端已连接。
收到客户端请求:请输出从1到1000内所有的水仙花数
计算结果:1到1000内的水仙花数:153 370 371 407
关闭客户端后,服务器端捕获 FD_CLOSE,输出“连接已关闭,当前连接数:0”,资源被正确释放。
六、踩过的坑
坑一:先启动客户端导致连接失败。 客户端先跑时服务器还没监听,FD_CONNECT 事件错误码非零。调整为“先服务器后客户端”即可。
坑二:误以为 connect 立即返回就是失败。 WSAEventSelect 把套接字置为非阻塞后,connect 必然立即返回 WSAEWOULDBLOCK,这是正常现象,连接结果要看 FD_CONNECT 事件。代码逻辑按此调整后运行正常。
坑三:中文乱码。 VS 2022 默认源文件可能是 UTF-8,控制台默认 GBK。通过统一源文件编码与项目字符集设置解决。如果只是实验演示,乱码不影响水仙花数计算和事件模型验证。
坑四:事件数组移除时的下标问题。 用末尾元素覆盖被删元素后,循环下标要回退一步,否则会跳过刚被覆盖过来的那个事件。
七、WSAEventSelect 的优缺点
优点:
- 事件与套接字一一对应,编程结构清晰;
- 无需轮询,避免忙等;
- 相比 select 突破了
FD_SETSIZE的限制,可管理更多套接字; - 事件对象可以与其他内核对象一起被
WaitForMultipleObjects等待,便于与线程、事件等同步机制配合。
缺点:
WSAWaitForMultipleEvents最多等待 64 个事件对象,大规模并发需要多线程分组或改用 IOCP;- 事件仍在主线程串行处理,单个事件处理耗时会影响整体响应;
- 非阻塞语义要求开发者仔细处理
WSAEWOULDBLOCK和错误码。
对于教学实验和中小规模连接场景,WSAEventSelect 是一个兼顾清晰与效率的好选择;要追求高并发,IOCP 才是更合适的终点。
八、完整代码
服务器端 Server.cpp
#define _WINSOCK_DEPRECATED_NO_WARNINGS
#include <winsock2.h>
#include <ws2tcpip.h>
#include <iostream>
#include <string>
#include <sstream>
#pragma comment(lib, "ws2_32.lib")
#define PORT 8888
#define MAX_EVENTS 10
bool isNarcissistic(int n) {
if (n < 100 || n > 999) return false;
int a = n / 100;
int b = (n / 10) % 10;
int c = n % 10;
return n == (a*a*a + b*b*b + c*c*c);
}
int main() {
WSADATA wsaData;
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) {
std::cout << "WSAStartup failed." << std::endl;
return 1;
}
SOCKET listenSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (listenSock == INVALID_SOCKET) {
std::cout << "socket failed: " << WSAGetLastError() << std::endl;
WSACleanup();
return 1;
}
sockaddr_in serverAddr{};
serverAddr.sin_family = AF_INET;
serverAddr.sin_addr.s_addr = INADDR_ANY;
serverAddr.sin_port = htons(PORT);
if (bind(listenSock, (sockaddr*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR) {
std::cout << "bind failed: " << WSAGetLastError() << std::endl;
closesocket(listenSock);
WSACleanup();
return 1;
}
if (listen(listenSock, SOMAXCONN) == SOCKET_ERROR) {
std::cout << "listen failed: " << WSAGetLastError() << std::endl;
closesocket(listenSock);
WSACleanup();
return 1;
}
std::cout << "服务器已启动,监听端口 " << PORT << ",等待客户端连接..." << std::endl;
WSAEVENT eventArray[MAX_EVENTS];
SOCKET sockArray[MAX_EVENTS];
int eventCount = 0;
eventArray[eventCount] = WSACreateEvent();
sockArray[eventCount] = listenSock;
WSAEventSelect(listenSock, eventArray[eventCount], FD_ACCEPT);
eventCount++;
while (true) {
DWORD index = WSAWaitForMultipleEvents(eventCount, eventArray, FALSE, WSA_INFINITE, FALSE);
if (index == WSA_WAIT_FAILED) break;
index = index - WSA_WAIT_EVENT_0;
for (int i = index; i < eventCount; i++) {
if (WSAWaitForMultipleEvents(1, &eventArray[i], TRUE, 0, FALSE) == WSA_WAIT_TIMEOUT) {
continue;
}
WSANETWORKEVENTS netEvents;
WSAEnumNetworkEvents(sockArray[i], eventArray[i], &netEvents);
if (netEvents.lNetworkEvents & FD_ACCEPT) {
if (netEvents.iErrorCode[FD_ACCEPT_BIT] == 0) {
SOCKET clientSock = accept(listenSock, NULL, NULL);
if (clientSock != INVALID_SOCKET) {
std::cout << "客户端已连接。" << std::endl;
if (eventCount < MAX_EVENTS) {
eventArray[eventCount] = WSACreateEvent();
sockArray[eventCount] = clientSock;
WSAEventSelect(clientSock, eventArray[eventCount], FD_READ | FD_CLOSE);
eventCount++;
} else {
std::cout << "事件数组已满,拒绝新连接。" << std::endl;
closesocket(clientSock);
}
}
}
}
if (netEvents.lNetworkEvents & FD_READ) {
if (netEvents.iErrorCode[FD_READ_BIT] == 0) {
char buffer[1024] = {0};
int ret = recv(sockArray[i], buffer, sizeof(buffer) - 1, 0);
if (ret > 0) {
std::cout << "收到客户端请求:" << buffer << std::endl;
std::stringstream resultStream;
resultStream << "1到1000内的水仙花数:";
for (int n = 100; n <= 999; n++) {
if (isNarcissistic(n)) {
resultStream << n << " ";
}
}
std::string result = resultStream.str();
std::cout << "计算结果:" << result << std::endl;
send(sockArray[i], result.c_str(), (int)result.length(), 0);
} else if (ret == 0) {
std::cout << "客户端断开连接。" << std::endl;
}
}
}
if (netEvents.lNetworkEvents & FD_CLOSE) {
closesocket(sockArray[i]);
WSACloseEvent(eventArray[i]);
sockArray[i] = sockArray[eventCount - 1];
eventArray[i] = eventArray[eventCount - 1];
eventCount--;
i--;
std::cout << "连接已关闭,当前连接数:" << (eventCount - 1) << std::endl;
}
}
}
for (int i = 0; i < eventCount; i++) {
closesocket(sockArray[i]);
WSACloseEvent(eventArray[i]);
}
WSACleanup();
return 0;
}
客户端 Client.cpp
#define _WINSOCK_DEPRECATED_NO_WARNINGS
#include <winsock2.h>
#include <ws2tcpip.h>
#include <iostream>
#include <string>
#pragma comment(lib, "ws2_32.lib")
#define SERVER_IP "127.0.0.1"
#define PORT 8888
int main() {
WSADATA wsaData;
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) {
std::cout << "WSAStartup failed." << std::endl;
return 1;
}
SOCKET clientSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (clientSock == INVALID_SOCKET) {
std::cout << "socket failed: " << WSAGetLastError() << std::endl;
WSACleanup();
return 1;
}
sockaddr_in serverAddr{};
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(PORT);
inet_pton(AF_INET, SERVER_IP, &serverAddr.sin_addr);
WSAEVENT event = WSACreateEvent();
WSAEventSelect(clientSock, event, FD_CONNECT | FD_READ | FD_CLOSE);
connect(clientSock, (sockaddr*)&serverAddr, sizeof(serverAddr));
std::cout << "正在连接服务器..." << std::endl;
while (true) {
DWORD index = WSAWaitForMultipleEvents(1, &event, FALSE, WSA_INFINITE, FALSE);
if (index == WSA_WAIT_FAILED) break;
WSANETWORKEVENTS netEvents;
WSAEnumNetworkEvents(clientSock, event, &netEvents);
if (netEvents.lNetworkEvents & FD_CONNECT) {
if (netEvents.iErrorCode[FD_CONNECT_BIT] == 0) {
std::cout << "连接成功,发送请求..." << std::endl;
std::string request = "请输出从1到1000内所有的水仙花数";
send(clientSock, request.c_str(), (int)request.length(), 0);
} else {
std::cout << "连接失败,错误码:" << netEvents.iErrorCode[FD_CONNECT_BIT] << std::endl;
break;
}
}
if (netEvents.lNetworkEvents & FD_READ) {
if (netEvents.iErrorCode[FD_READ_BIT] == 0) {
char buffer[4096] = {0};
int ret = recv(clientSock, buffer, sizeof(buffer) - 1, 0);
if (ret > 0) {
std::cout << "收到服务器回应:" << std::endl;
std::cout << buffer << std::endl;
break;
}
}
}
if (netEvents.lNetworkEvents & FD_CLOSE) {
std::cout << "服务器关闭了连接。" << std::endl;
break;
}
}
closesocket(clientSock);
WSACloseEvent(event);
WSACleanup();
std::cout << "按回车键退出..." << std::endl;
std::cin.get();
return 0;
}
九、总结
这次实验把 WSAEventSelect 从文档里的五个 API 变成了一个能跑起来的双向通信程序。最大的收获是理解了“调用只发起、事件才通知结果”的异步思维:connect 立即返回不是失败,WSAWaitForMultipleEvents 有信号只说明“有事发生”,真正发生了什么要看 WSAEnumNetworkEvents。事件数组的增删、非阻塞返回值的判断、启动顺序这些细节,也让我意识到网络程序的正确性必须靠多轮运行和窗口对照来验证,而不是只靠阅读代码。
后续可以尝试的方向:用线程池分组管理超过 64 个事件对象、加入 FD_WRITE 处理大块数据发送、或者直接对比 IOCP 模型在高并发下的表现。
本文代码在 Windows 11 + Visual Studio 2022 下编译运行通过,使用 Winsock 2.2,链接 ws2_32.lib。

浙公网安备 33010602011771号