VC异步多线程Socket双端工程:IOCP服务端与客户端拆解
简介面向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因为双端能不能对上八成取决于包头长度、字节序和最大包限制。目录清单可以按下面这张表对照源码包里不一定会一字不差但入口基本都在这些位置。目录主要内容二次开发入口serverWin32 服务端入口、IocpServer、ConnCtx、发送队列PostAccept、Worker、SendPacketclient异步连接、收包线程、心跳重连、消息回调ConnectAsync、RecvLoop、HeartbeatLoopcommonProtocol.h、FrameParser、字节序工具、日志PacketHeader、BuildFrame、Appendconfig端口、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.2WSA_FLAG_OVERLAPPED决定这个 socket 能不能参与重叠 I/OSOMAXCONN是系统允许的等待队列上限直接用它比手写 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::vectorchar sendQueue; std::mutex sendMutex; std::atomicbool 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_guardstd::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 异步 connectWSAEWOULDBLOCK 不是失败客户端连接我一般写成“非阻塞 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::functionvoid(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::vectorchar buf_; Handler handler_; };参数说明Append可以多次调用缓冲区自己维护ntohl和ntohs负责把网络字节序转回主机序bodyLen超限时直接清空缓冲避免一条坏包把后续消息永久卡住。收包线程里每次recv到多少就 Append 多少不要自己拼“刚好 4096”的逻辑。处理器handler_里面只做轻量分发耗时业务另投线程否则会把收包线程堵住。4.3 心跳、超时与断线重连心跳线程不要用大 Sleep退出时会等很久。我一般用条件变量等 5 秒收到停止信号立刻醒。连接正常就发一个MSG_PING超过两次没收到MSG_PONG就标记断开并进入重连。下面这段是简化版真实工程里把重连次数和退避时间放配置。void AsyncClient::HeartbeatLoop() { std::unique_lockstd::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 包一层析构里 closesocketIOCP 退出前确认没有在飞的 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这两个函数读起跑通本机回环后再动协议。希望帮到你。本文还有配套的精品资源点击获取