Windows下C语言Socket通信:select/send/recv异常行为排查
C语言TCP服务器-客户端PING/PONG异常问题分析与解决
问题背景
开发C语言服务器-客户端程序,需求为:客户端连接服务器后,服务器定时向客户端发送PING消息。测试中遇到select、send和recv的异常行为。
服务器核心代码
while (1) { read_set = set; write_set = set; error_set = set; timeout = ref_timeout; // Windows平台下select第一个参数为NULL int result = select(NULL, &read_set, &write_set, &error_set, &timeout); if (result < 0) { puts("Error select"); return 1; } if (result == 0) puts("timed out"); else { for (int index = 0; index < 2; index++) { SOCKET i = sockets[index]; if (FD_ISSET(i, &error_set)) { printf("%zu error\n", i); FD_CLR(i, &set); closesocket(i); } if (FD_ISSET(i, &write_set)) { const char* ping = "PING"; int size = send(i, ping, strlen(ping)+1, 0); printf("%d bytes sent\n", size); if (size < 0) { puts("Error send"); closesocket(i); FD_CLR(i, &set); } send(i, NULL, 0, 0); } if (FD_ISSET(i, &read_set)) { if (i == listen_socket){ struct sockaddr addr; int addrlen; SOCKET new_socket = accept(listen_socket, &addr, &addrlen); FD_SET(new_socket, &set); if (new_socket > biggest) biggest = new_socket; printf("new conn %zu\n", new_socket); sockets[1] = new_socket; } else { char msg[100]; memset(msg, 0, sizeof(msg)); if (recv(i, msg, sizeof(msg), 0) < 0) { closesocket(i); FD_CLR(i, &set); } else { printf("%zu %s\n", i, msg); } } } } } }
客户端核心代码
char buffer[100]; const char* pong = "PONG"; for (int i = 0; i < 3; i++) { memset(buffer, 0, sizeof(buffer)); int result = recv(client_socket, buffer, sizeof(buffer), 0); if (result < 0) { puts("something went wrong receiving"); closesocket(client_socket); return 1; } if (send(client_socket, pong, strlen(pong)+1, 0) < 0) { puts("something went wrong sending"); return 1; } printf("msg: %s\n", buffer); Sleep(10000); }
预期与实际行为
- 预期行为:客户端阻塞在recv时,服务器select检测到客户端socket处于write_set,发送5字节PING消息;接收客户端的PONG后,阻塞直到客户端下一次准备接收,定时重复该流程。
- 实际异常:第一次recv后,服务器持续发送5字节PING消息,虽能正确接收PONG,但无法停止发送;杀死客户端进程后,send立即失败。
疑问
这是Windows平台特性吗?还是对select的用法理解有误?如何让客户端告知服务器暂时不接收消息?
问题分析与解决
1. 异常原因:对select的write_set逻辑理解错误
这不是Windows平台特性,是对select机制的误用:
- select的
write_set触发条件是:socket的发送缓冲区有可用空间(默认情况下,只要连接正常且缓冲区未满,就会处于可写状态)。 - 客户端Sleep期间,TCP连接保持正常,服务器的发送缓冲区很快就会被清空(客户端TCP栈会接收数据并存入接收缓冲区,直到缓冲区满),所以select会持续检测到客户端socket可写,导致服务器不断执行send操作。
- 服务器代码中每次循环都把
write_set设置为包含所有socket的set,包括监听socket(监听socket不需要写操作,完全没必要加入write_set),进一步放大了问题。
2. 核心修正方案:实现真正的定时发送逻辑
要实现“定时发送PING”,不能依赖write_set触发发送,而是要利用select的timeout来控制定时:
- 将
ref_timeout设置为你需要的定时间隔(比如5秒)。 - 当select返回0(即超时触发)时,遍历所有客户端socket,执行PING发送操作。
- 平时循环中只把需要处理读事件的socket加入
read_set,不需要写入时不要把客户端socket加入write_set。
示例修正后的服务器定时触发逻辑:
while (1) { read_set = set; // 不需要写入时,write_set初始化为空 FD_ZERO(&write_set); error_set = set; timeout = ref_timeout; int result = select(NULL, &read_set, &write_set, &error_set, &timeout); if (result < 0) { puts("Error select"); return 1; } // 超时触发:执行定时PING发送 if (result == 0) { puts("timed out, sending PING to clients..."); // 遍历所有客户端socket(排除监听socket) for (int index = 0; index < 2; index++) { SOCKET i = sockets[index]; if (i == listen_socket) continue; if (FD_ISSET(i, &set)) { // 确保socket仍在活跃集合中 const char* ping = "PING"; int size = send(i, ping, strlen(ping)+1, 0); printf("%d bytes sent to %zu\n", size, i); if (size < 0) { puts("Error send"); closesocket(i); FD_CLR(i, &set); } } } } else { // 处理读事件和错误事件(保留原有逻辑,移除write_set自动发送) for (int index = 0; index < 2; index++) { SOCKET i = sockets[index]; if (FD_ISSET(i, &error_set)) { printf("%zu error\n", i); FD_CLR(i, &set); closesocket(i); } if (FD_ISSET(i, &read_set)) { if (i == listen_socket){ struct sockaddr addr; int addrlen = sizeof(struct sockaddr); // 必须初始化addrlen SOCKET new_socket = accept(listen_socket, &addr, &addrlen); FD_SET(new_socket, &set); if (new_socket > biggest) biggest = new_socket; printf("new conn %zu\n", new_socket); sockets[1] = new_socket; } else { char msg[100]; memset(msg, 0, sizeof(msg)); int recv_len = recv(i, msg, sizeof(msg), 0); if (recv_len < 0) { closesocket(i); FD_CLR(i, &set); } else if (recv_len == 0) { // 客户端主动关闭连接 printf("%zu disconnected\n", i); closesocket(i); FD_CLR(i, &set); } else { printf("%zu %s\n", i, msg); } } } } } }
3. 客户端告知服务器暂停接收的实现方式
在应用层协议中设计控制逻辑:
- 客户端发送
"PAUSE"消息,服务器收到后暂停定时PING发送; - 客户端发送
"RESUME"消息,服务器恢复发送; - 客户端Sleep前发送
"PAUSE",Sleep结束后发送"RESUME"。
示例客户端修改:
char buffer[100]; const char* pong = "PONG"; const char* pause = "PAUSE"; const char* resume = "RESUME"; for (int i = 0; i < 3; i++) { memset(buffer, 0, sizeof(buffer)); int result = recv(client_socket, buffer, sizeof(buffer), 0); if (result < 0) { puts("something went wrong receiving"); closesocket(client_socket); return 1; } if (send(client_socket, pong, strlen(pong)+1, 0) < 0) { puts("something went wrong sending"); return 1; } printf("msg: %s\n", buffer); // 告知服务器暂停发送 send(client_socket, pause, strlen(pause)+1, 0); Sleep(10000); // 告知服务器恢复发送 send(client_socket, resume, strlen(resume)+1, 0); }
服务器端处理读事件时增加控制消息判断:
// ... 原有读事件处理逻辑 else { char msg[100]; memset(msg, 0, sizeof(msg)); int recv_len = recv(i, msg, sizeof(msg), 0); if (recv_len < 0) { closesocket(i); FD_CLR(i, &set); } else if (recv_len == 0) { printf("%zu disconnected\n", i); closesocket(i); FD_CLR(i, &set); } else { printf("%zu %s\n", i, msg); // 处理控制消息 static int client_paused[2] = {0}; if (strcmp(msg, "PAUSE") == 0) { client_paused[index] = 1; } else if (strcmp(msg, "RESUME") == 0) { client_paused[index] = 0; } } }
定时发送PING时检查暂停状态:
// 超时触发发送PING时 if (client_paused[index] == 0) { // 仅当未暂停时发送 const char* ping = "PING"; int size = send(i, ping, strlen(ping)+1, 0); // ... 后续处理 }
4. 其他代码优化点
accept调用时必须初始化addrlen为sizeof(struct sockaddr),否则可能导致未定义行为;- 移除无用的
send(i, NULL, 0, 0)调用; - 处理
recv返回0的情况(客户端主动关闭连接); - 不要用固定大小的
sockets数组,改用动态链表或可扩容数组管理客户端socket,避免覆盖问题。
内容的提问来源于stack exchange,提问作者Fred Esch
相关产品推荐
相关产品推荐

