简介:面向具备Java Socket编程经验、希望用MFC实现更高效率即时通讯的开发者,这份示例演示了如何用CSocket构建一个服务器与多个客户端之间的通信结构。服务端通过CPtrList集合保存客户端socket对象,实现思路与Java中用Vector保存socket对象相似,但借助MFC的CSocketFile与CArchive异步通信机制,代码比多线程方案更简洁,消息可在服务端与所有客户端同步显示。压缩包共75个文件,大小3.44MB,包含15个头文件、13个C++源文件、13个obj中间文件及可直接运行的2个exe程序,既有完整源码又有可立即运行的演示程序。代码基于VC++6.0编写,适用于Windows XP SP3环境,注释非常详细,辅助类统一置于util目录,手工代码按Java骆驼命名法书写,整体结构清晰易读。资源已有755人学习,适合顺着服务端onAccept与客户端OnSendButton两条主流程研读,快速把已有Socket经验迁移到MFC环境中。 做局域网即时通讯工具,放在今天看似乎有点土,但真遇到一个“车间工控机上必须用MFC写Socket服务器、带几十个客户端”的项目,你还是得老老实实把基础的东西梳理清楚。前阵子我接了个需求:现有管理系统是用MFC写的,没有外网环境,不允许装额外运行库,只能在原程序里加一块“服务器对多个客户端的简单即时通讯”功能——A客户端发一句话,其他客户端实时收到。
这篇文章就围绕这个MFC Socket编程示例展开,从服务器端怎么监听、客户端如何接入、消息如何转发,到实际调试中踩过的几个坑,完整写出来。适合正在学MFC网络编程的人,也适合被安排做类似局域网小工具的开发者参考。思路不止一条,我会把选型理由和代码骨架都讲清楚,方便你直接照着自己的项目改。
1. 先捋清楚需求:MFC做局域网聊天,选型上有什么讲究
1.1 这个示例解决的需求是什么
表面上这是一个“聊天室”,但深入看需求,它和公网聊天软件有个本质区别:不需要账号体系,不需要消息持久化,不需要离线推送。A客户端发一条消息,服务器收到之后广播给其他所有客户端,B、C、D都能看到,仅此而已。这个定位很重要,它决定了整个服务器端代码可以做得非常轻。
另一个隐藏需求是“必须嵌进MFC窗口程序”。这意味着网络部分不能独立成一个控制台程序,而是要作为主对话框的一个功能模块:界面上点“启动监听”按钮,服务器就开始工作,所有的收发结果都实时反映到界面的列表控件里。
我把这个示例的目标拆成了三点:
- 一个服务器进程,多个客户端进程,全部在同一局域网内。
- 任一个客户端发消息,服务器广播给其余所有客户端。
- 客户端上线、下线时,服务器和客户端都能感知到,并刷新在线列表。
需求确定之后,接下来最大的问题就是:用哪套Socket API跟MFC配合。
1.2 三种Socket封装方式怎么选
MFC环境里做Socket,摆在你面前的无非是三套东西:CAsyncSocket、CSocket,以及原生Winsock。很多新手一上来就选CSocket,因为它封装得最“高级”,配合CSocketFile和CArchive能像读写文件一样收发数据。但我个人的建议是:工作线程里的网络通信,尽量别碰CSocket。
原因很简单。CSocket是阻塞模式的,它内部会调用Windows的消息泵,在MFC的UI线程里用还凑合,一旦放到你自己创建的工作线程里,它的消息处理机制很容易和MFC的主消息循环打架,轻则收不到数据,重则界面卡死。CAsyncSocket则是异步消息驱动的,靠窗口消息触发FD_READ、FD_WRITE等事件,但它要求所在线程必须有一个消息循环——而我们的聊天服务器恰恰需要在后台线程里跑阻塞的accept和recv。
三者的取舍可以用一张表说清楚:
| 方案 | 运行机制 | 适合场景 | 我的判断 |
|---|---|---|---|
| CAsyncSocket | 窗口消息通知收发事件 | UI线程、连接数较少 | 工作线程里不可用 |
| CSocket | 阻塞式封装,内部依赖消息泵 | 简单C/S、串行通信 | 和后台线程配合易出问题 |
| 原生Winsock + 线程 | 全手动控制,自由度最高 | 通用客户端/服务端 | 本项目采用 |
所以最终我就选了原生Winsock,配合自己创建的线程来做。代码量确实比直接用CAsyncSocket多了一点,但换来的是可控性:每一个socket的阻塞、非阻塞、收发、关闭都清清楚楚,出了问题也好调试。
1.3 并发模型敲定:一客户端一线程,不做select也不上IOCP
服务器要同时服务多个客户端,就必须处理并发。常见做法有三类:阻塞accept加每客户端一个线程、单线程select模型、以及IOCP。
IOCP性能最强,能扛上万连接,但代码复杂度和调试难度都很高,这个项目完全不需要。select模型在几百个连接以内也很好用,单线程统一管理所有socket,不用考虑线程切换和共享数据加锁,代码写起来虽然绕一点,但是稳定。不过它有一个比较尴尬的地方:select每次调用之前都要重建fd_set,收数据的时候还要区分是哪一个socket触发了事件,逻辑上比“每客户端开一个线程”要抽象不少。
我的选择是“监听线程 + 每客户端一个接收线程”。
- 主线程负责UI和按钮响应。
- 一个监听线程专门在accept上阻塞等新连接。
- 每来一个客户端,就创建一个接收线程,阻塞在recv上等这个客户端的数据。
这个模型的好处是简单直观:每个客户端的逻辑是一个独立的函数,不互相干扰。代价是你得维护一个客户端列表,并且要处理好列表的临界区保护。但在这个规模的示例里,这点代价完全可以接受。实测下来,负载几十个客户端非常稳,CPU占用几乎可以忽略。
2. 服务器端的数据组织与监听线程设计
2.1 客户端信息怎么登记
既然是多客户端,服务器端就必须维护一张“在线客户端表”。每个客户端我定义了一个结构体来保存基本信息:
typedef struct tagClientInfo { SOCKET socket; // 与客户端通信的socket CString strName; // 客户端名称 int nClientId; // 客户端ID,给UI识别用 HANDLE hThread; // 接收线程句柄 } CLIENT_INFO, *LPCLIENT_INFO;这里nClientId不需要做太复杂,递增整数就行。每次accept一个新客户端,就分配一个自增ID,作为这个连接在服务器里的唯一标识。客户端名称由客户端在连接后第一条消息里上报,服务器收到后把它更新进去,再广播给所有客户端,告知“XXX上线了”。
保存客户端列表我用的是一个简单的数组加一个计数器。考虑到多个接收线程会同时往列表里插入和删除节点,我对列表的所有操作都放在一个CRITICAL_SECTION临界区里保护。这个临界区在服务器启动时初始化,在整个程序生命周期内有效。
2.2 监听线程的启动与退出
监听线程的入口函数必须是静态函数或全局函数,这是C++线程函数的老规矩。我一般这样写:
UINT ListenThreadProc(LPVOID pParam) { CServerDlg* pDlg = (CServerDlg*)pParam; pDlg->DoListen(); return 0; }实际的监听逻辑放在对话框类的DoListen成员函数里,这样线程函数只是薄薄一层壳,类内部的成员变量都可以直接访问,不用再通过参数传一大堆东西。
DoListen里面的核心流程是:WSAStartup初始化、socket创建、bind绑定端口、listen进入监听、然后进入accept循环。有一点需要注意:WSAStartup和WSACleanup必须成对出现。我的做法是在程序启动时初始化Winsock,程序退出时清理,不在监听线程里反复调用,避免引用计数的隐患。
服务器绑定端口用的是sockaddr_in结构,端口我固定写成7001,避免跟常见的HTTP、MySQL等端口冲突。bind之前我还会给监听socket设置SO_REUSEADDR选项,这一步后面排错章节会详细说。
2.3 accept循环与客户端接收线程创建
监听线程最核心的部分就是下面这个循环:
while (!m_bStopListen) { SOCKET clientSock = accept(m_listenSock, NULL, NULL); if (clientSock == INVALID_SOCKET) { // 如果是要退出监听,这个错误是预期的 if (m_bStopListen) break; continue; } LPCLIENT_INFO pInfo = new CLIENT_INFO; pInfo->socket = clientSock; pInfo->nClientId = ++m_nLastClientId; pInfo->strName.Empty(); // 加锁,把新客户端加入列表 EnterCriticalSection(&m_csClientList); m_clientList.Add(pInfo); LeaveCriticalSection(&m_csClientList); // 通知UI“新客户端上线” ::PostMessage(GetSafeHwnd(), WM_USER_CLIENT_JOIN, pInfo->nClientId, 0); // 为这个客户端单独开一个接收线程 pInfo->hThread = AfxBeginThread(ClientThreadProc, pInfo)->m_hThread; }这里有一个细节:CLIENT_INFO内存是在监听线程里new出来的,然后传给接收线程,接收线程在客户端断开后负责delete。谁分配谁释放的原则并不适用,所以我约定接收线程负责释放pInfo,注释里写清楚,避免以后维护的人看懵。
每个客户端单独开线程,理论上这个线程会在客户端断开、接收循环退出后自然结束。MFC里AfxBeginThread创建的线程句柄一般不需要手动CloseHandle,线程对象会在线程结束后自动清理,这一点比直接用CreateThread省心不少。
3. 服务器端消息转发与断线处理
3.1 协议头设计:先解决粘包问题
Socket通信里最经典的坑就是粘包和拆包。TCP是流式协议,recv返回的缓冲区长度不固定。可能一次recv同时收到了两条消息,也可能一条消息要分好多次recv才能收完。如果不做处理,直接把recv到的字节当完整消息用,界面上通常会出现“消息错乱、第一条后半截接第二条内容”这种奇怪的现象。
解决方案是定义一个简单的协议头,每次发送的消息都带上“消息类型”和“数据长度”两个字段。我用了一字节对齐的结构体:
#pragma pack(push, 1) typedef struct tagPacketHeader { WORD wType; // 消息类型:1=上线, 2=文本, 3=下线 WORD wLen; // 后面跟着的数据区字节数 } PACKET_HEADER, *LPACKET_HEADER; #pragma pack(pop)用#pragma pack(push, 1)是为了防止结构体字节对齐,否则这个4字节的小结构体可能被编译器撑到8字节,客户端和服务器端如果一边打包一边没打包,解析出来的长度就全是错的。
接收端的解包逻辑用一个缓冲区累积数据,每次recv之后不停地尝试解包:
char recvBuffer[8192]; int recvLen = 0; while (true) { int nRet = recv(sock, recvBuffer + recvLen, sizeof(recvBuffer) - recvLen, 0); if (nRet <= 0) break; // 连接关闭或出错 recvLen += nRet; while (recvLen >= sizeof(PACKET_HEADER)) { PACKET_HEADER* pHeader = (PACKET_HEADER*)recvBuffer; int nPacketLen = sizeof(PACKET_HEADER) + pHeader->wLen; if (recvLen < nPacketLen) break; // 数据还没到齐,继续recv // 处理这一包完整的消息 OnReceivePacket(pHeader->wType, recvBuffer + sizeof(PACKET_HEADER), pHeader->wLen); // 把剩余数据搬到缓冲区头部,继续解下一包 int nRemainLen = recvLen - nPacketLen; if (nRemainLen > 0) memmove(recvBuffer, recvBuffer + nPacketLen, nRemainLen); recvLen = nRemainLen; } }这段代码是整个示例里最值得细看的。内层while循环处理完一包后把剩余数据搬到缓冲区开头,继续解析,这样即使一次recv收到五条完整消息,也会被全部解出来。数据没到齐时就直接break,等下一次recv继续累积。用memmove而不是memcpy,是因为数据来源和目的地址有重叠,memcpy在重叠场景下是未定义行为。
3.2 消息上报UI的线程安全写法
接收线程收到消息后,不能直接调用SetDlgItemText、UpdateData这些MFC界面函数。MFC的控件操作不是线程安全的,工作线程里直接操作控件,轻则界面刷不出来,重则死锁崩溃。
正确做法是投递自定义消息到主窗口,让UI线程真正去干活:
#define WM_USER_CLIENT_MSG (WM_USER + 100) // 接收线程里,解析出一包文本消息后: CString* pMsg = new CString(buffer, nDataLen); ::PostMessage(pDlg->GetSafeHwnd(), WM_USER_CLIENT_MSG, pInfo->nClientId, (LPARAM)pMsg);这里用PostMessage而不是SendMessage。PostMessage是异步的,把消息扔进窗口队列就立即返回,接收线程不会阻塞。SendMessage会等UI处理完才返回,万一UI线程正在等待某个临界区,而发送线程又持有这个临界区,就会互相等待,形成死锁。
主窗口在消息处理函数里做两件事:把消息文本追加到界面的列表控件显示,然后向除发送者之外的所有客户端转发。转发的过程中要遍历客户端列表,所以前后要用EnterCriticalSection保护。一个容易忽略的细节是:send操作不要在临界区里面执行。如果某个客户端socket出问题卡住了,send会阻塞,临界区就一直不释放,其他线程全部卡死。
我的做法是先进入临界区,把需要转发的socket拷贝到一个临时数组,退出临界区后再逐个send。这样既能保证列表一致性,又不会长时间持锁。
3.3 断线检测与资源清理
客户端断开时,recv会返回0或SOCKET_ERROR。接收线程看到返回小于等于0,就认为连接结束了。此时要做的清理工作有好几件:
- 调用closesocket关闭这个客户端的socket。
- 从全局客户端列表里移除该客户端记录。
- 通知UI刷新客户端列表,显示“XXX下线了”。
- delete掉最开始new出来的CLIENT_INFO结构体。
我在实现里是让接收线程在退出前PostMessage通知UI,带上下线客户端的ID。UI线程在处理这个消息时,会主动去列表里把这个ID对应的记录删掉——注意,删除列表操作本身要加临界区,所以这个动作放在UI线程做没问题,接收线程不直接操作列表,避免了双重锁的问题。
实际中还会遇到一种情况:客户端程序崩溃,没有正常关闭socket。服务器端recv会收到WSAECONNRESET错误,代码里要把它当作断开处理,不能让线程傻傻地卡在recv里。recv的返回值判断最好写成:
if (nRet == 0) break; // 对端正常关闭 else if (nRet == SOCKET_ERROR) { int nErr = WSAGetLastError(); // WSAECONNRESET表示对端非正常关闭,同样视为断线 break; }这样不管是正常退出还是崩溃,服务器都能在几秒内感知到客户端下线。不需要额外的心跳机制,但对于要求高可靠的场景,心跳仍然有必要,后面扩展部分会说。
4. 客户端实现的关键点
4.1 连接服务器与接收线程
客户端的结构比服务器简单得多。整个客户端就是MFC对话框程序,界面上有IP地址编辑框、端口编辑框、消息输入框、消息列表、一个“连接服务器”按钮和一个“发送”按钮。
点击连接按钮后,我做了这几步:
- 调用socket创建SOCK_STREAM套接字。
- 用inet_addr或getaddrinfo把IP地址字符串转成sockaddr_in。
- connect连接服务器。
- 连接成功后,创建接收线程,阻塞在recv上。
客户端接收线程的代码和服务器的接收线程基本一样,也是recv循环加粘包解包,收到消息后PostMessage到客户端主窗口。唯一差别是客户端收到的是服务器广播过来的消息,其中可能包含自己发出去的消息。我通常会在消息里带上发送者名称,客户端收到后根据名称判断是自己发的还是别人发的。
4.2 发送数据时加锁
客户端主线程负责处理按钮点击发送消息,而接收线程又在同时收发数据。如果用户发消息的速度很快,两个线程同时调用send,就可能出现数据交错混乱。所以在客户端的发送逻辑里,我对send加了一把锁:
void CClientDlg::SendTextMessage() { // 复用同一个临界区保护发送动作 EnterCriticalSection(&m_csSend); BOOL bRet = SendPacket(m_socket, MSG_TYPE_TEXT, strText, nLen); LeaveCriticalSection(&m_csSend); if (!bRet) // 提示发送失败 }SendPacket内部先send包头再send数据区。保护这一步很重要,尤其是包头和消息体分两次send的时候,不加锁的话,线程A发完包头还没发消息体,线程B抢先把自己的包头发出去了,接收端解析出来的数据就全是错乱的。这属于“看着没啥问题,跑起来偶发异常”的经典案例。
4.3 界面刷新与退出逻辑
客户端收到消息后,界面刷新也是走PostMessage。我在客户端对话框里自定义了一个消息,接收到消息就追加到消息列表控件,并自动滚动到底部:
LRESULT CClientDlg::OnRecvMessage(WPARAM wParam, LPARAM lParam) { CString* pMsg = (CString*)lParam; m_listMsg.AddString(*pMsg); m_listMsg.SetCurSel(m_listMsg.GetCount() - 1); delete pMsg; return 0; }退出的时候有一个非常关键的细节:对话框响应WM_CLOSE时,不能直接OnOK退出。因为接收线程还阻塞在recv里,如果窗口销毁了,接收线程PostMessage会失败,更严重的是线程所使用的socket还没关闭,程序进程不会完全退出。
正确的关闭顺序是:
- 设置一个退出标志位,通知接收线程准备退出。
- 调用shutdown通知对端不再发送数据。
- 调用closesocket,让阻塞中的recv立即返回错误。
- 等待接收线程结束。
- 再销毁对话框。
只有把socket从系统层断掉,recv才能从阻塞状态返回,线程才能退出。这个顺序反过来操作,程序就会卡在“退出但线程还在跑”的状态,任务管理器里进程删不掉。
5. 实际排错记录:四个最容易翻车的地方
5.1 bind时报“only one usage of each socket address”是怎么回事
这个错误提示很多人应该眼熟:服务器程序上次运行还在,你重新编译再启动,或者程序异常崩溃后马上重启,bind突然失败,报错信息是一长串“bind: Only one usage of each socket address (protocol/network address/port) is normally permitted”。
直接原因就是端口还处于占用状态。Windows下连接关闭后,端口并不立即释放,可能停留在TIME_WAIT状态;或者是上次的程序进程没有真正退出,socket句柄没有释放。这种情况在调试MFC程序时特别常见,因为VS调试中途点“停止”并不一定把后台线程的socket关干净。
处理步骤我建议分两步:
- 先用命令查端口占用:
netstat -ano | findstr 7001,看是不是有旧进程还占着7001端口。 - 代码层面,在bind前对监听socket设置SO_REUSEADDR,允许地址重用。
BOOL bReuse = TRUE; setsockopt(m_listenSock, SOL_SOCKET, SO_REUSEADDR, (const char*)&bReuse, sizeof(bReuse));设置之后,即使有TIME_WAIT状态的连接,bind也能成功。但注意:如果另一个进程还活着并且正在监听同一个端口,SO_REUSEADDR也救不了你,必须把那个进程结束掉。
5.2 IPv6优先导致的连接失败
我在VS2019里编译后的客户端,connect服务器的127.0.0.1竟然失败了。排查半天,问题出在getaddrinfo上。Windows的getaddrinfo默认解析地址时,如果系统装有IPv6协议栈,可能优先返回IPv6地址族的结果。如果服务器只绑定了IPv4的INADDR_ANY,客户端却尝试用IPv6地址去connect,自然连接不上。
解决方式有两种。简单粗暴的就是不绕圈子,直接用inet_addr把IP字符串转成in_addr,明确使用AF_INET地址族:
SOCKADDR_IN serverAddr; serverAddr.sin_family = AF_INET; serverAddr.sin_addr.s_addr = inet_addr(strIP); serverAddr.sin_port = htons(nPort);想做得更健壮的,就在getaddrinfo的hints里明确限定AF_INET,并且遍历返回结果,跳过IPv6地址。这个坑在纯IPv4局域网环境里几乎必现,我建议直接写死AF_INET,省心。
5.3 工作线程里直接操作控件导致的界面卡死
这个坑我刚开始写MFC网络代码时也踩过。接收线程收到消息后,想着“刷新个列表而已,直接SetDlgItemText多方便”,结果一运行,消息来了界面偶尔卡住,点哪儿都没反应。更严重的时候,整个程序直接假死,必须从任务管理器杀掉。
原因前面已经提到过:MFC的控件操作依赖窗口消息机制,工作线程没有消息循环,直接操作控件会导致内部的原子锁操作互相等待。比如接收线程正在给列表AddString,UI线程同时也在重绘列表,两个线程同时操作同一个控件内部数据,崩溃和死锁都是迟早的事。
这个问题的正解就是PostMessage,把要显示的数据打包成堆对象,扔给UI线程处理。代码上要记得在UI线程的处理函数里delete掉LPARAM指向的对象,否则每来一条消息就泄漏十几字节,跑一晚上内存占用涨上去一大截。
5.4 监听线程退不出来的问题
程序关闭时,如果监听线程还在accept上阻塞着,线程无法自行退出。我早期的写法是设置m_bStopListen标志位,然后WaitForSingleObject等线程结束,结果等到超时线程也没退出。原因很明显:accept一直阻塞着,根本没机会去检查那个标志位。
后来在第2章的监听循环里加了一个关键操作——关闭监听socket。accept阻塞在哪个socket上,就把哪个socket关掉:
void CServerDlg::StopListen() { m_bStopListen = TRUE; if (m_listenSock != INVALID_SOCKET) closesocket(m_listenSock); m_listenSock = INVALID_SOCKET; }closesocket之后,accept会立刻返回INVALID_SOCKET,循环判断到m_bStopListen为TRUE,正常退出。监听线程就“解除了封印”,程序也能顺利关闭了。同理,所有客户端接收线程在程序退出时也要逐个关闭对应的socket,否则同样卡在recv上。
6. 扩展方向与个人心得
6.1 心跳与超时检测
前面提到客户端正常断开和异常崩溃,服务器也能靠recv返回值感知到。但如果中间设备断网、客户端机器直接断电,服务器不一定能立刻发现——TCP的保活机制默认可能要几个小时才能触发一次。对即时通讯来说,这个延迟是不能忍的。
更可靠的做法是加上心跳包。客户端每隔5秒向服务器发送一个空文本消息类型的PING包,服务器收到后更新这个客户端的“最后活跃时间”。服务器单独用一个定时器或者一个清理线程,每10秒扫描一遍客户端列表,把超过20秒没有心跳的客户端踢下线,同时通知UI和其余客户端。
这样做的代价是代码量会增加不少,但换来的是在线列表的准确性。尤其是客户端断电这种场景,服务器最多20秒就能感知掉线,而不是靠TCP自己磨洋工。
6.2 从多线程改成select模型的思路
如果你要带300个甚至更多客户端,每客户端一个线程的做法就不太合理了。线程上下文切换开销变大,内存占用也随之上涨。此时可以把服务器的收发统一改成select模型,监听socket和所有客户端socket集中在一个线程里管理。
select大概的思路是:维护一个总socket数组,每次调用select之前重建fd_set,把所有待监视的socket加进去,然后用FD_ISSET判断哪些socket有读事件。监听socket可读说明有新连接,client socket可读说明有数据到达。这种模型不用为每个客户端开线程,临界区也不需要了,天然就规避了很多并发问题。
需要注意的坑是fd_set的容量限制,默认FD_SETSIZE是64,用select模型要手动加大这个宏定义。再就是每次循环都要清空重建fd_set,这是个低效但必要的动作,很多人第一次写会漏掉。
6.3 项目组织上的一个小建议
这个示例里最容易出的问题其实是“协议不一致”。服务器和客户端对消息格式的定义各有各的版本,一边改了结构体长度,另一边忘了改,排查起来非常痛苦。我自己的习惯是把协议定义、包头结构、发送函数、解包函数单独放在一个common.h和common.cpp里,服务器和客户端项目共同引用这一份代码。
这样改任何协议字段,两边同步生效,编译的时候就能发现不兼容,而不是等运行起来再查数据错乱。代码量不多,但工程性提升非常明显。
另一个小技巧是给自己留一个调试开关,在服务器界面上勾选“显示原始报文”复选框,把收到的十六进制数据全部打印出来。排查粘包、协议头错位、字节序问题的时候,这个开关能省下大量时间。实际项目中很多怪异现象,靠肉眼看协议十六进制比靠猜快得多。
本文还有配套的精品资源,点击获取