简介:这是一份面向具备 Java Socket 编程经验、希望转向 C++ 网络开发的程序员所准备的 MFC 实战示例,通过一个服务器对多个客户端的即时通讯场景,演示 CSocket 异步通信、CSocketFile 与 CArchive 的配合用法。服务端借助 CPtrList 集合保存各客户端 socket 对象,思路与 Java 中用 Vector 加多线程的方案相通,但 MFC 的异步机制让代码更简洁。压缩包共 75 个文件,约 3.44MB,包含 15 个 h 头文件、13 个 cpp 源文件及配套 obj、exe、dsp、dsw 等工程与编译产物,辅助类统一收纳在 util 目录中,结构清晰。资源注释详尽,作者手写部分遵循 Java 命名规范,便于快速定位功能代码。阅读时服务端可从 onAccept 回调入手,客户端则从 OnSendButton 方法切入,即可把握整体通信流程。目前已有 755 人学习下载,适合想对比 Java 与 MFC 网络编程差异、提升即时通讯程序编写效率的开发者参考。
1. 一个服务器对多个客户端的 MFC Socket 编程:从单连接到多路复用的关键一跃
很多做 Windows 桌面工具的开发者,第一次接触网络编程时写的都是「一对一」的 TCP 小 demo:客户端连上,服务器回一句,然后断开。可一旦需求变成「一个服务端同时接多个客户端,任意一端发消息,其他端都能收到」,代码立刻从几十行膨胀到几百行,而且开始出现各种玄学问题——有的客户端连不上,有的消息发出去对方收不到,有的关掉一个客户端整个服务端就卡死。这个标题讲的,就是用 MFC 这套框架,把「一个服务器对多个客户端」的即时通讯骨架搭起来。它解决的核心不是协议本身,而是并发连接的管理和消息的广播分发。适合已经会写单连接 Socket、想往多客户端方向走一步的 Windows C++ 开发者,也适合需要给内部工具加一个轻量聊天/通知通道的工程师。下面我按自己实际落地的顺序,把选型、代码、参数和踩过的坑一次讲清楚。
2. 选型先定死:为什么是 MFC + 阻塞 Socket + 多线程,而不是别的组合
2.1 三种常见技术路线的取舍
在 Windows 上做多客户端服务端,绕不开三个选择:用阻塞 Socket 配多线程、用 select 做 IO 多路复用、或者用 IOCP 做异步。MFC 本身对 Socket 的封装有两层,一层是 CAsyncSocket,一层是 CSocket。CAsyncSocket 基于窗口消息通知,适合单线程事件驱动;CSocket 在它之上加了阻塞语义,配合线程用起来更接近传统写法。
我一般会选「阻塞 Socket + 每客户端一线程」的方案,原因很实际:即时通讯这种场景,连接数通常在几十到几百,不是上万长连接,线程开销可以接受;而且阻塞写法逻辑线性,调试时断点能直接停在 recv 上,不用去追消息循环。IOCP 性能最好,但代码复杂度高一个量级,对「简单即时通讯」来说是过度设计。select 方案单线程能扛,但一旦某个客户端发大包,整个循环都会被拖住,广播延迟会抖。
| 方案 | 连接数上限 | 代码复杂度 | 调试难度 | 适用场景 |
|---|---|---|---|---|
| 阻塞 Socket + 多线程 | 数百 | 低 | 低 | 内部工具、简单 IM |
| select 多路复用 | 上千 | 中 | 中 | 中等并发、单线程 |
| IOCP 异步 | 上万 | 高 | 高 | 高并发服务器 |
提示:如果连接数预期超过 500,或者消息频率很高,建议直接上 IOCP,不要用线程硬扛,否则上下文切换会把 CPU 吃满。
2.2 MFC 工程里 Socket 的初始化位置
MFC 的 Socket 功能需要先调用 AfxSocketInit,这个调用必须发生在使用任何 Socket 之前。常见做法是在 CWinApp 派生类的 InitInstance 里调用,而不是在对话框的 OnInitDialog 里。原因是如果放在对话框里,某些情况下对话框还没创建完就开始监听,会出现资源未初始化的报错。
// 在 CWinApp 派生类的 InitInstance 中初始化 BOOL CMyApp::InitInstance() { // 初始化 MFC Socket 库,失败直接返回 if (!AfxSocketInit()) { AfxMessageBox(_T("Socket 库初始化失败")); return FALSE; } CMyDlg dlg; m_pMainWnd = &dlg; dlg.DoModal(); return FALSE; }这段代码的关键点是 AfxSocketInit 的返回值必须检查。它内部会加载 ws2_32.dll 并做 WSAStartup,如果系统网络组件异常,这里就会失败。参数方面没有额外配置,但要注意它只能调用一次,重复调用不会报错但也没有意义。
2.3 服务端与客户端的职责划分
服务端要做三件事:监听端口、接受连接、维护客户端列表。客户端要做两件事:连接服务端、收发消息。广播逻辑放在服务端,因为只有服务端知道当前有哪些客户端在线。客户端不需要知道其他客户端的地址,所有消息都经过服务端转发,这就是典型的星型拓扑。
这种划分的好处是客户端逻辑极简,坏处是服务端成为单点。对于「简单即时通讯」这个目标,星型拓扑完全够用,不要一上来就搞 P2P 或者去中心化,那会把问题复杂度拉高好几倍。
3. 服务端落地:监听线程、客户端线程与广播队列怎么写
3.1 监听线程的创建与 accept 循环
服务端主线程不能直接跑 accept 循环,否则界面会卡死。常见做法是开一个独立的监听线程,在里面死循环 accept,每接受一个连接就为它创建一个客户端线程。
// 监听线程函数 UINT CServerDlg::ListenThread(LPVOID pParam) { CServerDlg* pDlg = (CServerDlg*)pParam; while (pDlg->m_bRunning) { // 阻塞等待客户端连接 SOCKET clientSock = accept(pDlg->m_listenSock, NULL, NULL); if (clientSock == INVALID_SOCKET) { // 如果是因为退出标志导致的失败,直接跳出 if (!pDlg->m_bRunning) break; continue; } // 为新客户端创建线程 ClientContext* ctx = new ClientContext(); ctx->sock = clientSock; ctx->pDlg = pDlg; AfxBeginThread(ClientThread, ctx); } return 0; }accept 的第二个和第三个参数传 NULL 表示不关心客户端地址,如果需要记录 IP 就传 sockaddr_in 结构。这里有个细节:accept 返回 INVALID_SOCKET 不一定是错误,也可能是监听套接字被关闭导致的,所以要先判断退出标志再决定是否 continue。ClientContext 是一个自定义结构,用来把套接字和对话框指针打包传给线程,避免用全局变量。
3.2 客户端线程的 recv 循环与消息边界
TCP 是字节流,没有消息边界。很多新手直接假设一次 recv 就是一条完整消息,结果消息一长就被截断,或者两条消息粘在一起。解决办法是自定义一个简单的包头,前 4 个字节存消息长度,后面跟实际内容。
// 客户端线程:接收消息并广播 UINT CServerDlg::ClientThread(LPVOID pParam) { ClientContext* ctx = (ClientContext*)pParam; SOCKET sock = ctx->sock; char header[4]; while (ctx->pDlg->m_bRunning) { // 先收 4 字节包头 int ret = recv(sock, header, 4, 0); if (ret <= 0) break; // 连接断开或出错 int msgLen = *(int*)header; if (msgLen <= 0 || msgLen > 4096) break; // 长度非法,防攻击 char* buffer = new char[msgLen + 1]; int received = 0; // 循环收满整个消息体 while (received < msgLen) { ret = recv(sock, buffer + received, msgLen - received, 0); if (ret <= 0) break; received += ret; } buffer[msgLen] = '\0'; // 广播给所有其他客户端 ctx->pDlg->Broadcast(sock, buffer, msgLen); delete[] buffer; } // 清理:从列表移除、关闭套接字 ctx->pDlg->RemoveClient(sock); closesocket(sock); delete ctx; return 0; }这段代码有两个关键参数:msgLen 上限设 4096,是为了防止恶意客户端发一个超大长度导致服务端分配巨量内存;received 循环是为了处理 TCP 半包,recv 不保证一次收满。广播时传入发送者的 sock,是为了不把消息发回给发送者自己,这个逻辑在 Broadcast 里实现。
3.3 广播时的线程安全与发送锁
多个客户端线程可能同时调用 Broadcast,而 Broadcast 要遍历客户端列表并逐个 send。如果两个线程同时遍历同一个列表,或者一个在遍历一个在增删,就会崩溃。所以必须加锁。
// 广播函数,带临界区保护 void CServerDlg::Broadcast(SOCKET sender, const char* data, int len) { CSingleLock lock(&m_csClientList, TRUE); // 进入临界区 for (auto it = m_clientList.begin(); it != m_clientList.end(); ++it) { if (*it == sender) continue; // 不回发给发送者 send(*it, (const char*)&len, 4, 0); // 先发长度 send(*it, data, len, 0); // 再发内容 } }CSingleLock 是 MFC 对临界区的封装,构造时传 TRUE 表示立即加锁。这里要注意 send 也可能阻塞,如果某个客户端网络很慢,send 会卡住,导致锁一直被持有,其他线程全部等待。更稳妥的做法是给每个客户端加一个发送队列,由独立线程发送,但那是进阶优化,简单场景下先接受这个限制。
注意:临界区不要嵌套加锁,MFC 的临界区不是可重入的,同一个线程重复进入会死锁。
4. 客户端落地:连接、收消息线程与界面更新
4.1 连接服务端的时机与超时处理
客户端连接用 connect,但 connect 默认是阻塞的,如果服务端没开,会卡住一段时间。常见做法是把 connect 放在一个工作线程里,或者用非阻塞 connect 加 select 做超时。
// 客户端连接函数 bool CClientDlg::ConnectToServer(CString ip, int port) { m_sock = socket(AF_INET, SOCK_STREAM, 0); if (m_sock == INVALID_SOCKET) return false; sockaddr_in addr; addr.sin_family = AF_INET; addr.sin_port = htons(port); addr.sin_addr.S_un.S_addr = inet_addr(CT2A(ip)); // 设置发送和接收超时,避免永久阻塞 int timeout = 5000; setsockopt(m_sock, SOL_SOCKET, SO_RCVTIMEO, (char*)&timeout, sizeof(timeout)); setsockopt(m_sock, SOL_SOCKET, SO_SNDTIMEO, (char*)&timeout, sizeof(timeout)); if (connect(m_sock, (sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR) { closesocket(m_sock); m_sock = INVALID_SOCKET; return false; } // 连接成功后启动接收线程 AfxBeginThread(RecvThread, this); return true; }inet_addr 要求传入的是窄字符,所以用 CT2A 做转换。SO_RCVTIMEO 和 SO_SNDTIMEO 设 5 秒,是为了防止服务端异常时客户端一直卡在 recv 上。这两个参数在调试阶段可以设长一点,正式使用时建议缩短。
4.2 接收线程与自定义消息更新 UI
MFC 的界面控件只能在主线程操作,接收线程收到消息后不能直接 SetWindowText,必须通过自定义消息 PostMessage 到主线程。
// 自定义消息号 #define WM_USER_RECV_MSG (WM_USER + 100) // 接收线程 UINT CClientDlg::RecvThread(LPVOID pParam) { CClientDlg* pDlg = (CClientDlg*)pParam; char header[4]; while (pDlg->m_bConnected) { int ret = recv(pDlg->m_sock, header, 4, 0); if (ret <= 0) break; int msgLen = *(int*)header; if (msgLen <= 0 || msgLen > 4096) break; char* buffer = new char[msgLen + 1]; int received = 0; while (received < msgLen) { ret = recv(pDlg->m_sock, buffer + received, msgLen - received, 0); if (ret <= 0) break; received += ret; } buffer[msgLen] = '\0'; // 通过 PostMessage 把消息传给主线程 pDlg->PostMessage(WM_USER_RECV_MSG, (WPARAM)buffer, 0); } return 0; }PostMessage 的 WPARAM 传的是 new 出来的 buffer 指针,主线程处理完必须 delete,否则内存泄漏。这个约定要在消息处理函数里写清楚。
4.3 消息处理函数与内存释放
// 消息映射 ON_MESSAGE(WM_USER_RECV_MSG, &CClientDlg::OnRecvMsg) // 处理函数 LRESULT CClientDlg::OnRecvMsg(WPARAM wParam, LPARAM lParam) { char* buffer = (char*)wParam; CString msg(buffer); // 追加到聊天记录框 m_chatList.AddString(msg); m_chatList.SetTopIndex(m_chatList.GetCount() - 1); delete[] buffer; // 必须释放 return 0; }这里用 AddString 追加到列表框,SetTopIndex 让滚动条自动到底部。如果消息量很大,列表框会越来越慢,可以考虑用 RichEdit 或者限制显示条数。
5. 避坑与排查:多客户端场景下最容易翻车的五个点
5.1 客户端列表遍历时崩溃
现象:服务端运行一段时间后,某个客户端断开,服务端直接崩溃。原因:客户端线程在 RemoveClient 里删除了列表元素,而另一个线程正在 Broadcast 里遍历同一个列表,迭代器失效。解决:所有对 m_clientList 的读写都必须放在同一个临界区里,RemoveClient 也要加锁,并且删除后立即 break 遍历。
5.2 消息粘包导致内容错乱
现象:发送「你好」和「在吗」两条消息,接收端显示成「你好在吗」或者「你好在」。原因:TCP 是流式协议,两次 send 可能被合并成一次 recv。解决:严格按「4 字节长度 + 内容」的格式收发,接收端循环收满指定长度再处理,不要假设一次 recv 对应一条消息。
5.3 关闭客户端时服务端卡死
现象:点关闭按钮后,客户端界面卡住几秒才退出。原因:接收线程还在阻塞 recv,主线程等待线程结束。解决:关闭时先调用 shutdown(sock, SD_BOTH),这会唤醒阻塞的 recv 并返回 0,然后 closesocket,最后等待线程退出。不要直接 closesocket,那样 recv 的行为在不同系统上不一致。
5.4 端口被占用导致监听失败
现象:服务端启动时报绑定失败。原因:上一次程序没有正常退出,端口还处于 TIME_WAIT 状态。解决:bind 之前设置 SO_REUSEADDR,允许重用本地地址。
int opt = 1; setsockopt(m_listenSock, SOL_SOCKET, SO_REUSEADDR, (char*)&opt, sizeof(opt));5.5 中文乱码
现象:发送中文,接收端显示乱码。原因:发送端用 CString 的 Unicode 版本,接收端按 char 处理。解决:统一用 UTF-8 编码发送,发送前用 WideCharToMultiByte 转换,接收端按 UTF-8 解析。或者整个工程都用多字节字符集,但这不是长久之计。
提示:调试网络问题时,先用网络调试工具确认数据是否真的发出去了,再排查代码,能省很多时间。
6. 进阶技巧:把广播延迟压下来并验证连接稳定性
6.1 用发送队列替代直接 send
前面提到 Broadcast 里直接 send 会持锁阻塞。改进方法是给每个客户端维护一个发送队列,Broadcast 只负责把数据塞进队列并唤醒发送线程,实际 send 由各客户端的发送线程完成。这样即使某个客户端网络慢,也不会影响其他人。
// 发送队列结构 struct SendPacket { char* data; int len; }; // 发送线程 UINT CServerDlg::SendThread(LPVOID pParam) { ClientContext* ctx = (ClientContext*)pParam; while (ctx->pDlg->m_bRunning) { SendPacket* pkt = nullptr; { CSingleLock lock(&ctx->csSendQueue, TRUE); if (ctx->sendQueue.empty()) { // 队列空则等待,用事件对象唤醒 lock.Unlock(); WaitForSingleObject(ctx->hSendEvent, 100); continue; } pkt = ctx->sendQueue.front(); ctx->sendQueue.pop(); } send(ctx->sock, (const char*)&pkt->len, 4, 0); send(ctx->sock, pkt->data, pkt->len, 0); delete[] pkt->data; delete pkt; } return 0; }这个改动的核心是把「锁内 send」变成「锁内入队、锁外 send」。hSendEvent 是一个手动重置事件,Broadcast 入队后 SetEvent,发送线程 WaitForSingleObject 到事件后继续处理。参数上,WaitForSingleObject 的超时设 100 毫秒,是为了即使事件丢失也能周期性检查退出标志。
6.2 用心跳检测死连接
TCP 连接在没有数据往来时,对端异常断电,本端可能长时间不知道。解决办法是客户端每隔 30 秒发一个心跳包,服务端超过 90 秒没收到任何数据就判定连接失效并清理。
// 服务端在 ClientThread 里记录最后活跃时间 ctx->lastActive = GetTickCount(); // 另开一个检测线程,每 30 秒扫描一次 void CServerDlg::CheckTimeout() { DWORD now = GetTickCount(); CSingleLock lock(&m_csClientList, TRUE); for (auto it = m_clientList.begin(); it != m_clientList.end(); ) { ClientContext* ctx = FindContext(*it); if (ctx && (now - ctx->lastActive > 90000)) { shutdown(*it, SD_BOTH); it = m_clientList.erase(it); } else { ++it; } } }GetTickCount 返回的是毫秒数,90 秒就是 90000。这个值不要设太短,否则网络抖动会误杀正常连接;也不要太长,否则死连接占着资源。
6.3 验证方法:用脚本模拟多客户端
手工开多个客户端窗口测试效率太低。我一般写一个简单的 Python 脚本,模拟 50 个客户端同时连接并互发消息,观察服务端 CPU 和内存。
import socket import threading import time def client_task(cid): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect(('127.0.0.1', 8888)) for i in range(10): msg = f"client {cid} msg {i}".encode('utf-8') s.send(len(msg).to_bytes(4, 'little') + msg) time.sleep(0.1) s.close() threads = [] for i in range(50): t = threading.Thread(target=client_task, args=(i,)) t.start() threads.append(t) for t in threads: t.join() print("done")这个脚本用 4 字节小端长度做包头,和服务端的格式一致。跑起来后看服务端是否能正常广播、是否有崩溃、内存是否持续增长。如果内存一直涨,多半是 PostMessage 的 buffer 没释放,或者客户端列表没清理。
6.4 一个我踩过的坑
早期版本里,我在 Broadcast 里直接 send,结果一个客户端用手机热点,网络极慢,send 阻塞了十几秒,整个服务端所有客户端都收不到消息。后来改成发送队列,问题立刻消失。这个教训是:任何在锁内做的 IO 操作,都要假设它会阻塞,然后想办法把它挪出去。希望帮到你。
本文还有配套的精品资源,点击获取