
简介一份使用MFC框架编写的TCP服务器完整工程面向需要学习Windows网络编程或MFC套接字应用的开发者。资源基于CAsyncSocket封装Winsock API包含服务器初始化、监听、接受连接、收发数据及错误处理等核心逻辑并支持多线程并发适合作为入门TCP服务端编程的参考工程。压缩包共20个文件约39KB以C头文件h、源文件cpp为主另有工程配置dsp、dsw、clw、图标ico及说明文档txt工程由VC6.0创建可编译生成Debug版本以便调试。目前已有311人学习下载。通过阅读和运行这份代码可以直观理解MFC中OnAccept、OnReceive、OnSend等回调机制掌握异步套接字服务器的基本架构与常见排错思路直接在此基础上扩展业务逻辑。 最近整理代码库的时候翻出来一个老项目MFC写的一个TCP服务器。说起来有点意思很多人觉得MFC早该退休了可真到实际工控、上位机、局域网小工具开发时MFC依然是很多老项目的“主力军”我自己也用它做过设备采集服务、PLC通信中转这类东西积累了不少经验。这篇文章我打算把这个项目讲透——为什么要在MFC里做TCP服务器线程模型怎么搭数据怎么收发踩了哪些坑直接给你能复现的实现思路。适合正在用VS2008/2013/2019做MFC开发、需要在桌面程序上集成TCP服务能力的朋友也适合刚从Linux服务器转过来、搞不懂Windows界面线程怎么和socket相处的同学。我尽量把每个选型背后的“为什么”都说清楚方便你迁移到自己的项目里。1. 项目整体设计与方案选型1.1 为什么要在MFC里做TCP服务器先说个很现实的问题真正的生产级TCP服务器多数跑在Linux上CPU天梯图上那些至强、霄龙基本都服务端环境。那为什么还要费劲在MFC里做服务器因为实际场景里有一大类需求是“边收敛数据边可视化”。比如你接了好几台设备设备通过TCP把温度、压力、开关量推过来你既要负责监听连接又要把实时数值显示在仪表盘上还要能点按钮下发指令。这时候如果你拆成两个程序——一个Linux服务器收数据一个MFC客户端显示——就会多出一层中间件延迟和维护成本都上去了。MFC做TCP服务器的核心价值就是把网络监听、数据收发、业务逻辑、界面展示放在同一个进程里。数据到了就能直接刷新控件不需要走IPC或数据库中转开发效率明显更高。代价是MFC的界面消息循环是单线程的你必须在网络响应和UI流畅之间做好分工否则程序必卡死。1.2 四种实现方案对比我最早接触这个项目时第一反应是用socket原始API硬写。后来发现结构化之前得先想清楚线程模型以下是MFC工程里接入TCP服务器的四种主流方式我逐一分析过方案基本原理适用场景难点CAsyncSocket 消息回调MFC封装socket事件映射为窗口消息连接数少、消息量小消息风暴时界面卡顿WSAAsyncSelect 自定义消息Win32原生异步选择注册到窗口句柄中等连接数网络事件和窗口生命周期耦合工作线程 阻塞socket独立线程里accept/recv结果投递回界面最通用适合长期运行线程同步和退出处理IOCP完成端口异步I/O系统级高并发大并发服务器代码复杂度高MFC里很少用最终我选了工作线程 阻塞socket方案。核心原因是MFC界面线程是消息泵驱动的任何阻塞操作放进去界面立刻假死。而独立工作线程里用阻塞socket逻辑线性清晰accept一个连接就开一个收发线程符合大多数人“服务器持续监听每连接独立处理”的思维习惯。连接数不超过几十个时这个方案的性能和可维护性都够用线程开销也可以接受。2. 核心细节解析TCP协议与MFC的配合2.1 TCP三次握手与listen/accept的关系在看代码前先梳理协议层。TCP三次握手不是accept触发的而是内核协议栈自动完成的。你在服务端调用listen时内核就已经开始接受SYN请求并为完成握手的连接维护一个已完成队列。accept的职责只是从队列里取一个已经握手成功的连接返回给应用程序一个可用的socket句柄。这里有个很重要的经验如果客户端显示TCP连接已建立但服务端accept迟迟不返回往往不是网络问题而是你没有及时调用accept或者已完成队列被写满了。MFC里如果你把accept放在某个按钮点击事件里就会出现这种怪现象——客户端connect秒成功服务端毫无反应。处理办法是让accept持续运行在独立线程里或者用异步事件通知。另一个细节是listen的backlog参数我习惯设为SOMAXCONN让系统决定。手动设一个固定小值比如5会在连接突发时直接丢握手包客户端那边表现为connect超时排查起来相当隐蔽。2.2 粘包与拆包处理思路TCP是字节流协议没有消息边界。设备一次发来1KB数据recv可能分两次返回两次间隔很短的数据也可能合成一次返回。说白了TCP不保证你每次recv拿到的正好是一条完整业务消息。做过串口的人比较好理解串口也按字节流读要靠帧头帧尾或者长度来卡边界。TCP服务器同理。实战中我见过三种拆包方案固定长度每条消息固定N字节按N字节切分最简单但灵活性差。长度前缀消息头固定2字节或4字节表示长度后面跟着body。这是最常用的Modbus TCP就是这么干的——MBAP头里就有长度字段。特殊分隔符以换行符或特定标记作结尾适合文本协议比如REST接口或自定义AT指令风格。在这个MFC项目里我采用长度前缀方式。收到数据后先攒入缓冲区检查够不够读长度头够了再根据长度字段判断整个消息是否完整不完整就继续等下一段recv数据。这段逻辑虽然枯燥但它是TCP服务器稳定性的分水岭。2.3 跨线程更新UI的正确姿势这是MFC项目最容易写崩的地方。工作线程收到数据后绝对不允许直接调用CWnd::SetWindowText这类界面函数。MFC的控件不是线程安全的子线程操作控件轻则显示错乱重则直接崩溃。正确做法是把数据通过消息投递给主线程由界面线程统一更新控件。我常用PostMessage而非SendMessage因为PostMessage只把消息扔进队列就返回不阻塞网络线程SendMessage会同步等待界面处理完万一界面卡住网络线程也被拖死。自定义消息一般这样定义在对话框头文件里写#define WM_NETWORK_DATA (WM_APP 101)然后在消息映射表里加ON_MESSAGE(WM_NETWORK_DATA, CMyServerDlg::OnNetworkData)工作线程里用::PostMessage(hWnd, WM_NETWORK_DATA, (WPARAM)socketId, (LPARAM)pBuffer)把数据指针传过去。注意pBuffer的生命周期最好用new分配后在线程里post界面线程处理完delete不然会产生用后释放的随机崩溃。3. 实操过程从初始化到稳定收发的完整实现3.1 初始化环节第一件事是初始化Winsock库。在对话框的OnInitDialog里或者一个专门的初始化函数中调用WSAStartup。版本号我习惯用MAKEWORD(2, 2)兼容性好也支持现代特性。对应的程序退出前记得调WSACleanup。然后创建监听socket。这里有个非常关键的选项SO_REUSEADDR我建议你务必设置。它允许服务器重启时立刻复用TIME_WAIT状态的端口否则服务端一崩溃重启很可能报“bind失败”让你误以为是代码问题。初始化环节的代码骨架大致这样bool CMyServerDlg::InitWinsock() { WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), wsaData) ! 0) { AfxMessageBox(_T(WSAStartup failed)); return false; } m_listenSocket socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (m_listenSocket INVALID_SOCKET) { AfxMessageBox(_T(socket create failed)); WSACleanup(); return false; } BOOL bReuse TRUE; setsockopt(m_listenSocket, SOL_SOCKET, SO_REUSEADDR, (const char*)bReuse, sizeof(bReuse)); sockaddr_in addr; addr.sin_family AF_INET; addr.sin_port htons(6000); // 监听端口 addr.sin_addr.s_addr htonl(INADDR_ANY); // 监听所有网卡 if (bind(m_listenSocket, (sockaddr*)addr, sizeof(addr)) SOCKET_ERROR) { // 常见错误: WSAEADDRINUSE AfxMessageBox(_T(bind failed)); closesocket(m_listenSocket); WSACleanup(); return false; } if (listen(m_listenSocket, SOMAXCONN) SOCKET_ERROR) { AfxMessageBox(_T(listen failed)); closesocket(m_listenSocket); WSACleanup(); return false; } return true; }3.2 监听线程与每连接处理线程bind和listen完成后就可以启动一个线程专门循环accept。每次accept成功就把返回的新socket交给一个新线程去收发。这样设计的好处是一个连接卡住了不会影响其他连接清理逻辑也清晰。监听线程的循环结构大概长这样UINT ListenThreadProc(LPVOID pParam) { CMyServerDlg* pDlg (CMyServerDlg*)pParam; while (pDlg-m_bRunning) { sockaddr_in clientAddr; int addrLen sizeof(clientAddr); SOCKET client accept(pDlg-m_listenSocket, (sockaddr*)clientAddr, addrLen); if (client INVALID_SOCKET) { // 是被正常关闭还是异常? if (WSAGetLastError() WSAEINTR) break; continue; } // 把客户端IP和socket传入工作线程处理 ClientInfo* pInfo new ClientInfo; pInfo-socket client; strcpy_s(pInfo-ip, inet_ntoa(clientAddr.sin_addr)); pInfo-port ntohs(clientAddr.sin_port); AfxBeginThread(ClientWorkThread, pInfo); } return 0; }注意accept线程结束时对WSAGetLastError的判断。我在退出时习惯调用closesocket关闭监听socket这会让阻塞中的accept返回SOCKET_ERROR并且错误码是WSAEINTR通过这个状态位我们就可以干净地跳出循环而不是用TerminateThread暴力结束线程。客户端收发线程里主循环就是阻塞recv。收到数据后按前面说的长度前缀方案解析完整消息然后把解析结果PostMessage回主窗口。发送数据时则要考虑粘包与半包发送缓冲区不满时调用send能一次发出还好数据很大时send可能只发送一部分必须循环发送直到全部写完。封装一个SendAll函数是很必要的。3.3 优雅退出的细节这个坑我踩过好多次。MFC对话框点关闭时UI线程直接退出但后台线程还在accept、recv程序会报运行时错误或直接卡死在Debug模式。处理的顺序很重要先把m_bRunning置false让线程循环条件失效。关闭监听socket令accept返回错误退出。遍历所有客户端socket并closesocket让recv返回0或错误收发线程退出。等待所有线程句柄结束再关闭软件。关闭socket时有个细节直接closesocket正在阻塞recv的socket在Windows上通常能立刻解除阻塞但严谨的做法是先调用shutdown(sock, SD_BOTH)让对端也收到FIN再closesocket。这样客户端不会莫名收到“连接被重置”的提示。4. 常见问题与排查技巧实录4.1 bind报错only one usage of each socket address这个错误我最早是在控制台程序里看到的错误信息很长bind: only one usage of each socket address。在Windows上对应WSAEADDRINUSE意思就是端口被占了。触发原因主要有三类上次程序退出时端口处于TIME_WAIT状态如果没设置SO_REUSEADDR就立刻重启会报这个错。你同时开了两个实例都去绑定同一个端口。端口被其他进程占用比如系统服务或另一个程序抢先绑定。排查手段很简单命令行执行netstat -ano | findstr 6000看看谁占着端口最后一列是PID再去任务管理器对照进程。如果确定是TIME_WAIT导致的设置SO_REUSEADDR即可如果被其他程序占用就得改监听端口或结束占用进程。4.2 TCP连接重复建立导致资源耗尽MFC服务器跑着跑着连接数越来越多内存持续上涨一查发现是有客户端反复断开重连。每来一次连接我就new一个线程线程退出时如果没有彻底清理资源句柄就会泄漏。后来我总结了一个原则任何用new创建的对象或线程务必在函数的每条退出路径上释放干净。为了好维护我会用一个std::mapSOCKET, ClientInfo*统一管理所有连接断开时从map里移除并delete而不是让线程退出后自己在角落默默清理。这样即使有异常主界面也能看到当前连接总数排查泄漏容易得多。4.3 客户端连上就报“连接被重置”TCP连接建立后服务端过一会儿才读到recv返回0或-1客户端却报错。这个现象多半不是服务端代码问题而是客户端自己关闭了socket或者服务端收发线程在处理消息时抛了未捕获异常导致线程退出socket被操作系统回收连接被硬断。另一种常见情况是收发线程模型混乱有人用一个线程同时做accept和recv。accept返回后不创建新线程而是直接在当前线程里recv结果新连接再进来时没人accept客户端connect成功但服务端无响应最终超时或重置。4.4 常见问题速查表现象可能原因处理思路bind报地址已被占用端口处于TIME_WAIT或有进程占用设置SO_REUSEADDR、netstat查占用connect成功但收不到任何数据accept线程未持续运行确认accept循环在独立线程中偶发数据错乱粘包/拆包未处理加长度前缀或分隔符协议程序关闭时卡死线程未优雅退出先停循环再关socket再等线程收到TCP connection reset by peer对端未正常关闭或服务端线程崩溃检查关闭流程用shutdown代替直接closesocket界面假死网络逻辑跑了界面线程所有socket操作移到工作线程内存持续增长连接对象未释放用map统一管理连接生命周期5. 结尾实际开发中的延伸经验这个项目后来我还做了几个扩展简单分享下思路。如果哪一步卡住了很可能是环境或数据格式问题不一定是代码逻辑问题。如果你打算把它用于生产环境建议再补三件事一是心跳机制客户端意外断电时TCP不一定立刻感知服务端要定期检测空闲连接并断开二是日志系统网络服务器最大的痛点就是不好复现bug至少要把连接建立、断开、收发长度记下来三是打包发布MFC程序在别的机器上跑别忘记带上VC运行库用VS2013做的项目尤其要留意。最后再分享一个调试技巧接收线程里每次recv后可以顺手把数据长度和首字节打印到调试窗口。很多时候你觉得是网络丢数据其实是对端就没把数据发全数据格式问题往往比网络问题更隐蔽。多看原始字节流再判断是不是协议问题能少走很多弯路。本文还有配套的精品资源点击获取