简介:这是一份面向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_REUSEADDR
listen的第二个参数控制等待接受队列的最大长度。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,中间再填逻辑,这样不容易漏。希望帮到你。
本文还有配套的精品资源,点击获取