news 2026/10/9 7:01:11

VC Socket TCP多线程客户端服务器:从单连接阻塞到并发处理实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
VC Socket TCP多线程客户端服务器:从单连接阻塞到并发处理实战

简介:这是一份面向C++网络编程初学者与进阶开发者的Visual C++ TCP套接字实战示例,聚焦多线程客户端-服务器架构,帮助读者理解Winsock底层API的使用方式与并发连接处理思路。资源包共32个文件,以11个h头文件、10个cpp源文件为核心,辅以dsp、dsw、mak等工程配置文件和rc、ico等界面资源,整体约37KB,结构紧凑,便于直接编译运行与逐模块研读。示例围绕WSAStartup初始化、socket创建、bind与listen监听、accept接受连接、多线程分发及send/recv数据收发等关键环节展开,并包含临界区同步等线程安全处理,可帮助读者掌握原始套接字编程的完整流程与常见排错思路。目前已有530人学习下载,适合希望从底层理解TCP通信机制、为分布式系统与实时通信应用打基础的开发者参考。

1. VC Socket TCP 多线程客户端服务器:从单连接阻塞到并发处理的分水岭

很多用 Visual C++ 写网络程序的人,第一个能跑通的版本几乎都是单线程阻塞式:服务端accept一个连接,然后recv死等,客户端发一句服务端回一句。这个模型在实验室里没问题,一旦放到真实场景——比如十来个采集终端同时上报数据——就会立刻暴露问题:第二个客户端连上来时,服务端还卡在第一个连接的recv上,新连接只能排队等在 backlog 里,界面直接假死。标题里的“多线程客户端服务器结构”,解决的正是这个分水岭问题:让服务端能同时服务多个 TCP 连接,让客户端收发互不阻塞。

这篇文章面向的是还在用 Visual C++(VC6.0 到 VS2022 都算)维护工控、采集、测试类上位机的工程师。我会把线程模型怎么选、WSAStartup和socket怎么配、accept循环怎么拆、共享数据怎么加锁、连接断开怎么收尾,按能直接抄进工程的方式讲一遍。热词里那些tcp连接、socket网络编程、多线程、visual c++不是摆设,它们对应的正是这套结构里最容易翻车的几个点。下面从选型开始,一层层落到代码。

2. 线程模型选型:每连接一线程还是线程池

2.1 三种常见结构的适用边界

在 VC 里做 TCP 并发,绕不开三种结构。第一种是「每连接一线程」,accept返回一个新 socket 就CreateThread或_beginthreadex开一个线程专门伺候它,线程里循环recv,连接断了线程退出。第二种是「线程池 + IO 复用」,用select或WSAEventSelect在一个或少数几个线程里管理所有 socket,收到可读事件再分发处理。第三种是「重叠 IO / IOCP」,用WSARecv配合完成端口,吞吐最高但代码量最大。

对绝大多数 VC 上位机项目,我的建议是:连接数在几十到一两百、每连接数据量不大、开发周期紧,直接用每连接一线程。它的好处是逻辑线性、调试直观,一个连接出问题不影响别人。线程池方案适合连接数上千的场景,但select在 Windows 上默认最多管 64 个 socket(FD_SETSIZE),要改就得重新编译,坑不少。IOCP 适合高吞吐服务器,但 VC6.0 对它的支持很别扭,不建议新手碰。

提示:如果连接数长期稳定在 200 以内,每连接一线程完全够用,不要为了“看起来高级”去上 IOCP,维护成本会吃掉你所有收益。

2.2 服务端骨架:WSAStartup 到 accept 循环

先把服务端的启动和监听写出来。这段代码在 VC6.0 和 VS2022 下都能编译,注意链接ws2_32.lib。

#include <winsock2.h> #include <windows.h> #include <process.h> #pragma comment(lib, "ws2_32.lib") #define DEFAULT_PORT 9527 #define MAX_CLIENTS 256 // 每个连接对应一个客户端上下文 struct ClientContext { SOCKET sock; sockaddr_in addr; }; // 工作线程:专门处理一个连接的收发 unsigned __stdcall ClientThread(void* param) { ClientContext* ctx = (ClientContext*)param; char buf[4096]; int ret; while (true) { // 阻塞接收,直到对端发数据或断开 ret = recv(ctx->sock, buf, sizeof(buf) - 1, 0); if (ret > 0) { buf[ret] = '\0'; // 这里做业务处理,示例直接回显 send(ctx->sock, buf, ret, 0); } else if (ret == 0) { // 对端正常关闭 break; } else { // SOCKET_ERROR,看错误码决定是否退出 int err = WSAGetLastError(); if (err == WSAECONNRESET || err == WSAENOTCONN) break; // 其他错误可重试,这里简单退出 break; } } closesocket(ctx->sock); delete ctx; return 0; } int main() { WSADATA wsa; if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) { return -1; // 初始化失败,通常是运行库或环境问题 } SOCKET listenSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (listenSock == INVALID_SOCKET) { WSACleanup(); return -1; } // 允许端口复用,避免重启时 bind 失败 BOOL opt = TRUE; setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR, (char*)&opt, sizeof(opt)); sockaddr_in serverAddr; memset(&serverAddr, 0, sizeof(serverAddr)); serverAddr.sin_family = AF_INET; serverAddr.sin_addr.s_addr = htonl(INADDR_ANY); serverAddr.sin_port = htons(DEFAULT_PORT); if (bind(listenSock, (sockaddr*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR) { closesocket(listenSock); WSACleanup(); return -1; } if (listen(listenSock, SOMAXCONN) == SOCKET_ERROR) { closesocket(listenSock); WSACleanup(); return -1; } while (true) { sockaddr_in clientAddr; int addrLen = sizeof(clientAddr); SOCKET clientSock = accept(listenSock, (sockaddr*)&clientAddr, &addrLen); if (clientSock == INVALID_SOCKET) { // accept 失败通常是资源耗尽或监听 socket 被关 continue; } ClientContext* ctx = new ClientContext(); ctx->sock = clientSock; ctx->addr = clientAddr; // _beginthreadex 比 CreateThread 更适合 C 运行库 unsigned tid = 0; HANDLE h = (HANDLE)_beginthreadex(NULL, 0, ClientThread, ctx, 0, &tid); if (h == NULL) { // 线程创建失败,回收资源 closesocket(clientSock); delete ctx; continue; } CloseHandle(h); // 不关心线程句柄就关掉,避免句柄泄漏 } closesocket(listenSock); WSACleanup(); return 0; }

这段代码的逻辑说明:WSAStartup必须在任何 socket 调用之前执行,MAKEWORD(2,2)表示请求 Winsock 2.2。SO_REUSEADDR是为了解决热词里那个高频报错——windows socket error: 通常每个套接字地址只允许使用一次,服务端重启时旧连接处于TIME_WAIT,不设这个选项bind会直接失败。listen的 backlog 用SOMAXCONN让系统决定队列长度。accept循环本身是单线程的,它只负责“接客”,接到就把 socket 交给新线程,自己立刻回去等下一个连接,这样就不会被某个连接的recv卡住。

参数上,DEFAULT_PORT按项目改,MAX_CLIENTS只是示意,实际限制来自系统句柄和内存。_beginthreadex的第四个参数是传给线程函数的指针,这里传ClientContext*,线程里用完delete,谁分配谁释放,避免内存泄漏。

2.3 客户端的多线程收发拆分

客户端如果只有一个线程,recv阻塞时界面就没法响应发送按钮。常见做法是把接收放到独立线程,主线程只管发。下面是一个最小客户端结构。

// 客户端接收线程 unsigned __stdcall RecvThread(void* param) { SOCKET sock = (SOCKET)(UINT_PTR)param; char buf[4096]; while (true) { int ret = recv(sock, buf, sizeof(buf) - 1, 0); if (ret > 0) { buf[ret] = '\0'; // 把数据投递到界面线程,不要在这里直接操作控件 // PostMessage(g_hWnd, WM_APP + 1, (WPARAM)new std::string(buf), 0); } else { break; // 断开或出错 } } return 0; } // 主线程里连接并启动接收线程 SOCKET ConnectToServer(const char* ip, int port) { SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (sock == INVALID_SOCKET) return INVALID_SOCKET; sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(port); addr.sin_addr.s_addr = inet_addr(ip); if (connect(sock, (sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR) { closesocket(sock); return INVALID_SOCKET; } unsigned tid = 0; HANDLE h = (HANDLE)_beginthreadex(NULL, 0, RecvThread, (void*)(UINT_PTR)sock, 0, &tid); if (h) CloseHandle(h); return sock; }

逻辑说明:接收线程只做recv,拿到数据后通过PostMessage把数据指针抛给界面线程,界面线程再delete。千万不要在接收线程里直接调用 MFC 控件方法,跨线程操作 UI 是 VC 里最经典的崩溃来源。参数上,inet_addr只支持点分十进制,如果要用域名得换getaddrinfo。connect是阻塞的,连不上会等默认超时,工控场景建议配合select或设置非阻塞加超时,避免界面卡死。

3. 共享数据与线程同步:临界区怎么用才不出错

3.1 什么时候必须加锁

多线程服务端里,每个连接线程通常要把数据写到一个全局结构,比如在线客户端列表、统计计数、日志队列。只要有两个以上线程可能同时读写同一块内存,就必须同步。VC 里最轻量的是临界区CRITICAL_SECTION,比互斥体快,适合锁粒度小、持有时间短的场景。

#include <windows.h> #include <vector> CRITICAL_SECTION g_cs; std::vector<SOCKET> g_clients; // 初始化,在 main 开头调用 void InitLock() { InitializeCriticalSection(&g_cs); } // 添加客户端 void AddClient(SOCKET s) { EnterCriticalSection(&g_cs); g_clients.push_back(s); LeaveCriticalSection(&g_cs); } // 移除客户端 void RemoveClient(SOCKET s) { EnterCriticalSection(&g_cs); for (size_t i = 0; i < g_clients.size(); ++i) { if (g_clients[i] == s) { g_clients.erase(g_clients.begin() + i); break; } } LeaveCriticalSection(&g_cs); }

逻辑说明:EnterCriticalSection和LeaveCriticalSection必须成对出现,中间任何提前return都会导致死锁。参数上,临界区不需要像互斥体那样传名字,它只在进程内有效。如果锁里的操作可能抛异常或耗时很长,考虑用 RAII 封装,或者缩小锁范围——比如只在push_back和erase时加锁,不要在锁里做send。

3.2 线程退出与资源回收的配合

线程退出时要把自己从全局列表里摘掉,同时关闭 socket。顺序很重要:先RemoveClient,再closesocket,最后delete上下文。如果先关 socket 再摘列表,别的线程可能拿到一个已经失效的 socket 去send,触发WSAENOTSOCK。另外,主线程退出前要确保所有工作线程已经结束,可以用一个计数或事件对象等待。

// 线程结束时 RemoveClient(ctx->sock); closesocket(ctx->sock); delete ctx;

注意:closesocket之后不要再对该 socket 做任何操作,包括shutdown,否则错误码会变得很难排查。

4. 避坑与排查:VC Socket 多线程最常见的五个翻车点

4.1 现象:服务端只能连一个客户端,第二个连上没反应

原因:accept之后直接在循环里recv,没有把新连接交给独立线程,导致accept循环被阻塞。解决:按第 2 章的骨架,accept只负责创建线程,收发逻辑全部放进线程函数。

4.2 现象:程序退出时崩溃,报访问违例

原因:工作线程还在跑,主线程已经WSACleanup并释放了全局资源。解决:维护一个线程计数,主线程退出前等待计数归零,或者用WaitForSingleObject等所有线程句柄。另外,WSACleanup必须在所有 socket 关闭之后调用。

4.3 现象:bind失败,错误码 10048

原因:端口被占用,或者上一次运行留下的TIME_WAIT状态还没释放。解决:设置SO_REUSEADDR,如第 2 章代码所示。如果仍然失败,用netstat -ano | findstr 9527找到占用进程,确认是不是自己的程序没退干净。

4.4 现象:客户端发送成功但服务端收不到,或者收到粘包

原因:TCP 是字节流,没有消息边界。send一次不代表recv一次就能收全,也可能两次send被合并成一次recv。解决:自定义包头,比如前 4 字节表示长度,接收端先收长度再收内容。不要依赖recv的返回次数来切分消息。

4.5 现象:多线程下界面卡死或随机崩溃

原因:在接收线程里直接操作 MFC 控件,或者多个线程同时写同一个CString。解决:所有 UI 更新通过PostMessage投递到主线程,数据用new分配、主线程delete。共享的std::vector、std::string必须加锁,或者改用线程安全队列。

5. 进阶技巧:用 select 做超时控制与连接健康检查

每连接一线程的模型有个软肋:如果客户端异常断网(拔网线),服务端的recv可能长时间不返回,线程一直挂着。TCP 本身有 keepalive,但默认 2 小时才探测,工控场景等不起。我的习惯是在接收线程里用select加超时,定期检查连接是否还活着。

// 带超时的接收,超时时间 5 秒 int RecvWithTimeout(SOCKET sock, char* buf, int len, int timeoutSec) { fd_set readSet; FD_ZERO(&readSet); FD_SET(sock, &readSet); timeval tv; tv.tv_sec = timeoutSec; tv.tv_usec = 0; int ret = select(0, &readSet, NULL, NULL, &tv); if (ret == SOCKET_ERROR) return -1; // select 出错 if (ret == 0) return 0; // 超时,没有数据 // 有可读事件,正常 recv return recv(sock, buf, len, 0); }

逻辑说明:select的第一个参数在 Windows 上忽略,填 0 即可。FD_SET把 socket 加入读集合,timeval设超时。返回 0 表示超时,这时可以发一个心跳包或者累计几次超时后主动断开。参数上,timeoutSec按业务定,采集类一般 3 到 10 秒。注意select会修改readSet和tv,每次调用前都要重新FD_ZERO和FD_SET,这是血泪经验,很多人在这里翻车。

另一个技巧是给send也加超时。默认send在缓冲区满时会阻塞,如果对端不读,发送线程会卡住。可以用setsockopt设置SO_SNDTIMEO:

int sendTimeout = 5000; // 毫秒 setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, (char*)&sendTimeout, sizeof(sendTimeout));

这样send超过 5 秒没发完就返回SOCKET_ERROR,错误码WSAETIMEDOUT,你可以选择重试或断开。接收同理可以设SO_RCVTIMEO,但和select二选一即可,混用容易把自己绕进去。

最后说一个验证方法:写完服务端后,不要只用一个客户端测。开三个 telnet 或自己写的小工具同时连,其中一个故意只连不发,看另外两个是否正常收发。再模拟拔网线——在虚拟机里禁用网卡——观察服务端线程是否在超时后退出。这套流程走一遍,基本能覆盖 90% 的多线程 socket 问题。我自己维护的采集程序就是靠select超时加心跳,跑了三年没再出现线程泄漏。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/9 6:59:58

Python+Vue全栈打造爱奇艺影视数据可视化分析系统

爱奇艺大屏背后&#xff0c;其实是一套Python Vue全栈项目。今天我把整个系统的源码思路、数据库设计、可视化实现全部拆开聊聊&#xff0c;包括我在实际开发中踩过的坑、怎么解决&#xff0c;以及哪些地方你一定也会遇到&#xff0c;建议直接收藏。1. 项目骨架设计&#xff1…

作者头像 李华
网站建设 2026/10/9 6:59:49

CUDA Kernel Agent:2026年GPU算力与AI智能的双向奔赴

CUDA Kernel和AI Agent&#xff0c;这两个词放在一起&#xff0c;2026年之前很多人会觉得它们是两条平行线&#xff1a;一个在最底层做GPU并行计算&#xff0c;一个在最上层做智能决策。但2026年的技术版图里&#xff0c;这两条线开始加速交汇——Kernel不再只是被手写和被调优…

作者头像 李华
网站建设 2026/10/9 6:58:21

拓扑学在数据科学中的落地:从连通性到持久同调与Mapper实践

学数据科学的人&#xff0c;十有八九会在数学基础这里卡一道坎&#xff1a;微积分、线代、概率论还会硬着头皮刷题&#xff0c;到了拓扑学就只剩“这玩意到底有什么用”的迷茫。可偏偏现在的高维数据、聚类稳定性、流形假设、形状分析&#xff0c;甚至几何深度学习&#xff0c;…

作者头像 李华
网站建设 2026/10/9 6:58:06

费尔防火墙1.0源码解析:包过滤规则引擎与C代码实现

简介&#xff1a;这份资源是面向网络安全初学者与开发人员的防火墙源码学习包&#xff0c;以“费尔防火墙 1.0”源码为核心&#xff0c;帮助读者理解防火墙如何检测并阻断恶意流量、如何配置规则允许或禁止特定连接&#xff0c;以及如何处理异常行为。压缩包为zip格式&#xff…

作者头像 李华
网站建设 2026/10/9 6:56:52

4电平MMC仿真模型搭建与调试:从原理到参数配置详解

做电力电子的仿真这些年&#xff0c;我接触过不少刚入门模块化多电平换流器&#xff08;MMC&#xff09;的人&#xff0c;十有八九第一反应是“直接搭一个几十上百个子模块的完整工程模型&#xff0c;把波形跑出来不就行了”。结果往往是在仿真平台里卡到怀疑人生&#xff0c;算…

作者头像 李华
网站建设 2026/10/9 6:56:34

考虑不确定性的含集群电动汽车微电网随机优化调度Matlab实现

做微电网优化调度这个方向有一段时间了&#xff0c;前前后后也踩过不少坑。这次想聊的是一个比较有代表性的项目&#xff1a;考虑不确定性的含集群电动汽车并网型微电网随机优化调度研究&#xff08;Matlab代码实现&#xff09;。先说这个题目要怎么理解。它拆开来看其实有三个…

作者头像 李华