C++ 局域网双向聊天室(Winsock2 + 多线程)

从“一发一收”升级为实时双向对话,服务端和客户端都能随时收发消息。使用多线程分离接收与发送逻辑,实现流畅的聊天体验。


功能特点

  • 基于 TCP 协议,保证消息可靠到达
  • 服务端和客户端均支持双向收发,任意一方输入文字即可实时发送给对方
  • 使用独立接收线程,不会因为等待输入而阻塞消息接收
  • 输入 quit 优雅退出,对方会收到断开通知

环境与准备

  • Visual Studio 2019 / 2022
  • 新建两个控制台应用项目:ChatServerChatClient
  • 需要 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);
}

逐行拆解

  1. char buffer[1024]:接收缓冲区,每次最多读 1023 字节(留 1 字节给 '\0')。

  2. while (g_running):循环条件检查原子标志。如果主线程把 g_running 设为 false,循环退出。

  3. recv(clientSocket, buffer, sizeof(buffer) - 1, 0)

    • clientSocket 读取数据。
    • sizeof(buffer) - 1 确保不会溢出缓冲区,留一个字节给字符串结束符。
    • 返回值 bytes 为实际接收的字节数。
  4. if (bytes > 0):正常收到数据。

    • buffer[bytes] = '\0':在数据末尾手动添加字符串结束符,因为 recv 不会自动加 '\0'
    • std::cout << "\n[对方] " << buffer << std::endl:打印收到的消息,前面加换行是因为上一行可能还停在 [你] 的输入提示上。
    • std::cout << "[你] ":重新显示输入提示,让用户知道该输入了。
    • std::cout.flush():强制刷新输出缓冲区,确保提示立即显示。
  5. elserecv 返回 <= 0

    • bytes == 0:对方正常关闭了连接(调用了 closesocket)。
    • bytes == SOCKET_ERROR(即 -1):网络出错。
    • 两种情况都打印断开信息,把 g_running 设为 false,然后 break 退出循环。
  6. 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;
    }

创建监听 socketAF_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;
        }
    }

主线程发送循环

  1. while (g_running):每次循环开始检查运行标志。如果接收线程因为对方断开而把 g_running 设为 false,主线程也会退出循环。

  2. std::cout << "[你] ":显示输入提示。

  3. std::getline(std::cin, input):阻塞等待用户输入一整行(包括空格)。用户按回车后继续。

  4. if (input == "quit"):用户输入 quit,把 g_running 设为 falsebreak 退出循环。注意:这里没有发送 quit 给对面,只是自己退出。对面会因为 recv 返回 0 而检测到连接断开。

  5. send(clientSocket, input.c_str(), (int)input.size(), 0):发送用户输入的内容。c_str() 返回 const char*input.size() 返回字符串长度(不含 '\0')。

  6. 检查 send 返回值:如果等于 SOCKET_ERROR,说明发送失败(可能 socket 已关闭),设置 g_running = false 并退出。


第五部分:清理

    closesocket(clientSocket);
    closesocket(listenSocket);
    if (recvThread.joinable())
        recvThread.join();

    WSACleanup();
    return 0;

清理顺序

  1. closesocket(clientSocket):关闭通信 socket。这会让接收线程中的 recv 立即返回错误(或 0),从而退出循环。

  2. closesocket(listenSocket):关闭监听 socket(其实已经不再用了,但好习惯)。

  3. if (recvThread.joinable()) recvThread.join()

    • joinable() 检查线程是否还在运行。
    • join() 阻塞等待接收线程执行完毕。这一步很重要:如果不等待,主线程退出了,接收线程还在运行,程序会崩溃(析构时 std::thread 会调用 terminate)。
  4. WSACleanup():卸载 Winsock 库。


客户端代码拆解

客户端和服务端几乎一样,区别只在连接方式:

创建 socket

SOCKET clientSocket = socket(AF_INET, SOCK_STREAM, 0);

和服务端一样,但客户端不需要 bindlisten


设置服务器地址(使用 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 地址写错了
  • 防火墙拦截
  • 网络不通

后续收发逻辑

和服务端完全一致:

  1. 创建接收线程,专门处理 recv
  2. 主线程循环 getline + send
  3. quit 退出,清理资源。

运行测试

  1. 先启动服务端,看到“服务端启动,等待客户端连接…”。
  2. 再启动客户端,连接成功后会显示“已连接到服务器!”。
  3. 任意一方输入文字并按回车,消息会实时显示在对方屏幕上。
  4. 任意一方输入 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 字符串转为二进制地址

这个双向聊天室是“一发一收”的自然延伸,核心变化就是引入了多线程。理解了这个架构,后续增加多客户端、文件传输等功能就顺理成章了。

更多推荐