从零实现基于 Select 模型的 TCP 通信:一次 Winsock 多路复用实验复盘
从零实现基于 Select 模型的 TCP 通信:一次 Winsock 多路复用实验复盘
最近完成了“基于 Select 模型的通信程序设计”实验。实验要求用 Win32 Winsock 编写服务端和客户端,实现两台计算机之间的双向通信:客户端发送“计算从 1 到 100 的奇数和”,服务端解析请求、计算结果并回送。看似是一个简单的 TCP Demo,但真正动手后,Select 模型中 fd_set 的构建、select 的阻塞与返回、FD_ISSET 的事件判断,以及网络联调中的各种环境问题,都很有代表性。
本文记录实验的设计思路、核心代码、运行效果和踩坑经验,适合作为 Winsock Select 入门参考。
一、实验目标
本次实验主要掌握:
- Winsock 套接字编程的基本流程:初始化、创建、绑定、监听、连接、收发、清理;
- Select IO 多路复用模型的基本原理;
- 使用
fd_set管理多个套接字,通过select等待可读事件; - 服务端与客户端之间完成一次请求—响应式通信。
实验最终实现的效果是:客户端发送请求后,服务端返回:
1到100的奇数和 = 2500
因为 1+3+5+...+99 = 50^2 = 2500。
二、Select 模型一句话原理
select() 允许程序同时监视多个套接字的可读、可写或异常状态。程序把所有关心的套接字加入 fd_set 集合,调用 select 阻塞等待;当至少一个套接字就绪时,select 返回,程序再用 FD_ISSET 逐一判断哪些套接字产生了事件,并做相应处理。
这样,即使只有一个线程,也能同时照看“监听套接字”和“已连接客户端套接字”:
-
监听套接字可读:说明有新客户端连接,调用 accept;
-
客户端套接字可读:说明有数据到达,调用 recv。
服务端主循环可以概括为:
FD_ZERO 清空集合
FD_SET 加入监听套接字
FD_SET 加入已连接客户端套接字
select 阻塞等待
FD_ISSET 判断事件来源
accept 或 recv/send 处理
循环往复
三、服务端设计
服务端文件为 SelectServer.cpp,整体流程如下:
1、WSAStartup 初始化 Winsock 2.2;
2、socket(AF_INET, SOCK_STREAM, 0) 创建 TCP 流式套接字;
3、bind 绑定本地 IP 和端口 8888;
4、listen 进入监听状态;
5、进入主循环,构建 fd_set 读集合;
6、调用 select 等待事件;
7、若监听套接字可读,accept 新连接;
8、若客户端套接字可读,recv 数据;
9、若收到指定请求,调用 CalcOddSum 计算并 send 回送;
10、退出时 closesocket 并 WSACleanup。
1. 奇数和计算函数
int CalcOddSum()
{
int sum = 0;
for (int i = 1; i <= 100; i++)
{
if (i % 2 == 1)
sum += i;
}
return sum;
}
2. Select 主循环核心代码
下面是整理后的核心逻辑,省略了部分错误处理:
fd_set fdRead;
SOCKET sockClient = INVALID_SOCKET;
SOCKADDR_IN addrClient;
int nAddrLen = sizeof(SOCKADDR_IN);
char buf[BUF_SIZE];
while (true)
{
FD_ZERO(&fdRead);
FD_SET(sockListen, &fdRead);
if (sockClient != INVALID_SOCKET)
FD_SET(sockClient, &fdRead);
int ret = select(0, &fdRead, NULL, NULL, NULL);
if (ret == SOCKET_ERROR)
{
std::cout << "select出错,错误码:" << WSAGetLastError() << std::endl;
break;
}
// 新客户端连接
if (FD_ISSET(sockListen, &fdRead))
{
sockClient = accept(sockListen, (SOCKADDR*)&addrClient, &nAddrLen);
if (sockClient == INVALID_SOCKET)
{
std::cout << "accept接收连接失败" << std::endl;
continue;
}
std::cout << "客户端 " << inet_ntoa(addrClient.sin_addr)
<< ":" << ntohs(addrClient.sin_port)
<< " 已连接" << std::endl;
}
// 客户端数据到达
if (sockClient != INVALID_SOCKET && FD_ISSET(sockClient, &fdRead))
{
ZeroMemory(buf, BUF_SIZE);
int recvLen = recv(sockClient, buf, BUF_SIZE, 0);
if (recvLen <= 0)
{
std::cout << "客户端断开连接" << std::endl;
closesocket(sockClient);
sockClient = INVALID_SOCKET;
continue;
}
std::cout << "收到客户端消息:" << buf << std::endl;
if (strstr(buf, "计算从1到100的奇数和") != nullptr)
{
int sum = CalcOddSum();
char sendBuf[BUF_SIZE];
sprintf_s(sendBuf, BUF_SIZE, "1到100的奇数和 = %d", sum);
send(sockClient, sendBuf, strlen(sendBuf), 0);
std::cout << "已向客户端返回结果:" << sendBuf << std::endl;
}
}
}
这里有一个非常关键的点:每轮循环都要重新 FD_ZERO 并重新 FD_SET。因为 select 返回时会就地修改 fd_set,把未就绪的套接字从集合中剔除。如果不重建集合,后续轮询就可能漏掉事件。这一点是本次实验中最值得记住的坑。
另外,当前实现只维护了一个 sockClient 变量,适合“两台计算机之间”的点对点通信。如果要支持多个客户端,应使用数组或 std::vector
四、客户端设计
客户端文件为 SelectClient.cpp,流程更简单:
1、WSAStartup 初始化;
2、创建套接字;
3、设置服务器地址和端口;
4、connect 连接服务器;
5、send 发送请求字符串;
6、recv 接收服务器返回结果并输出;
7、清理套接字和 Winsock 环境。
核心代码示意:
cpp
SOCKET sockClient = socket(AF_INET, SOCK_STREAM, 0);
SOCKADDR_IN addrServ;
addrServ.sin_family = AF_INET;
addrServ.sin_port = htons(PORT);
addrServ.sin_addr.s_addr = inet_addr("127.0.0.1"); // 联调可用回环地址
if (connect(sockClient, (SOCKADDR*)&addrServ, sizeof(SOCKADDR_IN)) == SOCKET_ERROR)
{
std::cout << "连接服务器失败!" << std::endl;
closesocket(sockClient);
WSACleanup();
return -1;
}
const char* request = "计算从1到100的奇数和";
send(sockClient, request, strlen(request), 0);
char buf[BUF_SIZE] = { 0 };
int recvLen = recv(sockClient, buf, BUF_SIZE, 0);
if (recvLen > 0)
{
std::cout << "服务器返回结果:" << buf << std::endl;
}
实验报告中客户端曾硬编码服务器 IP,联调时建议先用 127.0.0.1 验证本机通信,再换成局域网 IP 进行两台机器测试。
五、运行效果
服务端启动后输出:
服务器启动成功,端口:8888,等待客户端连接...
客户端 172.19.76.222:49670 已连接
收到客户端消息:计算从1到100的奇数和
已向客户端返回结果:1到100的奇数和 = 2500
客户端输出:
成功连接服务器!
发送指令:计算从1到100的奇数和
服务器返回结果:1到100的奇数和 = 2500
按回车退出...
实验结果符合预期,服务端正确解析请求并返回了 2500。
六、踩坑与经验记录
1. fd_set 每轮必须重建
这是 Select 模型最容易被忽略的细节。select 会修改传入的集合,只保留就绪的套接字。因此每次循环都要:
FD_ZERO(&fdRead);
FD_SET(sockListen, &fdRead);
if (sockClient != INVALID_SOCKET)
FD_SET(sockClient, &fdRead);
否则会出现“第一轮正常、后续漏事件”的问题。
2. recv 返回值要区分三种情况
-
大于 0:收到数据;
-
等于 0:对端正常关闭连接;
-
SOCKET_ERROR:出错,可用 WSAGetLastError() 查看错误码。
实验中在 recvLen <= 0 时关闭客户端套接字并置为 INVALID_SOCKET,避免继续把无效套接字加入 fd_set。
3. 联调失败先查环境,再查代码
联调时客户端 connect 失败,排查过程包括:
-
用 netstat 确认 8888 端口是否被占用;
-
检查 Windows 防火墙是否放行;
-
核对客户端硬编码 IP 与服务器是否一致;
-
确认两台机器是否在同一网段。
最后发现是网络环境问题,改用 127.0.0.1 回环测试后跑通。网络编程中,环境问题往往比代码问题更耗时,因此要养成对 socket、bind、listen、connect、select、accept、recv 等调用检查返回值的习惯。
4. strstr 做协议解析比较脆弱
服务端用 strstr(buf, "计算从1到100的奇数和") 匹配请求,简单直观,但协议很脆弱:多一个空格、错一个字、源文件编码不一致导致中文匹配失败,都会让请求无法识别。实验中就踩过源文件编码不一致的坑。
更健壮的做法是设计应用层协议,例如“定长包头 + 变长包体”,或者用分隔符明确报文边界。
5. TCP 是流式传输,注意粘包/半包
一次 send 不一定对应一次完整的 recv。本实验报文较短,问题没有暴露;如果返回字符串变长,就可能需要多次 recv 才能拼出完整数据。这正是“粘包/半包”问题的根源。后续可以加入长度字段和收包缓冲区来处理。
6. timeout = NULL 会永久阻塞
实验中将 select 的超时参数设为 NULL,逻辑简单,但程序会永久阻塞,无法感知客户端异常掉线。实际工程中可以设置超时,并配合心跳包检测连接状态。
七、局限与改进方向
Select 模型适合入门和小规模连接场景,但也有明显局限:
1、fd_set 受 FD_SETSIZE 上限约束;
2、每次调用都要把集合在用户态与内核态之间拷贝;
3、返回后需要遍历集合判断就绪套接字,连接数多时效率下降;
4、当前服务端只维护一个客户端套接字,扩展多客户端需要管理套接字列表。
后续可以继续学习:
1、WSAEventSelect:基于事件对象的 Select 变体;
2、WSAAsyncSelect:基于 Windows 消息;
3、IOCP:Windows 下高并发完成端口模型;
4、Linux 下的 epoll。
八、总结
这次实验用一个“计算 1 到 100 奇数和”的小需求,把 Winsock TCP 通信和 Select 多路复用串了起来。最大的收获不是写出了能跑的代码,而是理解了 Select 的事件驱动过程:构建集合 → 阻塞等待 → 判断就绪 → 分派处理 → 重建集合。
同时,联调过程中遇到的 IP 配置、防火墙、端口占用、中文编码、fd_set 重建等问题,也让我体会到网络编程不仅考验代码,更考验对环境和协议细节的理解。这个 Demo 虽然简单,但作为学习 WSAEventSelect、IOCP、epoll 等更高级 IO 模型的起点,非常合适。

浙公网安备 33010602011771号