☰
Windows Socket编程实战:手写TCP客户端服务端通信与多线程改造
2026/10/20 15:21:38 网站建设 项目流程

1. 从一次调试事故说起:为什么还要手写Socket通信

很多人第一次接触网络编程,都是从"客户端发一句话,服务端回一句话"这个场景开始的。看起来简单得不能再简单,但我在实际带人做项目时发现,十个初学者里有七八个会在第一次跑通之前卡上大半天。卡住的地方往往不是逻辑本身,而是一些文档里不会写、教程里也一笔带过的细节:端口被占用、防火墙拦截、recv返回 0 到底意味着什么、为什么服务端只能处理一个连接。

这篇文章要聊的,就是在 Windows 环境下用 Socket 编程实现客户端和服务端互发消息这件事。它属于网络编程最基础的入门场景,适合刚接触 TCP/IP 通信的开发者、需要给上位机或工具做本地通信模块的工程师,以及想搞明白"消息到底是怎么从一台机器跑到另一台机器"的读者。我会把 Winsock 的初始化、TCP 连接的建立、消息的收发循环、常见报错的排查链路都拆开讲清楚,并且给出可以直接编译运行的完整代码。

需要先说明一点:Socket 编程的"简单"是假象。它的 API 就那么几个,但每个 API 背后都牵扯到操作系统网络栈的行为。你写的代码能不能稳定跑,取决于你对这些行为的理解程度。所以这篇文章不会只给你一段能跑的代码就完事,而是会把"为什么这么写"讲透。

2. Winsock 的启动与清理:被最多人忽略的两行代码

2.1 WSAStartup 到底在做什么

在 Windows 上写 Socket 程序,和 Linux 下最大的区别就是:你必须先调用WSAStartup,程序结束时必须调用WSACleanup。很多从 Linux 转过来的开发者第一次在 Windows 上编译,会发现socket()直接返回INVALID_SOCKET,错误码是WSANOTINITIALISED,原因就是漏了这一步。

WSAStartup的作用是加载 Winsock 动态链接库,并协商使用的版本。Windows 的 Socket 实现是以 DLL 形式提供的,进程在调用任何 Socket 函数之前,必须先把这个库加载进来。你可以把它理解成"开机自检"——不按这个按钮,后面的所有操作都不被允许。

WSADATA wsaData; int ret = WSAStartup(MAKEWORD(2, 2), &wsaData); if (ret != 0) { printf("WSAStartup failed: %d\n", ret); return 1; }

这里的MAKEWORD(2, 2)表示请求 2.2 版本的 Winsock。为什么用 2.2 而不是 1.1?因为 2.2 支持更多现代特性,比如getaddrinfo、重叠 I/O 等,而且从 Windows XP 之后系统默认就带 2.2,兼容性没有问题。请求的版本如果系统不支持,WSAStartup会返回错误,所以这个返回值一定要检查。

2.2 WSACleanup 不是可有可无的摆设

程序退出前调用WSACleanup,作用是释放 Winsock 库占用的资源,并让系统递减内部引用计数。短命的小程序不调用它,进程结束时操作系统也会回收,看起来"没影响"。但如果你的程序是长期运行的服务,或者在一个进程里反复初始化,不清理就会造成资源泄漏。

我个人的习惯是:把WSAStartup和WSACleanup成对写在main函数的首尾,中间无论怎么跳转,都用统一的出口去清理。这样最不容易漏。

提示:WSAStartup和WSACleanup必须成对出现,且WSACleanup要在所有 Socket 关闭之后调用。顺序反了可能导致未定义行为。

2.3 版本协商失败时的排查思路

如果WSAStartup返回非 0,常见原因有两个:一是请求的版本号写错了,比如写成了MAKEWORD(3, 0);二是系统网络组件异常。排查时先把版本降到MAKEWORD(2, 2)试试,如果还不行,检查一下系统的网络相关服务是否正常。绝大多数情况下,问题都出在版本号参数上。

3. 服务端的三步走:socket、bind、listen

3.1 创建监听套接字

服务端的第一步是创建一个套接字。TCP 通信对应的参数是AF_INET(IPv4 地址族)、SOCK_STREAM(面向连接的流式套接字)、IPPROTO_TCP(TCP 协议)。

SOCKET listenSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (listenSock == INVALID_SOCKET) { printf("socket failed: %d\n", WSAGetLastError()); WSACleanup(); return 1; }

这里返回的是SOCKET类型,在 Windows 上它其实是一个无符号整数句柄。判断失败要用INVALID_SOCKET,而不是 -1,这是 Windows 和 Linux 的一个明显差异,写惯了 Linux 代码的人很容易在这里栽跟头。

3.2 绑定地址与端口

bind的作用是把套接字和一个本地地址、端口关联起来。服务端必须绑定,客户端通常不需要(系统会自动分配临时端口)。

sockaddr_in serverAddr; serverAddr.sin_family = AF_INET; serverAddr.sin_addr.s_addr = INADDR_ANY; serverAddr.sin_port = htons(8888); if (bind(listenSock, (sockaddr*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR) { printf("bind failed: %d\n", WSAGetLastError()); closesocket(listenSock); WSACleanup(); return 1; }

几个关键点值得展开说。INADDR_ANY表示绑定到本机所有网卡地址,这样无论客户端从哪个网卡连过来都能收到。如果你只想让本机访问,可以绑定inet_addr("127.0.0.1")。htons是把主机字节序转成网络字节序,因为网络传输统一用大端序,而 x86 机器是小端序,不转换的话端口号就乱了。

端口选择上,1024 以下是系统保留端口,普通程序不要用。建议选 8000 以上的端口,比如 8888、9000 这类。但要注意,有些端口可能被其他软件占用,绑定失败时错误码是WSAEADDRINUSE,换个端口即可。

3.3 进入监听状态

if (listen(listenSock, SOMAXCONN) == SOCKET_ERROR) { printf("listen failed: %d\n", WSAGetLastError()); closesocket(listenSock); WSACleanup(); return 1; }

listen的第二个参数是等待连接队列的最大长度。SOMAXCONN是系统允许的最大值,直接用它就够了。这个队列的意义是:当多个客户端同时发起连接,而服务端还没来得及accept时,这些连接会先排在这里等待。队列满了之后,新的连接会被拒绝或忽略。

注意:listen只是把套接字变成被动监听状态,它本身不会阻塞,也不会接受连接。真正接受连接的是accept。

4. accept 与消息收发循环:单连接版本

4.1 accept 返回的是什么

accept会从等待队列里取出一个已完成三次握手的连接,返回一个全新的套接字,专门用于和这个客户端通信。

sockaddr_in clientAddr; int clientAddrLen = sizeof(clientAddr); SOCKET clientSock = accept(listenSock, (sockaddr*)&clientAddr, &clientAddrLen); if (clientSock == INVALID_SOCKET) { printf("accept failed: %d\n", WSAGetLastError()); closesocket(listenSock); WSACleanup(); return 1; }

这里有个非常重要的概念:listenSock和clientSock是两个不同的套接字。前者继续负责监听新的连接,后者负责和当前这个客户端收发数据。很多初学者会混淆这两个句柄,导致后续收发数据用错了套接字。

accept是阻塞的。如果当前没有客户端连接,程序会一直停在这一行。这就是为什么单线程的服务端一次只能处理一个客户端——它被accept或者后面的recv卡住了。

4.2 收发消息的核心逻辑

TCP 是字节流协议,send和recv操作的都是字节,没有"消息边界"的概念。这一点极其重要,后面会专门讲。

char buffer[1024]; while (true) { int bytesReceived = recv(clientSock, buffer, sizeof(buffer) - 1, 0); if (bytesReceived > 0) { buffer[bytesReceived] = '\0'; printf("收到客户端消息: %s\n", buffer); const char* reply = "服务端已收到你的消息"; send(clientSock, reply, (int)strlen(reply), 0); } else if (bytesReceived == 0) { printf("客户端已断开连接\n"); break; } else { printf("recv failed: %d\n", WSAGetLastError()); break; } }

recv的返回值有三种情况,必须分别处理:大于 0 表示实际收到的字节数;等于 0 表示对端正常关闭了连接(发了 FIN);小于 0 表示出错。把recv返回 0 当成错误处理,或者当成"没收到数据"继续循环,都是常见的错误。

4.3 为什么 recv 返回的字节数可能小于请求的字节数

这是 TCP 流式特性的直接体现。你请求读 1024 字节,recv可能只返回 100 字节,因为对端可能只发了 100 字节,或者数据还在网络传输途中。所以正确的做法是:把recv放在循环里,直到收满你期望的长度,或者遇到连接关闭。

对于"互发消息"这种简单场景,如果每条消息都很短(比如几十字节),通常一次recv就能收全。但这是运气,不是保证。要写出健壮的代码,必须处理"半包"问题。

5. 客户端的连接与交互

5.1 connect 的阻塞特性

客户端的流程是:创建套接字、连接服务端、收发数据。

SOCKET clientSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); sockaddr_in serverAddr; serverAddr.sin_family = AF_INET; serverAddr.sin_port = htons(8888); inet_pton(AF_INET, "127.0.0.1", &serverAddr.sin_addr); if (connect(clientSock, (sockaddr*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR) { printf("connect failed: %d\n", WSAGetLastError()); closesocket(clientSock); WSACleanup(); return 1; }

connect会触发 TCP 三次握手,默认是阻塞的,直到连接建立或失败才返回。连接本机的服务端几乎瞬间完成,但如果连的是远程地址且对方没开端口,会等待一段时间后返回超时错误。

inet_pton是把点分十进制的 IP 字符串转成二进制形式,比老旧的inet_addr更安全,因为它能明确区分成功和失败。连接本机测试时用127.0.0.1,连接局域网内其他机器时换成对方的实际 IP。

5.2 客户端发送与接收

const char* msg = "你好,我是客户端"; send(clientSock, msg, (int)strlen(msg), 0); char buffer[1024]; int bytesReceived = recv(clientSock, buffer, sizeof(buffer) - 1, 0); if (bytesReceived > 0) { buffer[bytesReceived] = '\0'; printf("收到服务端回复: %s\n", buffer); }

客户端的收发逻辑和服务端是对称的。send返回实际发送的字节数,正常情况下等于你请求发送的长度,但也可能小于(发送缓冲区满时)。对于短消息,一般不会出现这种情况。

5.3 连接失败的错误码解读

connect失败时,WSAGetLastError返回的错误码能直接告诉你原因。最常见的几个:

错误码含义常见原因
WSAECONNREFUSED (10061)连接被拒绝服务端没启动,或端口不对
WSAETIMEDOUT (10060)连接超时网络不通,或对方防火墙拦截
WSAEHOSTUNREACH (10065)主机不可达IP 地址错误,或路由问题
WSAEADDRNOTAVAIL (10049)地址不可用绑定了本机不存在的 IP

10061 是初学者遇到最多的。看到这个错误,第一反应应该是"服务端到底起来没有",而不是去改客户端代码。

6. 让服务端支持多个客户端:多线程改造

6.1 单连接服务端的局限

前面写的服务端,accept一个连接后就进入收发循环,直到这个客户端断开才会回到accept。这意味着第二个客户端连上来时,虽然三次握手能完成(因为还在等待队列里),但服务端根本不会去处理它,客户端会感觉"连上了但没反应"。

要支持多客户端,最直接的办法是每接受一个连接就开一个线程去处理。

6.2 线程函数的写法

DWORD WINAPI ClientThread(LPVOID lpParam) { SOCKET clientSock = (SOCKET)lpParam; char buffer[1024]; while (true) { int bytesReceived = recv(clientSock, buffer, sizeof(buffer) - 1, 0); if (bytesReceived > 0) { buffer[bytesReceived] = '\0'; printf("收到消息: %s\n", buffer); send(clientSock, "已收到", 6, 0); } else if (bytesReceived == 0) { printf("客户端断开\n"); break; } else { printf("recv error: %d\n", WSAGetLastError()); break; } } closesocket(clientSock); return 0; }

主线程里这样调用:

while (true) { SOCKET clientSock = accept(listenSock, NULL, NULL); if (clientSock == INVALID_SOCKET) break; CreateThread(NULL, 0, ClientThread, (LPVOID)clientSock, 0, NULL); }

把clientSock通过参数传给线程,每个线程独立处理一个客户端。这样主线程可以立刻回到accept等待下一个连接。

6.3 线程传参的坑

这里有一个非常隐蔽的坑:如果你把clientSock的地址(&clientSock)传给线程,而不是值本身,那么当主线程进入下一次循环、clientSock被覆盖时,线程里读到的就是错误的套接字。正确做法是传值,或者为每个连接动态分配一块内存来存套接字。

另外,CreateThread创建的线程默认不会被自动回收,如果客户端频繁连接断开,线程句柄会累积。生产环境应该用_beginthreadex或者线程池,并在适当的时候CloseHandle。对于学习场景,CreateThread够用,但要知道它的局限。

7. 那些让程序"看起来能跑但实际有问题"的细节

7.1 消息边界问题:为什么你的消息会粘在一起

TCP 不保留消息边界。客户端连续send两次"AAA"和"BBB",服务端可能一次recv收到"AAABBB",也可能分三次收到"A"、"AB"、"BB"。这叫粘包和拆包。

对于"互发消息"的简单场景,如果每次都是"发一条、等回复、再发下一条"的严格一问一答模式,通常不会出问题。但一旦改成连续发送,就必须自己定义消息边界。常见方案有两种:

  • 定长消息:每条消息固定长度,收满即认为是一条完整消息。
  • 长度前缀:消息头部用固定字节数表示正文长度,先读头部再读正文。
// 长度前缀示例:先发4字节长度,再发正文 int len = (int)strlen(msg); send(clientSock, (char*)&len, 4, 0); send(clientSock, msg, len, 0);

接收端先收 4 字节得到长度,再循环收满对应字节数。这是最通用的做法,建议在稍微正式一点的项目里都用它。

7.2 端口被占用怎么办

绑定失败返回WSAEADDRINUSE,说明端口已经被别的程序占了。排查方法是在命令行执行netstat -ano | findstr 8888,找到占用进程的 PID,再用任务管理器看是哪个程序。如果确认是自己上次运行的程序没退干净,等一会儿或者换个端口。

还有一个相关选项SO_REUSEADDR,它允许绑定处于 TIME_WAIT 状态的地址。服务端频繁重启时加上它会更方便:

int opt = 1; setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR, (char*)&opt, sizeof(opt));

注意:SO_REUSEADDR在 Windows 上的行为和 Linux 不完全一样,Windows 上它主要解决 TIME_WAIT 问题,不要指望它能让你绑定一个正在被监听的端口。

7.3 防火墙拦截的典型表现

本机测试(127.0.0.1)通常不受防火墙影响。但如果是局域网内两台机器通信,Windows 防火墙可能会拦截入站连接,表现为客户端connect超时或直接被拒绝。解决方法是给服务端程序放行,或者在测试时临时关闭防火墙(仅限内网测试环境)。

我踩过的一个坑是:服务端程序第一次运行时,系统会弹出防火墙提示框,如果当时点了"取消",之后就不会再弹,连接一直失败,但错误码看起来像是网络问题。遇到莫名其妙的连接失败,先检查防火墙规则里有没有这个程序的条目。

7.4 字符编码:中文消息乱码的根源

send和recv传输的是原始字节,不关心编码。如果你的源码文件是 GBK 编码,字符串字面量就是 GBK 字节;如果接收端按 UTF-8 解释,就会乱码。反过来也一样。

解决办法是统一编码。要么两端都用同一种编码(比如都存成 UTF-8 文件),要么在传输前显式转换。对于控制台程序,Windows 默认代码页是 GBK,用printf输出 UTF-8 字符串会乱码,需要先SetConsoleOutputCP(65001)把控制台切到 UTF-8。

8. 完整可运行代码与验证步骤

8.1 服务端完整代码

#include <winsock2.h> #include <ws2tcpip.h> #include <stdio.h> #pragma comment(lib, "ws2_32.lib") DWORD WINAPI ClientThread(LPVOID lpParam) { SOCKET clientSock = (SOCKET)lpParam; char buffer[1024]; while (true) { int n = recv(clientSock, buffer, sizeof(buffer) - 1, 0); if (n > 0) { buffer[n] = '\0'; printf("[服务端] 收到: %s\n", buffer); const char* reply = "服务端已收到"; send(clientSock, reply, (int)strlen(reply), 0); } else if (n == 0) { printf("[服务端] 客户端断开\n"); break; } else { printf("[服务端] recv 错误: %d\n", WSAGetLastError()); break; } } closesocket(clientSock); return 0; } int main() { WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) { printf("WSAStartup 失败\n"); return 1; } SOCKET listenSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (listenSock == INVALID_SOCKET) { printf("socket 失败: %d\n", WSAGetLastError()); WSACleanup(); return 1; } int opt = 1; setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR, (char*)&opt, sizeof(opt)); sockaddr_in serverAddr; serverAddr.sin_family = AF_INET; serverAddr.sin_addr.s_addr = INADDR_ANY; serverAddr.sin_port = htons(8888); if (bind(listenSock, (sockaddr*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR) { printf("bind 失败: %d\n", WSAGetLastError()); closesocket(listenSock); WSACleanup(); return 1; } if (listen(listenSock, SOMAXCONN) == SOCKET_ERROR) { printf("listen 失败: %d\n", WSAGetLastError()); closesocket(listenSock); WSACleanup(); return 1; } printf("[服务端] 监听 8888 端口...\n"); while (true) { SOCKET clientSock = accept(listenSock, NULL, NULL); if (clientSock == INVALID_SOCKET) { printf("accept 失败: %d\n", WSAGetLastError()); break; } printf("[服务端] 新客户端接入\n"); CreateThread(NULL, 0, ClientThread, (LPVOID)clientSock, 0, NULL); } closesocket(listenSock); WSACleanup(); return 0; }

8.2 客户端完整代码

#include <winsock2.h> #include <ws2tcpip.h> #include <stdio.h> #pragma comment(lib, "ws2_32.lib") int main() { WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) { printf("WSAStartup 失败\n"); return 1; } SOCKET clientSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (clientSock == INVALID_SOCKET) { printf("socket 失败: %d\n", WSAGetLastError()); WSACleanup(); return 1; } sockaddr_in serverAddr; serverAddr.sin_family = AF_INET; serverAddr.sin_port = htons(8888); inet_pton(AF_INET, "127.0.0.1", &serverAddr.sin_addr); if (connect(clientSock, (sockaddr*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR) { printf("connect 失败: %d\n", WSAGetLastError()); closesocket(clientSock); WSACleanup(); return 1; } printf("[客户端] 已连接服务端\n"); char input[1024]; char buffer[1024]; while (true) { printf("请输入消息(输入 quit 退出): "); if (fgets(input, sizeof(input), stdin) == NULL) break; input[strcspn(input, "\n")] = '\0'; if (strcmp(input, "quit") == 0) break; send(clientSock, input, (int)strlen(input), 0); int n = recv(clientSock, buffer, sizeof(buffer) - 1, 0); if (n > 0) { buffer[n] = '\0'; printf("[客户端] 服务端回复: %s\n", buffer); } else { printf("[客户端] 连接已关闭\n"); break; } } closesocket(clientSock); WSACleanup(); return 0; }

8.3 编译与验证

用 MSVC 编译时,ws2_32.lib通过#pragma comment自动链接,直接cl server.cpp和cl client.cpp即可。用 MinGW 的话需要手动加-lws2_32。

验证步骤:

  1. 先启动服务端,看到"监听 8888 端口"。
  2. 再启动客户端,看到"已连接服务端"。
  3. 客户端输入任意文字,服务端打印收到的内容并回复,客户端打印回复。
  4. 开第二个客户端,验证多客户端同时在线。
  5. 客户端输入 quit,服务端打印"客户端断开"。

如果第 2 步失败并报 10061,检查服务端是否真的在运行;如果报 10060,检查 IP 地址和防火墙。

9. 从能跑到好用:几个值得继续深挖的方向

把上面的代码跑通,只是网络编程的起点。真正做项目时,还有几个方向值得花时间:

阻塞改非阻塞或 I/O 多路复用。多线程模型在连接数上千时会因为线程切换开销而变慢。Windows 上可以用select、WSAAsyncSelect、WSAEventSelect,或者直接上 IOCP(完成端口)。IOCP 是 Windows 高性能网络服务的标准方案,但学习曲线陡峭,建议先把阻塞模型吃透再上。

心跳与超时。TCP 连接建立后,如果双方都不发数据,连接会一直保持,即使中间的网络设备已经断了。生产环境需要定期发心跳包,并设置SO_RCVTIMEO让recv超时返回,及时发现死连接。

优雅关闭。直接closesocket可能导致对端收到 RST 而不是 FIN,数据可能丢失。正确做法是先shutdown(sock, SD_SEND)发送 FIN,等对方也关闭后再closesocket。

错误处理要统一。每个 Socket 调用都要检查返回值,把WSAGetLastError的结果记录下来。我习惯封装一个PrintError函数,把错误码和对应的描述一起打出来,排查问题时能省很多时间。

最后分享一个我自己的调试习惯:遇到连接问题时,先在服务端机器上用netstat -ano | findstr 端口号确认监听状态,再从客户端telnet IP 端口测试连通性。这两步能快速区分是"服务端没起来"还是"网络不通",比盯着代码看效率高得多。Socket 编程的坑大多不在代码逻辑,而在这些环境层面的细节上,多动手验证永远比空想靠谱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询