简介:简易聊天系统由C++语言和Winsock网络库实现,面向正在学习计算机网络或Windows网络编程的初学者,也适用于课程设计、毕业设计起步与工程实训。资源压缩包共56个文件,大小仅1.23MB;其中cpp与h源码承担核心聊天逻辑,CMake和Ninja构建脚本支持多种编译方式,exe与pdb便于直接运行和调试,另含json、obj等构建中间文件,整体结构干净、便于查阅。当前已有44人学习下载,说明该示例对入门者有一定参考价值。项目采用客户端/服务器模型,完整覆盖创建套接字、绑定地址、监听连接、收发数据等关键流程;配套README说明和项目源码,能引导读者理解一次网络通信从建立到关闭的完整过程,并熟悉常用Socket接口的调用顺序与注意事项。代码经过测试可直接运行,小白可按示例逐步复现,进阶者可在此基础上扩展多线程、多客户端或界面功能,也可结合构建脚本改造工程结构,学习借鉴价值高。
1. 一份能跑通的 C++ winsock 简易聊天系统:它到底是什么、值不值得拆
前阵子要给内部网络加一个临时聊天入口,不想引第三方库,也不想上 WebSocket,翻出来的第一份资料就是这份 C++ winsock.zip。它不是什么分布式架构,就是一个老老实实的 Winsock 2.2 的 TCP 聊天系统:服务端 listen + accept + 广播,客户端 connect + 收发双线程,链路短到新人一天能读完全部代码。它解决的是最实际的问题——在 Windows 上从零搭一个局域网聊天室,不依赖任何第三方库,编译环境只认 winsock2.h 和 ws2_32.lib。适合刚啃完 socket 理论、想看看代码怎么落地的初学者,也适合要在 Windows 上快速出原型的一线开发。至于它到底怎么拆、坑在哪,往下看。
2. 从 socket 到聊天室:阻塞模型、select 与 socket 生命周期
聊天系统的本质是两个进程之间交换字节流,Winsock 只是把这个能力包装成了 Windows 上的一套 API。想把这个 zip 里的代码读出味道,先得把底层逻辑理顺:为什么它选择阻塞式 socket,而不是市面上更常见的 select 或 I/O 完成端口。
2.1 为什么选 winsock 而不是跨平台封装
Winsock 是 Windows 最原生的 socket 接口,头文件就两个(winsock2.h、ws2tcpip.h),链接库一个(ws2_32.lib),不引入任何第三方依赖。这份 demo 选了这条路,意味着你在任意一台装有 Windows SDK 的机器上都能直接编译,不用折腾 CMake 的第三方依赖路径,也不用担心 ASIO 版本不兼容。
对比一下其它方案:跨平台库确实好,但把编译链拉长,对一个“简易聊天系统”来说属于过度设计。你真正需要的是进程间 TCP 通信的最小闭环,Winsock 恰好把这个闭环压到最短——从 WSAStartup 到 closesocket,满打满算六个核心 API 调用。它就是“够用优先”的典型代表。如果你想在演示环境快速验证聊天交互逻辑,这是成本最低的一条路。
2.2 阻塞与非阻塞:这个 demo 最值得理解的取舍
多数 winsock 教程在讲完 bind、listen 之后直接跳进 select,但这份源码选择的是一条更直观的路:阻塞模式 + 多线程。阻塞 socket 的 recv 会一直等数据,直到内核把数据放进接收缓冲区才返回;它不消耗 CPU,但会占住一个线程。聊天室里每个客户端对应一个接收线程,客户端自己再开一个接收线程,逻辑上完全对称。
非阻塞模式则是 recv 调用立即返回,配合 select 轮询才能判断某个 socket 是否可读。它的优势是单线程管理几十上百个连接,代价是状态机复杂度上升,一个 FD_CLOSE 的处理迟早都要处理半天。我在别的项目里见过非阻塞模式翻车——明明客户端已经断开,状态没同步,广播列表里还留着死 socket。简易聊天系统用阻塞 + 线程,把“谁在等谁”这个问题直接消灭了。
2.3 socket 生命周期与必须掌握的字节序
服务端和客户端生命周期不完全一样,但骨架一致:
| 阶段 | 服务端 | 客户端 | 失败时看什么 |
|---|---|---|---|
| 初始化 | WSAStartup | WSAStartup | 返回非 0,检查是否请求了不存在的 Winsock 版本 |
| 创建 | socket | socket | INVALID_SOCKET,结合 WSAGetLastError 判断 |
| 绑定 | bind | 不需要 | 10048 端口被占,10049 地址不对 |
| 监听 | listen | 不需要 | 注意 backlog 参数 |
| 连接 | accept | connect | WSAETIMEDOUT / WSAECONNREFUSED |
| 收发 | send / recv | send / recv | 返回 0 表示对方关闭 |
| 清理 | closesocket / WSACleanup | 同左 | 顺序不能乱,先 shutdown 再 closesocket |
字节序是新人最容易忽略的。TCP 协议上跑的整数是网络字节序(大端),而 Windows 主机是小端,所以端口号和 IP 地址传给 bind 之前必须转换,否则你监听 8888,实际可能监听的是另一个数。
#include <winsock2.h> #include <ws2tcpip.h> #pragma comment(lib, "ws2_32.lib") WSADATA wsaData; // MAKEWORD(2, 2) 把 2.2 版本号打包成 WORD,参数是主次版本 int ret = WSAStartup(MAKEWORD(2, 2), &wsaData); if (ret != 0) { // 返回值直接给错误码,常见是 WSAVERNOTSUPPORTED(版本不支持) return -1; } SOCKET listenSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (listenSock == INVALID_SOCKET) { // WSAGetLastError() 查具体原因,常见 10047 是地址族不匹配 WSACleanup(); return -1; } sockaddr_in addr = { 0 }; addr.sin_family = AF_INET; // IPv4 地址族 addr.sin_port = htons(8888); // 主机序转网络序,端口别写死 addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网卡,不止 127.0.0.1 if (bind(listenSock, (SOCKADDR*)&addr, sizeof(addr)) == SOCKET_ERROR) { // 10048 端口被占用;10049 地址不可用,检查有没有写错 sin_addr closesocket(listenSock); WSACleanup(); return -1; } if (listen(listenSock, SOMAXCONN) == SOCKET_ERROR) { // SOMAXCONN 表示系统允许的最大等待队列长度,Windows 上默认足够 closesocket(listenSock); WSACleanup(); return -1; }这段代码是整个服务端的入口,逻辑只有一个:先告诉系统“我要用 Winsock”,再建一个 TCP socket,绑定地址和端口,最后进入监听态。注意 bind 失败之后必须自己 closesocket 再 WSACleanup,否则 Winsock 资源没释放,下次启动会听到更奇怪的问题。端口号不要直接用 0,0 是让系统随机分配,客户端连不上。
2.4 select 模型与多线程模型的分界线
如果这份代码你改着改着,发现客户端数量超过三五十个,线程数涨得离谱,那就该考虑 select。select 的思路是用一个 fd_set 集合装下所有 socket,每次调用前 FD_ZERO、FD_SET 重新填充,然后交给内核去判断哪些 socket 可读、可写、出错。
fd_set readfds; FD_ZERO(&readfds); // 清空集合,必须每次重新填 FD_SET(listenSock, &readfds); // 加入监听 socket for (SOCKET s : clients) { FD_SET(s, &readfds); // 逐个加入客户端 socket } int ret = select(0, &readfds, NULL, NULL, NULL); // ret > 0 表示有 socket 可读;ret == 0 表示超时 if (FD_ISSET(listenSock, &readfds)) { // 有新连接进来,走 accept }select 不一定比多线程快,但它把线程数打下来,开销可控。缺点也一样明显:每次调用前都要重建 fd_set,而且 FD_SETSIZE 默认只有 64,超过就得重新定义宏。聊天室这种几十人场景够用,上千并发就得换 IOCP。我的观点是:这份 demo 用多线程不是刻意简陋,而是先保证逻辑正确,如果你想拓宽视野,select 是第二步再吃的菜。
3. 服务端拆解:accept 循环、客户端列表与消息广播的实现
服务端是整个系统的中枢,一份能跑通的聊天源码,服务端代码一定把四件事讲清楚:监听怎么接、客户端列表怎么管、消息怎么广播、关闭怎么清理。逐段拆开看,每段都能直接映射到我们刚才讲的 socket 生命周期。
3.1 accept 循环与线程结构
服务端主线程通常跑一个死循环,反复调用 accept,每接到一个新连接就把对应的 SOCKET 放进在线列表。阻塞模式下的 accept 会一直挂起,直到内核完成 TCP 三次握手,返回一个新的 socket 描述符——这个新 socket 才是跟该客户端通信的专用通道,原来的 listenSock 继续等着接别人。
std::vector<SOCKET> clients; // 在线客户端 socket 列表 bool running = true; while (running) { SOCKET clientSock = accept(listenSock, NULL, NULL); if (clientSock == INVALID_SOCKET) { // 常见原因:accept 被信号打断或 listenSock 被关闭 // 不要 break,先查 WSAGetLastError() 再判断是退出还是临时失败 continue; } clients.push_back(clientSock); // 每接一个客户端,就给它开一个接收线程 HANDLE hThread = CreateThread( NULL, 0, ClientReceiveThread, (LPVOID)clientSock, 0, NULL); CloseHandle(hThread); // 线程句柄用完即关,不影响线程继续跑 }要注意的是 CreateThread 的第四个参数是 LPVOID,SOCKET 在 64 位下是 64 位无符号整数,直接强转成指针没问题,但不要先截成 int 再传,否则高 32 位丢失。刚接触这段代码的人常在这翻车——客户端能连上,但收发全乱套。
3.2 客户端列表的增删:vector 遍历删除的坑
在线列表用 std::vector 存,增就是 push_back,删就得小心。接收线程发现 recv 返回 0 或 SOCKET_ERROR,说明对方断开,这时要先 closesocket,再把对应 socket 从 vector 里移除。如果直接在循环里 erase,迭代器立刻失效,轻则漏删,重则访问野指针崩溃。
DWORD WINAPI ClientReceiveThread(LPVOID param) { SOCKET clientSock = (SOCKET)param; char buf[2048]; while (true) { memset(buf, 0, sizeof(buf)); int n = recv(clientSock, buf, sizeof(buf) - 1, 0); if (n <= 0) { // n == 0 是对方正常关闭,SOCKET_ERROR 是对端异常 closesocket(clientSock); // 遍历删除,注意 erase 之后要立刻 break for (auto it = clients.begin(); it != clients.end(); ++it) { if (*it == clientSock) { clients.erase(it); break; } } break; } buf[n] = '\0'; BroadcastMessage(clientSock, buf); } return 0; }这段代码暴露了一个细节:clients 是整个进程共享的资源,多个接收线程同时在读它、删它,严格来说需要一把锁。简易 demo 里通常不加,因为删除只发生在断线瞬间,风险可控;但要是你在它基础上加“在线人数统计”之类的功能,必须在读写两端都加上临界区,否则排查起来极其痛苦。
3.3 广播消息:跳过发送者还是回显给发送者
聊天室广播有两种设计:一种把消息发给所有客户端包括发送者自己,另一种跳过发送者,由发送者本地回显。这份源码里常见的是跳过发送者,因为客户端自己发送完立刻把消息打到屏幕上,延迟更低;服务端只负责把消息转发给其他人。实现上就是遍历列表,遇到发送者 socket 就 continue。
void BroadcastMessage(SOCKET sender, const char* msg) { std::string data = msg; for (SOCKET s : clients) { if (s == sender) continue; // 跳过消息发起者 SendAll(s, data.c_str(), (int)data.size()); } }这里最核心的坑是 send 的返回处理。TCP 不保证一次 send 发完所有字节,尤其消息超过几千字节时,内核缓冲区可能只接收了前半段。直接拿第一次 send 的返回值判断是否成功,必然出问题。
bool SendAll(SOCKET sock, const char* data, int len) { int sent = 0; while (sent < len) { int ret = send(sock, data + sent, len - sent, 0); if (ret == SOCKET_ERROR) { // 网络中断或缓冲区异常,返回失败让上层清理 return false; } sent += ret; // 只累加实际发出的字节数 } return true; }SendAll 是网络编程里的标准封装,它把“发一部分”这个可能重复发生的情况循环处理完,上层不用关心数据被拆成几次发出。凡是涉到 TCP 流式传输,发送一律走这个封装,能省掉大量“消息偶尔缺尾”的玄学问题。
3.4 服务端退出的正确顺序
很多人写完接入逻辑就忘了退出逻辑。服务端要关的时候,如果你直接 closesocket(listenSock) 然后 WSACleanup,那些阻塞在 recv 上的接收线程还在等数据,突然一个 WSACleanup 把底层资源全释放,线程再访问已经销毁的 socket 对象,轻则报错,重则整个进程崩掉。
正确的顺序是:先置 running = false 让 accept 主循环退出,再对每个客户端做 shutdown(sock, SD_BOTH) 把收发通道都关掉,让线程里的 recv 立刻返回 0,线程自然退出,最后才统一 closesocket 和 WSACleanup。
// 退出时按顺序来,别跳过 shutdown running = false; for (SOCKET s : clients) { shutdown(s, SD_BOTH); // 让阻塞 recv 立即返回,而不是直接 close closesocket(s); } closesocket(listenSock); WSACleanup();shutdown 和 closesocket 的区别是很多人的知识盲区:shutdown 只是关闭数据读写通道,socket 对象还在;closesocket 才真正释放句柄。先 shutdown 是为了让阻塞中的 recv 获得“连接已关闭”的信号,线程能干干净净退出。跳过这一步,就会偶发“服务端关闭时报错”的怪病。
4. 客户端拆解:connect 连接、收发双线程与消息协议
客户端代码量比服务端小,但陷阱不少。它要自己主动发起连接,同时处理用户输入和服务端推送这两条数据流,线程模型直接决定程序的流畅度。
4.1 connect 连接服务端:地址与端口的正确姿势
客户端的流程是最标准的“三步走”:创建 socket、填充服务器地址、connect。地址结构里的 sin_addr 需要填服务器的 IP,这里有一个历史遗留坑:老工程用 inet_addr 只支持点分十进制,新代码推荐 inet_pton,但 inet_pton 在 Windows 上需要声明 _WIN32_WINNT 大于某个版本,否则编译不过。最简单的兼容写法就是 inet_addr。
SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); sockaddr_in server = { 0 }; server.sin_family = AF_INET; server.sin_port = htons(8888); // inet_addr 不支持域名,只接受 "127.0.0.1" 这类字符串 server.sin_addr.s_addr = inet_addr("127.0.0.1"); int rc = connect(sock, (SOCKADDR*)&server, sizeof(server)); if (rc == SOCKET_ERROR) { // WSAECONNREFUSED:服务端没监听该端口 // WSAETIMEDOUT:网络不通或防火墙拦截 closesocket(sock); return -1; }connect 失败后不要直接跑路,要 closesocket 再退出,否则 socket 句柄泄漏。程序跑一天后突然报“无法创建 socket”,多半就是这里只 break 没 close。
4.2 收发分离:为什么必须两个线程
聊天的客户端同时要做两件事:读用户键盘输入,收服务端广播。如果都在主线程,你调用 recv 等待数据时,用户敲键盘没反应;反过来,你等用户输入时,服务端消息来了不会被及时接收。所以必须拆成两个线程:一个线程阻塞在 recv 上等着收,主线程循环读输入并 send,两边各干各的,互不阻塞。
DWORD WINAPI ReceiveThread(LPVOID param) { SOCKET sock = (SOCKET)param; char buf[2048]; while (true) { memset(buf, 0, sizeof(buf)); int n = recv(sock, buf, sizeof(buf) - 1, 0); if (n <= 0) { printf("连接已断开\n"); shutdown(sock, SD_BOTH); closesocket(sock); break; } buf[n] = '\0'; printf("%s\n", buf); // 收到一条完整消-息,直接打印 } return 0; } HANDLE hThread = CreateThread(NULL, 0, ReceiveThread, (LPVOID)sock, 0, NULL); CloseHandle(hThread); // 主线程负责读输入 std::string line; while (std::getline(std::cin, line)) { if (line == "quit") break; SendAll(sock, line.c_str(), (int)line.size()); } shutdown(sock, SD_BOTH); closesocket(sock);这个模式看似简单,但线程退出顺序必须小心。客户端要退出时,主线程先 shutdown,接收线程的 recv 会立刻返回 0 并退出,然后主线程再 closesocket,两边同步收场。
4.3 消息协议与编码:粘包的第一道防线
recv 返回的字节数完全看内核心情,可能一条消息分两次到,也可能两条消息一起到。要解决这个问题,必须自定义协议。最简单的协议是“4 字节长度 + 消息体”:
// 发送一条消息的标准流程 std::string msg = "hello"; uint32_t len = htonl((uint32_t)msg.size()); // 长度转网络字节序 SendAll(sock, (const char*)&len, 4); // 先发长度头 SendAll(sock, msg.c_str(), msg.size()); // 再发内容接收端要先完整收 4 字节长度,解析出消息体大小,再循环收满这么多字节才算收到一条完整消息。很多教程代码直接拿 recv 的返回值当消息长度,数据一多必然串台。这两段代码能解决 90% 的粘包问题。
编码方面也有讲究。Windows 控制台默认代码页是 GBK,但现代代码文件常存成 UTF-8,两边的字节流不一致,中文就会乱码。最简单粗暴的约定是客户端和服务端统一用 UTF-8,发送前把字符串转成 UTF-8 字节流,接收后按 UTF-8 解码。常见做法是使用 WideCharToMultiByte 与 MultiByteToWideChar 做转换,但前提是两端约定一致。
// 把宽字符串转成 UTF-8 字节流再发送,避免中文乱码 std::string ToUtf8(const std::wstring& wstr) { int size = WideCharToMultiByte(CP_UTF8, 0, wstr.c_str(), (int)wstr.size(), NULL, 0, NULL, NULL); std::string out(size, 0); WideCharToMultiByte(CP_UTF8, 0, wstr.c_str(), (int)wstr.size(), &out[0], size, NULL, NULL); return out; }编码问题看似很“软”,实际是跨机器联调时最硬的一块。两台机器系统区域设置不同、代码编译 charset 不同,乱码就会出现得毫无规律。我在自己的项目里一律把 UTF-8 定为标准,谁也不要猜。
5. 避坑手册:端口占用、粘包、广播死锁与 socket 清理顺序
这段是血泪经验。源码本身能跑通,但你在复现、修改、扩展它的过程中,大概率会在下面五个地方翻车。每一条我都按现象 → 原因 → 解决梳理,建议跑代码之前先扫一遍。
5.1 bind 总是失败:上一次的 socket 还处于 TIME_WAIT
现象:服务端刚关掉,马上重新启动,bind 返回 SOCKET_ERROR,WSAGetLastError 给的是 10048。
原因:上一次连接关闭后,TCP 连接进入 TIME_WAIT 状态,默认持续约两分钟。这个状态下的端口还没有完全释放,系统不允许立即复用。
解决:bind 之前设置 SO_REUSEADDR,让内核允许端口复用。
int reuse = 1; setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR, (const char*)&reuse, sizeof(reuse)); // 放在 bind 和 listen 之前,否则无效还有一种情况是 Windows 上需要同时设置 SO_EXCLUSIVEADDRUSE 才能彻底避免与其他程序抢端口,但简易聊天场景下 SO_REUSEADDR 已经够用。如果还报错,直接换端口测试,确认是端口问题还是程序问题。
5.2 收到的消息串台:粘包与半包
现象:两个客户端几乎同时发言,收到的消息混在一起;或者一条很长的消息缺了后半截。
原因:TCP 是流协议,消息之间没有天然边界。recv 返回的字节数受网络分段影响,可能多条消息合并、也可能一条消息被拆成多段。
解决:严格用“4 字节长度头 + 消息体”协议。发送端先发长度,接收端先收 4 字节算出长度,再循环收满消息体。不要用 sleep 在接收端等数据,那是玄学调参,治标不治本。协议一上,粘包问题一次性解决。
5.3 多客户端广播时卡死:发送阻塞与容器迭代失效
现象:客户端一多,某个瞬间广播整个程序卡住,服务端像是没反应了。
原因:有两个可能。一是某个客户端的接收窗口满了,send 在阻塞模式下会一直等它消费;二是广播时遍历 vector 的同时,另一个线程刚好在 erase 元素,迭代器失效导致野指针崩溃。
解决:广播代码里对每个 socket 的 SendAll 已经避免了一部分问题,但发送阻塞仍可能在慢客户端上发生。小范围聊天室基本不用管,量大了再把 send 超时设上。容器方面,删除统一走“标记 + 最后清理”或遍历删除后立即 break,避免中途失效。
5.4 程序退出时报访问越界:线程还阻塞在 recv 上
现象:点掉控制台窗口或主线程退出,弹出一个“0xXXXXXXXX 处未处理的异常”,崩溃位置在 recv 附近。
原因:主线程执行 WSACleanup 或 closesocket 时,接收线程还阻塞在 recv 上。底层 Winsock 资源被释放后,线程继续触碰那个 socket 句柄,访问已释放内存。
解决:退出时先 shutdown(sock, SD_BOTH),让阻塞的 recv 立刻返回,接收线程自己退出,然后再 closesocket。shutdown 在前,closesocket 在后,这个顺序写成注释贴在代码里都不为过。
5.5 客户端连接超时但程序没报错:防火墙、绑定地址与网段
现象:客户端 connect 一直转圈然后超时,服务端却没有收到任何连接请求。
原因:最常见的三件事——Windows 防火墙拦了 TCP 端口;服务端 bind 在了 127.0.0.1 而不是 INADDR_ANY,局域网的其它机器根本连不上;两台机器根本不在同一网段。
解决:先在本机用客户端连 127.0.0.1,通了再换局域网 IP。服务端如果是给多人用,bind 的地址务必是 INADDR_ANY。排查时按“本机回环 → 局域网 IP → 关防火墙临时测试”三步走,一次就能定位。我在现场调这种问题,从来不做无畏改动,都是直接按这个顺序卡点。
6. 给这份 demo 做一次手术:心跳保活、压测与协议升级
拿到手先别急着改功能,先按下面三个方向动一遍,你会对这套代码的理解深一层。
先加应用层心跳。TCP 的 keepalive 默认两个小时才探一次,聊天室里客户端拔网线,服务端可能半天发现不了。应用层心跳是每 30 秒发一个 PING 包,服务端 90 秒没收到就判定掉线。
// 客户端主循环里加一段定时心跳 DWORD lastTick = GetTickCount(); if (GetTickCount() - lastTick > 30000) { // 30 秒未发送则发 PING const char* ping = "PING"; uint32_t len = htonl(4); SendAll(sock, (const char*)&len, 4); SendAll(sock, ping, 4); lastTick = GetTickCount(); }再用脚本压一遍,别靠手动开几十个窗口假装压测。一个简单的 Python 脚本就能模拟几十个客户端同时连上、发消息、断开,直接看出广播逻辑扛不扛得住。
import socket import threading import time def client(idx): try: s = socket.create_connection(("127.0.0.1", 8888), timeout=5) s.send(f"client-{idx}".encode("utf-8")) time.sleep(1) s.close() except Exception as e: print(idx, e) threads = [threading.Thread(target=client, args=(i,)) for i in range(50)] for t in threads: t.start() for t in threads: t.join() print("50 clients done")压的时候重点看三处:所有线程能不能正常连上、有没有客户端收不到广播、服务端内存是否平滑。如果出现崩溃,先回头查第 5 章的 socket 清理顺序,九成问题在那里。
最后把协议从裸字符串升级成带类型字段的包:消息类型 + 用户名 + 内容长度 + 内容。这一步做完,你才算真正把消息边界和管理功能握在手里。从那以后我每次拆网络 demo 都强制走一遍这套流程:先加 SO_REUSEADDR,再补消息长度,最后多客户端压测。三步全过,这个资源才算真的能跑。希望帮到你。
本文还有配套的精品资源,点击获取