ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

VC Socket TCP多线程客户端服务器:从阻塞模型到可复现工程骨架

VC Socket TCP多线程客户端服务器:从阻塞模型到可复现工程骨架 简介这份资源是面向C网络编程初学者与进阶开发者的Visual C TCP套接字实战示例聚焦多线程客户端-服务器架构帮助读者理解Winsock底层API的使用方式与并发连接处理思路。压缩包共32个文件约37KB以h头文件与cpp源文件为主辅以dsp、dsw、mak等VC工程文件及rc资源脚本、ico图标等工程结构完整可直接在Visual C环境中打开编译。内容涵盖套接字初始化与清理、创建、绑定监听、accept接收连接、多线程分发与临界区同步、数据收发及连接关闭等关键环节并区分服务器端与客户端两套代码便于对照学习。已有530人学习下载适合希望脱离高级封装、直接掌握原始Winsock编程的开发者参考可据此搭建自己的并发通信框架并理解线程调度与资源保护的实现细节。1. VC Socket TCP 多线程客户端服务器从阻塞模型到可复现的工程骨架很多做 Visual C 网络编程的人第一次写 TCP 通信都是单线程阻塞式服务器accept一个连接然后recv死等客户端connect之后send再recv。本地跑没问题一旦多个客户端同时连上来或者某个客户端发了一半数据卡住整个服务端就僵在那里界面直接假死。这不是代码写错了而是阻塞模型本身撑不住并发。标题里的「VC Socket TCP 多线程客户端服务器结构」要解决的就是这个问题用 Visual C 的 Winsock 接口把每个连接放到独立线程里处理让服务端能同时服务多个客户端客户端也能一边收一边发不互相卡。适合已经会写最基础 socket 收发、但一上并发就翻车的人也适合想把老 VC 项目里的单连接逻辑改成多线程结构的人。下面按「先立模型、再动手、最后排坑」的顺序把可复现的骨架拆开。2. 先把模型定下来阻塞、多线程与 TCP 字节流的三条硬约束2.1 为什么单线程阻塞式服务端一定会卡Winsock 默认创建的 socket 是阻塞模式。阻塞模式下accept没有新连接就停在那里recv没有数据也停在那里。单线程里只要有一个recv在等后面的accept就轮不到执行。表现就是第一个客户端连上后能通信第二个客户端连上来时服务端毫无反应因为线程还卡在第一个连接的recv上。常见做法是「一连接一线程」主线程只负责accept每接受一个连接就CreateThread或_beginthreadex开一个工作线程把新 socket 交给它工作线程内部循环recv主线程立刻回去继续accept。这样每个连接的阻塞互不影响。代价是连接数多时线程数暴涨所以后面要讲线程上限和退出清理。注意VC 里创建线程优先用_beginthreadex而不是CreateThread因为 CRT 需要初始化线程局部数据混用可能导致内存泄漏。2.2 TCP 是字节流不是消息包这是新手最容易翻车的地方。TCP 不保留发送端的send边界你send两次对端可能一次recv全收到也可能分三次收到。热词里「为什么 socket 接收到奇数字节后面会补一个随机数」这类困惑本质就是没处理粘包和拆包。所以协议层必须自己定边界。最稳的是「长度前缀」先发 4 字节表示正文长度再发正文。接收端先收满 4 字节解析出长度 N再循环收满 N 字节才算一条完整消息。下面这段是收满指定字节数的辅助函数是整个结构的地基// 收满 len 字节才返回返回实际收到的字节数0 表示对端正常关闭SOCKET_ERROR 表示出错 int RecvAll(SOCKET s, char* buf, int len) { int total 0; while (total len) { int n recv(s, buf total, len - total, 0); if (n 0) return 0; // 对端关闭 if (n SOCKET_ERROR) { int err WSAGetLastError(); if (err WSAEINTR) continue; // 被信号打断重试 return SOCKET_ERROR; } total n; } return total; }参数说明s是已连接的 socketbuf是接收缓冲区len是期望收满的字节数。逻辑说明循环累加只有收满len才退出中途对端关闭返回 0出错返回SOCKET_ERROR。这个函数配合长度前缀就能把字节流还原成一条条消息。2.3 多线程共享数据的边界在哪一连接一线程时每个工作线程只碰自己的 socket 和缓冲区天然不共享这是它简单可靠的原因。真正需要共享的是「连接列表」和「退出标志」主线程要记录当前有哪些连接关闭服务时要通知所有线程退出。这两处必须加锁VC 里用CRITICAL_SECTION比Mutex轻量。CRITICAL_SECTION g_cs; // 保护连接列表 volatile LONG g_running 1; // 服务运行标志1 运行 0 停止 std::vectorSOCKET g_clients; // 当前连接集合 // 初始化 InitializeCriticalSection(g_cs); // 使用 EnterCriticalSection(g_cs); g_clients.push_back(clientSock); LeaveCriticalSection(g_cs);参数说明g_running用volatile LONG配合InterlockedExchange修改保证多线程可见性。逻辑说明只在读写共享集合时进出临界区recv这种耗时操作绝不能放在锁里否则一个慢连接会拖死所有线程。3. 服务端落地accept 主循环加工作线程的完整骨架3.1 Winsock 初始化与监听 socket 的创建VC 里用 Winsock 必须先WSAStartup退出时WSACleanup。监听 socket 的创建顺序是socket→bind→listen。端口用htons转网络字节序INADDR_ANY表示监听所有网卡。WSADATA wsa; if (WSAStartup(MAKEWORD(2, 2), wsa) ! 0) { printf(WSAStartup failed: %d\n, WSAGetLastError()); return -1; } SOCKET listenSock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (listenSock INVALID_SOCKET) { /* 处理错误 */ } sockaddr_in addr {0}; addr.sin_family AF_INET; addr.sin_addr.s_addr INADDR_ANY; addr.sin_port htons(9527); // 监听端口按需改 if (bind(listenSock, (sockaddr*)addr, sizeof(addr)) SOCKET_ERROR) { printf(bind failed: %d\n, WSAGetLastError()); return -1; } if (listen(listenSock, SOMAXCONN) SOCKET_ERROR) { printf(listen failed: %d\n, WSAGetLastError()); return -1; }参数说明MAKEWORD(2,2)请求 Winsock 2.2SOMAXCONN是系统允许的最大等待队列。逻辑说明bind失败最常见的是端口被占用错误码WSAEADDRINUSE换端口或先排查占用进程。listen之后 socket 进入被动监听状态等待accept。3.2 主循环 accept 与线程分发主线程只做一件事循环accept拿到新 socket 后开线程。accept返回的clientAddr可以拿到对端 IP 和端口方便打日志。while (g_running) { sockaddr_in clientAddr; int addrLen sizeof(clientAddr); SOCKET clientSock accept(listenSock, (sockaddr*)clientAddr, addrLen); if (clientSock INVALID_SOCKET) { if (!g_running) break; // 主动关闭导致的 accept 失败正常退出 printf(accept failed: %d\n, WSAGetLastError()); continue; } printf(client connected: %s:%d\n, inet_ntoa(clientAddr.sin_addr), ntohs(clientAddr.sin_port)); EnterCriticalSection(g_cs); g_clients.push_back(clientSock); LeaveCriticalSection(g_cs); // 每个连接一个工作线程 HANDLE h (HANDLE)_beginthreadex(NULL, 0, ClientThread, (void*)clientSock, 0, NULL); if (h) CloseHandle(h); // 不关心线程句柄就关掉避免句柄泄漏 }参数说明_beginthreadex第四个参数把clientSock传给线程函数注意这里传的是 socket 值本身不是指针避免指针生命周期问题。逻辑说明accept失败时先判断g_running如果是主动关闭导致的直接跳出循环不要当错误处理。线程句柄用完即关否则句柄数会持续增长。3.3 工作线程的收发放大与退出清理工作线程拿到 socket 后循环收消息、处理、回发。退出条件有三个对端关闭、出错、服务停止。无论哪种退出都要closesocket并从连接列表移除。unsigned __stdcall ClientThread(void* param) { SOCKET s (SOCKET)param; char lenBuf[4]; std::vectorchar body; while (g_running) { int r RecvAll(s, lenBuf, 4); // 先收 4 字节长度 if (r 0) break; // 关闭或出错 int bodyLen ntohl(*(int*)lenBuf); // 网络序转主机序 if (bodyLen 0 || bodyLen 1024 * 1024) break; // 防御非法长度 body.resize(bodyLen); r RecvAll(s, body.data(), bodyLen); if (r 0) break; // 业务处理这里简单回显 int sendLen htonl(bodyLen); send(s, (char*)sendLen, 4, 0); send(s, body.data(), bodyLen, 0); } // 清理 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); closesocket(s); return 0; }参数说明bodyLen上限设 1MB 是防御性编程防止对端发个超大长度把内存撑爆。逻辑说明先收长度再收正文这是长度前缀协议的标准收法。回发时同样先发长度再发正文保证对端能正确解析。清理阶段先从列表移除再关 socket顺序不能反否则其他线程可能拿到已关闭的 socket。4. 客户端落地连接、收发与多线程收发的取舍4.1 客户端连接与超时控制客户端connect在阻塞模式下如果服务端没响应会卡很久。VC 里控制连接超时的常见做法是先把 socket 设为非阻塞connect后立刻返回WSAEWOULDBLOCK再用select等待可写超时就放弃。SOCKET ConnectWithTimeout(const char* ip, int port, int timeoutMs) { SOCKET s socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); u_long nonBlock 1; ioctlsocket(s, FIONBIO, nonBlock); // 设为非阻塞 sockaddr_in addr {0}; addr.sin_family AF_INET; addr.sin_port htons(port); addr.sin_addr.s_addr inet_addr(ip); connect(s, (sockaddr*)addr, sizeof(addr)); // 非阻塞下立即返回 fd_set wset; FD_ZERO(wset); FD_SET(s, wset); timeval tv { timeoutMs / 1000, (timeoutMs % 1000) * 1000 }; if (select(0, NULL, wset, NULL, tv) 0) { closesocket(s); return INVALID_SOCKET; // 超时或出错 } nonBlock 0; ioctlsocket(s, FIONBIO, nonBlock); // 恢复阻塞 return s; }参数说明timeoutMs是连接超时毫秒数select的timeval秒和微秒分开填。逻辑说明非阻塞connect返回后不能直接判断成功必须用select等可写可写才说明连接建立。恢复阻塞模式是为了后续recv用简单阻塞逻辑。4.2 客户端要不要多线程客户端多线程的典型场景是「一边等用户输入发送一边持续收服务端推送」。如果客户端只做「发一条等一条」的请求响应单线程就够多线程反而增加复杂度。需要多线程时常见结构是主线程负责发送单独开一个接收线程循环recv收到消息后通过消息队列或回调交给界面线程。// 接收线程持续收收到就投递到主线程处理 unsigned __stdcall RecvThread(void* param) { SOCKET s (SOCKET)param; char lenBuf[4]; while (g_running) { int r RecvAll(s, lenBuf, 4); if (r 0) break; int bodyLen ntohl(*(int*)lenBuf); if (bodyLen 0 || bodyLen 1024 * 1024) break; std::vectorchar body(bodyLen); if (RecvAll(s, body.data(), bodyLen) 0) break; // 投递到主线程PostMessage 或自定义队列 // PostMessage(hWnd, WM_RECV_DATA, (WPARAM)new std::string(body.data(), bodyLen), 0); } return 0; }参数说明g_running是全局运行标志关闭时置 0 让线程退出。逻辑说明接收线程只负责收和投递不直接操作界面控件因为 VC 里跨线程操作 MFC 控件会出问题。投递用PostMessage把数据指针传过去主线程收到后delete这是 MFC 里常见的跨线程通信方式。4.3 发送端的线程安全如果多个线程都要往同一个 socket 发数据send本身不是原子的两条消息可能交错。解决办法是给发送加锁保证「发长度 发正文」是一个整体。CRITICAL_SECTION g_sendCs; void SendMessage(SOCKET s, const char* data, int len) { EnterCriticalSection(g_sendCs); int netLen htonl(len); send(s, (char*)netLen, 4, 0); send(s, data, len, 0); LeaveCriticalSection(g_sendCs); }参数说明g_sendCs是发送专用临界区和连接列表的锁分开减少竞争。逻辑说明长度和正文必须一起发中间不能被其他线程插入否则对端解析会错位。这个锁的粒度要小只包住两次send不要包业务计算。5. 避坑与排查多线程 socket 最常见的五类翻车5.1 现象服务端跑一会儿就 accept 失败错误码 10038 或 10093原因WSAStartup没调用或调用失败或者listenSock被意外关闭。10093 是WSANOTINITIALISED10038 是WSAENOTSOCK都指向 socket 句柄无效。解决确认WSAStartup在创建任何 socket 之前调用且返回值检查了确认没有在别处误关listenSock关闭服务时先置g_running 0再closesocket(listenSock)让accept返回最后WSACleanup。5.2 现象客户端发一条消息服务端收到两条或半条原因TCP 粘包拆包发送端两次send被合并或一次send被拆分。没有按长度前缀解析。解决统一用「4 字节长度 正文」协议接收端用RecvAll先收满 4 字节再收正文。不要假设一次recv就是一条完整消息这是血泪经验里出现频率最高的坑。5.3 现象关闭服务端时程序卡死或崩溃原因工作线程还在recv阻塞主线程直接closesocket并释放资源工作线程醒来后访问已释放内存。或者g_running没用volatile工作线程读不到变化。解决关闭流程分三步——先InterlockedExchange(g_running, 0)再closesocket(listenSock)让accept退出然后遍历g_clients逐个shutdown(s, SD_BOTH)让阻塞的recv返回最后等所有线程退出再WSACleanup。g_running必须用volatile LONG。5.4 现象连接数一多服务端创建线程失败原因一连接一线程连接数上千时线程数爆炸系统资源耗尽_beginthreadex返回 NULL。解决加连接数上限超过就拒绝并closesocket或者改用线程池 / IOCP 模型。VC 里如果只是中小规模几百连接以内一连接一线程够用再往上就要考虑AcceptEx IOCP这是另一个量级的改造。5.5 现象客户端收不到回包但服务端日志显示已发送原因客户端接收线程没启动或者接收线程里RecvAll的长度解析用了主机序没转网络序导致bodyLen是个巨大值一直等不到足够数据。解决检查ntohl/htonl是否配对使用检查接收线程是否真的在跑用抓包工具确认数据到了网卡但没被应用层读走还是根本没发出来。发送端send返回值也要检查返回SOCKET_ERROR说明对端已关闭。6. 进阶技巧用 select 做单线程多连接以及线程数的实测边界一连接一线程写起来直观但连接数上去后线程切换开销明显。如果不想上 IOCP 那么重select是个折中单线程用select同时监视监听 socket 和所有已连接 socket哪个可读就处理哪个。它把「阻塞等待」集中到一处避免了每连接一个线程。fd_set readSet; while (g_running) { FD_ZERO(readSet); FD_SET(listenSock, readSet); SOCKET maxSock listenSock; EnterCriticalSection(g_cs); for (size_t i 0; i g_clients.size(); i) { FD_SET(g_clients[i], readSet); if (g_clients[i] maxSock) maxSock g_clients[i]; } LeaveCriticalSection(g_cs); timeval tv {1, 0}; // 1 秒超时便于检查 g_running int ret select((int)maxSock 1, readSet, NULL, NULL, tv); if (ret 0) continue; if (FD_ISSET(listenSock, readSet)) { // 有新连接accept 后加入 g_clients } // 遍历 g_clientsFD_ISSET 为真的就 recv 处理 }参数说明select第一个参数在 Windows 上被忽略但习惯传maxSock 1timeval设 1 秒是为了定期检查g_running否则select会一直阻塞。逻辑说明select返回后可读的 socket 才去recv不会阻塞。缺点是每次都要重建fd_set连接数多时遍历开销大Windows 下select的FD_SETSIZE默认 64要支持更多连接得改宏或换模型。实测边界方面一连接一线程在普通开发机上跑到 500 到 800 连接时开始出现明显延迟线程创建和切换是瓶颈select单线程在 1000 连接以内响应稳定但 CPU 占用随连接数线性上升再往上就该考虑 IOCP。选哪个不是看哪个高级而是看你的连接规模和团队维护成本。我自己的习惯是先写一连接一线程把协议和业务跑通确认逻辑没问题后如果连接数确实上不去再针对性换模型不要一上来就上 IOCP 把自己绕进去。协议层的长度前缀和RecvAll是无论哪种模型都要写对的这部分翻车了换什么模型都救不回来。希望帮到你。本文还有配套的精品资源点击获取
返回列表