
简介这是一份面向C网络编程初学者与进阶开发者的Visual C TCP套接字实战示例聚焦多线程客户端-服务器架构帮助读者理解Winsock底层API的完整调用流程。资源包共32个文件以11个h头文件与10个cpp源文件为核心辅以dsp、dsw、mak等工程配置文件和rc、ico等界面资源整体约37KB结构紧凑可直接在VC环境中打开编译。内容涵盖WSAStartup初始化、socket创建、bind与listen监听、accept接受连接以及为每个新连接派生线程处理收发的多线程模型并涉及send/recv数据交换与shutdown关闭连接等关键环节。示例未依赖Boost.Asio等高层封装而是直接操作原始Winsock接口便于读者看清底层通信细节与线程调度逻辑。目前已有530人学习适合希望从零搭建可运行TCP通信框架、掌握并发连接处理思路的开发者参考。1. 从一份 VC Socket 源码包说起TCP 多线程客户端-服务器到底怎么落地很多人第一次接触网络编程都是从“能连上、能收发”开始的但真正到了项目里问题立刻变成多个客户端同时连上来怎么办主线程 accept 之后卡死了怎么办客户端一断服务器线程怎么回收这份vc socket tcp 多线程客户端--服务器结构的例子.zip就是冲着这些问题来的。它基于 Visual C 和原生 Winsock给出了一个不依赖 Boost.Asio、ACE 等抽象层的“裸写”示例服务器端用RawSocketServerWorker和ThreadDispatcher把监听、分发、会话处理拆开客户端则是一个独立的SocketClient工程。适合正在学 C socket 编程、需要一份能编译能跑、能看清线程模型的参考代码的从业者。它不教你 TCP 三次握手但能让你看到握手之后代码到底该怎么组织。2. 拆开压缩包RawSocketServerExample 的线程模型与关键类2.1 从文件清单看服务器端的职责划分拿到一个源码包我习惯先看文件清单因为文件名往往暴露了作者的设计意图。这份资源里服务器端工程叫RawSocketServerExample核心文件包括RawSocket.cpp/.h、RawSocketServerWorker.cpp/.h、ThreadDispatcher.cpp/.h、CRITSECT.CPP/.H、SocketServerDlg.cpp/.h。客户端工程是SocketClient入口在main.cpp。从命名能推断出RawSocket封装了 Winsock 的初始化、创建、绑定、监听这些底层调用RawSocketServerWorker是每个连接的工作线程体ThreadDispatcher负责把 accept 到的新连接分发给工作线程CRITSECT是对临界区的薄封装用来保护共享数据。SocketServerDlg是 MFC 对话框界面说明这个例子带了一个简单的 GUI 来启动/停止服务、显示日志。这种拆分方式在早期 VC 网络项目里很常见界面归界面网络归网络线程归线程。好处是每个文件职责单一坏处是如果你只盯着SocketServerDlg.cpp看会以为逻辑都在界面里其实真正的通信流程藏在RawSocketServerWorker和ThreadDispatcher里。2.2 服务器启动流程WSAStartup 到 listen 的代码路径服务器端要跑起来绕不开 Winsock 的初始化和套接字创建。虽然我看不到完整源码但根据文件结构和常见写法RawSocket.cpp里大概率有类似下面的初始化逻辑。这里我按典型实现补全方便你对照自己的代码// RawSocket.cpp 中常见的初始化片段 #include winsock2.h #pragma comment(lib, ws2_32.lib) BOOL CRawSocket::Init() { WSADATA wsaData; // 请求 Winsock 2.2 int nRet WSAStartup(MAKEWORD(2, 2), wsaData); if (nRet ! 0) { // 初始化失败通常是 ws2_32.dll 缺失或版本不匹配 return FALSE; } // 创建 TCP 套接字 m_hSocket socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (m_hSocket INVALID_SOCKET) { WSACleanup(); return FALSE; } return TRUE; }这段代码的逻辑很直接先WSAStartup再socket。参数AF_INET指定 IPv4SOCK_STREAM指定面向连接的流式套接字IPPROTO_TCP明确协议。很多人会忽略WSAStartup的返回值检查结果在没装 Winsock 的环境里直接崩溃。另一个常见问题是MAKEWORD(2, 2)写成了MAKEWORD(1, 1)导致后续getaddrinfo等函数不可用。绑定和监听通常在RawSocket的另一个方法里BOOL CRawSocket::BindAndListen(UINT nPort, int nBacklog) { sockaddr_in addr {0}; addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); // 监听所有网卡 addr.sin_port htons((u_short)nPort); if (bind(m_hSocket, (sockaddr*)addr, sizeof(addr)) SOCKET_ERROR) return FALSE; // nBacklog 是等待连接队列的最大长度 if (listen(m_hSocket, nBacklog) SOCKET_ERROR) return FALSE; return TRUE; }INADDR_ANY表示绑定本机所有网络接口htons把主机字节序转成网络字节序。nBacklog一般设SOMAXCONN或 5 到 10设太小会导致高并发时连接被拒绝设太大在旧系统上可能被截断。这些参数在SocketServerDlg里通常有对应的编辑框让用户输入。2.3 ThreadDispatcher 与 RawSocketServerWorker 的配合方式多线程服务器的核心在于 accept 之后怎么把新连接交给工作线程。ThreadDispatcher这个名字暗示它可能维护了一个线程池或者至少是一个“来一个连接就创建一个线程”的分发器。RawSocketServerWorker则是线程函数所在的类。典型流程是主线程在listen之后循环调用accept每接受一个客户端就调用ThreadDispatcher::Dispatch把客户端套接字句柄传进去。Dispatch内部要么创建新线程要么从空闲线程池里取一个把套接字交给RawSocketServerWorker::Run。// ThreadDispatcher.cpp 中可能的分发逻辑 void CThreadDispatcher::Dispatch(SOCKET clientSocket) { // 方式一直接创建线程 CWinThread* pThread AfxBeginThread( RawSocketServerWorkerThreadProc, // 线程函数 (LPVOID)clientSocket, // 把套接字作为参数传入 THREAD_PRIORITY_NORMAL, 0, CREATE_SUSPENDED, // 先挂起设置好再运行 NULL ); if (pThread) pThread-ResumeThread(); }这里用AfxBeginThread是 MFC 的写法如果工程是纯 Win32可能会用_beginthreadex。参数CREATE_SUSPENDED是为了在CWinThread对象完全构造好之前不开始执行避免竞态。把SOCKET直接强转成LPVOID传递是常见做法但要注意SOCKET在 64 位下是UINT_PTR强转指针类型时最好用INT_PTR过渡否则编译器会警告。RawSocketServerWorker的Run方法里通常会有一个recv循环// RawSocketServerWorker.cpp 中的接收循环 UINT CRawSocketServerWorker::Run() { char szBuf[4096]; int nRet 0; while (m_bRunning) { nRet recv(m_clientSocket, szBuf, sizeof(szBuf), 0); if (nRet 0) { // 收到数据交给业务处理 OnReceive(szBuf, nRet); } else if (nRet 0) { // 客户端正常关闭 break; } else { // 出错检查 WSAGetLastError() break; } } closesocket(m_clientSocket); return 0; }recv返回 0 表示对端关闭了连接返回SOCKET_ERROR要用WSAGetLastError看具体错误码。WSAEWOULDBLOCK在阻塞模式下不会出现但在非阻塞模式下需要特殊处理。这个例子大概率是阻塞模式所以recv会一直等到有数据或连接断开。2.4 客户端 SocketClient 的连接与收发客户端工程SocketClient相对简单main.cpp里通常是控制台程序流程是WSAStartup→socket→connect→send/recv→closesocket→WSACleanup。// main.cpp 中客户端的典型连接代码 SOCKET ConnectToServer(const char* pszIP, UINT nPort) { SOCKET s socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (s INVALID_SOCKET) return INVALID_SOCKET; sockaddr_in addr {0}; addr.sin_family AF_INET; addr.sin_port htons((u_short)nPort); addr.sin_addr.s_addr inet_addr(pszIP); // 简单场景可用 inet_addr if (connect(s, (sockaddr*)addr, sizeof(addr)) SOCKET_ERROR) { closesocket(s); return INVALID_SOCKET; } return s; }inet_addr只能处理点分十进制 IPv4 字符串不支持域名。如果服务器地址是localhost需要先用gethostbyname解析。客户端发送数据用send接收用recv注意send的返回值可能小于请求发送的字节数严格来说需要循环发送直到发完。3. 编译与运行从 .dsp 到可执行文件的实操步骤3.1 用 Visual C 6.0 或更高版本打开工程这份资源里包含SocketServer.dsp和SC.dsp这是 Visual C 6.0 的工程文件格式。如果你用的是 VS2010 及以上版本直接双击.dsp会提示转换。转换过程一般没问题但有几个点要注意MFC 版本差异VC6 用的是 MFC 4.2高版本 VS 用的是 MFC 14.x某些 API 签名有变化比如AfxBeginThread的返回值类型。字符集VC6 默认多字节字符集高版本 VS 默认 Unicode。如果代码里用了char和CString混搭转换后可能出现const char*到LPCTSTR的编译错误。解决办法是在项目属性里把字符集改成“使用多字节字符集”。#pragma comment(lib, ws2_32.lib)在高版本 VS 里依然有效不需要手动在链接器里加。打开顺序建议先编译SocketServer.dsp再编译SC.dsp。因为客户端不依赖服务器工程但先跑通服务器能让你更快看到监听端口是否正常。3.2 编译时常见的链接错误与字符集设置编译服务器工程时最常见的错误是unresolved external symbol通常是因为没有链接ws2_32.lib。如果代码里没有#pragma comment需要在项目属性 → 链接器 → 输入 → 附加依赖项里加上ws2_32.lib。另一个高频错误是error C2440: “初始化”: 无法从“const char [x]”转换为“LPCWSTR”。这是 Unicode 字符集导致的。解决方法有两种一是把项目字符集改成多字节二是在字符串前加_T()或L前缀。对于这份老代码我建议直接改字符集因为改动量最小。还有CRITSECT.CPP里如果用了InitializeCriticalSection但没有对应的DeleteCriticalSection在程序退出时可能报内存泄漏。MFC 的调试模式会输出警告但不影响运行。3.3 运行服务器与客户端端口、IP 和防火墙服务器编译成功后运行SocketServer.exe界面上通常有端口输入框和“启动”按钮。默认端口可能是 6000 或 8888具体看SocketServerDlg.cpp里的初始化值。点击启动后服务器会调用BindAndListen如果端口被占用bind会失败界面上应该有提示。客户端运行SC.exe需要输入服务器 IP 和端口。如果服务器和客户端在同一台机器IP 填127.0.0.1。如果不在同一台机器填服务器的局域网 IP并且确保 Windows 防火墙允许该端口的入站连接。很多人第一次跑不通就是因为防火墙拦了现象是客户端connect一直阻塞最后返回WSAETIMEDOUT。提示在 Windows 防火墙里临时关闭“域网络”和“专用网络”的入站规则或者为SocketServer.exe添加一条允许规则能快速排除防火墙问题。4. 避坑与排查多线程 Socket 编程里最容易翻车的五个点4.1 现象服务器只能接受一个客户端第二个连不上原因主线程在accept之后直接进入了recv循环没有回到accept。这是单线程服务器的典型写法但这份资源号称多线程所以更可能的原因是ThreadDispatcher里创建线程失败或者accept返回的新套接字被意外关闭了。解决在accept之后加日志确认新套接字句柄有效。检查AfxBeginThread的返回值如果为NULL用GetLastError看错误码。常见的是线程创建失败因为句柄泄漏进程达到了线程数上限。4.2 现象客户端断开后服务器线程没有退出内存持续增长原因recv返回 0 后工作线程没有正确退出循环或者退出了但没有closesocket。更隐蔽的是ThreadDispatcher里创建的CWinThread对象没有设置自动删除导致线程对象堆积。解决在RawSocketServerWorker::Run的末尾确保closesocket(m_clientSocket)被调用。如果用的是AfxBeginThread把CWinThread::m_bAutoDelete设为TRUE或者在线程函数返回前手动delete pThread。用任务管理器观察SocketServer.exe的句柄数和线程数能快速判断是否泄漏。4.3 现象send返回SOCKET_ERROR错误码WSAECONNRESET原因客户端异常断开比如直接杀进程服务器还在往这个套接字写数据。TCP 协议栈收到 RST 包后下一次send就会返回这个错误。解决在send之前检查套接字状态或者忽略WSAECONNRESET并关闭该连接。更稳妥的做法是给send设置超时避免在死连接上无限阻塞。setsockopt的SO_SNDTIMEO可以设置发送超时。4.4 现象bind失败错误码WSAEADDRINUSE原因端口被其他进程占用或者上一次服务器没有正常关闭套接字处于TIME_WAIT状态。Windows 默认不允许重用处于TIME_WAIT的地址。解决在bind之前调用setsockopt设置SO_REUSEADDRint nOpt 1; setsockopt(m_hSocket, SOL_SOCKET, SO_REUSEADDR, (char*)nOpt, sizeof(nOpt));这样即使端口处于TIME_WAIT也能立即重新绑定。注意SO_REUSEADDR在 Windows 上的行为和 Linux 略有不同Windows 上它允许完全重复绑定可能导致两个进程监听同一端口所以只在调试时用。4.5 现象界面卡死点击“停止”没反应原因MFC 对话框的主线程被阻塞了。如果accept是阻塞调用而主线程又在等accept返回消息循环就停了界面自然卡死。解决把accept循环放到单独的工作线程里主线程只负责界面刷新。ThreadDispatcher应该承担这个职责但如果代码里把accept写在了SocketServerDlg::OnStart里就会卡界面。检查SocketServerDlg.cpp中启动按钮的处理函数确认没有在主线程里做阻塞操作。5. 进阶技巧用临界区保护共享数据以及一个验证线程安全的小实验5.1 CRITSECT 封装与使用场景CRITSECT.H和CRITSECT.CPP的存在说明作者意识到多线程需要同步。临界区Critical Section是 Windows 上最轻量的线程同步原语适合保护短小的共享数据段。典型封装如下// CRITSECT.H 中可能的封装 class CCriticalSection { public: CCriticalSection() { InitializeCriticalSection(m_cs); } ~CCriticalSection() { DeleteCriticalSection(m_cs); } void Lock() { EnterCriticalSection(m_cs); } void Unlock() { LeaveCriticalSection(m_cs); } private: CRITICAL_SECTION m_cs; };在ThreadDispatcher里如果多个线程同时往一个客户端列表里添加或删除就需要用这个临界区包住操作。常见错误是忘记Unlock导致死锁。更好的做法是用 RAII 风格的CGuard类在析构函数里自动Unlock。5.2 验证多线程是否真的并发的实验想确认服务器是不是真的多线程处理可以做一个简单实验客户端连接后发送一个耗时请求比如让服务器Sleep(5000)再回复。同时用第二个客户端连接发送一个简单请求。如果第二个客户端能立刻收到回复说明服务器是多线程的如果也要等 5 秒说明是单线程串行处理。这个实验能帮你判断ThreadDispatcher是否真的在分发线程还是只是把套接字存起来然后主线程轮询。很多号称多线程的示例代码实际上只是在accept之后创建了线程但线程函数里又去抢同一个锁导致并发度为零。5.3 从这份代码迁移到现代 C 的建议这份代码用的是 MFC 和原生 Winsock如果你要迁移到现代 C 项目可以考虑用std::thread替代AfxBeginThread用std::mutex替代CRITICAL_SECTION。用RAII封装WSAStartup和WSACleanup避免忘记清理。用std::vectorchar替代固定大小的char szBuf[4096]避免缓冲区溢出。如果不需要 MFC 界面可以把SocketServerDlg换成控制台主循环减少依赖。但迁移之前建议先把这份原始代码跑通理解清楚accept、recv、send、closesocket的调用时序。我见过太多人直接上 Asio结果连阻塞和非阻塞的区别都说不清出了问题只能靠猜。从那以后我每次拿到新的网络库都强制自己先用原生 socket 写一个最小可运行版本再对比库的封装到底省了什么、藏了什么。希望这份 VC 的“裸写”示例能帮你把 TCP 多线程的地基打牢。本文还有配套的精品资源点击获取