简介:面向VC++开发者的异步多线程Socket示例工程,围绕异步事件驱动与多线程并发两大主题,服务端与客户端各成独立子工程,基于MFC CAsyncSocket与Winsock实现,适合初、中级开发者学习Windows网络编程。资源共36个文件,压缩包约56KB,以8个头文件、6个C++源文件为核心,另含工程配置、界面资源及辅助文件,服务端与客户端目录结构清晰,便于对照研读。已有432人学习下载。阅读该工程可掌握异步套接字回调(如OnAccept、OnReceive)的消息驱动机制,学会用多线程处理多个客户端连接与数据收发,并了解临界区、互斥量等同步对象的使用;工程还覆盖了Socket关闭、异常处理与资源释放等实践细节,对开发局域网通信工具或完成课程设计有直接参考价值。
1. 定位:VC 异步多线程 Socket 双端工程模板,服务端与客户端一个解决方案闭环
这套 vc 异步多线程 socket 工程拿到手,最让我安心的是服务端和客户端放在同一个解决方案里,编译完直接本机就能跑通,不用再到处找配套代码。它不是聊天界面 Demo,而是一个 winsock2 事件驱动加多线程分工的通信底座:监听线程只负责 accept,每个客户端连接有独立事件线程处理收发,业务消息再丢给工作线程池异步消费,客户端这边自带心跳保活和断线重连。适合想学 Windows Socket 非阻塞编程、或者手头需要一个「改一改就能用」的通信内核来做网关、模拟服务端、内网小工具的开发者。
2. 工程骨架:解决方案目录、协议帧与线程模型,动手前先对齐这三点
拿到 .sln 后不要急着点编译,先花十分钟把目录结构、协议格式和线程分工弄清楚,后面跑起来能少走很多弯路。原工程按「服务端 / 客户端 / 公共协议头」三块划分,这是 VC 工程里最常见也最容易维护的组织方式。
2.1 解决方案布局与编译入口
整个解决方案只有两个子工程,没有 MFC 依赖,纯 Win32 Console 程序,Socket 部分用的 winsock2。编译入口分别在 Server/ 和 Client/ 目录下,公共的协议帧定义放在各自的 protocol.h 里,两份内容一致。
VcAsyncSocket.sln Server/ Server.cpp # 服务端入口,读取端口与线程池配置 CServer.h / CServer.cpp # 监听、会话管理、消息线程池 CClientSession.h / CClientSession.cpp protocol.h Client/ Client.cpp # 客户端入口,配置服务端地址与心跳参数 CClient.h / CClient.cpp protocol.h第一次编译前有两件事要提前检查,都是老工程容易翻车的位置:工程属性里必须链接 ws2_32.lib,代码里通常用#pragma comment(lib, "ws2_32.lib")强制带上;代码用了std::thread、std::mutex和condition_variable,C++11 即可,Visual Studio 的新工具集直接打开就能编,不需要额外装库。
我在两个主函数入口都放了WSAStartup(MAKEWORD(2, 2), &wsa),进程退出时配套WSACleanup()。这个配对顺序如果丢了,程序本体能跑,但退出阶段偶尔会冒出来自运行时库的报错,属于典型黑匣子问题。服务端的端口和 worker 线程数、客户端的 IP 和心跳参数,都集中在两个 main 文件顶部改成宏常量,改配置不用翻类实现。
提示:如果编译报一堆 LNK2019,十有八九是 ws2_32.lib 没链上,先查这一条再查代码。
2.2 线程模型选型:为什么是 WSAEventSelect 加工作线程池
Windows 下写 Socket,可选的路不少,先看一张对比表:
| 模型 | 线程组织 | 承载连接数 | 复杂度 | 通知方式 |
|---|---|---|---|---|
| 阻塞 accept + recv | 每连接一线程 | 几十 | 低 | 无 |
| select | 单线程轮询 | 百级 | 中 | 超时轮询 |
| WSAAsyncSelect | 依赖消息窗口 | 百级 | 中 | 窗口消息 |
| WSAEventSelect | 事件对象加线程等待 | 百级 | 中高 | 内核事件 |
| IOCP 完成端口 | 固定线程池 | 千级以上 | 高 | 完成通知 |
这套模板用的是 WSAEventSelect 加多线程组合:accept 放在独立线程里阻塞执行,每个连接创建一个事件对象并注册 FD_READ / FD_WRITE / FD_CLOSE,会话线程用 WSAWaitForMultipleEvents 等待事件;解析出来的业务消息全部投递到公共线程池,由 worker 异步消费。这样选的核心原因是:在没有消息窗口泵的控制台工程里,WSAEventSelect 是事件驱动方案里最干净的一个,不依赖 UI 线程,也能把 Socket 收发和业务处理彻底解耦。
为什么不直接上 MFC 的 CAsyncSocket?CAsyncSocket 底层依赖窗口消息泵把网络事件翻译成 OnReceive 回调,控制台程序或者以后要装成 Windows 服务时没有消息循环,这套回调根本跑不起来。项目中把它排除掉,就是为了保留「去掉 UI 也能长期运行」的能力。至于为什么不一步到位用 IOCP,等连接量真的到千级再迁不迟,第六章会讲平滑迁移的着手点,前期别为用不上的复杂度买单。
2.3 协议帧协议:4 字节长度头加 4 字节命令字
TCP 是字节流,应用层必须自己划消息边界,否则两条消息粘在一起、或者一条被拆成两半,业务层根本没法定界。协议头用的是经典长度帧:
// 帧格式 [4字节 totalSize][4字节 cmd][payload...] // totalSize = sizeof(cmd) + payloadLen,即长度头之后整帧的字节数 inline bool BuildFrame(int cmd, const char* payload, int payloadLen, std::string& out) { int total = 4 + payloadLen; out.clear(); out.reserve(4 + total); out.append(reinterpret_cast<const char*>(&total), 4); out.append(reinterpret_cast<const char*>(&cmd), 4); if (payloadLen > 0) out.append(payload, payloadLen); return true; }参数说明:cmd是命令字,payload是负载数据,payloadLen是负载长度,total是长度头之后跟随的字节数,拼完的out就是可以直接 send 的完整帧。这里直接用了小端内存序,同机或 Windows 局域网内互通没有问题;将来要对 Linux 或嵌入式设备,total 和 cmd 必须换成 htonl / ntohl 做字节序转换,这是跨端联调第一名的高频坑。
协议里的命令字常量统一放在 protocol.h:CMD_HEARTBEAT、CMD_HEARTBEAT_ACK、CMD_DATA,后续加协议只往这个头文件补常量即可。MAX_FRAME_SIZE 也定义在这里,服务端解析时对超过 16MB 的帧直接断连,防止有人恶意构造超长长度头把内存打爆。
3. 服务端核心拆解:从 accept 到业务回调的一条完整链路
服务端这部分是整套资源的重点,理解它的三段式结构(accept 分发、会话事件循环、业务线程池),客户端代码基本就是删掉 accept 之后的镜像。
3.1 监听与 Accept 分发
CServer 负责创建监听 socket、拉起 accept 线程和 worker 线程池。监听 socket 我特意保留阻塞模式,没有扔进事件驱动,原因很简单:accept 本身频率极低,一个专用线程做最朴素的阻塞调用,逻辑最不容易出错;关闭时直接 closesocket 就能让它立刻返回,退出路径也干净。
bool CServer::Start(unsigned short port, int workerCount) { WSADATA wsaData; WSAStartup(MAKEWORD(2, 2), &wsaData); m_listen = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); int reuse = 1; setsockopt(m_listen, SOL_SOCKET, SO_REUSEADDR, reinterpret_cast<char*>(&reuse), sizeof(reuse)); sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(port); if (bind(m_listen, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) == SOCKET_ERROR) return false; if (listen(m_listen, SOMAXCONN) == SOCKET_ERROR) return false; m_stopEvent = WSACreateEvent(); m_stop = false; m_acceptThread = std::thread(&CServer::AcceptLoop, this); for (int i = 0; i < workerCount; i++) { m_workers.emplace_back(&CServer::WorkerLoop, this); } return true; } void CServer::AcceptLoop() { while (!m_stop) { sockaddr_in remote{}; int cb = sizeof(remote); SOCKET s = accept(m_listen, reinterpret_cast<sockaddr*>(&remote), &cb); if (s == INVALID_SOCKET) { // Stop() 里会 closesocket(m_listen),这里返回错误即退出循环 break; } auto* session = new CClientSession(this, s, remote); { std::lock_guard<std::mutex> lock(m_sessionMutex); m_sessions[session->Id()] = session; } session->Start(); } }说明:SO_REUSEADDR是为了让服务端重启时不必等 TIME_WAIT 状态过期,调试阶段频繁改代码重启,这个设置几乎是必须的。SOMAXCONN让系统按默认最大值排 connect 等待队列。新连接先放进 m_sessions 管理表再 Start,防止会话线程起来后回调上层时查不到自己。Stop 的关闭顺序是固定的:置 m_stop、closesocket(m_listen)、WSASetEvent(m_stopEvent)、再 join accept 线程和 worker,顺序一改就可能在退出时踩到野句柄。
void CServer::Stop() { m_stop = true; closesocket(m_listen); // accept 立即返回失败 WSASetEvent(m_stopEvent); // 所有会话线程退出等待 if (m_acceptThread.joinable()) m_acceptThread.join(); m_queueCv.notify_all(); for (auto& t : m_workers) t.join(); }这里有个容易被忽略的细节:所有 m_stop 类标志位建议用std::atomic<bool>,事件循环和多线程都在读写它,Release 优化下普通 bool 可能被缓存到寄存器,导致线程读到的永远是旧值,表现形式是退出时像死锁一样卡住。
3.2 会话事件循环:FD_READ / FD_WRITE / FD_CLOSE 的处理
每个 CClientSession 对应一个 socket。Start 里创建事件对象并调用 WSAEventSelect 注册关心的网络事件。这里有个关键点:WSAEventSelect 会把 socket 自动切成非阻塞模式,这是后面很多坑的根源,先记住这个前提。
void CClientSession::Start() { m_hEvent = WSACreateEvent(); if (m_hEvent == WSA_INVALID_EVENT) { Cleanup(); return; } WSAEventSelect(m_sock, m_hEvent, FD_READ | FD_WRITE | FD_CLOSE); m_thread = std::thread(&CClientSession::EventLoop, this); } void CClientSession::EventLoop() { WSAEVENT events[2] = { m_server->StopEvent(), m_hEvent }; while (!m_stop) { DWORD ret = WSAWaitForMultipleEvents(2, events, FALSE, WSA_INFINITE, FALSE); if (ret == WSA_WAIT_FAILED) break; DWORD idx = ret - WSA_WAIT_EVENT_0; if (idx == 0) break; // 服务端正在停止 WSANETWORKEVENTS ne{}; if (WSAEnumNetworkEvents(m_sock, m_hEvent, &ne) == SOCKET_ERROR) break; if (ne.lNetworkEvents & FD_READ) DoRecv(); if (ne.lNetworkEvents & FD_WRITE) DoSend(); if (ne.lNetworkEvents & FD_CLOSE) break; // 对端关闭或异常 } Cleanup(); }WSAEnumNetworkEvents有一个容易被忽略的行为:传入的 hEventObject 参数会在取走事件的同时把事件对象重置为无信号状态,所以不需要再单独调 WSAResetEvent,很多网上老代码会在后面补一次 ResetEvent,其实是多余的。
接收循环的写法如下,重点在非阻塞语义下的退出条件:
void CClientSession::DoRecv() { char buf[4096]; while (true) { int n = recv(m_sock, buf, sizeof(buf), 0); if (n > 0) { OnData(buf, n); if (n < static_cast<int>(sizeof(buf))) break; // 一次读干净 } else if (n == 0) { m_closed = true; // 对端优雅关闭 break; } else { int err = WSAGetLastError(); if (err == WSAEWOULDBLOCK) break; // 数据未到,等下一轮 FD_READ m_closed = true; // 真正出错 break; } } }里面的边界判断值得细看:n == 0代表对方调用了 closesocket 的优雅关闭,要当作断开处理;WSAEWOULDBLOCK是非阻塞模式下的正常状态,不是错误,不能走清理分支。n等于 4096 时我一般会再循环读一次,因为底层可能还有数据没取完,虽然等下一轮 FD_READ 也不会丢数据,但一个事件往返的延迟放大到每帧上,吞吐差距就出来了。
OnData 负责把裸字节喂给协议解析,对应 2.3 的帧拆解:
void CClientSession::OnData(const char* data, int len) { m_recvBuf.append(data, len); while (m_recvBuf.size() >= 4) { int totalSize = *reinterpret_cast<int*>(&m_recvBuf[0]); if (totalSize <= 0 || totalSize > MAX_FRAME_SIZE) { m_closed = true; // 长度头非法,直接断开 return; } if (m_recvBuf.size() < 4 + totalSize) break; // 半包,等待剩余字节 char* frame = &m_recvBuf[4]; int cmd = *reinterpret_cast<int*>(frame); m_server->DispatchMessage(Id(), cmd, frame + 4, totalSize - 4); m_recvBuf.erase(0, 4 + totalSize); } }长度头校验这一步不能省:真实场景里客户端可能是出 bug 的,也可能是被恶意构造脏数据,一个负数的 totalSize 会让循环疯狂触发 erase,性能问题事小,内存消耗失控事大。MAX_FRAME_SIZE 我限制为 16MB,超过就当协议错误断连,这是服务端自我保护的基本底线。
3.3 业务线程池:condition_variable 与消息队列
会话线程只做 Socket 收发,业务逻辑全部投给 worker 线程池,避免某条消息的处理耗时阻塞整个连接的收发。DispatchMessage 把解析好的 (sessionId, cmd, payload) 装成一条消息丢进队列。
void CServer::DispatchMessage(int sessionId, int cmd, const char* data, int len) { { std::lock_guard<std::mutex> lock(m_queueMutex); m_queue.push(MsgItem{ sessionId, cmd, std::string(data, len) }); } m_queueCv.notify_one(); } void CServer::WorkerLoop() { while (true) { std::unique_lock<std::mutex> lock(m_queueMutex); m_queueCv.wait(lock, [this]() { return m_stop || !m_queue.empty(); }); if (m_stop && m_queue.empty()) break; MsgItem item = std::move(m_queue.front()); m_queue.pop(); lock.unlock(); HandleMessage(item); // 业务侧在这里扩展 } }参数说明:m_queue 是保护在 m_queueMutex 下的 std::queue,m_queueCv 是条件变量。worker 在队列空时挂起等待,被 notify 唤醒后取走一条消息,先 unlock 再执行业务处理,目的是让长耗时业务不占着锁、阻塞其他 worker。启动时 worker 数量我一般给std::thread::hardware_concurrency() * 2,连接数几百时这个值足够用。
这种「收发线程加业务线程池」的拆分是本模板最值得抄的设计:将来加数据库、日志、转发逻辑,只需要在 HandleMessage 里写代码,不会再碰 Socket 层。需要回包的业务,HandleMessage 里调 session 的 SendFrame,把帧压进会话的发送队列由事件循环消费,回包本身也不占 worker 时间。
4. 客户端核心拆解:非阻塞 connect、心跳保活与断线重连
客户端不是简单把 recv/send 套一层循环,它要解决的问题不同:connect 不能阻塞主流程、服务端长时间没消息时要能判断死链、断线之后要自动恢复。模板里分别用 FD_CONNECT 事件、心跳定时器、指数退避重连解决这三件事。
4.1 异步 connect 与客户端事件循环
客户端 socket 创建后立刻注册 FD_CONNECT | FD_READ | FD_WRITE | FD_CLOSE,再调 connect。非阻塞 connect 会立即返回 SOCKET_ERROR 且错误码是 WSAEWOULDBLOCK,这不是失败,而是告诉你连接结果要由后续的 FD_CONNECT 事件来通知。
bool CClient::Connect(const std::string& ip, unsigned short port) { m_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); m_hEvent = WSACreateEvent(); if (m_hEvent == WSA_INVALID_EVENT) return false; WSAEventSelect(m_sock, m_hEvent, FD_CONNECT | FD_READ | FD_WRITE | FD_CLOSE); sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_port = htons(port); inet_pton(AF_INET, ip.c_str(), &addr.sin_addr); int rc = connect(m_sock, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)); if (rc == SOCKET_ERROR && WSAGetLastError() != WSAEWOULDBLOCK) { closesocket(m_sock); return false; // 立即失败的连接,比如端口根本没监听 } m_connecting = true; m_thread = std::thread(&CClient::EventLoop, this); return true; }事件循环和服务端会话基本一致,区别是多处理了一个 FD_CONNECT 分支,并且等待带了 500ms 超时来跑心跳检查:
void CClient::EventLoop() { WSAEVENT events[3] = { m_stopEvent, m_hEvent, m_sendWakeEvent }; while (!m_stop) { DWORD ret = WSAWaitForMultipleEvents(3, events, FALSE, 500, FALSE); if (ret == WSA_WAIT_TIMEOUT) { CheckHeartbeat(); continue; } if (ret == WSA_WAIT_FAILED) break; DWORD idx = ret - WSA_WAIT_EVENT_0; if (idx == 0) break; if (idx == 2) { // 发送唤醒事件 WSAResetEvent(m_sendWakeEvent); DoSend(); continue; } WSANETWORKEVENTS ne{}; WSAEnumNetworkEvents(m_sock, m_hEvent, &ne); if (ne.lNetworkEvents & FD_CONNECT) { int err = 0, len = sizeof(err); getsockopt(m_sock, SOL_SOCKET, SO_ERROR, reinterpret_cast<char*>(&err), &len); m_connecting = false; if (err != 0) { OnConnectFailed(err); break; } OnConnected(); } if (ne.lNetworkEvents & FD_READ) DoRecv(); if (ne.lNetworkEvents & FD_WRITE) DoSend(); if (ne.lNetworkEvents & FD_CLOSE) break; } Close(); }FD_CONNECT 分支里必须用 getsockopt 取 SO_ERROR 判断连接是否真正成功,这是异步 connect 的标准收尾动作。SO_ERROR 为 0 表示成功,非 0 是失败码(常见 10061 端口被拒、10060 连接超时)。很多人看到 lNetworkEvents 里有 FD_CONNECT 就认为连上了,实际结果藏在 SO_ERROR 里,这是客户端最容易踩的认知差。
m_sendWakeEvent 是第三个事件对象,解决一个很实际的问题:FD_WRITE 不会因为「队列有新帧」而触发,如果业务线程往发送队列里塞了一条消息,事件循环却在无限等待 socket 事件,这条消息可能要等到下一次网络写满缓冲才被发出去。SendFrame 入队后立刻 WSASetEvent(m_sendWakeEvent),事件循环第 idx==2 个分支被唤醒去 DoSend,相当于给发送路径装了个门铃:
void CClient::SendFrame(int cmd, const char* payload, int len) { std::string frame; BuildFrame(cmd, payload, len, frame); { std::lock_guard<std::mutex> lock(m_sendMutex); m_sendQueue.push(std::move(frame)); } WSASetEvent(m_sendWakeEvent); }m_sendMutex 保护发送队列,是因为 SendFrame 可能被任意业务线程调用,而 DoSend 在事件循环线程里消费队列,两个线程必须互斥。
4.2 心跳帧与断线重连
心跳的作用是探测半开连接:对端断电、网线被拔、进程崩溃这些情况不会主动发 FIN,recv 永远不会返回 0,服务端要等 TCP 自然超时需要很久很久。客户端的处理是每个心跳周期主动发一帧 CMD_HEARTBEAT,服务端在 HandleMessage 里收到后回 CMD_HEARTBEAT_ACK,客户端把收到任何数据的时间都刷新到 m_lastRecvTime。
void CClient::CheckHeartbeat() { if (m_connecting || m_closed) return; ULONGLONG now = GetTickCount64(); if (now - m_lastRecvTime > HEARTBEAT_TIMEOUT) { // 3 个周期没收到任何包 ForceReconnect(); return; } if (now - m_lastSendTime > HEARTBEAT_INTERVAL) { // 默认 5 秒 std::string frame; BuildFrame(CMD_HEARTBEAT, nullptr, 0, frame); SendFrame(CMD_HEARTBEAT, nullptr, 0); m_lastSendTime = now; } } void CClient::ForceReconnect() { m_stop = true; if (m_thread.joinable()) m_thread.join(); closesocket(m_sock); WSACloseEvent(m_hEvent); m_closed = true; // 指数退避:1 秒、2 秒、4 秒……最大 30 秒 uint32_t delayMs = std::min(30000u, (uint32_t)1 << std::min(m_reconnTick, 5u)) * 1000; m_reconnTick++; std::this_thread::sleep_for(std::chrono::milliseconds(delayMs)); m_stop = false; m_sock = INVALID_SOCKET; Connect(m_ip, m_port); }参数说明:HEARTBEAT_INTERVAL 默认 5 秒,HEARTBEAT_TIMEOUT 默认 15 秒,也就是三个周期无数据就判死。重连用指数退避而不是死循环,是为了避免服务端刚重启时几百个客户端同时重连把端口打爆。时间戳用 GetTickCount64 和 ULONGLONG 是因为它是 64 位无符号,49.7 天长跑不会绕回,老代码里用 GetTickCount 的话绕回后心跳判定会直接错乱,这是心跳模块里非常经典的历史坑。
5. 避坑指南:VC 异步 Socket 高频翻车点与排查步骤
这一章把调试这套模板时真实踩过的坑按「现象 -> 原因 -> 解决」整理出来,每一条都可以直接对着代码排查。
5.1 现象:recv 返回 10035 WSAEWOULDBLOCK,业务线程当成错误退出
现象:程序运行几十秒后连接一个接一个断开,日志里全是 10035 错误。 原因:WSAEventSelect 会自动把 socket 切成非阻塞模式。数据还没到的时候 recv 返回 SOCKET_ERROR、错误码 10035 是正常状态,不代表出错。 解决:把 10035 单独分流,遇到就 break 出去等下一轮 FD_READ;真正断开是 recv 返回 0,或者返回错误且 WSAGetLastError 不是 10035。排查时在 recv 返回 SOCKET_ERROR 的位置打印错误码,如果清一色 10035 且断连时间和打印次数吻合,基本就是这个坑。DoRecv 里的分支顺序不要写错,顺序错了自己很难看出来。
5.2 现象:发送缓冲写满后 FD_WRITE 再也不触发
现象:大量下发数据时,send 返回 10035 后程序等 FD_WRITE,等多久都不来,消息越积越多。 原因:FD_WRITE 只在 socket 发送缓冲从「满」变为「有可用空间」时触发一次,不是持续触发。如果发送缓冲一直没写满,或者写满后没人重新武装,它压根不会再次生效。 解决:send 遇到 WSAEWOULDBLOCK 时,用同样的参数重新调用一次WSAEventSelect(m_sock, m_hEvent, FD_READ | FD_WRITE | FD_CLOSE),这会重新武装 FD_WRITE,缓冲区有空间时事件会重新到来。排查手段是在 DoSend 里打点,观察 send 返回 10035 后事件是否再来;如果不再来,对比重新调用 WSAEventSelect 前后的行为差异,基本一测就定位。
5.3 现象:两条消息被拼成一条,或者一条被拆两半
现象:客户端连发两帧,服务端解析出的 cmd 是乱的,payload 错位,而且时好时坏,极具迷惑性。 原因:TCP 是字节流,消息边界是应用层责任。没有长度头,接收方永远不知道一帧在哪结束。 解决:按 2.3 的帧格式在 OnData 里做累积缓冲解析,只有凑够4 + totalSize字节才消费一帧,不足则留在 m_recvBuf。排查时在 OnData 入口打印 m_recvBuf.size() 和 totalSize,看到 size 不等于整帧长度就是粘包/半包在作祟。发送方也必须严格走 BuildFrame 组包,不要裸拼接字符串,两边协议不一致最折磨人。
5.4 现象:WaitForMultipleObjects 超过 64 个事件直接失败
现象:把连接数压到一百以上,主 I/O 线程的事件等待开始返回 WSA_WAIT_FAILED,新连接收不到数据。 原因:Windows 的 WSAWaitForMultipleEvents / WaitForMultipleObjects 单次最多等 64 个事件,WSA_MAXIMUM_WAIT_EVENTS 就是 64,这是操作系统层限制,不是代码写错。 解决:本模板每会话一个线程、每次只等 2 个事件,天然避开这个限制。如果有人想改成单事件循环管理几千连接,就必须按 64 个事件一组分片到多个线程,或者直接走 IOCP。想明白这一条,就知道为什么模板不把会话全部塞进同一个事件循环。
5.5 现象:关闭时偶发崩溃,句柄被复用到新连接
现象:Stop 之后程序有概率崩溃,或者新连接莫名收到上一次连接的数据,表现很玄学。 原因:关闭顺序错了。常见错误是在会话线程还在 WSAWaitForMultipleEvents 等待时,另一个线程先 closesocket 或 WSACloseEvent,事件句柄立刻失效甚至被系统复用,线程醒来后操作的是已经被复用的句柄。 解决:统一按「置停止标志 -> WSASetEvent(m_stopEvent) -> join 会话线程 -> 线程内 closesocket / WSACloseEvent」的顺序清理。Socket 和事件对象的释放只能由等待它的那个线程执行,这条规矩我写在 CClientSession::Cleanup 的注释里,算这套模板的红线。排查时如果崩溃栈停在 WSACloseEvent 或 closesocket 附近,优先检查是不是有两个线程在关同一把句柄。
6. 二次开发:把事件模型平滑迁移到 IOCP 长连接网关
如果要把这套模板撑到几千连接,WSAEventSelect 每次收发都要走「Wait -> EnumNetworkEvents -> 手动 recv」三步,线程切换成本偏高,64 事件上限也会逼你按片拆线程。这时候值得迁到 IOCP。迁移不是推翻重写,而是把「事件驱动」换成「完成驱动」的两段式。
第一,去掉所有 WSAEventSelect,把每个连接 socket 绑定到完成端口,投递一个 WSARecv 请求,把缓冲区交给内核:
void PostRecv(ClientCtx* ctx) { WSABUF wsaBuf{}; wsaBuf.len = sizeof(ctx->recvBuf); wsaBuf.buf = ctx->recvBuf; DWORD flags = 0; // overlapped 里要记录操作类型和 ctx 指针 WSARecv(ctx->sock, &wsaBuf, 1, nullptr, &flags, &ctx->overlapped, nullptr); }第二,worker 线程不再自己调 recv,而是从完成端口取通知:GetQueuedCompletionStatus 返回后,根据 OVERLAPPED 里记录的操作类型决定走读处理还是写处理,处理完马上再投递一个新的 WSARecv,保证链式不中断。原来的 OnData、DoSend 逻辑可以原样复用,只是被拆成「投递」和「完成后续」两段。
迁移完怎么验证?我一般会写一个压测客户端,开 2000 个连接同时发 CMD_DATA,服务端每条回一帧 CMD_HEARTBEAT_ACK,观察三件事:任务管理器里线程数是否稳定在 worker 数量附近、进程句柄数是否持续不涨、单帧处理延迟是否平稳。迁移完成后,协议帧解析、心跳、重连策略一个都不用动,改的只是 I/O 通知层,这也是当初把业务和收发拆开的价值所在。
这套模板给我留下的一个习惯是:每次动 Socket 工程,先画一张「谁触发、谁等待、谁关闭」的时序图再进场改代码,别对自己太自信。希望这套 vc 异步多线程 socket 的拆解,能帮你把前面那些本不该踩的坑一次绕过去,希望帮到你。
本文还有配套的精品资源,点击获取