基于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 的工作流程

先上一张文字版流程,后面代码就围绕它展开:

  1. WSACreateEvent 创建手工重置事件对象;
  2. WSAEventSelect(s, hEvent, 事件掩码) 把套接字与事件对象关联,并注册关心的事件(FD_ACCEPT、FD_READ、FD_CONNECT、FD_CLOSE 等)。注意:这个调用会自动把套接字设为非阻塞模式;
  3. WSAWaitForMultipleEvents 阻塞等待事件数组,返回首个有信号事件的下标;
  4. WSAEnumNetworkEvents 枚举具体发生了哪种网络事件,并自动复位事件对象;
  5. 按 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。

posted @ 2026-11-04 11:17  我今天真的要早睡了  阅读(3)  评论(0)    收藏  举报