简介:面向VC++网络编程初学者与需要搭建Socket通信原型的开发者,这份异步多线程Socket工程同时给出服务端和客户端两套实现,可用于理解事件驱动模型、并发连接处理以及等待网络I/O时的阻塞规避等关键问题。压缩包共36个文件,以头文件和C++源文件为核心,配合工程配置、资源脚本及说明文本,完整保留两个MFC工程的基本结构,体积约56KB,便于直接编译并对照调试。已有432人学习,适合作为课程实验、毕业设计或小型网络应用的起步模板。服务端侧重监听与并发接入,客户端侧重连接和双向收发;读者可从中梳理Winsock接口与MFC封装类的配合方式,观察CAsyncSocket异步回调、CWinThread线程管理以及临界区等同步手段的落地写法,参考收发事件和异常关闭流程,形成可复用的通信骨架与排错思路。
1. 先看它能干什么:VC 异步多线程 Socket 双端工程
拿到一份“VC 异步多线程 Socket 包括服务端和客户端”的工程包时,我第一反应是又一个阻塞 recv 套壳。跑起来才发现,服务端确实用 IOCP 把连接、收包、发包拆到完成端口和多个工作线程里,客户端也做了异步 connect、收包线程和心跳重连,两个 exe 本机一对就能互发消息。它适合正在补 Windows 网络编程课设、想把小工具通信层换掉,或者需要一个能读能改的 C++ Socket 双端样例的人;不适合直接当生产网关。下面按我拆包顺序,从目录、AcceptEx、客户端收包线程到联调避坑一路说清。
2. 工程结构与技术选型:IOCP 服务端和事件驱动客户端的拆分
2.1 目录怎么分:server、client、common 三块先看
这套 VC 异步多线程 Socket 工程没有把服务端和客户端混在一个 main.cpp 里,而是按职责切成三块:server 管监听和完成端口,client 管连接状态和收包线程,common 放协议头、字节缓冲和日志。我一般先看 common,因为双端能不能对上,八成取决于包头长度、字节序和最大包限制。目录清单可以按下面这张表对照,源码包里不一定会一字不差,但入口基本都在这些位置。
| 目录 | 主要内容 | 二次开发入口 |
|---|---|---|
| server | Win32 服务端入口、IocpServer、ConnCtx、发送队列 | PostAccept、Worker、SendPacket |
| client | 异步连接、收包线程、心跳重连、消息回调 | ConnectAsync、RecvLoop、HeartbeatLoop |
| common | Protocol.h、FrameParser、字节序工具、日志 | PacketHeader、BuildFrame、Append |
| config | 端口、IP、心跳间隔、最大包长 | 先改端口再跑双端 |
| build 或输出目录 | 编译产物和运行日志 | 不作为源码修改点 |
我习惯先把服务端和客户端各跑一个实例,确认本机回环能通,再去看代码。因为这类工程最常见的翻车不是 IOCP 本身,而是客户端连上了但服务端没有关联完成端口,或者包头长度按主机字节序发出去,接收端拆包拆成乱码。目录分得清,排查时就能快速定位是协议层还是网络层。common 里如果只有头文件没有 cpp,说明实现多放在双端各自的 cpp 里,这种情况要把发包和收包函数成对看。
2.2 为什么服务端用 IOCP、客户端用事件驱动
标题里的“异步多线程”在 Windows 上常见有三条路:WSAAsyncSelect消息驱动、WSAEventSelect事件驱动、IOCP 完成端口。服务端要扛多个连接,常见做法是 IOCP:监听套接字把 AcceptEx 投递到完成端口,工作线程用GetQueuedCompletionStatus取完成事件,一个线程可以处理很多连接,不需要为每个连接死等 recv。客户端连接数少,更多是交互体验问题,所以这套工程用异步 connect 加收包线程,既能避免界面卡死,也比把整套 IOCP 搬进客户端轻。
服务端的关键不是“开了几个线程”,而是每个连接有没有独立的上下文。连接上下文里要放 socket、接收缓冲、OVERLAPPED、发送队列和关闭标记。工作线程拿到完成事件后,通过OVERLAPPED反查上下文,再把收到的字节喂给拆包器。这里如果图省事用全局缓冲,多个线程同时收包就会互相踩数据,血泪经验是:IOCP 代码里凡是跨线程访问的队列,要么加锁,要么只让一个线程写。
客户端这边用select加非阻塞 socket 更直观:connect 返回WSAEWOULDBLOCK不代表失败,而是连接还在路上;用select等可写,再用SO_ERROR取真实错误码。收包线程里select等可读,再recv追加到FrameParser。这套模型对新手友好,调试时也能清楚看到连接状态从 Connecting 到 Connected 的变化。
2.3 编译链路:Winsock2 顺序、ws2_32.lib 和字符集
VC 工程里最容易忽略的是头文件顺序。winsock2.h要放在windows.h前面,否则可能把旧版winsock.h带进来,出现SOCKET重定义。下面这段放在公共头或每个网络源文件顶部都行,作用是压掉不必要的东西并显式链接库。代码块里保留注释,方便你对照自己的工程改。
#define WIN32_LEAN_AND_MEAN // 减少 windows.h 带入的旧网络头 #include <winsock2.h> // 必须早于 windows.h #include <ws2tcpip.h> // inet_pton、sockaddr_in 等 #include <windows.h> #pragma comment(lib, "ws2_32.lib") // 链接 Winsock2 库参数说明:WIN32_LEAN_AND_MEAN不是必须,但能减少头文件冲突;ws2_32.lib必须进链接器,否则WSAStartup、WSARecv、GetQueuedCompletionStatus相关符号会报 LNK2019。字符集我一般把协议层固定成char和std::string,界面层再用MultiByteToWideChar转换;如果工程是 Unicode 字符集,不要直接把TCHAR*往网络缓冲里塞,长度和编码都会变。
提示:先把 server 和 client 的“附加包含目录”都指到 common,别靠相对路径碰运气。换一台机器编译时,路径错一位就会报找不到
Protocol.h。
3. 服务端核心链路:从 AcceptEx 到 IOCP 工作线程收发
3.1 初始化 Winsock 与创建监听套接字
服务端启动顺序很固定:WSAStartup、创建监听 socket、bind、listen、创建完成端口。监听 socket 要用WSASocket并带WSA_FLAG_OVERLAPPED,因为后面 AcceptEx 需要重叠 I/O。端口从配置文件读,默认本机测试用 9000,绑定地址写INADDR_ANY才能接受局域网连接;只绑127.0.0.1的话,另一台机器连不上不是防火墙问题,而是监听范围问题。
WSADATA wsa{}; if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) { return -1; // 初始化失败,先看是否链接了 ws2_32.lib } SOCKET listenSock = WSASocket(AF_INET, SOCK_STREAM, IPPROTO_TCP, nullptr, 0, WSA_FLAG_OVERLAPPED); if (listenSock == INVALID_SOCKET) { WSACleanup(); return -1; } sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); // 本机与局域网都可连 addr.sin_port = htons(9000); // 端口按配置改 if (bind(listenSock, (sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR) { closesocket(listenSock); WSACleanup(); return -1; } if (listen(listenSock, SOMAXCONN) == SOCKET_ERROR) { closesocket(listenSock); WSACleanup(); return -1; }这段代码的参数里,MAKEWORD(2,2)表示请求 Winsock 2.2;WSA_FLAG_OVERLAPPED决定这个 socket 能不能参与重叠 I/O;SOMAXCONN是系统允许的等待队列上限,直接用它比手写 5 更省事。bind 失败先看端口是否被占用,listen 失败再看权限和地址。真实工程里我会把错误码用WSAGetLastError()打出来,别只返回 -1,否则联调时就是黑匣子。
3.2 AcceptEx 投递与连接上下文
AcceptEx 和 accept 的差别在于:它先投递一个异步接收请求,等客户端连上来后由完成端口通知。投递前要准备一个客户端 socket 和一个连接上下文,接收缓冲前两段地址参数留出空间,后面可以用GetAcceptExSockaddrs取对端地址。下面这段是简化后的投递逻辑,工程里通常封装成PostAccept()。
struct ConnCtx { SOCKET sock = INVALID_SOCKET; char recvBuf[4096]{}; WSABUF wsabuf{}; OVERLAPPED ov{}; std::vector<char> sendQueue; std::mutex sendMutex; std::atomic<bool> sending{false}; }; void PostAccept(SOCKET listenSock, LPFN_ACCEPTEX pAcceptEx, HANDLE iocp) { SOCKET client = WSASocket(AF_INET, SOCK_STREAM, IPPROTO_TCP, nullptr, 0, WSA_FLAG_OVERLAPPED); if (client == INVALID_SOCKET) return; auto* ctx = new ConnCtx(); ctx->sock = client; DWORD bytes = 0; BOOL ok = pAcceptEx(listenSock, client, ctx->recvBuf, 0, sizeof(sockaddr_in) + 16, sizeof(sockaddr_in) + 16, &bytes, &ctx->ov); if (!ok && WSAGetLastError() != ERROR_IO_PENDING) { closesocket(client); delete ctx; } }参数说明:pAcceptEx要通过WSAIoctl(SIO_GET_EXTENSION_FUNCTION_POINTER)从监听 socket 上取,不能直接声明一个同名函数;两个地址长度参数通常给sizeof(sockaddr_in)+16;最后一个OVERLAPPED*是完成通知的钥匙。连接完成后,工作线程里要做两件事:用setsockopt(SO_UPDATE_ACCEPT_CONTEXT)把监听 socket 的上下文更新给新连接,再把新 socket 关联到同一个 IOCP。少这一步,后面WSARecv的完成事件不会回到你的工作线程。
3.3 工作线程 GetQueuedCompletionStatus 与每连接上下文
IOCP 工作线程的核心循环就是取完成包、判断操作类型、继续投递下一步。工程里可以开 2 到 4 个线程,数量不必等于连接数。下面这段保留 accept、recv 两个分支,发送分支在同一套结构里加IO_SEND即可。CONTAINING_RECORD用来从OVERLAPPED*反查外层操作结构,所以你的IoOp第一个成员或偏移要固定。
enum IoType { IO_ACCEPT, IO_RECV, IO_SEND }; struct IoOp { OVERLAPPED ov{}; IoType type; ConnCtx* ctx; }; DWORD WINAPI Worker(LPVOID param) { HANDLE iocp = (HANDLE)param; while (true) { DWORD bytes = 0; ULONG_PTR key = 0; OVERLAPPED* pov = nullptr; BOOL ok = GetQueuedCompletionStatus(iocp, &bytes, &key, &pov, INFINITE); if (!pov) break; // 退出通知 auto* op = CONTAINING_RECORD(pov, IoOp, ov); ConnCtx* ctx = op->ctx; if (!ok) { closesocket(ctx->sock); delete ctx; delete op; continue; } if (op->type == IO_ACCEPT) { setsockopt(ctx->sock, SOL_SOCKET, SO_UPDATE_ACCEPT_CONTEXT, (char*)&key, sizeof(SOCKET)); CreateIoCompletionPort((HANDLE)ctx->sock, iocp, (ULONG_PTR)ctx, 0); PostRecv(ctx); // 开始收第一包 PostAccept(); // 继续收下一个连接 delete op; } else if (op->type == IO_RECV) { if (bytes == 0) { closesocket(ctx->sock); delete ctx; delete op; continue; } FeedParser(ctx, ctx->recvBuf, bytes); // 拆包后回调业务 PostRecv(ctx); // 继续投递收包 delete op; } } return 0; }GetQueuedCompletionStatus的bytes是本次完成传输的字节数,key是关联 socket 时传入的完成键,pov是完成包对应的 OVERLAPPED。INFINITE表示没有事件就等着,这也意味着退出时不能只关 socket,最好用PostQueuedCompletionStatus投一个空完成包唤醒线程。接收分支里 bytes 为 0 通常表示对端正常关闭,不要继续投递 recv,否则会反复触发错误。
3.4 收包拆包和发送队列:别假设一次 WSARecv 就是一整包
TCP 是字节流,IOCP 只保证“收到了一段字节”,不保证一段就是一条消息。工程里 common 下应该有拆包器,按包头里的 bodyLen 循环取完整包。发送侧也不能假设WSASend一次把队列发完,要维护发送队列和正在发送偏移。下面给出发送入队和拆包的核心写法,实际工程里把BuildFrame和回调表接上即可。
std::string BuildFrame(uint16_t msgId, const std::string& body) { if (body.size() > 512 * 1024) { throw std::runtime_error("body too large"); } PacketHeader h{}; h.bodyLen = htonl((uint32_t)body.size()); // 网络字节序 h.msgId = htons(msgId); h.flags = 0; std::string out(sizeof(h), '\0'); memcpy(&out[0], &h, sizeof(h)); out += body; return out; } bool SendPacket(ConnCtx* ctx, uint16_t msgId, const std::string& body) { std::string frame = BuildFrame(msgId, body); { std::lock_guard<std::mutex> lk(ctx->sendMutex); ctx->sendQueue.insert(ctx->sendQueue.end(), frame.begin(), frame.end()); } if (!ctx->sending.exchange(true)) { PostSend(ctx); // 只有队列空闲时才投递第一个发送请求 } return true; }参数说明:bodyLen 只算包体,不算 8 字节包头;msgId 用来区分登录、聊天、心跳等业务;512KB 是常见上限,防止恶意长度把内存撑爆。发送队列里存的是连续字节,sending用原子变量标记“是否已有 WSASend 在飞”,完成回调里按已发字节推进队列,队列空了再清标记。这里没有后悔药:如果每次 SendPacket 都直接 WSASend,不排队,多个线程同时写同一个 socket 会乱序,严重时发送缓冲直接报错。
4. 客户端核心链路:异步 connect、收包线程和心跳重连
4.1 异步 connect:WSAEWOULDBLOCK 不是失败
客户端连接我一般写成“非阻塞 connect + select 等可写 + SO_ERROR 判断”。这段代码的好处是连接超时可控,界面线程也不会卡三秒。下面这段把 socket 设为非阻塞后发起 connect,然后最多等 3 秒。注意select在 Windows 下第一个参数忽略,填 0 即可;timeval最好每次重新赋,因为某些实现会改写它。
SOCKET ConnectAsync(const char* ip, uint16_t port, int timeoutSec) { SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (s == INVALID_SOCKET) return INVALID_SOCKET; u_long nonBlock = 1; ioctlsocket(s, FIONBIO, &nonBlock); // 非阻塞 connect sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_port = htons(port); inet_pton(AF_INET, ip, &addr.sin_addr); int ret = connect(s, (sockaddr*)&addr, sizeof(addr)); if (ret == SOCKET_ERROR && WSAGetLastError() != WSAEWOULDBLOCK) { closesocket(s); return INVALID_SOCKET; } fd_set wfds; FD_ZERO(&wfds); FD_SET(s, &wfds); timeval tv{timeoutSec, 0}; int n = select(0, nullptr, &wfds, nullptr, &tv); if (n <= 0) { closesocket(s); return INVALID_SOCKET; } int err = 0, len = sizeof(err); getsockopt(s, SOL_SOCKET, SO_ERROR, (char*)&err, &len); if (err != 0) { closesocket(s); return INVALID_SOCKET; } return s; // 连接成功,后续交给收包线程 }参数说明:FIONBIO设为 1 表示非阻塞;select的写集合表示等待连接完成;SO_ERROR取到 0 才算真正成功,不为 0 要把它当连接失败处理。timeoutSec我通常给 3 到 5 秒,内网可以短一点。这里翻车最多的是把WSAEWOULDBLOCK当错误直接返回,结果客户端永远连不上。
4.2 收包线程和 FrameParser:半包、粘包都在这里消化
收包线程用select等可读,收到字节后直接追加到拆包器。拆包器只做一件事:缓冲里够 8 字节包头就解析长度,够 bodyLen 就切出一包,否则继续等。这样服务端一次发三条消息、或者一条大消息被切成三段,都能按顺序还原。下面这段是 common 里最值得抄走的部分。
class FrameParser { public: using Handler = std::function<void(uint16_t, const std::string&)>; explicit FrameParser(Handler h) : handler_(std::move(h)) {} void Append(const char* data, size_t n) { buf_.insert(buf_.end(), data, data + n); TryPop(); } private: void TryPop() { while (buf_.size() >= sizeof(PacketHeader)) { PacketHeader h{}; memcpy(&h, buf_.data(), sizeof(h)); uint32_t bodyLen = ntohl(h.bodyLen); uint16_t msgId = ntohs(h.msgId); if (bodyLen > 512 * 1024) { // 防止坏包撑爆内存 buf_.clear(); return; } if (buf_.size() < sizeof(h) + bodyLen) break; std::string body(buf_.data() + sizeof(h), bodyLen); buf_.erase(buf_.begin(), buf_.begin() + sizeof(h) + bodyLen); handler_(msgId, body); } } std::vector<char> buf_; Handler handler_; };参数说明:Append可以多次调用,缓冲区自己维护;ntohl和ntohs负责把网络字节序转回主机序;bodyLen超限时直接清空缓冲,避免一条坏包把后续消息永久卡住。收包线程里每次recv到多少就 Append 多少,不要自己拼“刚好 4096”的逻辑。处理器handler_里面只做轻量分发,耗时业务另投线程,否则会把收包线程堵住。
4.3 心跳、超时与断线重连
心跳线程不要用大 Sleep,退出时会等很久。我一般用条件变量等 5 秒,收到停止信号立刻醒。连接正常就发一个MSG_PING,超过两次没收到MSG_PONG就标记断开并进入重连。下面这段是简化版,真实工程里把重连次数和退避时间放配置。
void AsyncClient::HeartbeatLoop() { std::unique_lock<std::mutex> lk(waitMutex_); while (running_) { if (cv_.wait_for(lk, std::chrono::seconds(5), [this] { return !running_; })) { break; // Stop() 唤醒后立即退出 } lk.unlock(); if (IsConnected()) { Send(MSG_PING, {}); if (++missPong_ >= 2) { MarkDisconnected(); } } else { TryReconnect(); } lk.lock(); } }参数说明:5 秒是心跳间隔,missPong_统计连续未回包次数,达到 2 次就断开;TryReconnect里做指数退避,别在服务端没起时每秒重连几百次。收到 PONG 时把missPong_清零。wait_for的谓词返回 true 表示收到了停止信号,这样Stop()里notify_all()能马上让线程退出,不会卡在 join。
4.4 客户端发送与关闭顺序:先停线程还是先关 socket
客户端发送要和服务端一样加锁排队,避免界面线程和心跳线程同时写 socket。关闭顺序我踩过坑:先join收包线程,再关 socket,结果收包线程还在select里等,join 永远不返回。正确做法是先置running_ = false,关闭或 shutdown socket 让select/recv醒过来,再 join。下面这段是收尾模板。
void AsyncClient::Stop() { running_ = false; cv_.notify_all(); if (sock_ != INVALID_SOCKET) { shutdown(sock_, SD_BOTH); // 让阻塞中的收包线程醒来 closesocket(sock_); sock_ = INVALID_SOCKET; } if (recvThread_.joinable()) recvThread_.join(); if (heartbeatThread_.joinable()) heartbeatThread_.join(); WSACleanup(); }参数说明:SD_BOTH同时关闭收发方向;closesocket之后不要再使用旧 socket 句柄;join 放在关闭 socket 之后,线程才能从select或recv返回。如果收包线程里还有业务回调,回调里不要再调用 Stop,否则会自己 join 自己。这个顺序写成注释放在Stop()上方,后面接手的人少走弯路。
5. 编译运行与联调排查:端口、粘包和线程退出的坑
5.1 编译顺序和链接设置
打开工程后先编 common,再编 server 和 client。服务端和客户端的“附加包含目录”都要包含 common,链接器输入确认有ws2_32.lib。如果报LPFN_ACCEPTEX未定义,检查是否包含了mswsock.h并在WSAIoctl里取函数指针;如果报std::thread相关错误,老编译器要换_beginthreadex或升级工程工具集。字符集建议先统一成多字节做联调,协议层不要混TCHAR。
启动顺序是 server 先起,看到监听成功日志后再起 client。配置文件里服务端端口和客户端目标端口必须一致,客户端 IP 本机测试用127.0.0.1,局域网测试换成服务端机器地址。可以用 PowerShell 先探端口,不用装额外工具。
# 本机探端口,连通时 TcpTestSucceeded 为 True Test-NetConnection 127.0.0.1 -Port 9000这条命令只验证 TCP 端口能不能握手,不能验证你的应用层协议。握手成功但客户端收不到回包,问题一般在服务端没有关联 IOCP、没有投递 recv,或者客户端拆包长度字段解析错。联调时先把日志打到“accept 到连接”“收到多少字节”“拆出哪个 msgId”,比盯着界面猜快得多。
5.2 本机与局域网联调步骤
本机回环通过后,再做局域网。步骤不复杂:服务端绑定INADDR_ANY,防火墙放行对应端口,客户端填服务端局域网地址,先ping再探端口。不要一上来就改代码,先确认网络层通不通。服务端日志里能看到对端地址,客户端日志里能看到 connect 返回值,这两头一对,问题范围就缩小了。
注意:局域网连不上时先看服务端是不是只绑了
127.0.0.1,再看系统防火墙是否拦截,最后才怀疑 IOCP 代码。顺序反了会浪费很多时间。
联调时我一般准备两个客户端实例,一个正常发消息,一个中途强制结束进程,观察服务端有没有正确释放连接上下文。强制结束会触发对端 recv 返回 0,服务端要能在 bytes 为 0 时关闭 socket、从连接表移除并 delete 上下文。这个场景最能暴露句柄泄漏和重复关闭。
5.3 常见问题排查:五条踩坑记录
现象:服务端 bind 返回失败,错误码 10048。
原因:端口已被别的进程占用,或者上一次调试的 server.exe 没退干净。
解决:用netstat -ano | findstr :9000找到 PID,确认进程后结束它,或者把配置里的端口改成 9001 再跑。改端口后客户端配置要同步改,别只改一头。
现象:客户端 connect 返回 10061,或者异步 connect 一直等到超时。
原因:服务端没启动、监听地址不对、端口不一致,或者系统防火墙拦了入站。
解决:先在本机用Test-NetConnection探端口,确认服务端日志出现 listen 成功;局域网再检查绑定地址和防火墙规则。异步 connect 还要看SO_ERROR,不要只看 select 是否可写。
现象:客户端偶尔收到半条消息,或者两条消息粘在一起。
原因:把 TCP 当成消息边界,一次 recv 就当一包处理;或者包头长度字段用了主机字节序。
解决:统一用PacketHeader加FrameParser,按 bodyLen 循环拆包,发送端htonl/htons,接收端ntohl/ntohs。再加 512KB 上限,坏长度直接丢连接。
现象:关闭客户端时卡死,任务管理器里进程不退。
原因:收包线程阻塞在select或recv,主线程先 join 后关 socket;服务端工作线程卡在INFINITE等完成包。
解决:客户端先置停止标记、shutdown/closesocket 再 join;服务端退出时用PostQueuedCompletionStatus投空完成包唤醒每个工作线程,再关监听和完成端口。
现象:跑一段时间后句柄数上涨,连接多了就失败。
原因:AcceptEx 每轮创建的 socket 在失败分支没关,ConnCtx 没 delete,或者发送队列里的 OVERLAPPED 生命周期比 socket 长。
解决:每个失败分支都成对关闭 socket 和释放上下文;连接上下文用 RAII 包一层,析构里 closesocket;IOCP 退出前确认没有在飞的 WSARecv/WSASend。
5.4 日志和最小化复现
这套工程的日志不要只写“发送成功”。至少要打时间、线程 ID、连接句柄、msgId、bodyLen 和错误码。服务端 accept 时打对端 IP 和端口,收包时打本次字节数和缓冲剩余长度,关闭时打原因。客户端连接状态变化打一行,心跳超时打一行。日志一多会淹没,但排查阶段值得。
最小化复现的思路是:固定一个 msgId,包体只放 1 字节,客户端每 100ms 发一次,服务端原样回。先跑 1000 次看有没有断包;再把包体加到 10KB,观察发送队列是否按偏移推进;最后同时开 20 个客户端,看服务端连接上下文是否成对释放。这样比一上来压大文件更容易定位是协议层还是线程层的问题。
6. 二次开发改造:协议扩展、线程模型替换和压测开关
6.1 协议层扩展:消息 ID 和序列化先定规矩
二次开发最先改的是协议。我的做法是保留 8 字节包头,新增Message结构,用 msgId 区分登录、文本、文件块、心跳,body 里再用一个小型序列化。不要直接在 body 里塞结构体指针,跨编译器和对齐会出问题。改造时先把PacketHeader的flags用起来,标记压缩或分片,后面加功能不用改包头长度。
| 改造点 | 落点 | 验证方式 |
|---|---|---|
| 新增消息类型 | Protocol.h的 msgId 枚举 | 双端各发一条,日志核对 |
| 包体序列化 | BuildFrame与业务 handler | 空包、超长包、坏包各测一次 |
| 分片发送 | 发送队列按偏移推进 | 10KB、100KB 包体各跑 100 次 |
6.2 线程模型替换:客户端换 IOCP 前先量连接数
客户端如果连接数很少,现有的异步 connect 加收包线程够用;要改成 IOCP,先把发送队列和拆包器抽成独立类,再把收包线程替换成完成端口工作线程。服务端工作线程数也不要拍脑袋开几十个,先看 CPU 核数和压测结果。替换时保留原来的FeedParser回调,网络层换掉不影响业务层,这是最省事的改造路径。
6.3 我常留的三个开关
我会在配置里留三个开关:心跳间隔、最大包长、工作线程数。前两个联调时经常改,第三个压测时改。每次改完先跑本机回环,再跑 20 个客户端同时上下线,最后才看大数据包。从那以后我每次改收发队列,都先把关闭路径压测三遍再提交,因为退出卡死比连接失败更难查。如果你拿到的是这套双端 Socket 资源包,建议先从 server 的PostAccept和 client 的FrameParser这两个函数读起,跑通本机回环后再动协议。希望帮到你。
本文还有配套的精品资源,点击获取