C++ 局域网 双向聊天室(Winsock2 + 多线程)
C++ 局域网双向聊天室(Winsock2 + 多线程)
从“一发一收”升级为实时双向对话,服务端和客户端都能随时收发消息。使用多线程分离接收与发送逻辑,实现流畅的聊天体验。
功能特点
- 基于 TCP 协议,保证消息可靠到达
- 服务端和客户端均支持双向收发,任意一方输入文字即可实时发送给对方
- 使用独立接收线程,不会因为等待输入而阻塞消息接收
- 输入
quit优雅退出,对方会收到断开通知
环境与准备
- Visual Studio 2019 / 2022
- 新建两个控制台应用项目:
ChatServer和ChatClient - 需要 C++11 或更高版本(支持
<thread>和<atomic>)
每个源文件包含以下头文件和链接指令:
#include <iostream> // 控制台输入输出
#include <string> // std::string 字符串操作
#include <thread> // std::thread 多线程
#include <atomic> // std::atomic 原子变量(线程安全标志)
#include <winsock2.h> // Winsock API
#include <ws2tcpip.h> // inet_pton 等现代网络函数
#include <windows.h> // Windows 底层类型
#pragma comment(lib, "ws2_32.lib") // 链接 Winsock 库
为什么用 inet_pton 而不是 inet_addr:微软已弃用 inet_addr,推荐使用更安全的 inet_pton。它支持 IPv4 和 IPv6,且能检测无效 IP 字符串(返回 0 表示转换失败),避免潜在的安全问题。
核心设计思路
1. 为什么需要多线程
在“一发一收”版本中,服务端只调用一次 recv 就退出了,不需要考虑“等待接收的同时还要发送”的问题。
但双向对话中,任何一方都需要同时做两件事:
- 随时等待接收对方发来的消息(
recv阻塞) - 随时可以输入消息发送给对方(
std::getline阻塞)
如果只用单线程,流程会卡死:
循环:输入消息 → 发送 → 等待接收 → 收到后继续输入...
这样你输入的时候收不到消息,等你发完才开始收,体验极差。
解决方案:两个线程各干各的。
- 主线程:负责读取用户键盘输入并
send。 - 子线程:专门阻塞在
recv上,收到消息立即打印。
两个线程共享同一个 socket,但一个读一个写,互不冲突。
2. 线程安全退出机制
std::atomic<bool> g_running(true);
std::atomic<bool> 是一个原子布尔变量。多线程环境下,一个线程修改它,另一个线程能立即读到最新值,不会出现数据竞争。
作用:
- 主线程输入
quit时,把g_running设为false。 - 接收线程在每次循环开头检查
g_running,如果为false就退出。 - 同时,如果
recv返回 0(对方关闭连接),接收线程也会自动退出并置g_running = false。
服务端代码拆解
服务端代码和客户端代码结构完全对称,只有启动方式不同(服务端 bind+listen+accept,客户端 connect)。下面以服务端为主详细拆解。
第一部分:头文件和全局变量
#include <iostream>
#include <string>
#include <thread>
#include <atomic>
#include <winsock2.h>
#include <ws2tcpip.h>
#include <windows.h>
#pragma comment(lib, "ws2_32.lib")
std::atomic<bool> g_running(true);
- 全局原子变量
g_running控制整个程序的运行状态,主线程和接收线程共享。 - 初始值为
true,表示程序正常运行。
第二部分:接收线程函数
void ReceiveThread(SOCKET clientSocket) {
char buffer[1024];
while (g_running) {
int bytes = recv(clientSocket, buffer, sizeof(buffer) - 1, 0);
if (bytes > 0) {
buffer[bytes] = '\0';
std::cout << "\n[对方] " << buffer << std::endl;
std::cout << "[你] ";
std::cout.flush();
} else {
std::cout << "\n[系统] 对方已断开连接。" << std::endl;
g_running = false;
break;
}
}
closesocket(clientSocket);
}
逐行拆解:
-
char buffer[1024]:接收缓冲区,每次最多读 1023 字节(留 1 字节给'\0')。 -
while (g_running):循环条件检查原子标志。如果主线程把g_running设为false,循环退出。 -
recv(clientSocket, buffer, sizeof(buffer) - 1, 0):- 从
clientSocket读取数据。 sizeof(buffer) - 1确保不会溢出缓冲区,留一个字节给字符串结束符。- 返回值
bytes为实际接收的字节数。
- 从
-
if (bytes > 0):正常收到数据。buffer[bytes] = '\0':在数据末尾手动添加字符串结束符,因为recv不会自动加'\0'。std::cout << "\n[对方] " << buffer << std::endl:打印收到的消息,前面加换行是因为上一行可能还停在[你]的输入提示上。std::cout << "[你] ":重新显示输入提示,让用户知道该输入了。std::cout.flush():强制刷新输出缓冲区,确保提示立即显示。
-
else:recv返回<= 0。bytes == 0:对方正常关闭了连接(调用了closesocket)。bytes == SOCKET_ERROR(即 -1):网络出错。- 两种情况都打印断开信息,把
g_running设为false,然后break退出循环。
-
closesocket(clientSocket):线程退出前关闭 socket。注意这里调用closesocket后,主线程如果再调用send会失败,但主线程也会检查g_running,不会继续发送。
第三部分:main 函数 —— 初始化与监听
int main() {
WSADATA wsaData;
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) {
std::cerr << "WSAStartup 失败" << std::endl;
return 1;
}
WSAStartup:加载 Winsock 库。所有网络程序的第一步,不成功后面全废。
SOCKET listenSocket = socket(AF_INET, SOCK_STREAM, 0);
if (listenSocket == INVALID_SOCKET) {
std::cerr << "socket 创建失败" << std::endl;
WSACleanup();
return 1;
}
创建监听 socket:AF_INET 表示 IPv4,SOCK_STREAM 表示 TCP。
sockaddr_in serverAddr;
serverAddr.sin_family = AF_INET;
serverAddr.sin_addr.s_addr = INADDR_ANY;
serverAddr.sin_port = htons(8888);
if (bind(listenSocket, (sockaddr*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR) {
std::cerr << "bind 失败" << std::endl;
closesocket(listenSocket);
WSACleanup();
return 1;
}
bind:绑定端口 8888。INADDR_ANY 表示监听本机所有网卡。
htons:主机字节序转网络字节序,保证跨平台端口号解析正确。
if (listen(listenSocket, SOMAXCONN) == SOCKET_ERROR) {
std::cerr << "listen 失败" << std::endl;
closesocket(listenSocket);
WSACleanup();
return 1;
}
listen:进入监听模式。SOMAXCONN 让系统使用最大等待队列长度。
std::cout << "服务端启动,等待客户端连接..." << std::endl;
sockaddr_in clientAddr;
int clientAddrSize = sizeof(clientAddr);
SOCKET clientSocket = accept(listenSocket, (sockaddr*)&clientAddr, &clientAddrSize);
if (clientSocket == INVALID_SOCKET) {
std::cerr << "accept 失败" << std::endl;
closesocket(listenSocket);
WSACleanup();
return 1;
}
accept:阻塞等待客户端连接。成功后返回一个新的 socket(clientSocket),专门用于和这个客户端通信。
listenSocket 继续监听,但本程序只处理一个客户端,所以后面不再调用 accept。
std::cout << "客户端已连接!可以开始聊天(输入 quit 退出)" << std::endl;
第四部分:启动线程并处理收发
std::thread recvThread(ReceiveThread, clientSocket);
创建接收线程:std::thread 的构造函数第一个参数是函数名,后面的参数是传给该函数的参数。这里把 clientSocket 传给 ReceiveThread。
线程创建后立即开始执行 ReceiveThread(clientSocket),主线程继续往下走。
std::string input;
while (g_running) {
std::cout << "[你] ";
std::getline(std::cin, input);
if (!g_running) break;
if (input == "quit") {
g_running = false;
break;
}
int sent = send(clientSocket, input.c_str(), (int)input.size(), 0);
if (sent == SOCKET_ERROR) {
std::cerr << "发送失败" << std::endl;
g_running = false;
break;
}
}
主线程发送循环:
-
while (g_running):每次循环开始检查运行标志。如果接收线程因为对方断开而把g_running设为false,主线程也会退出循环。 -
std::cout << "[你] ":显示输入提示。 -
std::getline(std::cin, input):阻塞等待用户输入一整行(包括空格)。用户按回车后继续。 -
if (input == "quit"):用户输入quit,把g_running设为false,break退出循环。注意:这里没有发送quit给对面,只是自己退出。对面会因为recv返回 0 而检测到连接断开。 -
send(clientSocket, input.c_str(), (int)input.size(), 0):发送用户输入的内容。c_str()返回const char*,input.size()返回字符串长度(不含'\0')。 -
检查
send返回值:如果等于SOCKET_ERROR,说明发送失败(可能 socket 已关闭),设置g_running = false并退出。
第五部分:清理
closesocket(clientSocket);
closesocket(listenSocket);
if (recvThread.joinable())
recvThread.join();
WSACleanup();
return 0;
清理顺序:
-
closesocket(clientSocket):关闭通信 socket。这会让接收线程中的recv立即返回错误(或 0),从而退出循环。 -
closesocket(listenSocket):关闭监听 socket(其实已经不再用了,但好习惯)。 -
if (recvThread.joinable()) recvThread.join():joinable()检查线程是否还在运行。join()阻塞等待接收线程执行完毕。这一步很重要:如果不等待,主线程退出了,接收线程还在运行,程序会崩溃(析构时std::thread会调用terminate)。
-
WSACleanup():卸载 Winsock 库。
客户端代码拆解
客户端和服务端几乎一样,区别只在连接方式:
创建 socket
SOCKET clientSocket = socket(AF_INET, SOCK_STREAM, 0);
和服务端一样,但客户端不需要 bind 和 listen。
设置服务器地址(使用 inet_pton)
sockaddr_in serverAddr;
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(8888);
if (inet_pton(AF_INET, "127.0.0.1", &serverAddr.sin_addr) != 1) {
std::cerr << "无效的 IP 地址" << std::endl;
closesocket(clientSocket);
WSACleanup();
return 1;
}
inet_pton 详解:
- 第一个参数
AF_INET:指定 IPv4。 - 第二个参数
"127.0.0.1":点分十进制的 IP 字符串。 - 第三个参数
&serverAddr.sin_addr:转换后的二进制 IP 地址存到这里。 - 返回值:
1:转换成功。0:IP 字符串格式无效(比如"999.999.999.999")。-1:系统错误(比如AF_INET不支持)。
- 这个函数比
inet_addr更安全,因为inet_addr对无效输入(如"256.1.1.1")会返回INADDR_NONE,但有些版本会静默截断,容易埋坑。
连接服务器
if (connect(clientSocket, (sockaddr*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR) {
std::cerr << "connect 失败" << std::endl;
closesocket(clientSocket);
WSACleanup();
return 1;
}
connect:发起 TCP 三次握手。成功返回 0,失败返回 SOCKET_ERROR。
失败常见原因:
- 服务端没启动(端口没人在
listen) - IP 地址写错了
- 防火墙拦截
- 网络不通
后续收发逻辑
和服务端完全一致:
- 创建接收线程,专门处理
recv。 - 主线程循环
getline+send。 quit退出,清理资源。
运行测试
- 先启动服务端,看到“服务端启动,等待客户端连接…”。
- 再启动客户端,连接成功后会显示“已连接到服务器!”。
- 任意一方输入文字并按回车,消息会实时显示在对方屏幕上。
- 任意一方输入
quit,程序退出,对方会收到“对方已断开连接”的提示。
跨机测试:把客户端的 IP 地址 "127.0.0.1" 改为服务端的实际局域网 IP(在服务端用 ipconfig 查看),并确保防火墙允许 8888 端口。
完整代码(可直接复制)
ChatServer.cpp
#include <iostream>
#include <string>
#include <thread>
#include <atomic>
#include <winsock2.h>
#include <ws2tcpip.h>
#include <windows.h>
#pragma comment(lib, "ws2_32.lib")
std::atomic<bool> g_running(true);
void ReceiveThread(SOCKET clientSocket) {
char buffer[1024];
while (g_running) {
int bytes = recv(clientSocket, buffer, sizeof(buffer) - 1, 0);
if (bytes > 0) {
buffer[bytes] = '\0';
std::cout << "\n[对方] " << buffer << std::endl;
std::cout << "[你] ";
std::cout.flush();
} else {
std::cout << "\n[系统] 对方已断开连接。" << std::endl;
g_running = false;
break;
}
}
closesocket(clientSocket);
}
int main() {
WSADATA wsaData;
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) {
std::cerr << "WSAStartup 失败" << std::endl;
return 1;
}
SOCKET listenSocket = socket(AF_INET, SOCK_STREAM, 0);
if (listenSocket == INVALID_SOCKET) {
std::cerr << "socket 创建失败" << std::endl;
WSACleanup();
return 1;
}
sockaddr_in serverAddr;
serverAddr.sin_family = AF_INET;
serverAddr.sin_addr.s_addr = INADDR_ANY;
serverAddr.sin_port = htons(8888);
if (bind(listenSocket, (sockaddr*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR) {
std::cerr << "bind 失败" << std::endl;
closesocket(listenSocket);
WSACleanup();
return 1;
}
if (listen(listenSocket, SOMAXCONN) == SOCKET_ERROR) {
std::cerr << "listen 失败" << std::endl;
closesocket(listenSocket);
WSACleanup();
return 1;
}
std::cout << "服务端启动,等待客户端连接..." << std::endl;
sockaddr_in clientAddr;
int clientAddrSize = sizeof(clientAddr);
SOCKET clientSocket = accept(listenSocket, (sockaddr*)&clientAddr, &clientAddrSize);
if (clientSocket == INVALID_SOCKET) {
std::cerr << "accept 失败" << std::endl;
closesocket(listenSocket);
WSACleanup();
return 1;
}
std::cout << "客户端已连接!可以开始聊天(输入 quit 退出)" << std::endl;
std::thread recvThread(ReceiveThread, clientSocket);
std::string input;
while (g_running) {
std::cout << "[你] ";
std::getline(std::cin, input);
if (!g_running) break;
if (input == "quit") {
g_running = false;
break;
}
int sent = send(clientSocket, input.c_str(), (int)input.size(), 0);
if (sent == SOCKET_ERROR) {
std::cerr << "发送失败" << std::endl;
g_running = false;
break;
}
}
closesocket(clientSocket);
closesocket(listenSocket);
if (recvThread.joinable())
recvThread.join();
WSACleanup();
return 0;
}
ChatClient.cpp
#include <iostream>
#include <string>
#include <thread>
#include <atomic>
#include <winsock2.h>
#include <ws2tcpip.h>
#include <windows.h>
#pragma comment(lib, "ws2_32.lib")
std::atomic<bool> g_running(true);
void ReceiveThread(SOCKET clientSocket) {
char buffer[1024];
while (g_running) {
int bytes = recv(clientSocket, buffer, sizeof(buffer) - 1, 0);
if (bytes > 0) {
buffer[bytes] = '\0';
std::cout << "\n[对方] " << buffer << std::endl;
std::cout << "[你] ";
std::cout.flush();
} else {
std::cout << "\n[系统] 对方已断开连接。" << std::endl;
g_running = false;
break;
}
}
closesocket(clientSocket);
}
int main() {
WSADATA wsaData;
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) {
std::cerr << "WSAStartup 失败" << std::endl;
return 1;
}
SOCKET clientSocket = socket(AF_INET, SOCK_STREAM, 0);
if (clientSocket == INVALID_SOCKET) {
std::cerr << "socket 创建失败" << std::endl;
WSACleanup();
return 1;
}
sockaddr_in serverAddr;
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(8888);
if (inet_pton(AF_INET, "127.0.0.1", &serverAddr.sin_addr) != 1) {
std::cerr << "无效的 IP 地址" << std::endl;
closesocket(clientSocket);
WSACleanup();
return 1;
}
if (connect(clientSocket, (sockaddr*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR) {
std::cerr << "connect 失败" << std::endl;
closesocket(clientSocket);
WSACleanup();
return 1;
}
std::cout << "已连接到服务器!可以开始聊天(输入 quit 退出)" << std::endl;
std::thread recvThread(ReceiveThread, clientSocket);
std::string input;
while (g_running) {
std::cout << "[你] ";
std::getline(std::cin, input);
if (!g_running) break;
if (input == "quit") {
g_running = false;
break;
}
int sent = send(clientSocket, input.c_str(), (int)input.size(), 0);
if (sent == SOCKET_ERROR) {
std::cerr << "发送失败" << std::endl;
g_running = false;
break;
}
}
closesocket(clientSocket);
if (recvThread.joinable())
recvThread.join();
WSACleanup();
return 0;
}
总结
| 部分 | 作用 |
|---|---|
WSAStartup / WSACleanup |
加载 / 卸载 Winsock 库 |
socket |
创建通信端点 |
bind / listen / accept |
服务端专用:绑定端口、监听、接受连接 |
connect |
客户端专用:连接服务端 |
send / recv |
收发数据(主线程发、子线程收) |
std::thread |
创建接收线程,实现双向同时通信 |
std::atomic<bool> |
线程安全地控制程序退出 |
inet_pton |
安全地将 IP 字符串转为二进制地址 |
这个双向聊天室是“一发一收”的自然延伸,核心变化就是引入了多线程。理解了这个架构,后续增加多客户端、文件传输等功能就顺理成章了。
更多推荐

所有评论(0)