ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从VC6聊天Demo看异步Socket与Fifo队列架构:线程安全与TCP/UDP实战

从VC6聊天Demo看异步Socket与Fifo队列架构:线程安全与TCP/UDP实战 简介一份面向网络编程初学者的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 双端队列在消息链路里的位置队列两端各司其职FifoFirst 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 缓存命中率低。环形缓冲数组正好反过来内存一次性分配、下标访问、缓存友好代价是容量固定。聊天场景的消息速率再高也有限环形缓冲完全够用。templateclass 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::dequestd::string加一个std::mutex不是更省事吗这里得先看清楚这个项目的时代背景——它是 VC6 工程编译器对 C11 标准库的支持约等于零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、recvfromUDP 端的代码比 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 就是发送方IPfrom.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 里塞的是裸 MsgItemTCP 模式下一次 Receive 可能只收到半个结构体改造思路是队列里先攒原始字节流解析完消息头和长度字段后再按长度切出完整消息。第二步加心跳和超时机制UDP 广播没有连接状态用户掉线了别人根本不知道每 10 秒广播一条心跳消息连续收不到心跳就把对方从在线列表里移除。第三步把网络层从 MFC 的 CAsyncSocket 剥离成独立的 C 类UI 只暴露发送消息接口回调通过函数指针注册这样模块就能被 Qt 或者其它 UI 框架复用。改完这三步你会发现这个 Demo 的真正价值不是拿来跑通而是它的分层结构——异步回调只管收、Fifo 管缓存、工作线程管解析、UI 线程管展示——这个骨架在任何语言里都能平移。从那以后我每次带新人看网络程序都强制把它拆成这四层讲一遍让新人先画链路图再动手改代码改出来基本不会跑偏。希望帮到你动手试试吧。本文还有配套的精品资源点击获取
返回列表