
简介这是一份Visual C环境下基于Winsock的TCP多线程客户端-服务器结构示例面向具备基础C语法、希望理解网络编程核心流程的开发者。压缩包共32个文件约37KB11个头文件负责类与接口声明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 和 sendThreadDispatcher.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 直接返回 10093WSA_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_INETIPv4、套接字类型 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 RawSocketServerWorkerrecv 返回值的三类情况和消息边界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错误码 10048WSAEADDRINUSE换端口后能启动。原因上一个服务端实例没有正常退出端口处于 TIME_WAIT 状态。TCP 主动关闭一方会在 TIME_WAIT 停留约 120 秒防病毒软件劫持端口、另一个进程占用了端口也会报 10048。排错第一步查出谁占用netstat -ano | findstr 8800解决开发阶段最省事是换高位端口比如 48000 以上重新绑定要复用端口需要在 bind 前设置 SO_REUSEADDRBOOL 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服务端回 SYNACK客户端再回 ACK——TCP 三次握手完成。连接关闭时能看到 FIN 和 FINACK 的交换如果抓到 RST说明有连接没走优雅关闭流程回 4.3 查 shutdown。这套验证流程五分钟内能走完比盯着代码猜快得多。5.2 从每连接一线程到固定线程池一个最小骨架每连接一线程在几十个连接以内没问题但连接频繁建立销毁时线程创建和销毁开销明显线程数量不受控。最简单线程池是N 个固定 worker 临界区保护的连接队列注意这是现代改造的简易骨架不是原工程里的代码#include deque std::dequeSOCKET 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 和句柄泄漏。这些操作花不了五分钟但能把八成以上的基础性错误挡在上线之前。希望帮到你。本文还有配套的精品资源点击获取