简介:这是一份面向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/send |
CRITSECT.H/.CPP | 临界区封装 | 保护共享链表或日志,避免多线程写冲突 |
SocketServerDlg.cpp | MFC 界面 | 启动/停止监听,显示连接状态 |
ClosingDialog.cpp | 关闭确认 | 退出时先停线程再WSACleanup |
RawSocket这个名字容易让人误会成原始套接字(SOCK_RAW),其实它只是"未加高级抽象的普通 TCP 套接字"。真正的原始套接字需要管理员权限且常用于抓包,这里用SOCK_STREAM就够了。看RawSocket.cpp里Create函数的参数顺序,常见写法是地址族、类型、协议三件套,和 BSD socket 一致。
2.2 服务器启动的完整调用链
服务器不是一上来就accept,它有一条固定顺序。下面这段是这类工程的典型骨架,我按RawSocket封装后的调用方式写出来,参数含义逐行标注:
// SocketServerDlg.cpp 中启动监听的核心逻辑 BOOL CSocketServerDlg::StartServer(int nPort) { // 1. 初始化 Winsock,MAKEWORD(2,2) 表示请求 2.2 版本 WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) return FALSE; // 2. 创建监听套接字:AF_INET=IPv4, SOCK_STREAM=TCP, 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 怎么把连接分给 worker
accept返回新套接字后,不能直接在 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 没有 C++11 的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 用/MDd,Release 用/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 例子,都强制先跑一遍连接数压测和退出流程,确认线程和句柄都能干净回收,再往业务里塞。希望帮到你。
本文还有配套的精品资源,点击获取