Windows Socket编程实战:TCP客户端与服务端互发消息
1. 为什么要在Windows上折腾Socket编程很多人第一次接触网络编程都是从客户端发一句话服务端回一句话这个场景开始的。听起来简单得不行但真到动手的时候各种问题就冒出来了头文件找不到、链接报错、中文乱码、连接被拒绝、程序一跑就崩。我自己当年在Windows上写第一个Socket程序的时候光是一个WSAStartup就卡了大半天网上搜到的代码要么是Linux的要么是残缺不全的能直接跑起来的少之又少。这篇文章要聊的就是在Windows平台下用Socket编程实现客户端和服务端互发消息这件事。说白了就是写两个程序一个服务端负责监听端口、等待连接、接收消息并回复一个客户端负责主动连接服务端、发送消息、接收回复。这个场景是所有网络通信的Hello World聊天工具、远程控制、游戏联机、物联网设备通信底层都是这套东西在支撑。适合谁来读如果你已经学过C或C基础语法知道什么是函数、指针、结构体但从来没写过网络程序那这篇内容就是给你准备的。如果你之前只在Linux下写过Socket想搞清楚Windows下有什么不一样也能从这里找到答案。我会把每一步为什么这么做讲清楚把踩过的坑都摊开来说代码可以直接复制去编译运行。Windows下的Socket编程和Linux最大的区别在于Windows有一套自己的Winsock库需要额外初始化链接的时候要指定库文件关闭的时候还要做清理。这些细节看起来琐碎但少一步程序就跑不起来。下面我从整体设计开始一步步拆解。2. 整体设计思路与方案选型2.1 为什么选TCP而不是UDP做客户端和服务端互发消息第一个要做的决定就是用TCP还是UDP。这两种协议的区别用生活化的比喻来说TCP像是打电话先拨号、对方接通、确认双方都能听到然后才开始说话说的每句话都有确认丢了会重说UDP像是发短信直接扔出去对方收没收到、顺序对不对发送方不管。对于互发消息这个需求我们要的是可靠传输——客户端发的消息服务端必须收到服务端的回复客户端也必须收到而且顺序不能乱。所以TCP是自然选择。UDP更适合实时性要求高、能容忍少量丢包的场景比如视频直播、语音通话。我们这个场景不需要考虑这些直接上TCP。选TCP还有一个好处它是面向连接的服务端可以同时管理多个客户端连接每个连接用一个独立的Socket描述符表示。后面如果想扩展成多人聊天室架构上不用大改。2.2 阻塞模式还是非阻塞模式第二个决定是阻塞还是非阻塞。阻塞模式下调用recv如果没有数据到达程序就停在那里等直到有数据或者连接断开。非阻塞模式下recv会立刻返回没数据就返回一个错误码程序继续往下跑。对于入门级的互发消息程序我强烈建议先用阻塞模式。原因很简单逻辑直观代码好写不用处理select、epoll、IOCP这些复杂的事件模型。客户端和服务端各自开一个线程专门收消息主线程负责发消息就能实现随时能发、随时能收的效果。非阻塞和IOCP是后面做高并发服务器才需要关心的一上来就搞那些容易把自己绕晕。2.3 线程模型怎么安排既然要互发消息就意味着发送和接收要能同时进行。如果只有一个线程程序在等用户输入的时候就没法收消息在收消息的时候就没法发消息。所以至少需要两个线程一个负责接收一个负责发送。服务端的线程模型稍微复杂一点。服务端要先监听、再接受连接连接建立后针对每个客户端连接又需要独立的接收线程。为了简单我们这个版本先支持一个服务端对应一个客户端的场景等这个跑通了再扩展到多客户端就是加一个循环和线程池的事。具体安排是这样的服务端主线程创建监听Socket、绑定端口、监听、接受连接服务端接收线程循环调用recv接收客户端消息并打印服务端发送线程循环读取控制台输入并调用send发给客户端客户端主线程创建Socket、连接服务端客户端接收线程循环接收服务端消息并打印客户端发送线程循环读取控制台输入并发送发送和接收分开线程互不干扰这是最朴素也最有效的做法。2.4 字符编码的坑要提前想清楚Windows控制台默认用的是GBK编码而网络传输我们通常希望用UTF-8。如果客户端用UTF-8发服务端按GBK解中文就会变成乱码。这个问题在英文环境下不会暴露一旦消息里有中文就原形毕露。我的处理方式是发送前统一转成UTF-8接收后统一按UTF-8转回本地编码。Windows下可以用WideCharToMultiByte和MultiByteToWideChar这两个API做转换。如果嫌麻烦也可以全程用英文测试但实际项目里中文是躲不掉的早点把编码问题解决掉后面省心。3. 核心细节解析与实操要点3.1 Winsock库的初始化和清理Windows下用Socket第一件事是加载Winsock库。Linux下直接#include sys/socket.h就能用Windows不行必须先调用WSAStartup。WSADATA wsaData; int ret WSAStartup(MAKEWORD(2, 2), wsaData); if (ret ! 0) { printf(WSAStartup failed: %d\n, ret); return -1; }MAKEWORD(2, 2)表示请求Winsock 2.2版本。为什么是2.2因为2.2是Windows XP之后的标准版本支持IPv6和更多特性现在写程序基本都用这个。WSAStartup成功返回0失败返回错误码。程序结束前必须调用WSACleanup()释放资源。这个调用不是可选的虽然很多程序不调用也能跑但在某些环境下不清理会导致资源泄漏下次启动可能失败。注意WSAStartup和WSACleanup必须成对出现且WSACleanup要在所有Socket操作完成之后调用。3.2 头文件和链接库的正确写法Windows下Socket编程需要包含两个头文件#include winsock2.h #include ws2tcpip.hwinsock2.h是核心ws2tcpip.h提供了inet_pton、getaddrinfo这些现代API。顺序很重要winsock2.h必须在windows.h之前包含否则会有宏定义冲突。如果你确实需要windows.h可以定义WIN32_LEAN_AND_MEAN来减少冲突。链接的时候要指定ws2_32.lib。在Visual Studio里可以通过#pragma comment(lib, ws2_32.lib)自动链接也可以在项目属性里手动添加。用MinGW的话编译命令要加-lws2_32。#pragma comment(lib, ws2_32.lib)这行代码放在文件开头编译器会自动把库链接进来省得去项目设置里翻。3.3 Socket的创建、绑定、监听、接受服务端的流程是创建Socket、绑定地址和端口、开始监听、接受连接。每一步都有讲究。创建Socket用socket()函数SOCKET listenSock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (listenSock INVALID_SOCKET) { printf(socket failed: %d\n, WSAGetLastError()); WSACleanup(); return -1; }AF_INET表示IPv4SOCK_STREAM表示TCPIPPROTO_TCP明确指定TCP协议。返回值是SOCKET类型失败返回INVALID_SOCKET。绑定地址用sockaddr_in结构体sockaddr_in serverAddr; serverAddr.sin_family AF_INET; serverAddr.sin_addr.s_addr INADDR_ANY; serverAddr.sin_port htons(8888); int ret bind(listenSock, (sockaddr*)serverAddr, sizeof(serverAddr));INADDR_ANY表示监听本机所有网卡htons(8888)把端口号从主机字节序转成网络字节序。端口选8888是因为大于1024不需要管理员权限也不容易和系统服务冲突。监听用listen()ret listen(listenSock, SOMAXCONN);SOMAXCONN是系统允许的最大等待队列长度直接用这个值就行。接受连接用accept()SOCKET clientSock accept(listenSock, nullptr, nullptr);accept会阻塞直到有客户端连接进来。返回一个新的Socket专门用于和这个客户端通信。原来的listenSock继续监听新的连接。3.4 客户端连接服务端客户端比服务端简单创建Socket之后直接连接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); int ret connect(clientSock, (sockaddr*)serverAddr, sizeof(serverAddr)); if (ret SOCKET_ERROR) { printf(connect failed: %d\n, WSAGetLastError()); closesocket(clientSock); WSACleanup(); return -1; }inet_pton把点分十进制的IP字符串转成网络字节序的二进制形式。用127.0.0.1是本机测试如果服务端在另一台机器上换成对应的IP即可。3.5 发送和接收数据发送用send()接收用recv()int sendRet send(sock, buffer, len, 0); int recvRet recv(sock, buffer, bufSize, 0);send返回实际发送的字节数可能小于请求发送的长度所以严格来说要循环发送直到全部发完。对于小消息几KB以内通常一次就发完了但养成检查返回值的习惯没坏处。recv返回实际接收的字节数。返回0表示对方关闭了连接返回SOCKET_ERROR表示出错。这两个情况要区分处理。提示recv不保证一次收到完整消息。TCP是字节流协议没有消息边界。如果消息长度不固定需要在应用层自己定义协议比如先发4字节的长度再发内容。我们这个场景消息短一次recv基本能收全但要知道这个限制。3.6 多线程的同步问题发送线程和接收线程都会访问Socket但一个只读一个只写本身不冲突。真正需要注意的是控制台输出。两个线程同时printf输出会交错看起来乱七八糟。解决办法是加一个临界区或者互斥锁CRITICAL_SECTION cs; InitializeCriticalSection(cs); // 输出前 EnterCriticalSection(cs); printf(recv: %s\n, buffer); LeaveCriticalSection(cs);Windows下用CRITICAL_SECTION比Mutex轻量适合这种短时间的锁。记得程序结束时DeleteCriticalSection。4. 完整实操过程与核心代码实现4.1 服务端完整代码下面这份代码可以直接在Visual Studio或者MinGW下编译运行。我加了详细注释关键地方都标出来了。#include winsock2.h #include ws2tcpip.h #include stdio.h #include process.h #pragma comment(lib, ws2_32.lib) #define PORT 8888 #define BUF_SIZE 1024 SOCKET g_clientSock INVALID_SOCKET; CRITICAL_SECTION g_cs; bool g_running true; // 接收线程 unsigned int __stdcall RecvThread(void* param) { char buffer[BUF_SIZE]; while (g_running) { memset(buffer, 0, BUF_SIZE); int ret recv(g_clientSock, buffer, BUF_SIZE - 1, 0); if (ret 0) { EnterCriticalSection(g_cs); printf([client]: %s\n, buffer); LeaveCriticalSection(g_cs); } else if (ret 0) { printf(client disconnected\n); g_running false; break; } else { printf(recv error: %d\n, WSAGetLastError()); g_running false; break; } } return 0; } // 发送线程 unsigned int __stdcall SendThread(void* param) { char buffer[BUF_SIZE]; while (g_running) { memset(buffer, 0, BUF_SIZE); if (fgets(buffer, BUF_SIZE, stdin) nullptr) break; // 去掉换行符 size_t len strlen(buffer); if (len 0 buffer[len - 1] \n) { buffer[len - 1] \0; len--; } if (len 0) continue; if (strcmp(buffer, quit) 0) { g_running false; break; } int ret send(g_clientSock, buffer, (int)len, 0); if (ret SOCKET_ERROR) { printf(send error: %d\n, WSAGetLastError()); g_running false; break; } } return 0; } int main() { WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), wsaData) ! 0) { printf(WSAStartup failed\n); return -1; } InitializeCriticalSection(g_cs); SOCKET listenSock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (listenSock INVALID_SOCKET) { printf(socket failed: %d\n, WSAGetLastError()); WSACleanup(); return -1; } sockaddr_in serverAddr; serverAddr.sin_family AF_INET; serverAddr.sin_addr.s_addr INADDR_ANY; serverAddr.sin_port htons(PORT); if (bind(listenSock, (sockaddr*)serverAddr, sizeof(serverAddr)) SOCKET_ERROR) { printf(bind failed: %d\n, WSAGetLastError()); closesocket(listenSock); WSACleanup(); return -1; } if (listen(listenSock, SOMAXCONN) SOCKET_ERROR) { printf(listen failed: %d\n, WSAGetLastError()); closesocket(listenSock); WSACleanup(); return -1; } printf(server listening on port %d...\n, PORT); g_clientSock accept(listenSock, nullptr, nullptr); if (g_clientSock INVALID_SOCKET) { printf(accept failed: %d\n, WSAGetLastError()); closesocket(listenSock); WSACleanup(); return -1; } printf(client connected\n); HANDLE hRecv (HANDLE)_beginthreadex(nullptr, 0, RecvThread, nullptr, 0, nullptr); HANDLE hSend (HANDLE)_beginthreadex(nullptr, 0, SendThread, nullptr, 0, nullptr); WaitForSingleObject(hRecv, INFINITE); WaitForSingleObject(hSend, INFINITE); CloseHandle(hRecv); CloseHandle(hSend); closesocket(g_clientSock); closesocket(listenSock); DeleteCriticalSection(g_cs); WSACleanup(); printf(server exit\n); return 0; }4.2 客户端完整代码客户端结构类似只是把监听换成连接。#include winsock2.h #include ws2tcpip.h #include stdio.h #include process.h #pragma comment(lib, ws2_32.lib) #define PORT 8888 #define BUF_SIZE 1024 #define SERVER_IP 127.0.0.1 SOCKET g_sock INVALID_SOCKET; CRITICAL_SECTION g_cs; bool g_running true; unsigned int __stdcall RecvThread(void* param) { char buffer[BUF_SIZE]; while (g_running) { memset(buffer, 0, BUF_SIZE); int ret recv(g_sock, buffer, BUF_SIZE - 1, 0); if (ret 0) { EnterCriticalSection(g_cs); printf([server]: %s\n, buffer); LeaveCriticalSection(g_cs); } else if (ret 0) { printf(server disconnected\n); g_running false; break; } else { printf(recv error: %d\n, WSAGetLastError()); g_running false; break; } } return 0; } unsigned int __stdcall SendThread(void* param) { char buffer[BUF_SIZE]; while (g_running) { memset(buffer, 0, BUF_SIZE); if (fgets(buffer, BUF_SIZE, stdin) nullptr) break; size_t len strlen(buffer); if (len 0 buffer[len - 1] \n) { buffer[len - 1] \0; len--; } if (len 0) continue; if (strcmp(buffer, quit) 0) { g_running false; break; } int ret send(g_sock, buffer, (int)len, 0); if (ret SOCKET_ERROR) { printf(send error: %d\n, WSAGetLastError()); g_running false; break; } } return 0; } int main() { WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), wsaData) ! 0) { printf(WSAStartup failed\n); return -1; } InitializeCriticalSection(g_cs); g_sock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (g_sock INVALID_SOCKET) { printf(socket failed: %d\n, WSAGetLastError()); WSACleanup(); return -1; } sockaddr_in serverAddr; serverAddr.sin_family AF_INET; serverAddr.sin_port htons(PORT); inet_pton(AF_INET, SERVER_IP, serverAddr.sin_addr); if (connect(g_sock, (sockaddr*)serverAddr, sizeof(serverAddr)) SOCKET_ERROR) { printf(connect failed: %d\n, WSAGetLastError()); closesocket(g_sock); WSACleanup(); return -1; } printf(connected to server %s:%d\n, SERVER_IP, PORT); HANDLE hRecv (HANDLE)_beginthreadex(nullptr, 0, RecvThread, nullptr, 0, nullptr); HANDLE hSend (HANDLE)_beginthreadex(nullptr, 0, SendThread, nullptr, 0, nullptr); WaitForSingleObject(hRecv, INFINITE); WaitForSingleObject(hSend, INFINITE); CloseHandle(hRecv); CloseHandle(hSend); closesocket(g_sock); DeleteCriticalSection(g_cs); WSACleanup(); printf(client exit\n); return 0; }4.3 编译和运行步骤用Visual Studio的话新建一个空项目把服务端代码和客户端代码分别放到两个项目里或者用两个解决方案。编译之前确认项目属性里链接了ws2_32.lib不过代码里已经有#pragma comment了一般不用手动加。用MinGW命令行编译g server.cpp -o server.exe -lws2_32 g client.cpp -o client.exe -lws2_32运行的时候先启动服务端看到server listening on port 8888...之后再启动客户端。客户端连上后两边都可以输入消息按回车发送。输入quit退出。4.4 参数选择背后的计算逻辑端口号为什么选8888其实1024到65535之间都可以。选8888是因为好记而且不在常用服务端口范围内。如果选80、443这些需要管理员权限而且可能和系统服务冲突。缓冲区大小为什么是1024对于文本消息1024字节足够放下一段话。如果消息可能很长可以调到4096或8192。但要注意recv一次不一定能收完所以缓冲区大小和消息长度是两回事。缓冲区只是单次接收的上限。SOMAXCONN的值在Windows下通常是0x7fffffff相当于不限制。实际能同时处理的连接数受系统资源和代码逻辑限制。我们这个版本一次只处理一个客户端所以这个参数影响不大。5. 常见问题与排查技巧实录5.1 编译链接报错怎么办最常见的错误是undefined reference toWSAStartup8或者无法解析的外部符号__imp_WSAStartup。这说明没有链接ws2_32.lib。解决办法是在代码里加#pragma comment(lib, ws2_32.lib)或者在编译命令里加-lws2_32。另一个常见错误是winsock2.h和windows.h的顺序问题。如果先包含了windows.h再包含winsock2.h会报大量重定义错误。解决办法是把winsock2.h放在最前面或者定义WIN32_LEAN_AND_MEAN。5.2 连接被拒绝怎么排查客户端调用connect返回SOCKET_ERRORWSAGetLastError()返回10061表示连接被拒绝。原因通常是服务端没启动或者端口号不对或者防火墙拦了。排查步骤先确认服务端程序在运行看到监听提示再确认客户端连的IP和端口和服务端一致最后检查Windows防火墙有没有弹窗拦截如果有允许通过即可。本机测试用127.0.0.1一般不会被防火墙拦。5.3 中文乱码怎么解决如果发送你好收到的是乱码说明编码不一致。Windows控制台默认GBK网络传输建议UTF-8。转换方法// GBK转UTF-8 int WideCharToMultiByte( CP_UTF8, 0, wideStr, -1, utf8Str, bufSize, nullptr, nullptr );实际写的时候先用MultiByteToWideChar把GBK转成宽字符再用WideCharToMultiByte转成UTF-8。接收端反过来操作。如果嫌麻烦测试阶段可以先用英文但正式项目一定要处理编码。5.4 程序退出时卡住不结束有时候按quit之后程序没退出卡在WaitForSingleObject。原因是接收线程还在阻塞在recv上没有收到数据也没有断开连接所以不会返回。解决办法是发送quit之后主动调用shutdown(sock, SD_BOTH)这会通知对方连接要关闭recv会返回0线程就能退出了。shutdown(g_sock, SD_BOTH);5.5 常见问题速查表问题现象可能原因解决办法编译报错找不到WSAStartup没链接ws2_32.lib加#pragma comment或-lws2_32头文件重定义错误winsock2.h和windows.h顺序不对winsock2.h放最前或定义WIN32_LEAN_AND_MEANconnect返回10061服务端没启动或端口不对检查服务端状态和端口配置中文显示乱码编码不一致统一用UTF-8传输本地做转换程序退出卡住recv阻塞未返回退出前调用shutdownbind返回10048端口被占用换端口或结束占用进程accept返回10038Socket无效检查socket创建是否成功提示WSAGetLastError()是排查Windows Socket问题的核心工具任何Socket API调用失败后第一时间调用它获取错误码然后对照Winsock错误码文档定位问题。5.6 几个我踩过的坑第一个坑是忘了调用WSAStartup直接创建Socket结果返回INVALID_SOCKET错误码10093查了半天才发现是没初始化。这个错误在Linux下不会出现所以从Linux转过来的容易中招。第二个坑是send之后立刻closesocket导致数据没发出去。TCP有发送缓冲区send成功只表示数据写入了缓冲区不代表对方收到了。如果马上关闭缓冲区里的数据可能被丢弃。解决办法是发送完后调用shutdown(sock, SD_SEND)然后等对方关闭或者recv返回0再closesocket。第三个坑是多线程同时操作Socket。虽然一个读一个写通常没问题但如果一个线程在recv另一个线程closesocket行为是未定义的。正确的做法是先shutdown让recv返回线程退出后再closesocket。6. 从单客户端扩展到多客户端的思路现在这个版本一次只能服务一个客户端实际用起来肯定不够。扩展成多客户端其实不难核心改动在服务端把accept放到循环里每接受一个连接就创建一组新的接收和发送线程把所有客户端Socket存到一个列表里。但这样改会带来新问题发送线程怎么知道往哪个客户端发如果要做广播需要遍历所有客户端Socket逐个发送。多线程同时访问客户端列表需要加锁。客户端断开后要从列表里移除避免向已关闭的Socket发送数据。更优雅的方案是用select或者IOCP做事件驱动单线程或少量线程就能管理大量连接。但那是另一个话题了先把阻塞模式的多线程版本跑通理解清楚Socket的基本操作再往上走会顺很多。我个人的建议是不要一上来就追求高并发、高性能。先把一个客户端能稳定通信这件事做扎实把错误处理、编码转换、优雅退出这些细节处理好这些才是实际项目里最容易出问题的地方。并发模型是架构层面的选择而基础操作的扎实程度决定了你能不能快速定位和解决问题。最后分享一个小技巧调试Socket程序的时候用netstat -ano | findstr 8888可以查看端口占用情况确认服务端有没有真正监听成功。用Wireshark抓包可以看到实际传输的数据排查编码问题和协议问题时特别有用。这些工具用熟了排查效率能提升好几倍。