简介:这是一份Visual C++环境下基于Winsock的TCP多线程客户端-服务器结构示例,面向具备基础C++语法、希望理解网络编程核心流程的开发者。压缩包共32个文件,约37KB,11个头文件负责类与接口声明,10个C++源文件实现具体逻辑,还包含dsp、dsw、mak工程文件以及rc、ico等界面资源文件,可直接在Visual C++中打开并编译运行。示例以RawSocketServerExample为主线,完整覆盖WSAStartup初始化环境、socket创建套接字、bind绑定地址、listen监听端口、accept接受连接的完整链路,并借助ThreadDispatcher为每个客户端连接创建独立线程,配合RawSocketServerWorker与CRITSECT临界区保护,清晰展示了多线程并发通信模型的搭建方式。已有530人学习下载,适合作为VC++网络编程课程设计、面试复习或入门实战的参考源码,也可为聊天室、小型文件传输等应用提供可直接改造的通信骨架,便于快速定位关键代码并改造复用。
1. 为什么还要翻这份 VC++ 老代码:Winsock 多线程 TCP 的完整链路
说实话,2025 年去翻一份 Visual C++ 6.0 年代的 TCP 多线程示例,听起来像考古。但真要在 Windows 上写 socket 网络编程,你会发现绕不开的东西全在里面:WSAStartup 的初始化顺序、socket 参数怎么选、accept 之后如何把连接交给新线程、临界区怎么保护共享数据。这份"vc socket tcp 多线程客户端--服务器结构的例子"没有用任何封装,就是裸调 Winsock 的原始 API,把 bind、listen、accept、recv、send 这条链路走完整。服务端是 MFC 对话框程序,客户端是纯控制台 main.cpp,两边都能单独编译。适合正在做网络编程课设的学生、要维护老 VC++ 工程却找不到完整示例的工程师、以及想搞明白每连接一线程模型到底怎么工作的新手。
2. 工程目录与 Winsock 地基:从 .dsw 到 RawSocket.cpp 的职责划分
拿到压缩包先别急着编译。这套工程里同时躺着两份 Visual C++ 工程:SocketServer 是服务端,SC 是客户端,另外还有一批公共文件。先把文件职责分清楚,后面改代码才不会改错地方。
2.1 文件角色对照:服务器、客户端和公共设施
解压后文件清单如下,按"服务端 / 客户端 / 公共"三类拆开:
| 文件 | 归属 | 职责 |
|---|---|---|
| SocketServer.dsw / .dsp / .mak / .opt | 服务端工程 | VC6 的 workspace 与项目文件 |
| SocketServer.cpp / SocketServer.h | 服务端 | 程序入口,初始化 MFC 应用 |
| SocketServerDlg.cpp / SocketServerDlg.h | 服务端 | 主对话框,触发开始/停止监听,显示连接状态 |
| RawSocket.cpp / RawSocket.h | 服务端 | Winsock API 的轻量封装,socket 创建、bind、listen、accept 都在这里 |
| RawSocketServerWorker.cpp / .h | 服务端 | 工人线程入口,处理单个客户端连接的 recv 和 send |
| ThreadDispatcher.cpp / ThreadDispatcher.h | 服务端 | 线程分发器,accept 到新连接后创建 worker 线程 |
| CRITSECT.H / CRITSECT.CPP | 服务端 | 临界区封装,保护连接列表、线程计数等共享数据 |
| ClosingDialog.cpp / .h | 服务端 | 退出前提示的模态对话框 |
| names.h、resource.h、SocketServer.rc / .rc2 / .ico | 服务端 | 常量定义、资源 ID 与图标 |
| StdAfx.cpp / StdAfx.h | 两边 | 预编译头,Winsock 的 include 通常放在 StdAfx.h 里 |
| SC.dsp / SC.mak / SC.plg | 客户端工程 | 客户端项目文件 |
| main.cpp | 客户端 | 客户端入口,纯控制台程序,连接与收发都在这 |
这个结构是 VC6 时代多线程网络程序的典型样板:界面线程负责交互,网络线程负责收发,两者用临界区交互,绝不跨线程直接操作控件。RawSocket 这个命名容易让人以为和"原始套接字"(raw socket,可以收 IP 层报文)有关,但看代码会发现它其实就是直接调 Winsock API 的普通 TCP 套接字,没有经过 MFC 的 CSocket 封装。这一点先对齐,后面读代码不会理解岔。
编译要点:服务端依赖 MFC,客户端是纯 Win32 控制台。用新版本 VS 打开 .dsp 会被提示转换工程向导,转换完通常能编译,但要把"字符集"设置匹配源代码里的 TCHAR 用法。老工程最常见的编译错误是 winsock2.h 和 windows.h 头文件顺序冲突,解决办法是把#include <winsock2.h>放到 StdAfx.h 最顶端,再#pragma comment(lib, "ws2_32.lib")链接库。
2.2 WSAStartup 与 WSACleanup:初始化顺序错了就是黑匣子
Windows 下所有 socket 调用之前必须先初始化 Winsock 库,这是整个链路里第一步、也是最容易被忽略的一步。排查时见过不止一次:程序报"套接字创建失败",connect 直接返回 10093(WSA_NOT_INITIALISED),查半天才发现 WSAStartup 没调或调用失败没处理。
// 程序入口处,main 或 InitInstance 的第一步 WSADATA wsaData; int ret = WSAStartup(MAKEWORD(2, 2), &wsaData); if (ret != 0) { // 返回非 0 说明初始化失败,此时别继续走任何 socket 逻辑 return -1; } // 检查协商出来的版本,2.2 是 WinXP 之后所有 Windows 都支持的标准版本 if (LOBYTE(wsaData.wVersion) != 2 || HIBYTE(wsaData.wVersion) != 2) { // 版本不满足要求,释放资源退出 WSACleanup(); return -1; }MAKEWORD(2, 2) 表示请求 Winsock 2.2:低字节是主版本号,高字节是次版本号,别把顺序记反。WSADATA 返回后检查 wVersion 是可靠习惯,老系统上可能只协商出 1.1,部分 API 行为有差异。与 WSAStartup 配对的是 WSACleanup,程序退出前调用一次即可。如果 WSAStartup 被调多次,WSACleanup 也要调同样次数,Winsock 内部有引用计数。
2.3 套接字创建参数:SOCK_STREAM、AF_INET、0 的真实含义
初始化完成后第一步就是创建套接字。看 RawSocket.cpp 里大概率能看到这段:
// 创建 TCP 套接字 SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (s == INVALID_SOCKET) { // INVALID_SOCKET 是 (SOCKET)(~0),不是 0 也不是 -1 int err = WSAGetLastError(); // 立刻保存错误码 // 10047 表示地址族与套接字类型不匹配 return INVALID_SOCKET; }三个参数分别对应:地址族 AF_INET(IPv4)、套接字类型 SOCK_STREAM(有序、可靠、面向连接的字节流)、协议 IPPROTO_TCP。第三参数传 0 也可以,表示由系统根据前两参自动推导,但显式写出来意图更清楚。如果是 UDP,第二参数换成 SOCK_DGRAM,第三参数对应 IPPROTO_UDP。TCP 和 UDP 从这里就分开了,这个选择会影响后面 send / recv 的语义。
错误码处理有一个反复强调的习惯:WSAGetLastError 的返回值必须立刻存到局部变量,因为任何一次函数调用都可能改写线程的 last-error 代码。先 printf 再取错误码,printf 内部就可能把它覆盖掉,排错时看到一个和当前问题毫无关系的数字,纯属浪费时间。
3. 多线程服务端实现:accept 循环、工人线程与临界区保护
服务端的核心不在 socket API 本身,而在 accept 之后的调度。主线程负责 accept,新连接交给独立线程处理,这是"每连接一线程"模型,也是 ThreadDispatcher 存在的意义。
3.1 主线程的 accept 循环:listen 队列长度到底管什么
bind 和 listen 是一对,bind 决定监听地址和端口,listen 决定挂起连接队列能排多长。
sockaddr_in serverAddr; serverAddr.sin_family = AF_INET; // htonl(INADDR_ANY):监听本机全部网卡 // 只想本机调试可以改成 inet_addr("127.0.0.1") serverAddr.sin_addr.s_addr = htonl(INADDR_ANY); serverAddr.sin_port = htons(8800); // 端口号,可换成其他高位端口 if (bind(listenSocket, (sockaddr*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR) { int err = WSAGetLastError(); // 10048 端口被占用;10049 本机地址不可用 return -1; } // 第二参数 5 是挂起连接队列长度,不是最大并发连接数 if (listen(listenSocket, 5) == SOCKET_ERROR) { return -1; }bind 时 sin_family、sin_addr、sin_port 三个字段都要初始化,sin_zero 用 ZeroMemory 清零是标准做法。端口号必须用 htons 转字节序,这是 Windows 上最容易漏的一步:漏了之后客户端连本地 8800,抓包看到 SYN 却发到了另一个端口,非常诡异。listen 的 backlog 参数经常被误解,它控制的是"还没被 accept 的连接"排队长度,worker 线程处理够快时实际并发量可以远超 backlog。backlog 设 5 是保守值,Windows 上可设 SOMAXCONN,但老代码写死 5 一样能跑。
accept 循环是主线程的终身任务:
// 主线程的 accept 循环 while (bRunning) { sockaddr_in clientAddr; int addrLen = sizeof(clientAddr); SOCKET clientSock = accept(listenSocket, (sockaddr*)&clientAddr, &addrLen); if (clientSock == INVALID_SOCKET) { // 10038 表示 socket 被关闭,说明有别的线程调了 closesocket // 10004 表示监听被中断,程序退出时常见 break; } // 到这里已拿到建立的 TCP 连接,可以直接收发数据 // 下一步交给 ThreadDispatcher 创建 worker 线程 }accept 默认阻塞,没有连接时主线程会一直卡在里面,这是正常现象。退出服务端时不能直接 closesocket(listenSocket),那会打断正在阻塞的 accept 产生 10038。常见做法是先标记退出标志,再关闭监听套接字,accept 返回错误后跳出循环,最后等待所有 worker 线程结束。
3.2 ThreadDispatcher 与 _beginthreadex:创建线程的两种姿势
accept 返回后,新连接的 socket 归谁管?答案是新线程。ThreadDispatcher 这名字听着唬人,核心就是一个 _beginthreadex 调用:
// ThreadDispatcher:为每个客户端连接创建 worker 线程 unsigned int threadId; // 用 _beginthreadex 而不是 CreateThread,后者有 CRT 安全隐患 HANDLE hThread = (HANDLE)_beginthreadex( NULL, // 安全属性,默认 0, // 栈大小,0 用系统默认 WorkerThreadMain, // 线程函数,必须是 __stdcall pArgs, // 参数指针,指向包含 socket 的结构体 0, // 0 表示立即运行 &threadId // 线程 ID 输出 ); if (hThread == NULL) { // 创建失败要关闭 socket,否则连接句柄泄漏 closesocket(clientSock); delete pArgs; }C 运行时环境里创建线程优先用 _beginthreadex 而不是 CreateThread。_beginthreadex 会为线程分配 CRT 的 _tiddata 结构,保证 malloc、strtok、errno 这些 CRT 函数在线程里安全使用;CreateThread 创建的线程用 CRT 函数可能出内存泄漏。VC6 时代的代码这个选择几乎是必须的。
pArgs 这种参数传递方式有隐藏问题:如果用栈变量传 socket,线程还没启动参数就被下一轮循环覆盖。正确做法是 new 一个结构体传进去,worker 线程收到后自己负责 delete,分配和释放的责任要绑定清楚。顺便说明:客户端连接进来后,TCP 三次握手在 accept 返回前就完成了,worker 线程拿到 socket 时底层连接已建立,不需要额外处理。
3.3 RawSocketServerWorker:recv 返回值的三类情况和消息边界
worker 线程的核心是收发循环,RawSocketServerWorker.cpp 的主体结构一般是:
unsigned __stdcall WorkerThreadMain(void* arg) { WorkerArgs* args = (WorkerArgs*)arg; SOCKET s = args->sock; delete args; // 参数用完立刻删除,避免泄漏 char buf[1024]; while (true) { // recv 阻塞等待数据,第三个参数 0 表示不带标志 int n = recv(s, buf, sizeof(buf), 0); if (n > 0) { // 收到 n 字节,这里做回显:原样发回去 send(s, buf, n, 0); } else if (n == 0) { // 对端优雅关闭,正常退出循环 break; } else { // 出错:10054 连接被重置,10053 连接被中止 break; } } closesocket(s); return 0; }recv 返回值三类情况必须记住:大于 0 是收到字节数;等于 0 表示对端执行优雅关闭(FIN),此时退出循环;小于 0 是出错,具体原因查 WSAGetLastError。很多初学者把 n == 0 当"没数据",于是死循环狂转 CPU,这是 socket 编程第一个经典误读。
另一个必须点破的是 TCP 字节流模型。recv 返回 n 字节,不代表一次就收到一个完整消息。发端一次 send 100 字节,收端可能分两次收到 30 + 70;发端三次 send 各 50 字节,收端可能一次 recv 收到 150。TCP 没有消息边界,应用层必须自己定义帧格式,常见做法是固定头部 + 长度字段。这份例子里回显演示没实现私有协议,但接手自己工程时要格外小心,别拿 recv 返回值当消息边界用。
3.4 CRITSECT 临界区:保护共享变量的最低成本方案
多线程环境下,连接数统计、客户端列表、日志缓冲区这些共享数据必须有同步保护。VC6 时代可用的同步原语里,临界区(CRITICAL_SECTION)是开销最小、最容易用对的。
// CRITSECT.H / CRITSECT.CPP 的核心封装 class CritSect { public: CritSect() { InitializeCriticalSection(&m_cs); } ~CritSect() { DeleteCriticalSection(&m_cs); } void Enter() { EnterCriticalSection(&m_cs); } void Leave() { LeaveCriticalSection(&m_cs); } private: CRITICAL_SECTION m_cs; };临界区是用户态锁,没有陷入内核的开销,短临界区的性能远好于互斥量和事件对象。代价是只支持单进程内互斥,且没有超时机制,一个线程 Enter 后忘 Leave,其他线程会永久阻塞,这就是死锁现场。
使用纪律有两条:临界区代码要短,只保护真正的共享数据访问;不要在临界区内调用可能阻塞的 recv 或 Sleep,否则所有线程排队等锁。更典型的误用是拿临界区包住整个 recv 循环,美其名曰"防止多线程同时收数据",结果并发模型名存实亡。这份例子里 CRITSECT 的正确用法是保护"活动连接列表"或"线程计数"这类瞬时操作,拿到锁改完数值立刻 Leave,绝不拖延。
4. 联调避坑与排查:连接失败、端口占用、线程泄漏的现场复盘
代码能编译、能启动,但一到联调就翻车,这是老工程的常态。下面五条是调试这类项目时遇到最多的现场,每条按"现象 → 原因 → 解决"复盘。
4.1 客户端 connect 之前必做的三件事
客户端 main.cpp 结构相对简单,但 connect 之前三步不能省:
WSADATA wsaData; WSAStartup(MAKEWORD(2, 2), &wsaData); // 第一件事:初始化 Winsock SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); // 第二件事:创建套接字 sockaddr_in addr; addr.sin_family = AF_INET; addr.sin_port = htons(8800); // 端口必须和服务端一致 addr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 回环地址,本机调试用 // 第三件事:connect 前确认服务端已监听,否则直接 10061 int ret = connect(s, (sockaddr*)&addr, sizeof(addr)); if (ret == SOCKET_ERROR) { int err = WSAGetLastError(); // 10061 目标端口没有程序监听 // 10060 连接超时,常见于地址不通或防火墙拦截 closesocket(s); return 1; }connect 在 TCP 下是阻塞调用,返回成功时连接已建立。10061 和 10060 一句话判别:一个立刻失败,一个等很久失败。立刻失败先查服务端起没起;等很久失败先查防火墙和 IP 可达性。
提示:10061 立刻失败,10060 等待超时。先看错误码再排查环境,能省一半时间。
4.2 坑一:bind 返回 10048,端口被占用
现象:服务端启动时 bind 返回 SOCKET_ERROR,错误码 10048(WSAEADDRINUSE),换端口后能启动。
原因:上一个服务端实例没有正常退出,端口处于 TIME_WAIT 状态。TCP 主动关闭一方会在 TIME_WAIT 停留约 120 秒;防病毒软件劫持端口、另一个进程占用了端口也会报 10048。排错第一步查出谁占用:
netstat -ano | findstr 8800解决:开发阶段最省事是换高位端口(比如 48000 以上)重新绑定;要复用端口需要在 bind 前设置 SO_REUSEADDR:
BOOL reuse = TRUE; setsockopt(listenSocket, SOL_SOCKET, SO_REUSEADDR, (const char*)&reuse, sizeof(reuse));这个选项允许绑定处于 TIME_WAIT 的端口。注意它只适用于监听套接字,且必须在 bind 之前设置,bind 之后设置无效。
4.3 坑二:recv 返回 0 不代表连接已经干净关闭
现象:客户端主动断开后,服务端 worker 线程 recv 返回 0,退出循环并 closesocket。但抓包发现服务端发出的不是 FIN 而是 RST,客户端那边报 10054 连接被重置。
原因:closesocket 的默认行为是立即关闭。如果发送缓冲区还有未发完的数据,或服务端直接 closesocket 而客户端还没读完接收缓冲区,TCP 协议栈会放弃优雅关闭直接发 RST。RST 意味着连接异常终止,对端还在 recv 就收到 10054。
解决:优雅关闭要先 shutdown 再等待对端关闭:
shutdown(s, SD_SEND); // 告诉对端:我不再发送 // 继续 recv 直到返回 0,等对端也关闭 while (recv(s, buf, sizeof(buf), 0) > 0) {} closesocket(s); // 这时才真正关闭shutdown(SD_SEND) 会触发 FIN,对端 recv 返回 0 后若主动关闭,服务端再 closesocket 就不会产生 RST。worker 线程退出时这个流程尤其重要,服务端数据可能还没完全到达客户端,直接关闭会丢数据。
4.4 坑三:工作线程直接操作对话框控件,程序闪退
现象:worker 线程里调用 SetDlgItemText 更新界面上的连接计数,程序运行一段时间后随机崩溃,且只在多客户端并发时出现。
原因:Windows 的 UI 控件由创建它的线程管理。worker 线程不是创建控件的线程,直接调用 SetDlgItemText 会让 MFC 底层走到控件窗口过程,而窗口过程必须在 UI 线程上下文执行,跨线程调用就是未定义行为。
解决:worker 线程不碰控件,用 PostMessage 把数据丢给 UI 线程:
// worker 线程里只发消息,不碰控件 ::PostMessage(g_hMainDlg, WM_UPDATE_CLIENT_COUNT, (WPARAM)clientCount, 0); // UI 线程的消息处理函数里再 SetDlgItemTextg_hMainDlg 是 UI 线程的窗口句柄,worker 线程只做 PostMessage。这样跨线程 UI 调用收敛成一条消息路径,规则简单不容易再错。
4.5 坑四:WSAGetLastError 被覆盖,排错排到怀疑人生
现象:connect 失败后,先打印一个调试字符串再取 WSAGetLastError,拿到的错误码是 0 或和实际现象完全对不上,怎么查都找不到原因。
原因:WSAGetLastError 返回的是线程的 last-error 值,任何函数调用都可能修改它。printf、OutputDebugString 甚至赋值语句里编译器的安全 cookie 检查,都可能覆盖掉想读的错误码。这不是玄学,是 Windows 线程错误码机制的特性。
解决:socket 调用返回错误后第一件事把错误码存局部变量:
int err = WSAGetLastError(); // 立刻保存,后面再慢慢分析拿到 err 后再对照系统错误码表排查,别边打日志边取错误码。习惯是函数开头放一个int lastError;,所有 Winsock 调用都是先保存、再判断、最后打日志,顺序永不颠倒。
4.6 坑五:线程参数用了栈变量,连接一多就乱套
现象:线程创建多次后,多个客户端连接的数据相互串,A 客户端发的数据 B 客户端收到了,worker 线程拿到的 socket 值看起来完全不对。
原因:accept 循环里为了省事写了 SOCKET 局部变量,把它的指针传给 _beginthreadex。循环第二次迭代时栈内存被复用,上一个线程可能还没启动完就被新值覆盖:
// 错误的做法:&client 是栈变量,循环一轮就被覆盖 SOCKET client; while (bRunning) { client = accept(...); _beginthreadex(NULL, 0, worker, &client, 0, &tid); }解决:new 一块堆内存,worker 线程负责释放。主线程 new、worker 线程 delete,释放责任绑定在持有者上,不跨层释放。初始化参数结构体时把 socket 值复制进去,而不是复制指针。这个方案在 3.2 里已经写过,核心原则是生命周期必须明确。
5. 验证方法与进阶改造:Wireshark 看三次握手,把每连接一线程换成线程池
5.1 用 netstat 与 Wireshark 完成联调验证
启动服务端后先确认监听端口:
netstat -ano | findstr 8800输出里有TCP 0.0.0.0:8800 LISTENING一行,说明 bind 和 listen 都成功了。客户端连接一次后,按同样命令能看到一条 ESTABLISHED 连接。端口和状态都对上了,再谈测试。
然后启动 Wireshark 抓本地回环流量,过滤表达式写tcp.port == 8800。抓包后让客户端连一次,会看到三条报文:客户端发 SYN,服务端回 SYN+ACK,客户端再回 ACK——TCP 三次握手完成。连接关闭时能看到 FIN 和 FIN+ACK 的交换;如果抓到 RST,说明有连接没走优雅关闭流程,回 4.3 查 shutdown。这套验证流程五分钟内能走完,比盯着代码猜快得多。
5.2 从每连接一线程到固定线程池:一个最小骨架
每连接一线程在几十个连接以内没问题,但连接频繁建立销毁时,线程创建和销毁开销明显,线程数量不受控。最简单线程池是"N 个固定 worker + 临界区保护的连接队列",注意这是现代改造的简易骨架,不是原工程里的代码:
#include <deque> std::deque<SOCKET> g_queue; // 待处理连接队列 CritSect g_lock; // 保护队列的临界区 HANDLE g_hEvent; // 队列非空事件 void SubmitSocket(SOCKET s) { g_lock.Enter(); g_queue.push_back(s); g_lock.Leave(); SetEvent(g_hEvent); // 唤醒一个空闲 worker } unsigned __stdcall PoolWorker(void* arg) { while (true) { WaitForSingleObject(g_hEvent, INFINITE); g_lock.Enter(); if (g_queue.empty()) { g_lock.Leave(); continue; } SOCKET s = g_queue.front(); g_queue.pop_front(); g_lock.Leave(); HandleClient(s); // 处理单个连接的收发 } }两个细节值得注意:事件用自动重置还是手动重置,决定唤醒一个还是唤醒全部 worker。worker 数量大于 1 时要用 AutoReset 事件只唤醒一个,否则多个线程醒来抢同一批任务,造成惊群。固定 worker 数量一般按 CPU 核心数设,I/O 密集场景适当多点也没问题。
从那以后我每次写完一个 TCP 服务端,都会强制走一遍完整流程:netstat 看端口,Wireshark 看三次握手,客户端反复连断几次确认没有 RST 和句柄泄漏。这些操作花不了五分钟,但能把八成以上的基础性错误挡在上线之前。希望帮到你。
本文还有配套的精品资源,点击获取