VC++ Socket TCP多线程客户端服务器实战:从阻塞到并发
发布时间:2026/9/28 1:57:48来源:尧图网络
简介这是一份面向C网络编程初学者与进阶开发者的Visual C TCP套接字实战示例聚焦多线程客户端-服务器架构帮助读者理解Winsock底层API的调用流程与并发连接处理思路。资源包共32个文件以11个h头文件与10个cpp源文件为核心辅以dsp、dsw、mak等VC工程配置文件和rc资源脚本整体约37KB结构紧凑便于直接编译运行与逐模块研读。示例围绕套接字初始化、绑定监听、accept接收连接、多线程分发与数据收发等关键环节展开并配有线程调度、临界区保护等实现可帮助读者掌握为每个新连接创建独立线程、主线程持续监听的处理模式。目前已有530人学习下载适合希望从原始Winsock API入手、打牢TCP通信与多线程编程基础的开发者参考借鉴。1. VC Socket TCP 多线程客户端服务器从单连接阻塞到并发处理的实战跨越用 Visual C 写 TCP 通信很多人第一次跑通的都是单线程阻塞版本服务端 accept 一个连接然后 recv 卡住等数据客户端连上来发一条消息服务端回一条看起来没问题。可一旦第二个客户端连进来整个程序就僵住了——第一个连接没断开accept 根本回不到第二次循环。这不是代码写错了而是阻塞式 Socket 的天然限制。要解决它就得把「接受连接」和「处理数据」拆到不同线程里让服务端能同时服务多个客户端。这篇文章围绕 VC 环境下 Socket TCP 多线程客户端服务器结构把线程模型怎么选、代码怎么写、参数怎么调、坑在哪一步步拆开讲清楚。适合已经会用 VC 建工程、调过基本 Socket API但还没把并发处理跑顺的开发者。2. 线程模型选型为什么「一连接一线程」是 VC 入门首选2.1 三种常见并发模型的取舍在 Windows 平台上用 VC 做 TCP 并发服务端常见做法有三种一连接一线程、线程池 IO 完成端口、以及 select/WSAEventSelect 事件驱动。IO 完成端口性能最好但代码复杂度高调试时回调栈深得让人头疼事件驱动单线程就能管很多连接但业务逻辑一复杂就容易写成状态机地狱。一连接一线程虽然在高并发下线程切换开销大但它的代码结构和阻塞式 Socket 几乎一致新手最容易理解出问题也最容易定位。对于连接数在几百以内、每条连接数据交互不算频繁的场景这个模型完全够用。我一般会这样判断如果服务端要同时处理的客户端不超过 500 个且每个连接不是持续满速传大文件就直接用一连接一线程。超过这个量级再考虑完成端口。VC 里创建线程用_beginthreadex而不是CreateThread因为前者会初始化 C 运行时库的线程局部存储避免在用到strtok、rand等函数时出现难以复现的玄学问题。2.2 服务端主线程与工作线程的职责划分服务端主线程只做一件事循环accept每接受一个新连接就创建一个工作线程把已连接的 Socket 句柄通过参数传进去。工作线程负责这条连接上的所有recv和send直到对端关闭或出错才退出。这样主线程永远不会被某个连接的recv阻塞新客户端随时能连进来。这里有个关键细节传给工作线程的 Socket 变量不能是栈上的局部变量否则主线程下一轮循环把它覆盖了工作线程拿到的就是脏数据。正确做法是每次accept后在堆上new一个 SOCKET线程函数用完再delete。这个坑我见过太多次现象是服务端偶尔把消息发给错误的客户端或者直接崩溃排查起来非常费劲。2.3 最小可运行的服务端骨架下面这段代码是服务端主线程的核心逻辑省略了错误处理的细节但保留了线程创建和参数传递的关键结构。// VC TCP 多线程服务端 - 主线程 accept 循环 #include winsock2.h #include process.h #include iostream #pragma comment(lib, ws2_32.lib) // 工作线程函数处理单个客户端连接 unsigned __int64 __stdcall ClientThread(void* pParam) { SOCKET clientSock *(SOCKET*)pParam; delete (SOCKET*)pParam; // 立即释放堆上的句柄副本 char buf[1024]; int ret; while ((ret recv(clientSock, buf, sizeof(buf) - 1, 0)) 0) { buf[ret] \0; std::cout 收到: buf std::endl; // 回显给客户端 send(clientSock, buf, ret, 0); } std::cout 客户端断开, ret ret std::endl; closesocket(clientSock); return 0; } int main() { WSADATA wsaData; WSAStartup(MAKEWORD(2, 2), wsaData); SOCKET listenSock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); sockaddr_in addr { 0 }; addr.sin_family AF_INET; addr.sin_port htons(9527); addr.sin_addr.s_addr INADDR_ANY; bind(listenSock, (sockaddr*)addr, sizeof(addr)); listen(listenSock, SOMAXCONN); std::cout 服务端启动, 端口 9527 std::endl; while (true) { sockaddr_in clientAddr; int addrLen sizeof(clientAddr); SOCKET clientSock accept(listenSock, (sockaddr*)clientAddr, addrLen); if (clientSock INVALID_SOCKET) continue; // 在堆上分配句柄副本传给工作线程 SOCKET* pSock new SOCKET(clientSock); _beginthreadex(NULL, 0, ClientThread, pSock, 0, NULL); } closesocket(listenSock); WSACleanup(); return 0; }这段代码里_beginthreadex的第三个参数是线程函数地址第四个参数是传给线程的void*。线程函数第一件事就是把堆上的 SOCKET 取出来然后delete避免内存泄漏。recv返回 0 表示对端正常关闭返回SOCKET_ERROR表示出错两种情况都跳出循环并closesocket。listen的第二个参数用SOMAXCONN让系统决定等待队列长度一般不需要手动调小。2.4 客户端的多线程发收分离客户端如果只是发一条收一条单线程就够。但如果要一边等用户输入一边收服务端推送就得把recv放到独立线程里。VC 客户端通常用主线程处理界面消息另起一个线程专门recv收到数据后通过自定义消息PostMessage通知窗口刷新。这样不会因为recv阻塞导致界面卡死。// 客户端接收线程独立于 UI 线程运行 unsigned __int64 __stdcall RecvThread(void* pParam) { SOCKET sock *(SOCKET*)pParam; char buf[1024]; int ret; while ((ret recv(sock, buf, sizeof(buf) - 1, 0)) 0) { buf[ret] \0; // 通过自定义消息把数据传给主窗口 ::PostMessage(g_hMainWnd, WM_USER_RECV, (WPARAM)new std::string(buf), 0); } return 0; }这里用new std::string把数据堆上分配主窗口收到消息后负责delete避免跨线程传递栈内存。PostMessage是异步的不会阻塞接收线程比SendMessage安全。3. 避坑与排查VC Socket 多线程最容易翻车的五个地方3.1 现象第二个客户端连不上服务端像死了一样原因主线程在accept之后直接在当前线程里recv没有创建工作线程。第一个连接没断开recv一直阻塞主线程回不到accept。解决把recv循环放到_beginthreadex创建的工作线程里主线程只负责accept。检查代码里accept后面是否直接跟了recv。3.2 现象服务端随机崩溃崩溃点有时在send有时在recv原因传给工作线程的 SOCKET 是栈上局部变量主线程下一轮accept覆盖了同一块栈内存工作线程拿到的句柄已经失效或指向错误对象。解决每次accept后用new SOCKET(clientSock)在堆上分配副本线程函数用完delete。不要传栈变量的地址。3.3 现象客户端关闭后服务端线程不退出句柄数一直涨原因recv返回 0 或SOCKET_ERROR后线程函数没有closesocket或者closesocket之后没有return线程继续跑。解决确保recv循环退出后立即closesocket(clientSock)然后return 0。可以用std::cout打印线程退出日志确认每个连接都有对应的退出记录。3.4 现象WSAStartup返回 10093或者socket返回INVALID_SOCKET原因没有调用WSAStartup或者MAKEWORD(2, 2)写成了MAKEWORD(1, 1)导致版本不匹配。也有可能是ws2_32.lib没有链接。解决在main或WinMain最开始调用WSAStartup(MAKEWORD(2, 2), wsaData)并在工程设置里确认链接了ws2_32.lib。VC 可以用#pragma comment(lib, ws2_32.lib)自动链接。3.5 现象send返回SOCKET_ERROR错误码 10054 或 10053原因10054 表示对端强制关闭了连接通常是客户端进程被任务管理器结束没有走正常的closesocket流程。10053 表示本机协议栈主动断开常见于发送缓冲区满且对端长时间不接收。解决在send前检查返回值遇到 10054 直接清理线程资源并退出。对于 10053可以适当减小发送数据量或增加发送间隔。不要忽略send的返回值否则会在对端断开后继续往无效句柄写数据。4. 参数调优与稳定性加固让多线程服务端扛住真实流量4.1 调整listenbacklog 与SO_REUSEADDRlisten的第二个参数控制等待接受队列的最大长度。Windows 上SOMAXCONN的值是 0x7fffffff实际生效值由系统决定通常不需要改。但如果服务端重启时提示「地址已在使用」需要在bind之前设置SO_REUSEADDR。int opt 1; setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR, (char*)opt, sizeof(opt));这个选项让服务端在TIME_WAIT状态下也能绑定同一端口调试阶段反复重启时非常有用。生产环境如果端口固定也建议加上。4.2 设置recv超时避免线程永久挂起工作线程里的recv默认是无限等待的。如果客户端连上来但不发数据这个线程就一直占着。可以给 Socket 设置接收超时让recv定期返回线程有机会检查退出标志。int timeout 5000; // 5 秒 setsockopt(clientSock, SOL_SOCKET, SO_RCVTIMEO, (char*)timeout, sizeof(timeout));设置后recv超时会返回SOCKET_ERROR错误码WSAETIMEDOUT。在线程循环里判断这个错误码如果是超时就继续循环同时检查一个全局的volatile bool退出标志方便服务端关闭时通知所有线程退出。4.3 用InterlockedIncrement统计在线连接数多线程环境下用普通int做计数器会出现竞态条件。VC 提供了InterlockedIncrement和InterlockedDecrement它们保证原子操作不需要额外加锁。volatile LONG g_onlineCount 0; // 新连接建立时 InterlockedIncrement(g_onlineCount); // 线程退出时 InterlockedDecrement(g_onlineCount);这个计数可以用来做限流如果g_onlineCount超过预设上限主线程accept后直接closesocket并拒绝服务避免线程数爆炸。4.4 发送大块数据时的分包与粘包处理TCP 是字节流协议没有消息边界。客户端send两次服务端可能一次recv全收到也可能分两次收到。常见做法是自定义一个包头前 4 个字节表示后续数据长度接收方先收 4 字节再根据长度收剩余部分。// 发送方先发长度再发内容 int len (int)strlen(msg); send(sock, (char*)len, 4, 0); send(sock, msg, len, 0); // 接收方先收 4 字节长度 int len 0; int received 0; while (received 4) { int ret recv(sock, (char*)len received, 4 - received, 0); if (ret 0) return; // 连接断开 received ret; } // 再根据 len 收内容这个模式在 VC 里用recv循环实现注意recv的第三个参数是剩余要收的字节数不是缓冲区总大小。粘包问题不处理业务层解析消息时就会读到半截数据表现为「为什么 socket 接收到奇数字节后面会补一个随机数」这类困惑。5. 进阶技巧用_beginthreadex返回值做线程句柄管理_beginthreadex返回一个uintptr_t本质上是线程句柄。很多人创建完线程就不管了线程结束后句柄没有关闭导致句柄泄漏。正确做法是把返回值保存下来在确认线程退出后调用CloseHandle。但工作线程是分离运行的主线程怎么知道它什么时候结束一个实用技巧是在工作线程函数最后把线程 ID 或者一个完成标志写到全局结构里主线程定期扫描并清理已结束的线程句柄。更简单的做法是创建一个管理线程专门WaitForSingleObject等待各个工作线程句柄收到信号后CloseHandle。// 线程管理结构 struct ThreadInfo { HANDLE hThread; SOCKET sock; }; // 创建工作线程时保存句柄 ThreadInfo* pInfo new ThreadInfo; pInfo-sock clientSock; pInfo-hThread (HANDLE)_beginthreadex(NULL, 0, ClientThread, pInfo, 0, NULL); // 管理线程中等待并清理 WaitForSingleObject(pInfo-hThread, INFINITE); CloseHandle(pInfo-hThread); delete pInfo;这个结构把 Socket 和线程句柄绑在一起线程函数从pInfo里取 Socket结束后管理线程负责回收句柄和结构体内存。注意_beginthreadex返回的句柄和CreateThread一样都需要CloseHandle否则每次连接都会泄漏一个内核对象。另一个值得养成的习惯是在 VC 调试模式下用_CrtSetDbgFlag(_CRTDBG_LEAK_CHECK_DF)开启内存泄漏检测程序退出时输出泄漏的堆分配。多线程下如果new SOCKET之后忘记delete这个工具能直接告诉你泄漏发生在哪一行。我自己的习惯是每个new后面立刻写delete中间再填逻辑这样不容易漏。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网