简介:一份面向网络编程初学者的Socket异步通信与多人聊天示例工程,内容涵盖Socket异步收发、多线程并发处理、双端队列缓冲以及UDP广播通信等关键知识点,适合作为课程设计或自学者入门参考。项目基于Visual C++开发,压缩包共31个文件,主要包含cpp源文件、h头文件等核心代码,另有bmp位图与ico图标等界面资源,整体体积仅120KB,结构紧凑,便于快速阅读与调试。示例代码演示了如何创建接收线程和发送线程,采用锁或信号量处理同步问题,并利用双端队列作为消息缓冲区,在保证线程安全的同时提升消息处理效率。已有229人学习,适合希望深入理解网络编程、多线程同步及数据结构实际应用的开发者参考。通过该项目可以掌握小型聊天系统从通信框架到界面实现的完整脉络,获得线程启停、消息缓冲、UDP广播等常见问题的处理思路。
1. 一个VC6时代的异步Socket聊天Demo:为什么我现在还在拆它
这个压缩包我拆过不下十次,每次给新人讲 Socket 异步通信,我都会直接拿它当底稿:一个 VC6 时代的 MFC 对话框程序,把异步 Socket、收发线程、双端队列、TCP/UDP 两种链路、多人聊天界面全塞进了一个很小的工程。它的核心价值不是界面多漂亮,而是把网络回调里收到的字节流先丢进 Fifo 队列,再由独立工作线程取出来处理——这套"产销分离"的模型,到现在依然是高并发聊天系统的基本盘。适合三类人:第一次把多线程塞进网络编程的应届生、要快速出一个聊天 Demo 的桌面端开发者、想搞清楚异步回调与线程协作到底怎么落地的 C++ 工程师。源码读完一遍,你大概率会照着它重写一版,因为里面踩过的坑,你现在多半还会踩。
2. 架构拆解:异步Socket、收发线程与Fifo双端队列怎么配合
2.1 Socket异步通信:事件通知驱动,为什么比阻塞recv适配聊天
先回答一个最基础的问题:为什么这个项目叫"异步通信",而不是老老实实开一个线程去 recv 然后 block 在那里?因为阻塞 recv 的线程会被挂起,数据到达前你拿它没办法。你要是给每个客户端连接都配一个阻塞 recv 线程,十个连接就要养十个线程,线程切换开销、栈空间占用都是实打实的,而且聊天这种事本质上就是"大部分时间没消息、偶尔来一条",阻塞线程大部分时间都白挂着。
这个 Demo 用的是 MFC 的 CAsyncSocket 那一套事件驱动模型,底层是 WSAAsyncSelect。它的做法是:Socket 注册到窗口消息循环里,内核在收到数据、连接建立、连接关闭这些事件发生时,往窗口过程投递一条自定义消息,程序在消息响应函数里把事情处理掉。这样网络层本身不占线程,只占一个消息循环。你不需要手动去等 recv 返回,数据来了系统会通知你。
但这里有个微妙的地方:事件通知只是告诉你"有数据了",不代表数据能一次读完。TCP 是字节流,一次 recv 可能只收到半条消息,也可能一次收到好几条。所以真正可靠的方案是——回调里只负责把原始字节塞进缓冲区,什么时候凑够一条完整消息、怎么解析,交给另一个专门的线程去做。这个项目最聪明的部分就在这:它用 Fifo.h 这个双端队列当缓冲区,把"网络回调"和"消息处理"彻底解耦了。
2.2 双端队列在消息链路里的位置:队列两端各司其职
Fifo(First In First Out)这个名字容易让人误会它只是普通队列,但它其实是双端队列,两端都能操作。在这个聊天项目里,它的分工很明确:队尾只负责接收数据,网络回调收到多少字节就 PushTail 多少;队头只负责消费数据,工作线程循环 PopHead 取出一条完整消息去更新界面。
为什么非要双端队列而不是普通队列?因为聊天场景里消息是有优先级的。普通队列只能一头进一头出,最多保证先进先出。双端队列可以让紧急消息从队头插进去,比如"某个用户下线了"这种系统通知,如果跟在长消息后面排队,用户会看到明显的延迟;插到队头就能立刻处理。这个需求在多人聊天里非常常见,所以选型是有依据的,不是炫技。
队列本身在锁的保护下工作,Push 和 Pop 都是 O(1) 操作。它的容量是构造时定死的环形缓冲,满了就返回失败而不是无限增长,这个设计决定了它不会在突发流量下把内存撑爆。
2.3 一条消息从网卡到界面按钮要穿过哪些层
可以把这个链路拆成四层,理解了这四层,这个项目的骨架你就全拿捏了。第一层是 Socket 事件层,WSAAsyncSelect 通知"这个 socket 可读了";第二层是网络回调层,OnReceive 里调用 Receive 把字节从内核缓冲区搬到用户态,然后按字节 PushTail 进 Fifo;第三层是工作线程层,一个独立线程死循环 PopHead,取出来的数据按协议头解析成消息结构体;第四层是 UI 层,工作线程不能直接改界面控件,它通过 PostMessage 往对话框发一条自定义消息,让 UI 线程去更新聊天列表。
这个分层最直接的好处是:网络回调里绝不做耗时操作,它只搬运数据;工作线程阻塞或慢一点,顶多让队列深一点,不会卡界面;而 UI 永远只被 UI 线程碰,彻底躲开了"跨线程操作控件"这个雷区。消息结构体一般长这样:
struct MsgItem { char nick[32]; // 发送者昵称,协议里固定32字节 int msgType; // 0=普通消息,1=上下线通知,2=心跳 int msgLen; // 消息体长度,用于处理半包/粘包 char data[2048]; // 消息体,按msgLen取用 };这里把 nick 定死成 32 字节、data 定死成 2048 字节,是典型的嵌入式定长协议思路。好处是工作线程拿到结构体后不需要再做内存分配和释放,坏处是超过 2048 字节的聊天内容会被截断。对一个 Demo 来说,这个取舍是合格的——先保稳定,再谈扩展。msgType 和 msgLen 这两个字段是后面处理粘包拆包的命根子,别省。
3. 代码级拆解:Fifo.h双端队列与线程安全的取舍
3.1 环形缓冲版Fifo:核心API与容量设计
Fifo.h 在这个项目里是个独立文件,不依赖 MFC,纯标准 C++ 就能编译。它的实现不是链表,而是环形缓冲数组,这个选型我要多说两句。链表的好处是扩容灵活,但每次 Push 都要 new 一个节点,在高频消息下会频繁触发堆分配,而且节点之间内存不连续,CPU 缓存命中率低。环形缓冲数组正好反过来:内存一次性分配、下标访问、缓存友好,代价是容量固定。聊天场景的消息速率再高也有限,环形缓冲完全够用。
template<class T> class Fifo { public: explicit Fifo(int capacity = 1024) : _capacity(capacity) { _buf = new T[_capacity]; _head = _tail = 0; ::InitializeCriticalSection(&_cs); } ~Fifo() { ::DeleteCriticalSection(&_cs); delete[] _buf; } bool PushTail(const T& item) { ::EnterCriticalSection(&_cs); int next = (_tail + 1) % _capacity; if (next == _head) { // 队尾再走一步就撞上队头,说明队列满了 ::LeaveCriticalSection(&_cs); return false; } _buf[_tail] = item; _tail = next; ::LeaveCriticalSection(&_cs); return true; } bool PopHead(T& item) { ::EnterCriticalSection(&_cs); if (_head == _tail) { // 队头等于队尾,说明队列空 ::LeaveCriticalSection(&_cs); return false; } item = _buf[_head]; _head = (_head + 1) % _capacity; ::LeaveCriticalSection(&_cs); return true; } private: T* _buf; int _capacity; int _head; // 队头下标,Pop时移动 int _tail; // 队尾下标,Push时移动 CRITICAL_SECTION _cs; };这段代码里最容易被忽略的是"预留一格"的设计。环形队列判满和判空的条件冲突了:空的时候_head == _tail,满的时候如果也让_head == _tail成立,就没法区分了。所以这个实现牺牲一个存储格,让队列实际最多只能装_capacity - 1个元素,换来一个干净利落的满判断——(_tail + 1) % _capacity == _head。capacity 默认给 1024,如果你的聊天消息很频繁或者单条消息很大,改成 4096 或 8192 都行,但别给太大,因为它是一次性分配的连续内存。
3.2 把临界区缝进Push/Pop:锁粒度与竞态
多线程环境下队列最容易翻车的点就是竞态。想象一下这种情况:工作线程在 PopHead 里刚读完_head,还没来得及移动下标,网络回调线程就 PushTail 进来了,两个线程同时在改队列的内部状态,要么数据被覆盖,要么下标错乱。所以这个项目用的是 Windows 临界区 CRITICAL_SECTION,把它包住了整个操作过程。
锁的粒度值得注意。PushTail 和 PopHead 都是把"检查状态→操作数据→移动下标"作为一个原子整体锁起来的。有人图省事,只锁数据复制那一行,不锁下标移动,那等于没锁。这个项目两个函数各锁各的,锁内不调用用户代码、不做网络操作,所以临界区极其短小,两个线程争锁的概率很低,性能不会有问题。
还有一个容易踩的坑:临界区对象是成员变量,在构造函数里 Initialize、在析构函数里 Delete。如果你把这个队列复制了一份,或者把它按值塞进了某个容器,CRITICAL_SECTION 的句柄被隐式拷贝,两个实例共用一个内核对象,会出现莫名其妙的死锁。记住:带锁的队列只允许指针传递或引用传递,永远不要拷贝构造。
3.3 为什么不用std::deque加锁,而自己维护环形队列
写到这里肯定有人问:为什么不用std::deque<std::string>加一个std::mutex,不是更省事吗?这里得先看清楚这个项目的时代背景——它是 VC6 工程,编译器对 C++11 标准库的支持约等于零,std::mutex、std::thread这些全都不存在,所以用 Windows API 加自定义容器是最务实的路。抛开时代背景,这个选型也依然有合理成分:std::deque是分块链表,Push 到尾部偶尔会触发块分配,而且它只能保证"尾进头出",不支持高效地从头部插入紧急消息。真要实现同样的功能,你得再包一层std::list或者维护两个 deque,反而更复杂。
那如果把环境换到现代 C++ 呢?我一般的做法是:保留这个 Fifo 的接口设计,把内部实现替换成std::deque加std::mutex加std::condition_variable,再加一个PushFront方法用于插队。但有一条原则不会变:锁必须封在队列内部,外部的 Socket 回调和 UI 线程绝对不能直接碰队列的下标或缓冲数组。接口稳定了,内部怎么换都不影响整体架构。
4. UDP广播链路:从SO_BROADCAST到消息分发
4.1 TCP与UDP在这个聊天Demo里的分工
项目里同时涉及 TCP 和 UDP,很多人一开始会困惑:既然 TCP 可靠,为什么不全都用 TCP?答案是场景不同。TCP 适合一对一、需要保证顺序和完整性的数据,比如两个人私聊、传输文件;UDP 适合一对多广播,比如进入聊天室的时候要让所有在线用户立刻知道"我来了",这种通知如果逐个用 TCP 发,服务器要写一个循环遍历所有连接,任何一个连接阻塞都会拖慢全局。
UDP 的代价是它本身就是"发了就不管"的协议,内核不保证送达、不保证顺序、不保证不重复。在局域网内这种环境下,丢包率极低,对于"某某上线了"这种通知类消息,偶尔丢一条重新补发就是,没必要为此维护一套 TCP 的状态机。所以这个项目的定位很清晰:UDP 只负责广播和轻量通知,TCP 负责可靠的会话数据,两者各管一段。
4.2 UDP收发核心代码:socket、bind、sendto、recvfrom
UDP 端的代码比 TCP 简洁得多,因为无连接,不需要 listen、accept、握手。核心就四个步骤:创建 socket、设置广播选项、bind 端口、然后 sendto 发送 / recvfrom 接收。
// 创建UDP socket SOCKET s = ::socket(AF_INET, SOCK_DGRAM, 0); if (s == INVALID_SOCKET) { // 检查 WSAGetLastError(),常见是 WSAEAFNOSUPPORT return; } // 关键:允许发送广播包,这一步漏了 sendto 会报 WSAEACCES BOOL bBroadcast = TRUE; ::setsockopt(s, SOL_SOCKET, SO_BROADCAST, (const char*)&bBroadcast, sizeof(bBroadcast)); // bind 固定端口,不然别人不知道该往哪发 sockaddr_in local; local.sin_family = AF_INET; local.sin_port = htons(8899); local.sin_addr.s_addr = htonl(INADDR_ANY); ::bind(s, (sockaddr*)&local, sizeof(local)); // 发送广播:目标地址是子网定向广播地址 sockaddr_in target; target.sin_family = AF_INET; target.sin_port = htons(8899); target.sin_addr.s_addr = inet_addr("192.168.1.255"); int n = ::sendto(s, msg, len, 0, (sockaddr*)&target, sizeof(target)); if (n == SOCKET_ERROR) { int err = WSAGetLastError(); // 常见 WSAEACCES = 没开SO_BROADCAST } // 接收:recvfrom 可以拿到发送者地址,用于回显昵称 char buf[2048]; sockaddr_in from; int fromLen = sizeof(from); int r = ::recvfrom(s, buf, sizeof(buf) - 1, 0, (sockaddr*)&from, &fromLen); if (r > 0) { buf[r] = '\0'; // from.sin_addr 就是发送方IP,from.sin_port 是发送方端口 }这里有两个参数细节要解释。第一个是 SO_BROADCAST 选项,它相当于内核的一个保险栓,不主动打开就禁止发广播包,防止程序误发广播把局域网打满,所以漏掉它最常见的报错是 WSAEACCES。第二个是广播地址,255.255.255.255是全局广播地址,很多路由器和系统默认不转发;192.168.1.255是子网定向广播地址,只要你的子网掩码是 255.255.255.0,这个地址就能把包发到 192.168.1.x 网段所有机器上,这也是我在实测里更推荐的写法。
4.3 局域网多人聊天的消息格式与分发策略
UDP 广播有个天然特性:发出去的消息网段内所有监听同端口的进程都能收到,这一下就把"多人聊天"做成了。不需要服务器中转,每个客户端自己广播自己的消息,别人的消息直接到达本机。消息格式得定成自描述的结构,因为 UDP 是数据报协议,一次 recvfrom 拿到的就是发送方一次 sendto 的内容,天然没有 TCP 的粘包问题,但你需要自己解决"怎么从字节流里认出这是一条消息"。
这个项目里我建议的消息头沿用第 2 章的 MsgItem 结构,只是把传输层从 TCP 换成 UDP。重点说一下分发策略:收到一条广播消息后,第一件事是判断来源。如果是自己发出去的消息,直接丢掉——因为本机 UI 已经把这条消息显示过了,不丢的话你会看到自己说的话重复出现两遍,这是 UDP 聊天最经典的体验问题。判断依据是from.sin_addr是否等于本机 IP,或者你给每条消息带一个全局唯一的自增 ID。其次,消息类型是普通聊天,就追加到聊天框;是上下线通知,就更新右侧在线用户列表。
// 分发逻辑骨架 if (from.sin_addr.S_un.S_addr == myIpAddr) { return; // 丢弃自己广播的消息,避免UI重复显示 } switch (item.msgType) { case MSG_CHAT: AppendChatText(nick, data); // 显示聊天消息 break; case MSG_USER_IN: // 上线通知 AddUserToList(nick, from.sin_addr); break; case MSG_USER_OUT: // 下线通知 RemoveUserFromList(nick); break; default: break; }把myIpAddr比较放在 switch 之前,这个顺序能省下很多无谓的字符串格式化开销。多人聊天场景下,N 个在线用户每人广播一条,每个人实际要处理 N-1 条,自己那条是唯一可以确定丢弃的,放在最前面判断是性价比最高的过滤。
5. 避坑:异步回调与线程生命周期的五个翻车现场
5.1 程序关了但进程还在:工作线程没等它就走了
现象:对话框点关闭按钮,界面瞬间消失,但打开任务管理器,进程还挂在列表里,CPU 占用一直是 0%,怎么关都关不掉,只能结束进程。
原因:关闭对话框只销毁了窗口,但后台工作线程还在死循环里跑。线程的执行函数被捆绑在队列的 PopHead 上,队列长时间空着,它就一直在睡 10 毫秒再醒来轮询,永远不退出。窗口销毁不等于进程退出,只要还有非守护线程活着,进程就赖着不走。
解决:给工作线程一个退出标志,并且关闭窗口时先置标志、再等线程结束、最后才销毁窗口。顺序反了就会变成窗口没了但线程还活着。
// OnClose 里按顺序做三件事 void ChatDlg::OnClose() { m_bRunning = FALSE; // 1. 先通知线程退出 ::WaitForSingleObject(m_hThread, 2000); // 2. 等线程退出,最多等2秒 m_listener.Close(); // 3. 最后关socket,释放端口 CDialog::OnClose(); // 4. 销毁窗口,这时进程才能干净退出 }线程函数里对应地要检查标志:
UINT WorkThreadProc(LPVOID lpParam) { ChatDlg* pDlg = (ChatDlg*)lpParam; MsgItem item; while (pDlg->m_bRunning) { // 每轮循环都检查标志 if (pDlg->m_fifo.PopHead(item)) { ::PostMessage(pDlg->m_hWnd, WM_MSG_READY, 0, 0); } else { ::Sleep(10); // 队列空,睡一小会再轮询 } } return 0; }WaitForSingleObject 的第二个参数是超时时间,给 2000 毫秒是个血泪经验总结。如果线程当前正好在 PopHead 的临界区里,它很快就会出来,2000 毫秒足够;如果线程因为别的原因卡死了,超时之后至少还能走强制销毁流程,不会让整个关闭动作无限期挂起。
5.2 Fifo数据时而错乱:临界区漏加与锁粒度不对
现象:程序跑起来之后,聊天消息偶尔会串台。A 发的消息显示成 B 发的,或者消息框里多出几段乱码,而且问题出现毫无规律,重开程序又好了。
原因:这是典型的队列竞态。我在 3.2 里说过,锁必须覆盖整个操作过程。但很多人写代码时只看"数据复制"那部分,把下标移动放到了锁外面,觉得移动下标是赋值操作、很安全。事实上队列满判断靠的就是_head和_tail的关系,下标移动一旦发生在锁外,另一个线程看到的队列状态就是半新半旧的,数据错乱完全是必然的。
解决:锁的作用范围严格对齐"读取/修改队列内部状态"的全部代码。卡一个死标准——PushTail 和 PopHead 里每一个读取了_head或_tail的语句都必须待在临界区里。检查方法也简单:临时把锁删掉跑一遍,如果崩得稀里哗啦,说明锁没问题;如果锁在的时候偶发错误、锁删掉后立刻崩,那就更说明锁不完整,顺着_head和_tail的引用逐行找漏网的代码。
还有个隐蔽点:队列里存的是 MsgItem 结构体,它内部有 char 数组,Pod 类型没有深拷贝问题,但如果哪天你把这个队列改成存std::string或自定义类,临界区里赋值触发的临时对象构造和析构会让锁持有时间明显变长,那时候就得考虑把锁粒度再拆细,或者直接换无锁队列。现阶段别过度设计。
5.3 UDP本机能收到、跨机器收不到:广播地址与SO_BROADCAST
现象:聊天程序在单机上双开两个实例互相广播,消息收发都正常。换到两台物理机,同一个交换机同一个网段,广播消息死活过不去,抓包发现本机网卡确实把包发出去了,对端机器上就是没有进程能收到。
原因:两个配置错误,一个软件一个硬件。软件层面,八成是把广播地址写成了127.0.0.1或255.255.255.255。127.0.0.1是本机回环地址,包永远出不了网卡;255.255.255.255是受限广播地址,很多路由器默认丢弃,换了网段基本必挂。硬件层面,少部分交换机会开启广播风暴抑制功能,直接丢弃过量的广播包,这个只能去交换机配置里确认。
解决:广播地址改成子网定向广播地址192.168.1.255,子网掩码决定最后的数字是几。先查本机 IP 和掩码,再用ipconfig确认网段,然后用ping 192.168.1.255测试广播连通性,能通再跑程序。测试工具推荐用网络调试助手,单独发一条 UDP 广播包到对应端口,这样能快速定位是发送端的问题还是接收端的问题,不要把时间浪费在和聊天程序死磕上。
5.4 重开程序报"每个套接字地址只允许使用一次":端口未释放
现象:程序正常退出,马上再次启动,bind 直接失败,错误码是 WSAEADDRINUSE,对应的中文报错就是那句著名的"通常每个套接字地址(协议/网络地址/端口)只允许使用一次。"
原因:TCP 连接关闭后,端口会进入 TIME_WAIT 状态,内核默认要等 2MSL(大约 2 到 4 分钟)才会真正释放。你程序退出时 socket 是关闭了,但内核还在为这个端口保留连接记录,这时候立刻重新 bind 同一个端口,内核不允许。还有另一种可能性更低级:上一个程序实例没退干净,进程还占着端口。后者用 netstat 一查就知道。
解决:开发调试阶段,给监听 socket 设置 SO_REUSEADDR,允许内核在 TIME_WAIT 状态下复用地址。这是最快见效的后悔药。
BOOL bReuse = TRUE; ::setsockopt(s, SOL_SOCKET, SO_REUSEADDR, (const char*)&bReuse, sizeof(bReuse));这两个选项别搞混:SO_REUSEADDR 解决的是 TIME_WAIT 状态下的地址复用问题;SO_BROADCAST 解决的是广播包的发送权限问题。我把它们写在同一章里的原因是实际项目里有大量的人把两者混为一谈,ethtool 抓包时端口已经在 TIME_WAIT 了,还以为是广播选项没设对,白查半天。
5.5 TCP第二个客户端连不上:Accept只做了一次
现象:聊天程序里 TCP 模式连第一个客户端一切正常,消息收发都通。第二个客户端尝试连接,TCP 握手能完成(用 telnet 能看到连接建立),但服务器端界面毫无反应,没有任何新用户加入的提示。
原因:Accept 这个函数是从已完成连接队列里摘一个连接出来,摘走一个就少一个。很多初版代码只把它放在 OnAccept 消息响应里做一次,第一个连接被摘走后,监听 socket 虽然还在继续监听、内核也还能完成握手,但程序再也没去队列里取新的连接,后面的连接全部堆积在 backlog 里。
解决:每次 OnAccept 都调用 Accept,处理完新连接后,让监听 socket 和窗口的消息循环保持绑定关系,确保下次还能收到 FD_ACCEPT 通知。同时把 backlog 设置在 5 到 10 之间,别用默认值 1。
void ChatDlg::OnAccept() { SOCKET client = m_listener.Accept(); // 摘一个出来 AddClient(client); // 把这个连接加入连接池 // 注意:这里不要关闭监听socket,也不要停止监听 }加完这行,第二个、第三个客户端都能进得来。但也要提醒一句:这个项目是单连接池手工管理,连接多了之后,你得把"管理连接"和"收发数据"的逻辑从 UI 线程里挪出去,否则界面会被 Accept 和收数据的回调卡住,那就是新的一层问题了,等改造阶段再动。
6. 把Demo跑起来,并改造成自己的聊天模块
6.1 第一次运行的自测步骤
这个工程是 VC6 时代的 .dsp/.dsw 工程,现代 Visual Studio 打开时会提示转换,转换后大概率需要重新设置字符集,原工程多半是 ANSI 编码,中文引号和注释在新编译器下容易报 C4819 警告,直接忽略不影响编译。建议先把工程属性里的"字符集"改成"使用多字节字符集",和原工程保持一致,省一堆编码问题。
编译通过后,第一次运行按顺序测四件事。第一,本机双开两个实例,一个用 UDP 广播模式,一个收消息,验证本机回环链路通不通。第二,两个实例都切到局域网模式,用两台物理机互发消息,验证广播地址和防火墙放行。Windows 防火墙默认可能会拦 UDP 入站,记得在入站规则里放行对应端口。第三,切到 TCP 模式,先连第一个客户端,再连第二个,验证 Accept 逻辑是否完整。第四,关掉程序立刻重开,验证 SO_REUSEADDR 是否生效,端口能不能立刻复用。
我自己每次跑这种 Demo 都会强烈建议用网络调试助手当第三方收发包工具,它能独立地验证你程序的收发逻辑——程序发出去的包,助手能收到;助手发的包,程序也能解析。这样能干净地把"网络通不通"和"程序逻辑对不对"两个问题拆开,不用盲猜。
6.2 从Demo到可用模块的三步改造
这个项目直接拿去生产肯定不行,但改成业务能用的聊天模块,我建议按三步走。第一步处理粘包拆包,现在 Fifo 里塞的是裸 MsgItem,TCP 模式下一次 Receive 可能只收到半个结构体,改造思路是队列里先攒原始字节流,解析完消息头和长度字段后再按长度切出完整消息。第二步加心跳和超时机制,UDP 广播没有连接状态,用户掉线了别人根本不知道,每 10 秒广播一条心跳消息,连续收不到心跳就把对方从在线列表里移除。第三步把网络层从 MFC 的 CAsyncSocket 剥离成独立的 C++ 类,UI 只暴露发送消息接口,回调通过函数指针注册,这样模块就能被 Qt 或者其它 UI 框架复用。
改完这三步,你会发现这个 Demo 的真正价值不是拿来跑通,而是它的分层结构——异步回调只管收、Fifo 管缓存、工作线程管解析、UI 线程管展示——这个骨架在任何语言里都能平移。从那以后我每次带新人看网络程序,都强制把它拆成这四层讲一遍,让新人先画链路图再动手改代码,改出来基本不会跑偏。希望帮到你,动手试试吧。
本文还有配套的精品资源,点击获取