简介这是一份基于MFC Socket实现“一个服务器对多个客户端”即时通讯的编程示例适合具备Java Socket基础、希望改用C/MFC写出更高效率通信程序的开发者。示例在服务器端用CPtrList集合保存客户端socket对象借助CSocket的异步特性完成一对多消息广播并通过CSocketFile与CArchive简化数据读写代码注释详细辅助类统一放在util目录结构清晰易读。压缩包共75个文件以h/cpp源文件为主附带obj中间文件、exe可执行程序以及dsw/dsp工程文件等整体仅3.44MB适用于VC 6.0与Windows XP/2003环境。目前已有755人学习下载既能帮助理解MFC异步Socket编程模型也可作为课程设计或局域网即时通讯工具的改造基础。 今年以来一直在做一套设备远程运维的小工具底层通信用的就是“一个服务器对多个客户端的 MFC Socket”结构。前后写过好几个版本从最初的裸 Winsock API到 CAsyncSocket 异步事件再到阻塞 Socket 加多线程踩过的坑基本能凑一篇万字长文。最近把代码整理成了一个教学版——服务器开一个简单的监听端口多个聊天客户端连上来后任何一个人发言服务器会把消息广播给其他所有客户端效果上就是一个简化版聊天室。如果你正好在读 MFC 网络编程或者要做局域网消息推送、设备状态上报类的程序这篇东西值得花几分钟看完。我尽量不说废话直接讲设计和能跑的代码。这个示例本身不复杂但它把几个关键点全占齐了监听 Socket 的接入方式、多客户端的管理、网络线程与 UI 线程的通信、TCP 的粘包边界、以及退出时 socket 和线程的清理顺序。这些点如果你都理清了后面做大并发或者做更复杂的协议也只是横向扩展的事。1. 设计思路为什么“服务器端一定要多线程”1.1 先用一个最朴素的模型想明白数据流向先别急着写代码。这个场景的通信模型其实特别简单服务器是中心节点客户端只管跟服务器建立连接。客户端 A 发一条消息服务器收到后解析出来再把这条消息转发给客户端 B、C、D……不需要让客户端之间互相知道对方的 IP 和端口这种星型结构在局域网里实现起来最省事。但这里冒出第一个设计问题。服务器上不只有一个客户端如果服务器在 keep 住这个连接的同时去处理另一个客户端的消息那就必须“同时处理多路数据”。处理方法无非两种一种是一条连接用一个线程去处理收数据时阻塞在 recv 上收到一条处理一条另一种是服务器用单线程同时监听所有连接通过 select 或者更高级的 IOCP 去轮询谁有数据可读。教学版的场景是几十个客户端以内的局域网聊天我用的是前者每个客户端连接一进服务器就创建一个工作线程专门伺候它。这个模型最好理解而且不容易把初学者绕晕。真正上万连接的高并发场景才需要考虑后者的方案。1.2 三种 MFC Socket 方案怎么选MFC 里常见的 Socket 封装有两种再加上最底层的 Winsock API实际写的时候很多人会犹豫到底用哪个。我直接给你一个选型表格这都是我实际试过后的结论方案工作方式优点缺点CAsyncSocket异步消息驱动收到数据时自动回调 OnReceive代码直观回调跑在主线程多连接高频通信时界面容易卡CSocketCAsyncSocket 的阻塞模式封装使用简单能配 CFile 收发内部有消息泵在多线程里配合不当会出现奇怪问题原生 Winsock API完全手动控制灵活稳定网上资料最丰富所有细节得自己管理容错要自己写我最后的实现是“组合拳”服务器的监听 Socket 用 MFC 的 CAsyncSocket因为 OnAccept 回调写起来最方便但每个客户端连接接入后不继续用 CAsyncSocket 的 OnReceive而是把底层 SOCKET 句柄 Detach 出来放到独立的工作线程里用阻塞 recv 收数据。这样既保留了 MFC 在监听接入时的简洁又避免了所有数据回调都在 UI 线程里排队处理。1.3 线程模型与共享数据的安全服务器端整体结构是这样的主界面线程负责监听和界面展示每当 OnAccept 触发就创建一个 CWinThread 工作线程每个工作线程阻塞在自己的 recv 上收到完整消息后调用广播函数把消息转发给其他所有在线客户端。既然是多个线程同时操作同一批客户端 Socket 列表那同步就躲不掉。我在服务器端用一个CRITICAL_SECTION保护客户端的 SOCKET 句柄集合广播时先进入临界区遍历完再出来。这个加锁代价很低但能避免两个线程同时往一个 SOCKET 上 send 的情况发生——Windows 的 SOCKET 虽然是内核对象但多个线程同时 send 同一句柄轻则数据交错重则直接报错这个坑我踩过。2. 服务器端核心实现从监听、接入到消息分发2.1 初始化与监听 Socket 的建立服务器这边用 VS2013 以上版本的工程默认是 Unicode 字符集。在对话框初始化函数里先初始化 Winsock 库再创建监听套接字。有一点要注意MFC 的 CAsyncSocket 内部会自己调用 WSAStartup但你如果在纯 Winsock 工作线程里收发数据最好还是显式调用一次防止模块间初始化顺序出问题。WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), wsaData) ! 0) { AfxMessageBox(_T(Winsock 初始化失败)); return FALSE; } m_pListenSock new CListenSocket(); if (!m_pListenSock-Create(m_nPort, SOCK_STREAM, FD_ACCEPT)) { CString strErr; strErr.Format(_T(创建监听套接字失败错误码%d), GetLastError()); AfxMessageBox(strErr); return FALSE; } m_pListenSock-Listen();这里 Create 的第三个参数 FD_ACCEPT 表示我只关心“有新客户端连接”这个事件。CAsyncSocket 的本质是把网络事件映射到窗口消息再通过虚函数回调出来。你监听什么事件就传什么标志不需要的可以不传。监听套接字的类是从 CAsyncSocket 派生的所以 OnAccept 是主线程消息循环驱动回调。我在这回调里做两件事调用 Accept 接受连接把拿到的 SOCKET 句柄交给一个新线程处理。void CListenSocket::OnAccept(int nErrorCode) { if (nErrorCode 0 g_pServerDlg) { g_pServerDlg-OnAcceptNewClient(); } CAsyncSocket::OnAccept(nErrorCode); }OnAccept 里先创建临时 CAsyncSocket 对象去接 Accept成功后立刻 Detach 出底层句柄然后把这个句柄塞给工作线程。为什么不用原生的 accept 函数因为监听 Socket 如果自己手动用 accept很可能导致 CAsyncSocket 内部状态不一致。你就把 CAsyncSocket 当成监听接入的壳子接入完成后立刻脱壳后面的事全交给原生 Winsock两边的好处都占了。2.2 客户端处理线程里的收包、拆包逻辑一旦拿到一个 SOCKET 句柄就创建一个线程去循环读。这个循环的结构是所有网络程序的大动脉写错一个细节后面全是毛病。UINT CServerDlg::ClientThreadProc(LPVOID lpParam) { ClientCtx* pCtx (ClientCtx*)lpParam; SOCKET hSock pCtx-hSocket; while (TRUE) { int nMsgLen 0; if (!RecvFull(hSock, (char*)nMsgLen, sizeof(int))) break; nMsgLen ntohl(nMsgLen); if (nMsgLen 0 || nMsgLen 64 * 1024) break; char* pBuf new char[nMsgLen 1]; if (!RecvFull(hSock, pBuf, nMsgLen)) { delete[] pBuf; break; } pBuf[nMsgLen] \0; CString strMsg UTF8ToCString(pBuf); pCtx-pServer-Broadcast(hSock, strMsg); delete[] pBuf; } pCtx-pServer-RemoveClient(hSock); closesocket(hSock); delete pCtx; return 0; }这里 RecvFull 是一个循环接收函数。为什么不用一次 recv 就认为收满了TCP 是流协议数据在网络上会被拆成一个个包send 一次并不代表 recv 一次就能收到同样长度的数据。比如发送方发了 1000 字节接收方第一次 recv 可能只收到 356 字节剩下的 644 字节在后续的包里面。如果没有 RecvFull直接按 1000 字节去处理数据就会出现消息截断。BOOL RecvFull(SOCKET s, char* buf, int nNeed) { int nOffset 0; while (nOffset nNeed) { int nRet recv(s, buf nOffset, nNeed - nOffset, 0); if (nRet 0) return FALSE; nOffset nRet; } return TRUE; }这个函数是我所有 Socket 项目里的标准工具函数从第一版一直沿用到现在。凡是涉及定长数据接收的地方直接套它就不会出错。2.3 广播与客户端列表管理广播的本质是遍历客户端列表把消息按协议封包后 send 出去。注意要排除发送者自己否则服务器会把消息反弹给原客户端。void CServerDlg::Broadcast(SOCKET hExclude, const CString strMsg) { CStringA utf8 CT2A(strMsg, CP_UTF8); EnterCriticalSection(m_csClientList); for (auto it m_listClient.begin(); it ! m_listClient.end(); it) { if (*it hExclude) continue; int nLen utf8.GetLength(); if (SendPacket(*it, utf8.GetBuffer(), nLen) FALSE) { // 发送失败说明这个客户端可能已经断开了 // 先记录下来等遍历结束统一清理 m_listPendingRemove.push_back(*it); } } LeaveCriticalSection(m_csClientList); if (!m_listPendingRemove.empty()) { for (SOCKET s : m_listPendingRemove) RemoveClient(s); m_listPendingRemove.clear(); } }我在这一步吃过亏必须提醒你一句不能在临界区里面直接修改正在遍历的列表。如果你在广播过程中发现某个 Socket 发不出去想顺手 erase 掉这个元素会让迭代器失效轻则崩溃重则出现不可复现的偶发错误。正确做法是先把要删除的 Socket 存到临时数组遍历完退出临界区后再统一清理。SendPacket 也不复杂就是加了一个 4 字节的长度前缀BOOL SendPacket(SOCKET s, const char* data, int len) { int nNetLen htonl(len); if (send(s, (const char*)nNetLen, sizeof(int), 0) SOCKET_ERROR) return FALSE; if (len 0 send(s, data, len, 0) SOCKET_ERROR) return FALSE; return TRUE; }这个“长度前缀 消息正文”的结构是整个协议的骨架。接收方先读 4 字节拿到消息长度再按长度读正文。这样就能把 TCP 字节流里的一条条消息切分开来解决粘包问题。3. 客户端实现连接服务器与安全刷新 UI3.1 客户端的连接流程客户端相对简单。界面上有一个 IP 输入框、一个端口输入框、一个消息输入框、一个聊天记录 ListBox 和一个发送按钮。点击“连接服务器”按钮后创建 SOCKET填充 sockaddr_in 结构体然后 connect。m_sock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (m_sock INVALID_SOCKET) { AfxMessageBox(_T(创建套接字失败)); return; } sockaddr_in addr; addr.sin_family AF_INET; addr.sin_port htons((u_short)m_nPort); addr.sin_addr.s_addr inet_addr(CT2A(strIP)); if (connect(m_sock, (SOCKADDR*)addr, sizeof(addr)) SOCKET_ERROR) { closesocket(m_sock); m_sock INVALID_SOCKET; AfxMessageBox(_T(连接服务器失败请检查网络或服务器状态)); return; } // 连接成功后启动接收线程 AfxBeginThread(RecvThreadProc, this);连接成功之后客户端要开启一条接收线程专门在后台等服务器转发的消息。这一条是必须的因为客户端不能让主界面线程阻塞在 recv 上否则界面会假死。3.2 接收线程与主界面交互PostMessage 而不是 SendMessage接收线程拿到数据后面临一个核心问题子线程不能直接去更新 UI 控件。MFC 里所有窗口控件都归属主线程的消息循环如果你在子线程里直接m_listChat.AddString(...)轻则控件不刷新重则直接触发断点断言程序崩溃。我用的方式是自定义消息。接收线程把收到的消息字符串 new 出来通过::PostMessage(hWnd, WM_RECV_MSG, 0, (LPARAM)pStr)投递给主窗口然后在主窗口的消息处理函数里释放这块内存并更新界面。UINT CClientDlg::RecvThreadProc(LPVOID lpParam) { CClientDlg* pDlg (CClientDlg*)lpParam; while (TRUE) { int nNetLen 0; if (!RecvFull(pDlg-m_sock, (char*)nNetLen, sizeof(int))) break; int nLen ntohl(nNetLen); if (nLen 0 || nLen 64 * 1024) break; char* pBuf new char[nLen 1]; if (!RecvFull(pDlg-m_sock, pBuf, nLen)) { delete[] pBuf; break; } pBuf[nLen] \0; CString* pStrMsg new CString(UTF8ToCString(pBuf)); ::PostMessage(pDlg-m_hWnd, WM_RECV_MSG, 0, (LPARAM)pStrMsg); delete[] pBuf; } closesocket(pDlg-m_sock); pDlg-m_sock INVALID_SOCKET; ::PostMessage(pDlg-m_hWnd, WM_CONN_LOST, 0, 0); return 0; }这里用 PostMessage 而不是 SendMessage 有个讲究。SendMessage 是同步的子线程发出去后要等主线程处理完才返回如果主线程正好在处理别的耗时逻辑子线程就等于被拖住了。PostMessage 只管把消息丢到主线程消息队列里就立即返回子线程不用等这会大大降低收发效率的耦合度。在实际交付的项目里我一直坚持这个原则网络线程只管收数据和发消息任何 UI 操作全通过 PostMessage 丢回主线程。主线程这边接收到自定义消息后紧接着做两件事把消息内容 AddString 到聊天记录 ListBox 中同时滚动到底部。LRESULT CClientDlg::OnRecvMsg(WPARAM wParam, LPARAM lParam) { CString* pStr (CString*)lParam; if (pStr) { m_listChat.AddString(*pStr); m_listChat.SetCurSel(m_listChat.GetCount() - 1); delete pStr; } return 0; }3.3 发送消息与字符串编码的坑发送消息时对话框把用户输入的 CString 取出来转换成 UTF-8 字节流再调用 SendPacket。为什么必须做一步字符集转换因为 MFC 在 VS2013 及以上默认构建的是 Unicode 字符集CString 内部是 UTF-16 编码而 socket 的 send/recv 都是 char 字节流。如果不做任何转换直接把 CString 的内存丢给 sendWindows 这边看着没事Linux 服务器端收到的就是一堆带 0x00 的乱码。void CClientDlg::OnBtnSend() { if (m_sock INVALID_SOCKET) { AfxMessageBox(_T(当前未连接服务器)); return; } CString strMsg; m_editInput.GetWindowText(strMsg); if (strMsg.IsEmpty()) return; CStringA utf8 CT2A(strMsg, CP_UTF8); if (SendPacket(m_sock, utf8.GetBuffer(), utf8.GetLength()) FALSE) { AfxMessageBox(_T(消息发送失败连接可能已断开)); } m_editInput.SetWindowText(_T()); }对应地接收消息的转换函数也封装成了一个工具函数CString UTF8ToCString(const char* utf8Buf) { int nLen MultiByteToWideChar(CP_UTF8, 0, utf8Buf, -1, NULL, 0); CString strResult; MultiByteToWideChar(CP_UTF8, 0, utf8Buf, -1, strResult.GetBufferSetLength(nLen - 1), nLen - 1); strResult.ReleaseBuffer(); return strResult; }这套 UTF-8 传输方案在网络编程里是再常见不过的做法了。尤其是跨平台场景比如客户端是 Windows 的 MFC 程序服务器部署在 Linux 上只要双方约定好协议用 UTF-8就不会出现中文乱码问题。4. 完整运行效果与调试过程实录4.1 启动顺序是关键先服务器再客户端我实际调试时的操作顺序是先双击启动服务器端界面上监听端口默认 9527再启动一个或多个客户端填上服务器 IP 和端口点击连接。服务器日志框里会依次打印“客户端已接入”的信息界面上方的在线列表也会增加一条记录。客户端 A 在输入框里打出一句“大家好”点击发送。接收端客户端 B 的聊天记录框会立刻显示这条消息前缀带一个来源标识。如果此时开着 Wireshark 对 loopback 接口抓包能看到客户端 A 发出来的数据包结构先是 4 字节长度前缀网络字节序紧接着是 UTF-8 编码的正文。我第一版做的时候服务器和客户端跑在同一台电脑上直接连 127.0.0.1方便调试。后面为了验证局域网传输找了两台真实机器测试服务器端代码一行没改客户端把 IP 改一下就能连上。4.2 观察线程运行状态调试过程中我习惯打开 VS 的“并行监视”窗口可以看到服务器端主线程负责 UI另外几个工作线程分别阻塞在各个客户端的 recv 调用上。线程栈里停在最核心的ntdll!NtWaitForSingleObject或ws2_32!recv上这是正常状态说明线程在等工作数据没有死循环也没有跑飞。内存方面我连续跑了一个多小时客户端在线、离线反复操作服务器进程的内存占用一直很平稳没有持续增长。如果你在跑的过程中看到内存在随时间缓慢上涨十有八九是丢了delete[]或者delete重点检查接收线程里 new 出来的缓冲区和 PostMessage 带上来的 CString 是否都在主线程释放干净。4.3 项目打包时容易被忽略的 DLL教学版代码在自己的开发机上跑没有任何问题但如果要发给别人运行新手往往会栽在打包这一步。MFC 程序默认动态链接 MFC 和 C 运行时库目标机器上缺了mfc140u.dll、msvcp140.dll、vcruntime140.dll就启动失败。我的处理方式是在 VS 工程属性里把“MFC 的使用”改成“在静态库中使用 MFC”同时“运行时库”改成“多线程/MT”。这样生成的 exe 体积会大一些但部署的时候只要拷一个 exe 过去就能跑。要是项目里还需要数据库、配置文件把那些一起放在同一个目录就行。5. 从实际项目中总结的避坑清单5.1 bind 失败、端口被占用这类老问题怎么定位服务器启动时如果提示“创建监听套接字失败”错误码是 WSAEADDRINUSE10048说明端口被占用。最常见的原因是上次程序没正常退出处于 TIME_WAIT 状态的套接字还没释放或者有别的进程占用了这个端口。这时候直接在命令行执行netstat -ano | findstr 9527定位到占用端口的进程 PID再到任务管理器里查这个 PID 是谁。如果是自己的残留进程直接结束掉。如果是系统服务占用换个端口就行。5.2 粘包问题TCP 是流不是消息我特意在前面用长度前缀来封装消息完全就是为了解决粘包。如果不用这个机制客户端 A 连续发送“你好”和“在吗”服务器端可能一次 recv 就收到“你好在吗”这两个消息连在一起的数据怎么切都切不开。使用长度前缀后接收端先读 4 字节得到消息长度再读对应长度的正文就相当于给每个消息画了一条清晰的边界线。只要 RecvFull 写得严密粘包问题就彻底解决了。5.3 关闭程序时最容易崩溃先关 Socket 还是先退线程很多初学者在程序退出时直接 closesocket然后立刻销毁对话框。结果工作线程还在 recv 阻塞socket 句柄突然变成无效句柄recv 返回错误线程又去访问已经被销毁的对话框对象瞬间崩溃。正确的退出顺序是这样的先通知工作线程退出或者让它自己检测到 recv 返回错误后退出等待所有工作线程结束WaitForMultipleObjects最后关闭监听 Socket、执行 WSACleanup、销毁 UI。我实现里用的是“关闭 Socket 让 recv 主动失败”的思路。服务器退出时先遍历客户端列表把所有客户端 SOCKET 都 shutdown 加 closesocket。工作线程的 recv 会立刻返回 SOCKET_ERROR代码随后走break退出循环线程自然结束。服务器再等一小会儿或者简单地用Sleep给线程一点收尾时间然后退出主进程。这个顺序能保证不会出现线程访问到已释放资源的情况。5.4 界面卡顿不要在 OnReceive 里直接干活如果你没用多线程而是在 CAsyncSocket 的 OnReceive 里直接调用UpdateData或操作列表控件那数据量小的时候挺流畅一旦有客户端频繁发消息主线程忙于处理网络回调界面就会变得非常卡甚至点击按钮都没反应。还有一个隐藏坑CAsyncSocket 的 OnReceive 回调里不允许进行阻塞操作比如 Sleep 或 WaitForSingleObject否则会影响消息泵的正常运行。所以我才建议把网络收发放到独立线程里主线程只处理 UI 消息。6. 这个示例还能怎么扩展6.1 心跳机制与断线检测现在的教学版代码服务器判断客户端是否下线靠的是 recv 返回 0 或 SOCKET_ERROR。但真实场景里经常出现网络闪断、客户端电脑休眠、网线被拔等情况底层 TCP 可能很久都检测不到连接已经死了。解决方法就是加心跳包。客户端每隔几秒向服务器发送一个短字节的 PING 消息服务器记录每个客户端最后一次收到数据的时间每隔一段时间扫一遍如果某个客户端超过 N 秒没有任何数据过来就强制关闭它的 socket从在线列表里移除。6.2 私聊、群组与消息持久化消息协议里长度前缀后面只有一个正文没有消息类型和接收方 ID。要做私聊我可以把消息头扩展成“消息类型 源 ID 目标 ID 消息正文”区分文本消息、系统通知、私聊消息等。群组功能则是给每个组分配一个房间号服务器维护房间号和客户端 ID 的映射表转发时只发给同一房间的人。再往后走消息也可以考虑落库比如用 SQLite 存聊天记录或者用 Redis 做在线状态管理。这部分就不是 Socket 本身的范畴了但它的接入位置就在 Broadcast 函数的入口处。做完这个示例我最大的心得体会是MFC 本身提供的网络封装说到底只是辅助真正考验功力的地方在于线程模型的设计、共享数据的保护和退出流程的严谨性。你把这三个问题想清楚不管换什么界面库底层通信逻辑照样能原封不动地复用。这几年我维护过的项目里早期乱加的线程和随手写的 Close 顺序遇到高负载时往往变成隐藏炸弹反而是这套看似老土的“每连接一线程 阻塞 recv”方案在中小规模场景下最稳。后面你如果打算在 Linux 服务器上部署服务端客户端这边还能继续用 MFC 这套代码只要把协议字节序和编码统一好跨平台对接并不难。本文还有配套的精品资源点击获取