资讯动态

VC6 RawSocket多线程TCP服务器源码解析与避坑指南

发布时间:2026/10/9 9:19:45 来源:尧图企业网站定制
简介这是一份面向C网络编程初学者与进阶开发者的Visual C TCP套接字实战示例聚焦多线程客户端-服务器架构帮助读者理解Winsock底层API的调用流程与并发连接处理思路。资源包共32个文件以11个h头文件与10个cpp源文件为核心辅以dsp、dsw、mak等工程配置文件和rc、ico等界面资源整体约37KB结构紧凑便于直接编译运行与逐模块研读。内容涵盖WSAStartup初始化、socket创建、bind与listen监听、accept接受连接、多线程分发以及send/recv数据收发等关键环节并配有线程调度与临界区保护相关实现可帮助读者掌握原始套接字编程的完整链路。目前已有530人学习下载适合作为课程设计、网络实验或自学TCP并发通信的参考范例。1. 从一份 VC6 时代的 RawSocket 源码包说起如果你手上正好有一份vc socket tcp 多线程客户端--服务器结构的例子.zip别急着双击.dsw就编译。这份包是典型的 Visual C 6.0 工程里面塞了RawSocketServerExample、SocketClient两个工程还有ThreadDispatcher.cpp、CRITSECT.CPP、RawSocketServerWorker.cpp这些一看就是手写线程调度的文件。它解决的不是怎么调 API而是多个客户端同时连上来服务器怎么不卡死、不串包、不崩。适合两类人一是刚学完socket()、bind()、listen()想找个能跑的多线程骨架的二是维护老工控、老上位机项目需要一份不依赖 Boost.Asio、纯 Winsock 的参考实现。这份代码没有抽象层RawSocket类直接包了WSAStartup和accept循环读起来像黑匣子被拆开但恰恰是这种原始能让你看清线程和套接字是怎么绑在一起的。2. 拆开工程RawSocket 封装与 ThreadDispatcher 的线程模型2.1 先认清文件清单里谁负责什么拿到一个 VC6 工程第一步不是编译是看.dsp和文件后缀。这份包里SocketServer.dsp是服务器工程SC.dsp是客户端工程.dsw是工作区把两者串起来。核心文件可以按职责分三组文件职责关键点RawSocket.h/.cpp套接字生命周期封装WSAStartup、socket、bind、listen、accept都在这里ThreadDispatcher.h/.cpp线程分发器管理 worker 线程的创建与回收RawSocketServerWorker.h/.cpp单连接工作线程每个客户端一个实例负责recv/sendCRITSECT.H/.CPP临界区封装保护共享链表或日志避免多线程写冲突SocketServerDlg.cppMFC 界面启动/停止监听显示连接状态ClosingDialog.cpp关闭确认退出时先停线程再WSACleanupRawSocket这个名字容易让人误会成原始套接字SOCK_RAW其实它只是未加高级抽象的普通 TCP 套接字。真正的原始套接字需要管理员权限且常用于抓包这里用SOCK_STREAM就够了。看RawSocket.cpp里Create函数的参数顺序常见写法是地址族、类型、协议三件套和 BSD socket 一致。2.2 服务器启动的完整调用链服务器不是一上来就accept它有一条固定顺序。下面这段是这类工程的典型骨架我按RawSocket封装后的调用方式写出来参数含义逐行标注// SocketServerDlg.cpp 中启动监听的核心逻辑 BOOL CSocketServerDlg::StartServer(int nPort) { // 1. 初始化 WinsockMAKEWORD(2,2) 表示请求 2.2 版本 WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), wsaData) ! 0) return FALSE; // 2. 创建监听套接字AF_INETIPv4, SOCK_STREAMTCP, IPPROTO_TCP协议 m_listenSocket socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (m_listenSocket INVALID_SOCKET) return FALSE; // 3. 设置 SO_REUSEADDR避免重启时端口被 TIME_WAIT 占用 BOOL bReuse TRUE; setsockopt(m_listenSocket, SOL_SOCKET, SO_REUSEADDR, (const char*)bReuse, sizeof(bReuse)); // 4. 绑定地址INADDR_ANY 表示监听本机所有网卡 sockaddr_in addr; addr.sin_family AF_INET; addr.sin_port htons((u_short)nPort); // 端口必须转网络字节序 addr.sin_addr.s_addr htonl(INADDR_ANY); if (bind(m_listenSocket, (sockaddr*)addr, sizeof(addr)) SOCKET_ERROR) return FALSE; // 5. 监听SOMAXCONN 交给系统决定等待队列长度 if (listen(m_listenSocket, SOMAXCONN) SOCKET_ERROR) return FALSE; // 6. 启动接受线程主界面线程不被阻塞 m_pAcceptThread AfxBeginThread(AcceptProc, this); return TRUE; }逻辑说明第 3 步的SO_REUSEADDR是血泪经验没有它服务器调试时反复启停会撞上WSAEADDRINUSE也就是热词里那个通常每个套接字地址只允许使用一次的报错。第 4 步htons和htonl不能省x86 是小端网络是大端端口号写反了客户端就连不上。第 6 步用AfxBeginThread而不是CreateThread是因为 MFC 工程里混用会导致 CRT 资源泄漏这是 VC6 时代的经典坑。2.3 ThreadDispatcher 怎么把连接分给 workeraccept返回新套接字后不能直接在 accept 线程里recv否则一个慢客户端就把后面所有连接堵死。ThreadDispatcher.cpp干的事就是拿到新SOCKET创建一个RawSocketServerWorker实例把套接字句柄传进去再启动线程。常见做法是用一个CList或std::vector保存 worker 指针配合CRITSECT加锁因为 accept 线程和界面线程可能同时访问这个列表。// ThreadDispatcher.cpp 中分发新连接的典型写法 void CThreadDispatcher::Dispatch(SOCKET clientSocket, sockaddr_in* pAddr) { // 进入临界区保护 worker 列表 m_cs.Lock(); CRawSocketServerWorker* pWorker new CRawSocketServerWorker(); pWorker-SetSocket(clientSocket); pWorker-SetPeerAddr(pAddr); // 创建 worker 线程参数是 worker 自身指针 CWinThread* pThread AfxBeginThread(WorkerProc, pWorker); if (pThread NULL) { // 线程创建失败要关掉套接字否则句柄泄漏 closesocket(clientSocket); delete pWorker; m_cs.Unlock(); return; } pWorker-SetThread(pThread); m_workerList.AddTail(pWorker); // 登记退出时统一回收 m_cs.Unlock(); }参数说明clientSocket是accept返回的新句柄和监听套接字是两个不同的描述符别搞混。pAddr保存对端 IP 和端口日志里打印inet_ntoa时要用它。m_cs是CRITSECT封装VC6 没有 C11 的std::mutex只能靠临界区。这里有个容易翻车的地方AfxBeginThread默认创建的是可挂起线程如果忘了在 worker 里ResumeThread线程就永远不动现象是客户端连上了但服务器没反应。2.4 客户端 SC 工程的连接与收发客户端工程SC.dsp简单得多main.cpp里就是连服务器、发数据、收数据。但要注意connect是阻塞的服务器没开时界面会假死常见做法是放到单独线程或设非阻塞加select。// main.cpp 客户端连接与收发 SOCKET s socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); sockaddr_in server; server.sin_family AF_INET; server.sin_port htons(6000); // 和服务器端口一致 server.sin_addr.s_addr inet_addr(127.0.0.1); // 本机测试 if (connect(s, (sockaddr*)server, sizeof(server)) SOCKET_ERROR) { printf(connect failed: %d\n, WSAGetLastError()); return -1; } const char* msg hello server; send(s, msg, (int)strlen(msg), 0); char buf[1024] {0}; int n recv(s, buf, sizeof(buf) - 1, 0); // 阻塞直到有数据或断开 if (n 0) printf(recv: %s\n, buf); closesocket(s);recv返回值必须判断大于 0 是收到的字节数等于 0 是对端正常关闭SOCKET_ERROR才是出错。很多人只判断 0结果对端断开后死循环。TCP 是字节流一次recv不保证收到一条完整消息粘包问题后面单独说。3. 编译与运行VC6 工程在现代 Windows 上的落地步骤3.1 用 VC6 还是转 VS先做决定这份工程是.dsw/.dsp格式Visual C 6.0 原生打开。如果你机器上只有 VS2019/2022直接双击会提示转换转换后 MFC 部分大概率报错因为 VC6 的 MFC 和现代 MFC 差异不小。两条路一是装个 VC6 虚拟机或兼容模式跑最省事二是转 VS 后手动修主要改#include和项目属性里的字符集。我一般建议先按原版跑通确认逻辑没问题再考虑迁移。3.2 编译前必须检查的三处配置打开.dsp后别急着按 F7。先看 Project Settings第一链接库。RawSocket.cpp里如果有#pragma comment(lib, ws2_32.lib)就省事了没有的话要在 Link 选项卡的 Object/library modules 里手动加ws2_32.lib否则一堆unresolved external symbol全是 Winsock 函数。第二字符集。VC6 默认 MBCS现代 VS 默认 Unicode。如果转 VS 后inet_ntoa、sprintf报错就是字符集问题改成使用多字节字符集最省事。第三运行库。热词里反复出现的microsoft visual c redistributable是运行期依赖编译时对应 Project Settings 的 C/C 代码生成页。Debug 用/MDdRelease 用/MD和系统里的运行库版本对上否则换台机器就提示缺 DLL。3.3 运行顺序与端口验证先跑服务器再跑客户端。服务器启动后用netstat确认端口在监听netstat -ano | findstr :6000看到LISTENING状态和对应 PID 就说明bind和listen成功了。如果显示TIME_WAIT说明上次没正常关闭等一会儿或靠SO_REUSEADDR解决。客户端连上后服务器界面应该刷新出对端 IP。测试多线程是否生效同时开三四个客户端看服务器是不是每个连接都有独立日志而不是串在一起。提示VC6 编译出的 exe 在 Win10/Win11 上可能因 DEP 或兼容性设置闪退右键属性里勾以兼容模式运行能排除一部分环境干扰。4. 多线程收发的避坑粘包、临界区与资源回收4.1 现象客户端发两条消息服务器只收到一条原因TCP 是字节流协议没有消息边界。客户端连续两次send内核可能合并成一个段发出去服务器一次recv就把两条都读进来了。这不是 bug是 TCP 的设计。解决自定义包头前 4 字节放消息长度接收方先读长度再读正文。// 发送端先发长度再发正文 int len (int)strlen(msg); int netLen htonl(len); // 长度转网络字节序 send(s, (char*)netLen, sizeof(netLen), 0); send(s, msg, len, 0); // 接收端循环收满 4 字节长度再按长度收正文 int netLen 0, got 0; while (got (int)sizeof(netLen)) { int n recv(s, (char*)netLen got, sizeof(netLen) - got, 0); if (n 0) return; // 断开或出错 got n; } int bodyLen ntohl(netLen); // 再循环 recv bodyLen 字节逻辑同上参数说明htonl/ntohl处理 4 字节长度htons/ntohs处理 2 字节端口。recv必须循环因为一次调用可能只收到部分数据这是新手最容易翻车的地方。4.2 现象服务器跑一会儿就崩或日志错乱原因多个 worker 线程同时写同一个CList或同一个日志文件没有加锁。CRITSECT.CPP就是干这个的但很多人只在添加时加锁遍历或删除时忘了加。解决所有对共享容器的读写都包在Lock/Unlock之间且Unlock要保证在所有分支都执行否则死锁。4.3 现象客户端断开后服务器线程数只增不减原因worker 线程退出时没有从m_workerList里移除自己也没有delete。时间一长内存和句柄都泄漏。解决worker 的recv返回 0 或错误时先closesocket再通知 dispatcher 移除自己最后delete this或由 dispatcher 统一回收。注意不能在 worker 线程里delete自己后还访问成员变量。4.4 现象程序退出时卡死或报错原因界面线程直接WSACleanup但 worker 线程还在recv阻塞。解决退出顺序必须是——先关闭监听套接字停止 accept再逐个通知 worker 退出可shutdown套接字让recv返回等所有线程结束后才WSACleanup。ClosingDialog.cpp就是干这个协调工作的别绕过它直接关窗口。4.5 现象换台机器客户端连不上原因服务器bind用了INADDR_ANY没问题但防火墙拦了入站。或者客户端写死了127.0.0.1只能本机连。解决客户端 IP 改成服务器实际 IP服务器端在防火墙放行对应端口。热词里的tcp端口号冲突也常见换端口或netstat查占用。5. 从能跑到好用把这份例子改造成可复用骨架的三个技巧这份代码跑通只是起点真正拿去用还得做三件事。第一件是给RawSocket加超时。默认recv是无限阻塞的一个客户端连上不发数据worker 线程就永远挂着。常见做法是用setsockopt设SO_RCVTIMEO超时后recv返回SOCKET_ERROR错误码WSAETIMEDOUT这时可以检查退出标志决定是否结束线程。// 设置接收超时 3 秒 DWORD timeout 3000; setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char*)timeout, sizeof(timeout));第二件是把线程模型从一连接一线程改成线程池。连接数上百时一连接一线程的上下文切换开销很大。可以在ThreadDispatcher里预创建固定数量 worker用队列把新套接字派给空闲 worker。改动量不小但这是从示例走向生产的分界线。第三件是加心跳。TCP 连接可能因为网络中间设备静默断开双方都不知道。常见做法是客户端每隔几秒发一个空包或特定字节服务器超时没收到就主动关闭。names.h里如果有消息类型定义加一个MSG_HEARTBEAT就行。验证改造是否成功我一般用两个手段一是同时开 50 个客户端脚本压测看服务器内存和句柄数是否稳定二是用netstat观察连接状态确认没有大量CLOSE_WAIT堆积——CLOSE_WAIT多说明服务器没正确closesocket。从那以后我每次拿到这类多线程 socket 例子都强制先跑一遍连接数压测和退出流程确认线程和句柄都能干净回收再往业务里塞。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑