
1. 同步阻塞与异步非阻塞的本质差异先搞懂服务端为什么难写前阵子有个刚转C的同事跑来问我为什么自己写的SOCKET服务端第一个客户端连上来之后第二个客户端就一直连不上我一听就知道这是掉进了同步阻塞recv的经典陷阱。他写的接收逻辑是这样的在while循环里调用recv只要没有数据返回程序就卡在那里不动了后面排队的客户端自然只能干等。这个问题的根源就是没有分清C SOCKET编程里同步阻塞和异步非阻塞两种模式之间的本质差别。同步阻塞的意思是当你调用recv或send时程序会一直停在那个函数里直到有数据到达或者连接关闭为止。这个行为很像打电话——你拨出去之后就捧着听筒等着对方不说话你也不挂。这种方式最大的优点是逻辑简单、代码直接特别适合做客户端但放在服务端就麻烦了因为服务端要同时照顾N个客户端而一个阻塞的recv会直接把整个线程“钉死”在一路连接上其他连接全部失去响应。异步非阻塞则完全不同socket被设置为非阻塞模式后recv调用会立刻返回。有数据就返回数据长度没数据就返回SOCKET_ERROR并且WSAGetLastError()告诉你错误码是WSAEWOULDBLOCK10035意思是“现在没有数据你过一会儿再来问”。这种行为和前台挂号很像——你挂完号不用等在窗口先去办别的事过段时间再来刷一下叫号屏就行。服务端正是利用这种“随时查、马上走”的特性才能在一个线程内同时照看几十上百个客户端连接。所以这篇内容的主线就很清楚了先用同步阻塞多线程的方式把服务端跑起来理解最简单的“一连接一线程”模型再移植到异步非阻塞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_TCPSOCKET 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的情况结果已经断开的连接还停在循环里空转。WSAECONNRESET10054是很常见的错误码表示对端没有正常关闭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_ERRORWSAGetLastError()返回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::vectorSOCKET 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还是跨平台库上手都快很多。