资讯动态

MFC Socket一对多广播:CAsyncSocket服务端实现与改造指南

发布时间:2026/10/18 20:48:17 来源:尧图企业网站定制
简介这是一份基于MFC CSocket类实现“一个服务器对多个客户端”即时通讯的完整示例主要面向已有Java Socket编程经验、希望用C/MFC实现更高效率网络通信的开发者。示例包含SocketServer与ClientSocket两个工程核心代码注释详细服务端用CPtrList集合保存客户端socket对象与Java中Vector保存socket并结合多线程的思路形成对照可帮助读者理解MFC异步通信机制以及CSocketFile、CArchive配合使用的网络编程模型。压缩包共75个文件以cpp源文件、h头文件为主附带工程配置、资源文件及可直接运行的exe整体3.44MB便于边阅读边运行验证。学习时建议从服务端onAccept回调读起再结合客户端OnSendButton方法切入配合util目录中规范的辅助类可较快掌握一对多通信的整体流程。目前已有755人学习下载适合希望深入掌握MFC网络编程并借鉴Java思想实现一对多即时通讯的开发者。1. 一对多即时通讯示例MFC Socket 服务端广播机该怎么写如果你的工作场景里需要让一台电脑给多台电脑同时推送消息比如机房统一发通知、实验室批量下发指令、局域网内做消息广播那你大概率会遇到一个尴尬网上的 Socket 示例清一色是一对一聊天改造成多客户端时要么在 Accept 循环里卡死要么发消息时只发给了最后连上的那台机器。这份 MFC Socket 编程示例解决的正是这个问题——它用一个服务端对接多个客户端实现简单的即时通讯核心是广播转发任何客户端发来的消息服务端会原样转发给所有在线客户端。技术栈是 MFC 的 CAsyncSocket 配合多线程模型适合已经在用 VC 维护老项目的开发者也适合想弄懂 Socket 一对多架构的学生。2. 架构设计CAsyncSocket 加多线程为什么不用阻塞 Socket2.1 技术选型CAsyncSocket 与阻塞 Socket 的取舍在 Windows 平台上写 Socket 服务端最原始的做法是纯 C 风格的 socket、bind、listen、accept配合阻塞式 recv。这种写法在单客户端时很直观但一旦进入多客户端场景阻塞 recv 会让主线程卡在某个连接的接收上其他客户端的连接请求全部排队。要解决就得为每个连接开一个线程线程里再跑阻塞 recv——这确实可行但线程数量、退出清理、界面通知都会变成负担。MFC 提供的 CAsyncSocket 走的是另一条路它把 WSAAsyncSelect 封装进来让网络事件FD_READ、FD_ACCEPT、FD_CLOSE变成窗口消息在网络线程收到数据时系统会回调 OnReceive 函数。你用消息驱动代替了线程阻塞等待。在这份示例里服务端配合一个独立的工作线程来跑 Accept 循环客户端列表用一个数组管理。这样做的好处是界面线程不会被网络阻塞卡死收到数据后的通知天然落在消息循环里代码结构比起 N 个线程 N 个阻塞 recv 要清晰得多。为什么不干脆用 CAsyncSocket 完全取代线程因为 Accept 循环如果能被窗口消息驱动那 Listen 套接字也要绑定窗口句柄。这份示例选择把监听套接字放在一个 CWinThread 子线程里用阻塞 Accept 等待客户端接入Accept 返回后立即把新连接切换到消息驱动模式。混搭的好处在于Accept 循环逻辑简单直接而每个客户端连接的数据收发又由消息机制管理两边的优点都拿到了。2.2 目录结构与工程脉络打开工程后整体结构并不复杂核心文件按职责划分Server/ ServerDlg.h / ServerDlg.cpp // 主对话框启动监听、显示在线客户端列表、接收聊天记录 ListenThread.h / ListenThread.cpp // 监听线程Accept 循环所在 ClientSocket.h / ClientSocket.cpp // 从 CAsyncSocket 派生封装每个客户端的收发 ServerSocket.h / ServerSocket.cpp // 监听套接字封装 Client/ ClientDlg.h / ClientDlg.cpp // 客户端主界面输入IP、端口、连接、发送 ClientSocket.h / ClientSocket.cpp // 客户端用的 Socket 派生类服务端主对话框维护着一个客户端 Socket 指针数组每个元素对应一个已连接的客户端。数组上限通常写成 64 或 128超出时提示服务器已满。消息广播的逻辑就在主对话框里遍历数组对每个有效 Socket 调用 Send 方法。这种结构对二次开发很友好你要改的最大概率是两处服务端的广播策略改成私聊或分组客户端的消息解析改成自定义协议。这两处在代码里的位置都很集中后面我会在改造章节细讲。2.3 通信协议的简单约定这个示例是学习用途协议没有做成二进制包头那种复杂结构但也不是裸字符串裸奔。客户端发送的消息约定为固定格式消息内容服务端收到后直接转发。为了避免 TCP 粘包问题代码里在每条消息后面加了结束标记比如 \r\n接收方按行解析。虽然简陋但在学习阶段够用跑通了再去升级协议也不迟。服务端还会在客户端连接和断开时往广播里插入系统提示比如客户端 192.168.1.10 上线了这样所有客户端都能看到在线状态变化。这些逻辑在核心代码章节里都会落到具体代码上。3. 核心模块拆解服务端从监听、接入到消息广播的完整链路3.1 监听线程Accept 循环不能卡死主窗口服务端启动监听后主对话框创建一个监听线程。线程函数内部创建一个监听 Socket绑定端口后进入 while 循环调 Accept。我把关键代码以伪代码级别简化但在自己工程里可以直接抄// ListenThread.cpp UINT ListenThreadProc(LPVOID pParam) { CServerDlg* pDlg (CServerDlg*)pParam; CServerSocket listenSocket; listenSocket.Create(pDlg-m_nPort); // 绑定端口默认端口比如 8888 listenSocket.Listen(5); // 监听队列最大长度 5 while (!pDlg-m_bStopListen) // 退出标志由主对话框控制 { CClientSocket* pClient new CClientSocket(pDlg); if (listenSocket.Accept(*pClient)) { // 把新客户端加入界面列表和数组 pDlg-AddClient(pClient); } else { delete pClient; // Accept 失败要释放否则内存泄漏 Sleep(10); // 防止忙等 } } listenSocket.Close(); return 0; }这段逻辑里有一个新手常见的翻车点Accept 是阻塞的如果用它返回的 CClientSocket 来收发数据收数据时不能直接阻塞等待。正确做法是 Accept 成功后立刻把这个客户端的 Socket 句柄绑定到消息窗口。所以这里 CClientSocket 构造函数里要调用AsyncSelect(WM_SOCKET_MSG)把自己和主对话框窗口句柄关联起来之后的收发事件就变成消息了。监听线程与主对话框之间通过一个AddClient方法交接这个方法干两件事把 CClientSocket 指针存进数组同时把客户端 IP 端口格式化后加到列表框控件里。注意这里涉及跨线程访问 UI所以 AddClient 内部应该用 PostMessage 通知界面线程去更新控件而不是直接操作。因为这份示例规模不大直接在调用处用了临界区保护数组列表刷新则通过发消息这是稳妥的写法。3.2 客户端 Socket 派生类OnReceive 是收包的核心入口每个客户端对应一个 CClientSocket 实例它从 CAsyncSocket 派生重写 OnReceive 和 OnClose。收数据的主战场在 OnReceive// ClientSocket.cpp void CClientSocket::OnReceive(int nErrorCode) { if (nErrorCode ! 0) { // 错误处理通知界面移除该客户端 return; } char szBuf[4096] {0}; int nLen Receive(szBuf, sizeof(szBuf) - 1); if (nLen 0) { // 把收到的字节追加到粘包缓冲 m_strBuffer CString(szBuf); // 按行解析遇到 \r\n 就提取一条完整消息 int pos 0; CString strLine; while ((pos m_strBuffer.Find(_T(\r\n))) 0) { strLine m_strBuffer.Left(pos); m_strBuffer m_strBuffer.Mid(pos 2); // 把这条消息交给主对话框做广播 m_pDlg-BroadcastMessage(strLine, this); } } else if (nLen 0) { // 对端关闭连接 m_pDlg-RemoveClient(this); } else { // SOCKET_ERROR可调用 GetLastError 判断是否 WSAEWOULDBLOCK } }这段代码中有两个值得记住的细节。第一是 Receive 的返回值含义0 表示对端优雅关闭-1 表示出错出错时要用 WSAGetLastError 区分是暂时没数据WSAEWOULDBLOCK还是真错误。很多人一收到 -1 就移除客户端结果在非阻塞模式下误删了一堆好连接。第二是粘包问题。连续发送两条消息时TCP 可能把它们合并成一个包也可能把一个包拆成多个。如果直接按一次 Receive 当一条消息会出现聊天记录串行或者消息被截断。代码里用 m_strBuffer 做累积缓冲每次收到数据先追加再按结束标记拆分拆出来的完整行才进入业务逻辑。这就是最简单的按行拆包方案。3.3 广播逻辑遍历客户端数组逐个发送广播是服务端最核心的功能逻辑非常简单但越简单越容易出错// ServerDlg.cpp void CServerDlg::BroadcastMessage(const CString strMsg, CClientSocket* pSender) { // 组装带来源标识的消息比如 [192.168.1.10] 大家好 CString strFullMsg FormatMessage(strMsg, pSender); // 遍历客户端数组发给除发送者外的所有人或全部人按需求定 for (int i 0; i m_nClientCount; i) { CClientSocket* pClient m_ClientArray[i]; if (pClient ! NULL pClient ! pSender) // 跳过发送者 { int nRet pClient-Send(strFullMsg, strFullMsg.GetLength()); if (nRet SOCKET_ERROR) { // 发送失败记录错误码视情况移除该客户端 } } } }这里 Send 的返回值和 Receive 一样需要判断。如果返回 SOCKET_ERROR要立刻移除这个客户端吗不一定。如果错误码是 WSAEWOULDBLOCK说明发送缓冲区满了应该稍后重试而不是删连接。示例做了简化但你在真环境里遇到网络差的情况这个判断能帮你少踩很多坑。发送失败还有一种常见情况客户端已断开但服务端还没收到 FD_CLOSE此时 Socket 句柄已经无效了。这种半死亡连接在广播失败时最容易暴露。运行中观察到的现象是某个客户端软件关了服务端列表里它还在广播消息也发不到它。这时候通常要配合心跳机制才能彻底解决示例本身没有做属于留给你的改造点我在第 6 章会给出具体改法。3.4 断开清理RemoveClient 的两处陷阱客户端断开时OnClose 会被回调服务端要把该客户端从数组和界面列表里移除。这个流程有两个陷阱一是移除时要 Close 该 Socket 并 delete 对象但 OnClose 本身就是这个对象的方法直接在方法里 delete this 是可行的只是要小心后面的代码不要再访问它二是数组移除时要把尾部元素前移同时 m_nClientCount 减一不能只把指针置 NULL。示例采用的方式是标记删除加紧凑整理找到目标位置把它和最后一个元素交换然后删除最后一个。这样数组依然紧凑索引稳定。同时界面上的在线列表控件也要同步移除这一项。这块操作建议统一用一个 RemoveClient 方法完成不要在多个地方各写一遍。界面刷新和数组更新都被这个函数收敛住后续如果改成私聊或分组找这一个入口就够了。4. 客户端实现连接管理、消息收发与界面刷新4.1 客户端连接流程与状态反馈客户端的工作集中在对话框类里。界面提供IP 地址编辑框、端口编辑框、连接按钮、断开按钮、消息输入框、聊天记录显示框、在线列表。连接按钮的响应函数是核心// ClientDlg.cpp void CClientDlg::OnBtnConnect() { // 从编辑框取出 IP 和端口 CString strIP; m_editIP.GetWindowText(strIP); UINT nPort GetDlgItemInt(IDC_EDIT_PORT); // 创建客户端 Socket 并连接 m_ClientSocket new CClientSocket(this); BOOL bOK m_ClientSocket-Create(); if (bOK) { // AsyncSelect 注册 FD_READ / FD_CLOSE / FD_CONNECT 事件 m_ClientSocket-AsyncSelect(WM_SOCKET_MSG); // 连接非阻塞立即返回 int nRet m_ClientSocket-Connect(strIP, nPort); if (nRet SOCKET_ERROR) { int nErr GetLastError(); if (nErr ! WSAEWOULDBLOCK) // WSAEWOULDBLOCK 表示连接正在进行 { // 立即失败提示用户检查服务端是否启动 return; } } } }这一段是典型的非阻塞 Connect 写法。Connect 函数在非阻塞模式下几乎必然返回 SOCKET_ERROR错误码 WSAEWOULDBLOCK 代表连接在后台进行中这是正常失败。真正的成功是稍后通过 OnConnect 回调收到 FD_CONNECT 事件才知道。不少新手在这里崩过一次——看到返回失败就弹连接失败实际连上了。而示例的交互逻辑是Connect 返回 WSAEWOULDBLOCK 时设置一个正在连接状态按钮变灰等待 FD_CONNECT 事件后恢复。4.2 收到服务端广播后如何刷新到界面客户端的 CClientSocket 同样要重写 OnReceive把收到的消息转给对话框由对话框追加到聊天记录控件。这里严格遵守跨线程规矩OnReceive 是在消息循环上下文里被调用的可以直接操作控件但为了和 UI 逻辑解耦示例用 PostMessage 把消息数据传给对话框窗口// ClientSocket.cpp void CClientSocket::OnReceive(int nErrorCode) { char szBuf[4096] {0}; int nLen Receive(szBuf, sizeof(szBuf) - 1); if (nLen 0) { // 把消息通过自定义消息带过去窗口函数里再刷新 UI ::PostMessage(m_pDlg-GetSafeHwnd(), WM_MY_RECEIVE_MSG, nLen, (LPARAM)szBuf); } else if (nLen 0) { // 服务端关闭了连接 ::PostMessage(m_pDlg-GetSafeHwnd(), WM_MY_SERVER_CLOSE, 0, 0); } }这里 PostMessage 的 LPARAM 传进去的是一个字符指针对话框窗口函数在收到消息后要立即把数据拷贝到自己的 CString 里否则指针指向的缓冲区在下一个事件循环可能被覆盖。这是初学者最容易写出的悬垂指针场景收到消息后直接在新窗口函数里用 (char*)lParam 指向原缓冲区但没拷贝就弹窗结果内容偶尔对、偶尔乱码。客户端的界面更新逻辑我建议用 SetWindowText 追加到 RichEdit 或 ListBox 尾部。用 ListBox 比较简单AddString 一行一个消息滚动到最后一行需要发送 LB_SETTOPINDEX 消息这是老 MFC 玩家的肌肉记忆。示例里用的就是 ListBox 加自动滚动到底部。4.3 发送消息的编码与字节数陷阱客户端发送时界面编辑框取到的是 CString直接 Send 出去前有一个编码问题MFC 如果项目是 Unicode 字符集CString 是宽字符直接 Send 会把每个字符按两个字节发出去服务端按 ANSI 解析就会乱码。因此示例代码里做了一次 T2A 转换// ClientDlg.cpp void CClientDlg::OnBtnSend() { CString strMsg; m_editMessage.GetWindowText(strMsg); if (strMsg.IsEmpty()) return; // 追加行结束标记服务端按 \r\n 拆包 strMsg _T(\r\n); // 转换成 UTF-8 或 ANSI 字节流再发送 int nLen WideCharToMultiByte(CP_ACP, 0, strMsg, -1, NULL, 0, NULL, NULL); char* szBuf new char[nLen]; WideCharToMultiByte(CP_ACP, 0, strMsg, -1, szBuf, nLen, NULL, NULL); // 注意nLen 包含结尾的 \0发送时按 nLen - 1 处理 m_ClientSocket-Send(szBuf, nLen - 1); delete[] szBuf; }这段有一个非常隐蔽的坑点WideCharToMultiByte 转换后得到的 nLen 包含了字符串结尾的空字符直接原样发送会把 \0 也发过去服务端收到后按 CString 拼接遇到 \0 截断导致拆包错乱。所以要发送 nLen - 1 个字节把结尾空字符去掉。这个坑即便在有几年经验的开发身上也会偶发排查起来特别费劲我把它单列讲你自己写的时候也要注意。5. 常见问题排查运行时的那些玄学问题5.1 服务端启动后客户端连接却被立刻断开现象服务端监听线程正常启动客户端点连接后列表里刚出现新客户端又马上消失。原因通常有两个。第一个是监听线程里 Accept 返回后CClientSocket 没有调用 AsyncSelect导致系统没有给这个新 Socket 派发网络事件。此时客户端连接成功后服务端认为连接没有事件一旦客户端发送数据服务端收不到 FD_READ而客户端那边等服务端应答等不到可能主动关闭触发 FD_CLOSE 让服务端删掉客户端。第二个原因更常见——Accept 返回的新 Socket 句柄不是继承的监听 Socket 如果开启了 WSAEventSelect 事件新 Socket 需要重新关联事件否则收不到任何消息。解决检查 CClientSocket 构造函数至少在 Accept 成功后显式调用一次AsyncSelect(FD_READ | FD_CLOSE | FD_CONNECT)把事件关联到主对话框窗口句柄上。如果事件关联代码放在了 Create 阶段而不是 Accept 之后那就会失效——因为 Accept 之后才真正分配了 Socket 句柄。5.2 消息发送后另一端偶尔收到乱码或丢尾部现象客户端发的消息服务端聊天记录里显示不全中文字符有时候是乱码英文字符正常。原因这是两件事混在一起。乱码是字符集问题客户端项目是 Unicode 而服务端是 ANSISend 时直接把 CString 当字节流发送宽字符被逐个字节拆开。丢尾部则是粘包处理问题服务端 OnReceive 里如果直接用 Receive 后就处理没用累积缓冲解析短消息和长消息交错发送时长消息后半段停留在系统缓冲区里要等下一次触发 OnReceive 才会被读取。解决乱码问题统一转 UTF-8 发送两端约定好编码拆包统一用累积缓冲 结束标记模式不要在 OnReceive 里直接按一次 Receive 的内容当成完整消息。编码转换代码参考第 4.3 小节的 WideCharToMultiByte两端保持一致即可。5.3 广播消息时运行几小时后界面卡死现象服务端开机运行客户端反复上下线正常收发一阵子后服务端界面点击无响应CPU 占用不高。原因客户端对象泄漏。客户端断开时如果 RemoveClient 里只从数组移除、没调用 Close 也没 delete 对象或者 OnClose 回调被多次触发比如先触发一次 FD_CLOSE后续又因为 Socket 操作触发异常同一个 CClientSocket 被重复删除导致残留 Socket 句柄堆积。句柄数量达到系统上限后网络事件分发在新句柄上等待时永远得不到响应界面消息循环也被拖住。解决RemoveClient 要保证一个客户端只被删除一次用 m_bRemoved 标志位防止重复删除删除时先调用 ShutDown(SD_BOTH) 再 Close最后 delete this。如果 OnClose 因为异常被触发第二次要能安全返回而不执行第二次 delete。5.4 客户端点连接按钮后一直转圈没有成功提示现象点了连接按钮变灰但永远不出现连接成功也没有失败提示。原因FD_CONNECT 事件没被映射到窗口消息。客户端虽然调用了 AsyncSelect但消息 ID 可能没有在对话框头文件里定义出对应的消息处理函数或者 OnConnect 回调没有实现。事件到了窗口消息队列里没有处理函数消费它连接状态的更新自然就一直卡在连接中。解决确认 AsyncSelect 绑定的自定义消息 ID比如 WM_SOCKET_MSG已经被客户端对话框的 ON_MESSAGE 宏映射到 OnSocketEvent 函数OnSocketEvent 里按 lParam 的低字判断事件类型是 FD_CONNECT 时更新为已连接状态。如果用了类向导自动生成检查消息映射表是否被覆盖。5.5 服务端列表里显示客户端 IP 全部是 0.0.0.0现象多个客户端接入后服务端列表里显示的 IP 都是 0.0.0.0但消息能正常收发。原因Accept 返回后直接用 CAsyncSocket::GetPeerName 取 IP但调用时机不正确。Accept 成功后 Socket 还没有完全建立连接此时 GetPeerName 拿到的是全零地址。正常的做法是在 FD_CONNECT 或第一次成功接收数据后再去取对端地址。解决把取 IP 的逻辑从 Accept 之后挪到 OnReceive 或 OnConnect 里也就是连接已经稳定后调用 GetPeerName。示例里如果出现了这个现象可以直接在 OnReceive 首次触发时用 GetPeerName 刷新列表显示。6. 二次开发指南从广播机升级到可用的通讯工具拿到这份示例跑通后你大概率不会只满足于广播。我列几条高频改造路径按性价比排序。最常见的需求是私聊把 BroadcastMessage 改成按目标定位发送。做法是在消息格式里扩展成接收方标识:消息内容服务端收到后解析出接收方 ID在客户端数组里按 ID 查找目标 Socket只 Send 给那一个客户端。数组查找用线性遍历就够客户端数量不超过几百个时性能没问题。第二项改造是补上心跳机制。现在的示例里客户端崩了服务端未必立刻感知。做法是服务端每 5 秒遍历客户端数组调用一次 Send 发送心跳包连续 3 次没有收到客户端回包判定超时并主动 RemoveClient。客户端那边用定时器每 5 秒发一个心跳这样能保证假死连接被及时清理。心跳包和普通消息通过一个字节的类型标志区分比如消息首字节 0x01 表示心跳、0x02 表示聊天。这个修改集中在两个文件里发送端的消息组装接收端的拆包解析。第三项改造是把结束标记拆包换成长度包头。当前 \r\n 分隔符结构简单但消息内容里如果自带换行符就会拆错。升级用固定 4 字节包头存消息长度收到数据后先攒够 4 字节再取长度然后按长度读完整包。代码改动在 OnReceive 和 OnSend 两个位置拆包逻辑独立成一个函数ExtractPacket更方便单测。这个改造建议放在加了心跳之后因为心跳和聊天的包长度差异明显用长度包头能更好地区分。我自己改这个项目时的习惯是每次动手前先画一张状态流转图客户端从连接中到已连接到断开服务端从监听中到广播中把每条状态边上的触发事件写清楚。然后照着状态图去代码里找对应的消息处理函数改完再对照图走一遍流程缺了什么分支一目了然。从那以后我每次做网络编程改造都强制先画状态图再动代码这习惯帮我少翻了好几回车。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑