☰
C++ SOCKET编程:从同步阻塞到异步非阻塞select模型实现多客户端服务端
2026/10/9 3:13:48 网站建设 项目流程

1. 同步阻塞与异步非阻塞的本质差异:先搞懂服务端为什么难写

前阵子有个刚转C++的同事跑来问我:为什么自己写的SOCKET服务端,第一个客户端连上来之后,第二个客户端就一直连不上?我一听就知道,这是掉进了同步阻塞recv的经典陷阱。他写的接收逻辑是这样的:在while循环里调用recv,只要没有数据返回,程序就卡在那里不动了,后面排队的客户端自然只能干等。这个问题的根源,就是没有分清C++ SOCKET编程里同步阻塞和异步非阻塞两种模式之间的本质差别。

同步阻塞的意思是:当你调用recv或send时,程序会一直停在那个函数里,直到有数据到达或者连接关闭为止。这个行为很像打电话——你拨出去之后,就捧着听筒等着,对方不说话你也不挂。这种方式最大的优点是逻辑简单、代码直接,特别适合做客户端;但放在服务端就麻烦了,因为服务端要同时照顾N个客户端,而一个阻塞的recv会直接把整个线程“钉死”在一路连接上,其他连接全部失去响应。

异步非阻塞则完全不同:socket被设置为非阻塞模式后,recv调用会立刻返回。有数据就返回数据长度,没数据就返回SOCKET_ERROR,并且WSAGetLastError()告诉你错误码是WSAEWOULDBLOCK(10035),意思是“现在没有数据,你过一会儿再来问”。这种行为和前台挂号很像——你挂完号不用等在窗口,先去办别的事,过段时间再来刷一下叫号屏就行。服务端正是利用这种“随时查、马上走”的特性,才能在一个线程内同时照看几十上百个客户端连接。

所以这篇内容的主线就很清楚了:先用同步阻塞+多线程的方式把服务端跑起来,理解最简单的“一连接一线程”模型;再移植到异步非阻塞+select的方式,感受单线程管理多个连接的优势。客户端也会给两个版本,分别对应两种模式。如果你正在学C++网络编程,或者工作中需要写一个轻量级的消息转发服务端,这篇内容可以直接参考复现。

2. Windows下Winsock环境准备:三个最容易卡住初学者的地方

既然目标平台是Windows,那就绕不开Winsock这套API。有不少人把Linux的socket代码抄到Windows上编译报错,根源就是Winsock需要额外的初始化和库文件依赖。

2.1 头文件、库文件与初始化

Windows下写socket代码,必须先包含<winsock2.h>,而且这个头文件必须放在<windows.h>之前,否则会出现一长串莫名其妙的宏重定义冲突。如果只是想写纯socket程序,干脆不要包含windows.h。链接库用ws2_32.lib,最省事的做法是在代码里加一行:

#pragma comment(lib, "ws2_32.lib")

如果你的工程是用CMake或Visual Studio管理的,也可以在工程属性里手动添加上这个依赖,效果一样。

所有socket调用之前,必须执行WSAStartup进行初始化:

WSADATA wsaData; int ret = WSAStartup(MAKEWORD(2, 2), &wsaData); if (ret != 0) { printf("WSAStartup失败: %d\n", ret); return -1; }

MAKEWORD(2, 2)表示请求使用2.2版本的Winsock库,这是目前最通用的版本。程序结束后别忘了WSACleanup()来释放资源。我见过一些人调试时发现端口一直被占用,最后才意识到是程序异常退出时没有调用WSACleanup。

2.2 socket创建时的协议参数

创建socket时有三个参数,很多人习惯写成socket(AF_INET, SOCK_STREAM, 0),第三个参数直接传0。其实第三个参数是协议号,传0意味着让系统根据前两个参数自动推导。对于TCP通信来说,更明确的写法是IPPROTO_TCP:

SOCKET listenSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

这样写的好处是意图清楚,不会因为不同平台对协议号的处理差异而产生歧义。SOCK_STREAM代表流式套接字,对应TCP传输;如果改成SOCK_DGRAM,对应的是UDP,整个通信逻辑会完全不同。

2.3 端口立即复用:避免“地址已被占用”

在服务端bind之前,强烈建议设置SO_REUSEADDR选项。Windows下有一种非常经典的报错:

Windows socket error: 通常每个套接字地址(协议/网络地址/端口)只允许使用一次。

这个错误经常发生在这种场景:服务端程序在前一次运行中还有连接处于TIME_WAIT状态,此时直接重新启动服务端,bind就会失败。TIME_WAIT是TCP协议在连接关闭后主动保留的一段状态,目的是让迟到的数据包在网络中销声匿迹,通常持续几十秒到几分钟。设置SO_REUSEADDR可以让处于TIME_WAIT的端口地址立刻被重新绑定:

BOOL enable = TRUE; setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR, (char*)&enable, sizeof(enable));

这个选项在开发调试阶段几乎是必须的,不然你每改一次代码重启服务端,都要等好一会儿才能再次绑定端口。

3. 同步阻塞版服务端:先用多线程把多个客户端跑通

3.1 架构思路:accept循环 + 独立线程处理

同步阻塞模式天然不支持“单线程同时管多个连接”,所以服务端必须采用“一个客户端分配一个线程”的思路。主线程的工作只有一个:死循环accept,每来一个客户端,就创建一个std::thread把它接走。具体处理逻辑全部在子线程里做。

这种做法在客户端数量不大时非常稳定,代码也最容易被理解。比如你做上位机软件、工具类服务端,或者内部管理系统,同时在线人数几十个以内,完全够用。它的缺点也很明显:线程数量会随客户端数量线性增长,每个线程都有默认栈空间(Windows下通常1MB),几千个连接就能耗尽内存资源。

3.2 完整的同步阻塞服务端代码

下面是我在实际项目中常用的一套基础框架,你先把它跑通,再按自己的业务去扩展:

#include <winsock2.h> #include <ws2tcpip.h> #include <thread> #include <cstdio> #include <cstring> #pragma comment(lib, "ws2_32.lib") void clientHandler(SOCKET clientSock) { char buf[1024]; while (true) { int ret = recv(clientSock, buf, sizeof(buf), 0); if (ret > 0) { buf[ret] = '\0'; printf("[线程id: %lu]收到数据(%d字节): %s\n", GetCurrentThreadId(), ret, buf); const char* reply = "server ack"; send(clientSock, reply, strlen(reply), 0); } else if (ret == 0) { printf("客户端关闭连接\n"); break; } else { int err = WSAGetLastError(); if (err == WSAECONNRESET) { printf("连接被对端强制重置\n"); } else { printf("recv出错: %d\n", err); } break; } } closesocket(clientSock); printf("客户端已释放,线程结束\n"); } int main() { WSADATA wsaData; WSAStartup(MAKEWORD(2, 2), &wsaData); SOCKET listenSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); BOOL enable = TRUE; setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR, (char*)&enable, sizeof(enable)); sockaddr_in addr; addr.sin_family = AF_INET; addr.sin_addr.s_addr = INADDR_ANY; addr.sin_port = htons(8888); if (bind(listenSock, (sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR) { printf("bind失败: %d\n", WSAGetLastError()); return -1; } if (listen(listenSock, SOMAXCONN) == SOCKET_ERROR) { printf("listen失败: %d\n", WSAGetLastError()); return -1; } printf("同步阻塞服务端已启动,监听端口 8888\n"); while (true) { SOCKET clientSock = accept(listenSock, NULL, NULL); if (clientSock == INVALID_SOCKET) { printf("accept失败: %d\n", WSAGetLastError()); continue; } printf("新客户端接入,启动处理线程\n"); std::thread(clientHandler, clientSock).detach(); } closesocket(listenSock); WSACleanup(); return 0; }

这段代码里有几个细节值得注意。recv的返回值有三种可能:大于0表示真正收到了数据;等于0表示对端主动关闭了连接;小于0表示出错。很多新手只判断ret > 0,忽略了ret == 0的情况,结果已经断开的连接还停在循环里空转。WSAECONNRESET(10054)是很常见的错误码,表示对端没有正常关闭socket而是直接重置了连接,比如对端程序崩溃时就会出现。

3.3 同步阻塞的两个真实痛点

第一个痛点是accept阻塞。主线程的accept只要没有新连接,就会一直卡住,这没问题;但如果有客户端非常缓慢地建立连接,主线程会频繁被唤醒,CPU占用率会有小幅波动。第二个痛点是线程资源。每个连接都需要一个线程,而线程之间的切换开销不小。当你开着1000个连接时,线程调度器会疲于奔命,性能反而比200个连接时下降很多。更麻烦的是,如果某个客户端的网络不稳定,recv迟迟不返回,这个线程就被白白占用着,什么活也干不了。

如果你的应用场景是“少量长连接、每条连接消息量大”,同步阻塞加多线程是很合适的。如果是“大量短连接、单条消息不大”,它就不太划算了。

4. 客户端代码:从最简阻塞到非阻塞的connect轮询

4.1 同步阻塞客户端:连接、发送、接收

客户端的代码相对简单,因为客户端通常只需要照顾一条连接。最基础的做法如下:

#include <winsock2.h> #include <ws2tcpip.h> #include <cstdio> #include <cstring> #pragma comment(lib, "ws2_32.lib") int main() { WSADATA wsaData; WSAStartup(MAKEWORD(2, 2), &wsaData); SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); sockaddr_in serverAddr; serverAddr.sin_family = AF_INET; inet_pton(AF_INET, "127.0.0.1", &serverAddr.sin_addr); serverAddr.sin_port = htons(8888); if (connect(sock, (sockaddr*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR) { printf("connect失败: %d\n", WSAGetLastError()); return -1; } printf("连接服务端成功\n"); const char* msg = "hello, server"; send(sock, msg, strlen(msg), 0); char buf[1024]; int n = recv(sock, buf, sizeof(buf) - 1, 0); if (n > 0) { buf[n] = '\0'; printf("服务端回复: %s\n", buf); } closesocket(sock); WSACleanup(); return 0; }

注意send和recv的缓冲区大小。发送端如果一次性发送的数据超过内核缓冲区(一般不会,但大量并发时可能出现),send会阻塞或者返回部分发送字节数。接收端buf给1024字节,如果对端返回的消息超过这个长度,消息会被截断。所以真实工程里要么使用足够大的固定缓冲,要么在业务协议里规定消息长度字段,数据收够了再处理。

4.2 把客户端切到非阻塞模式

如果你的客户端需要同时监听“用户输入”和“网络数据”,或者需要给connect加超时,就必须使用非阻塞模式。切换方式只需要一行:

u_long mode = 1; ioctlsocket(sock, FIONBIO, &mode);

执行完之后,该socket上的send、recv、connect都会变成非阻塞。此时再调用connect,返回值通常不是0,而是SOCKET_ERROR,WSAGetLastError()返回WSAEWOULDBLOCK。这并不意味着连接失败,而是表示“连接过程已开始,还没有完成,你需要稍后检查结果”。

4.3 非阻塞connect如何判断成功或失败

判断非阻塞connect是否成功,标准做法是用select监听可写事件:

// 假设sock已经设置为非阻塞模式 int ret = connect(sock, (sockaddr*)&serverAddr, sizeof(serverAddr)); if (ret == SOCKET_ERROR && WSAGetLastError() != WSAEWOULDBLOCK) { printf("connect立即失败: %d\n", WSAGetLastError()); return -1; } fd_set writefds; FD_ZERO(&writefds); FD_SET(sock, &writefds); // 设定3秒超时 timeval tv; tv.tv_sec = 3; tv.tv_usec = 0; ret = select(0, NULL, &writefds, NULL, &tv); if (ret <= 0) { printf("连接超时或失败\n"); return -1; } // sockets可写,还需要进一步确认连接是否真正成功 int err = 0; int errLen = sizeof(err); getsockopt(sock, SOL_SOCKET, SO_ERROR, (char*)&err, &errLen); if (err != 0) { printf("连接失败: %d\n", err); return -1; } printf("非阻塞connect成功\n");

这一段是很多教程里不会细讲的。仅凭select返回可写就认为连接成功是不够的,因为连接被拒绝时,socket也会变成可写状态,只是SO_ERROR会带上具体的错误码。必须用getsockopt读一次SO_ERROR,才能确认连接是否真正建立。

5. 异步非阻塞版服务端:用select单线程管理多客户端

5.1 select模型的核心原理

要在一个线程内管理多个非阻塞socket,最基础的工具是select。它的核心工作方式是把所有需要监听的socket放入一个fd_set集合,然后告诉操作系统的select函数:“你去盯着这一帮socket,只要其中任何一个有可读事件、可写事件或异常事件,你就告诉我。”select返回后,程序用FD_ISSET逐个检查是哪个socket就绪了,然后处理对应的读写。

这个模型和银行大厅的“叫号屏”非常像:客户不来服务台前站着,而是等广播叫到自己的号才去窗口。你要管理的不是每个客户本身,而是“当前有哪些客户排队到了处理条件”。

5.2 非阻塞服务端的完整实现

下面这套代码就是标题里“异步非阻塞通信服务端”的直接落地。监听socket和非阻塞,accept返回的客户端socket也要立即切换为非阻塞模式,否则后续的recv会把线程拖死。

#include <winsock2.h> #include <ws2tcpip.h> #include <cstdio> #include <cstring> #include <vector> #pragma comment(lib, "ws2_32.lib") int main() { WSADATA wsaData; WSAStartup(MAKEWORD(2, 2), &wsaData); SOCKET listenSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); BOOL enable = TRUE; setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR, (char*)&enable, sizeof(enable)); u_long mode = 1; ioctlsocket(listenSock, FIONBIO, &mode); sockaddr_in addr; addr.sin_family = AF_INET; addr.sin_addr.s_addr = INADDR_ANY; addr.sin_port = htons(8888); bind(listenSock, (sockaddr*)&addr, sizeof(addr)); listen(listenSock, SOMAXCONN); printf("非阻塞select服务端已启动,监听端口 8888\n"); fd_set readfds; std::vector<SOCKET> clients; while (true) { FD_ZERO(&readfds); FD_SET(listenSock, &readfds); int maxSock = (int)listenSock; for (SOCKET s : clients) { FD_SET(s, &readfds); if ((int)s > maxSock) maxSock = (int)s; } int ret = select(maxSock + 1, &readfds, NULL, NULL, NULL); if (ret <= 0) { int err = WSAGetLastError(); if (err != WSAEINTR) { printf("select出错: %d\n", err); break; } continue; } // 有新连接到达 if (FD_ISSET(listenSock, &readfds)) { SOCKET clientSock = accept(listenSock, NULL, NULL); if (clientSock != INVALID_SOCKET) { ioctlsocket(clientSock, FIONBIO, &mode); clients.push_back(clientSock); printf("新客户端接入,当前连接数: %d\n", (int)clients.size()); } } // 处理已有客户端的数据 for (int i = 0; i < (int)clients.size();) { SOCKET s = clients[i]; if (FD_ISSET(s, &readfds)) { char buf[1024]; int n = recv(s, buf, sizeof(buf), 0); if (n > 0) { buf[n] = '\0'; printf("收到数据(%d字节): %s\n", n, buf); const char* reply = "ack from select server"; send(s, reply, strlen(reply), 0); i++; } else if (n == 0) { printf("客户端主动断开,连接数减一\n"); closesocket(s); clients.erase(clients.begin() + i); } else { int err = WSAGetLastError(); if (err != WSAEWOULDBLOCK) { printf("recv出错: %d,移除连接\n", err); closesocket(s); clients.erase(clients.begin() + i); } else { i++; } } } else { i++; } } } for (SOCKET s : clients) closesocket(s); closesocket(listenSock); WSACleanup(); return 0; }

5.3 select的边界问题与使用细节

select模型能解决“支持多个客户端连接”的问题,但它自身有三个边界条件必须心里有数。

第一个是fd_set的容量上限。Windows下默认FD_SETSIZE是64,也就是一个fd_set最多放64个socket。如果你用FD_SET往里塞第65个socket,就会越界写坏内存。后台服务器如果面临几百上千个连接,select就不够用了,需要换IOCP或者WSAAsyncSelect这类高级模型。

第二个是maxSock参数的取值。第一个参数是“最大socket值加1”,这只是为了select在内部遍历数组时知道上界。在Windows上这个值其实可以被忽略,传0也行,但为了跨平台可移植性,还是老老实实计算最大值。

第三个是WSAEWOULDBLOCK的处理。非阻塞socket上,recv很容易返回连接错误码10035。很多人没处理这个分支,直接当成致命错误把连接关闭了,于是客户端经常莫名其妙掉线。正确姿势是:收到WSAEWOULDBLOCK时什么都不做,留着下轮select再说。

6. 支持多客户端连接的架构演进:从select到IOCP的取舍

6.1 单线程select vs 多线程阻塞

标题里提到“支持多个客户端连接”,不同阶段可以用不同的架构实现,它们的性能和复杂度是递进关系:

架构方案并发能力代码复杂度适用场景
阻塞 + 多线程几百连接以内低学习、内部工具、少量客户端
非阻塞 + select几十到几百连接中中小型服务器、教学实践、轻量通信
WSAAsyncSelect几十连接,可集成消息循环中Windows GUI程序中的网络通信
IOCP成千上万连接高高性能服务器、网关、游戏服务器

坦率地讲,上了几千个并发连接,select不仅受FD_SETSIZE限制,而且每次都要遍历所有socket检查状态,复杂度是O(N),CPU消耗会很可观。IOCP则是事件驱动模型,内核帮你把就绪的I/O操作直接投递到完成队列,线程池从“完成队列”里取任务,规模大了几倍也不会因为遍历所有socket而浪费CPU。

6.2 为什么要先把同步和select学好

IOCP虽然强大,但一下子跳到IOCP学习曲线太陡,容易让人丧失信心。我个人的路径是先写阻塞版本,理解握手、数据流、断线握手这些TCP基础;再写select版本,理解非阻塞状态和就绪事件;最后才接触IOCP。这套路对新人最友好。而且很多实际项目里几十个连接的服务端,用select或者阻塞多线程就已经足够,没必要硬上复杂架构。

6.3 线程池也能补同步阻塞的短板

如果业务场景是长连接、大消息量,但你又不想用IOCP,可以做一个折中:阻塞模式 + 固定线程池 + 队列。主线程accept后不直接创建线程,而是把客户端socket丢进队列,线程池里的工作线程自己去队列里领socket。这样线程数量稳定可控,不会出现一个客户端一个线程的资源爆炸问题。缺点是线程调度的粒度比较粗,负载较高时仍有性能瓶颈。

7. 踩坑实录:多客户端通信中你一定会遇到的三个问题

7.1 “地址只允许使用一次”的完整排查链路

我遇到过一次很诡异的情况:服务端程序运行中崩溃退出,然后立刻重启,bind报10048错误。当时我第一反应是系统里还有残留进程,用任务管理器查了半天没看到,最后才想到可能是TIME_WAIT状态在作怪。复现链路是这样的:客户端断开连接后,服务端的socket会进入TIME_WAIT,持续约2分钟;如果此时服务端立刻重启并尝试bind同一个端口,就会失败。解决办法就是前面讲的SO_REUSEADDR,在bind之前设置上去,就能复用这个处于TIME_WAIT的地址。

如果你设置了SO_REUSEADDR还报10048,那就得查两件事:一是系统中是否真的还有进程占着端口,用netstat -ano | findstr "8888"去看;二是你的程序是否有多个实例在同时运行。排查顺序千万别反了,先用netstat确认归属,再决定要不要改代码。

7.2 TCP粘包与业务消息边界

做socket服务端时,很多人会遇到“客户端连续发了两条消息,服务端一次recv就全收到了”的情况,这就是粘包。TCP是面向字节流的协议,它不关心你的业务消息从哪里开始、到哪里结束,只保证字节顺序和最终完整性。所以多客户端通信时,必须在协议层面明确消息边界。

最简单的做法是“报文头加长度字段”:

[4字节: 消息长度N][N字节: 业务数据]

服务端先recv固定4字节,解析出长度N,再循环recv直到收满N字节。我用这个结构写过一版通用的封装,后来所有涉及socket的项目都直接复用。“每次recv都能拿到完整消息”是一个新手最容易产生的幻觉,必须从第一步就打破。

7.3 关闭socket的时机与线程安全

在多线程阻塞服务端中,如果某个客户端断开了,子线程调用closesocket,此时主线程的accept还在正常工作,没有影响。但如果你有一个全局的连接列表,并且多个线程同时往列表里增删节点,就必须加锁。我在这上面踩过一次很隐蔽的坑:客户端断开后,处理线程调用closesocket,但此时select的主循环手里还留着这个socket,下次select仍然会监听它,结果读出10053连接中断,然后再次删一次,导致vector越界。

解决办法是把socket的关闭流程统一收口:谁负责移除监听列表,谁负责closesocket。不要让两个线程各自独立地去close同一个标。真实工程中,经常用引用计数或者给每个连接加一个“已标记关闭”的标志位,这样关闭时只标记,真正close的时刻由管理线程统一执行。

如果你是新手,我的建议是先把阻塞+多线程版本改通,再尝试把服务端切换成select模型,客户端保留阻塞版本,两套组合交叉验证。等你彻底理解了非阻塞connect的判断逻辑和select的读取方式,再回头审视自己的业务场景,你会发现socket通信其实就三个核心问题:消息边界怎么定、连接生命周期怎么管、并发模型怎么选。把这三点想透了,无论后面是IOCP还是跨平台库,上手都快很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询