【Linux网络】UDP 聊天室怎么实现?从在线用户管理、消息广播到双线程收发与线程池

🔥个人主页:爱和冰阔乐

📚专栏传送门:《数据结构与算法》 、C++、 《Linux操作系统》

🐶学习方向:C++方向学习爱好者

⭐人生格言:得知坦然 ,失之淡然


🏠博主简介

文章目录

  • 前言
  • 一、从请求---响应到消息路由
    • [1.1 回显服务器为什么不能直接当聊天室](#1.1 回显服务器为什么不能直接当聊天室)
    • [1.2 聊天室的整体结构](#1.2 聊天室的整体结构)
  • 二、封装网络地址
  • 三、在线用户表与路由模块
    • [3.1 在线表保存什么](#3.1 在线表保存什么)
    • [3.2 广播消息](#3.2 广播消息)
    • [3.3 用户退出与 erase-remove](#3.3 用户退出与 erase-remove)
    • [3.4 完整 `Route` 类](#3.4 完整 Route 类)
  • [四、让 UDP 服务器接收路由回调](#四、让 UDP 服务器接收路由回调)
    • [4.1 服务器循环只需要调整回调参数](#4.1 服务器循环只需要调整回调参数)
    • [4.2 服务器入口](#4.2 服务器入口)
  • 五、客户端为什么要两个线程
    • [5.1 单线程顺序读写的问题](#5.1 单线程顺序读写的问题)
    • [5.2 共享线程参数](#5.2 共享线程参数)
    • [5.3 接收线程](#5.3 接收线程)
    • [5.4 发送线程](#5.4 发送线程)
    • [5.5 完整客户端入口](#5.5 完整客户端入口)
  • 六、线程池版工程怎样编译
    • [6.1 线程池接入后多了哪些依赖](#6.1 线程池接入后多了哪些依赖)
    • [6.2 启动顺序](#6.2 启动顺序)
    • [6.3 线程池不会自动解决线程安全](#6.3 线程池不会自动解决线程安全)
  • 七、这一版聊天室仍然有哪些边界
    • [7.1 UDP 不保证消息一定到达](#7.1 UDP 不保证消息一定到达)
    • [7.2 客户端地址可能变化](#7.2 客户端地址可能变化)
    • [7.3 退出协议并不可靠](#7.3 退出协议并不可靠)
    • [7.4 广播复杂度会随人数增长](#7.4 广播复杂度会随人数增长)
  • 总结

前言

前一篇已经完成 UDP 回显服务器和字典服务器。它们都属于一问一答:客户端发一份请求,服务器处理后只把响应返回给这个客户端。

聊天室的行为不同。某个用户发来的消息不能只返回给自己,而要转发给其他在线用户。于是服务器除了收发数据,还要维护在线用户列表,知道当前有哪些客户端,并把同一条消息分别发送给它们。

客户端也要改变。回显客户端可以"发送一次,再接收一次",但聊天室随时可能收到其他用户的消息。如果程序一直阻塞在键盘输入上,它就无法及时打印服务器转发的内容。所以客户端需要把发送和接收拆开:一个线程读键盘并发送,另一个线程持续接收。

这一篇围绕这两个变化展开,并补充聊天室在线列表删除时会用到的 erase-remove 写法,以及接入线程池后必须处理的共享数据问题。


一、从请求---响应到消息路由

1.1 回显服务器为什么不能直接当聊天室

回显服务器收到消息以后,只做一件事:把消息发回报文来源地址。

cpp 复制代码
recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0,
         reinterpret_cast<sockaddr*>(&peer), &len);

sendto(sockfd, buffer, strlen(buffer), 0,
       reinterpret_cast<sockaddr*>(&peer), len);

这里的 peer 只代表当前发送者。聊天室要实现广播,服务器还需要记住之前出现过的其他客户端。

UDP 本身没有"连接已经建立"这一状态,也没有 accept 返回一个专属客户端 Socket。因此可以约定:

  • 某个地址第一次向服务器发送消息,视为上线;
  • 服务器把它加入在线用户表;
  • 后续消息转发给在线表中的所有地址;
  • 用户发送 QUIT 时,从在线表中删除。

这只是应用层协议,是我们根据业务自行规定的,并不是 UDP 内置功能。

1.2 聊天室的整体结构

服务器收到一条消息后,先把"来源地址 + 消息内容"封装为转发任务,再交给路由模块。路由模块遍历在线用户表,调用 sendto 发给每个客户端。

客户端采用两个线程:

  • 发送线程从标准输入读取消息,再发给服务器;
  • 接收线程阻塞在 recvfrom,有消息就打印出来。

图中服务器还可以继续接线程池。网络线程只负责收包,把转发任务推入任务队列;工作线程负责遍历用户表并发送,从而避免一次广播长时间占住收包循环。

整理后的工程结构如下:

text 复制代码
udp_chat/
├── InetAddr.hpp
├── Route.hpp
├── UdpServer.hpp
├── UdpServer.cc
├── UdpClient.cc
├── ThreadPool.hpp
├── Thread.hpp
├── Mutex.hpp
├── Cond.hpp
└── makefile

ThreadPool.hpp、Thread.hpp、Mutex.hpp 和 Cond.hpp 复用前面线程部分已经完成的封装。这一篇重点放在网络服务器、路由和客户端怎样使用它们,不再把线程池实现重复粘贴一遍。

二、封装网络地址

聊天室会频繁比较、保存和打印客户端地址。直接到处操作 sockaddr_in 比较零散,因此先封装一个 InetAddr 类。

cpp 复制代码
#pragma once

#include <arpa/inet.h>
#include <netinet/in.h>

#include <cstdint>
#include <string>

class InetAddr {
public:
    InetAddr() = default;

    explicit InetAddr(const sockaddr_in& addr)
        : addr_(addr), port_(ntohs(addr.sin_port)) {
        char ip[INET_ADDRSTRLEN] = {0};
        inet_ntop(AF_INET, &addr.sin_addr, ip, sizeof(ip));
        ip_ = ip;
    }

    const std::string& Ip() const {
        return ip_;
    }

    uint16_t Port() const {
        return port_;
    }

    const sockaddr_in& NetAddr() const {
        return addr_;
    }

    std::string StringAddr() const {
        return ip_ + ":" + std::to_string(port_);
    }

    bool operator==(const InetAddr& other) const {
        return addr_.sin_family == other.addr_.sin_family &&
               addr_.sin_port == other.addr_.sin_port &&
               addr_.sin_addr.s_addr == other.addr_.sin_addr.s_addr;
    }

private:
    sockaddr_in addr_{};
    std::string ip_;
    uint16_t port_ = 0;
};

比较地址时,应该比较地址族、网络序端口和 IPv4 地址,而不是只比较打印后的字符串。当前程序只使用 IPv4,地址族实际上都为 AF_INET,保留比较能让语义更完整。

StringAddr() 只负责生成便于显示的形式:

text 复制代码
192.168.1.10:52346

服务器转发消息时,可以把它放在正文前面,让其他用户知道消息来自谁。

三、在线用户表与路由模块

3.1 在线表保存什么

UDP 服务器向客户端发送数据时,需要目标 sockaddr_in。所以在线表不能只保存一个用户名,也不能只保存端口号,而要保存完整网络地址。

第一版可以直接使用:

cpp 复制代码
std::vector<InetAddr> online_users_;

每次收到消息,先检查该地址是否已经存在:

cpp 复制代码
bool IsOnline(const InetAddr& peer) const {
    for (const auto& user : online_users_) {
        if (user == peer) {
            return true;
        }
    }
    return false;
}

如果不存在,就视为首次上线:

cpp 复制代码
void AddUser(const InetAddr& peer) {
    if (!IsOnline(peer)) {
        online_users_.push_back(peer);
    }
}

这里用 IP 和端口共同区分客户端。同一台主机上可以运行多个客户端,它们的 IP 相同,但临时端口不同,仍应被当成不同用户。

3.2 广播消息

收到消息后,服务器构造显示文本,再遍历在线表:

cpp 复制代码
void Broadcast(int sockfd,
               const std::string& message,
               const InetAddr& peer) {
    std::string output = peer.StringAddr() + "# " + message;

    for (const auto& user : online_users_) {
        const sockaddr_in& target = user.NetAddr();
        ssize_t n = sendto(
            sockfd,
            output.data(),
            output.size(),
            0,
            reinterpret_cast<const sockaddr*>(&target),
            sizeof(target));

        if (n < 0) {
            std::cerr << "sendto " << user.StringAddr()
                      << " failed: " << strerror(errno) << std::endl;
        }
    }
}

当前实现会把消息也发送给发送者本人,这样发送者能确认服务器已经收到并完成转发。如果不希望回显给自己,只要在循环中跳过 user == peer 即可。

3.3 用户退出与 erase-remove

最直观的删除方法是找到元素后调用 erase:

cpp 复制代码
void DeleteUser(const InetAddr& peer) {
    for (auto it = online_users_.begin();
         it != online_users_.end();
         ++it) {
        if (*it == peer) {
            online_users_.erase(it);
            return;
        }
    }
}

也可以使用 erase-remove 惯用法:

cpp 复制代码
#include <algorithm>

void DeleteUser(const InetAddr& peer) {
    auto new_end = std::remove_if(
        online_users_.begin(),
        online_users_.end(),
        [&peer](const InetAddr& user) {
            return user == peer;
        });

    online_users_.erase(new_end, online_users_.end());
}

remove_if 这个名字很容易让人误解。它不会直接缩短容器,而是把需要保留的元素移动到前面,返回新的逻辑结尾。真正删除尾部元素,还要继续调用 erase。

remove_if 只完成逻辑移动,erase 才真正改变容器大小。两者缺一不可。

3.4 完整 Route 类

cpp 复制代码
#pragma once

#include "InetAddr.hpp"

#include <algorithm>
#include <cerrno>
#include <cstring>
#include <iostream>
#include <mutex>
#include <string>
#include <vector>
#include <sys/socket.h>

class Route {
public:
    void MessageRoute(int sockfd,
                      const std::string& message,
                      const InetAddr& peer) {
        std::vector<InetAddr> targets;
        std::string output;

        {
            std::lock_guard<std::mutex> guard(mutex_);

            if (!IsOnline(peer)) {
                AddUser(peer);
                std::cout << "new user online: "
                          << peer.StringAddr() << std::endl;
            }

            if (message == "QUIT") {
                output = peer.StringAddr() + " logout";
            } else {
                output = peer.StringAddr() + "# " + message;
            }

            targets = online_users_;

            if (message == "QUIT") {
                DeleteUser(peer);
            }
        }

        for (const auto& user : targets) {
            const sockaddr_in& target = user.NetAddr();
            ssize_t n = sendto(
                sockfd,
                output.data(),
                output.size(),
                0,
                reinterpret_cast<const sockaddr*>(&target),
                sizeof(target));

            if (n < 0) {
                std::cerr << "sendto error: "
                          << strerror(errno) << std::endl;
            }
        }
    }

private:
    bool IsOnline(const InetAddr& peer) const {
        for (const auto& user : online_users_) {
            if (user == peer) {
                return true;
            }
        }
        return false;
    }

    void AddUser(const InetAddr& peer) {
        online_users_.push_back(peer);
    }

    void DeleteUser(const InetAddr& peer) {
        auto new_end = std::remove_if(
            online_users_.begin(),
            online_users_.end(),
            [&peer](const InetAddr& user) {
                return user == peer;
            });
        online_users_.erase(new_end, online_users_.end());
    }

    std::vector<InetAddr> online_users_;
    std::mutex mutex_;
};

这里没有拿着互斥锁执行整轮 sendto。锁内只完成用户表更新和目标快照复制,真正发送时已经释放互斥锁。否则某个发送动作变慢,会把其他上线、退出和转发任务全部堵在锁外。

这份代码仍有一个现实问题:

客户端异常退出时不会主动发送 QUIT,服务器会留下失效地址。要做成长期运行的服务,还需要增加心跳和超时淘汰。

线程池版路由不能只给 vector 加锁就结束:还要避免持锁执行网络发送,否则并发任务最终仍会退化成一条缓慢的串行链路。

四、让 UDP 服务器接收路由回调

前一篇已经把服务器业务抽象成回调。聊天室仍然可以沿用,只是回调除了消息,还需要 Socket 和客户端地址。

cpp 复制代码
using Handler = std::function<void(
    int,
    const std::string&,
    const InetAddr&)>;

4.1 服务器循环只需要调整回调参数

服务器的创建、绑定和接收流程与前文一致,不需要再复制一份完整实现。区别只在于:收到报文后,除了消息和客户端地址,还要把服务器 Socket 交给路由回调,因为 Route 要使用同一个 Socket 向其他客户端发送数据。

cpp 复制代码
ssize_t n = recvfrom(sockfd_, buffer, sizeof(buffer) - 1, 0,
                     reinterpret_cast<sockaddr*>(&peer), &len);

if (n > 0) {
    buffer[n] = '\0';
    InetAddr client(peer);
    handler_(sockfd_, std::string(buffer, n), client);
}

网络层仍然只做三件事:接收报文、整理参数、调用回调。消息发给谁,由 Route 决定。

4.2 服务器入口

cpp 复制代码
#include "Route.hpp"
#include "ThreadPool.hpp"
#include "UdpServer.hpp"

#include <cstdint>
#include <functional>
#include <iostream>
#include <memory>

using task_t = std::function<void()>;
using namespace ThreadPoolModule;

int main(int argc, char* argv[]) {
    if (argc != 2) {
        std::cerr << "Usage: " << argv[0] << " port" << std::endl;
        return 1;
    }

    uint16_t port = static_cast<uint16_t>(std::stoi(argv[1]));
    auto route = std::make_shared<Route>();
    auto* pool = ThreadPool<task_t>::GetInstance();

    UdpServer server(
        port,
        [route, pool](int sockfd,
                      const std::string& message,
                      const InetAddr& peer) {
            task_t task = [route, sockfd, message, peer] {
                route->MessageRoute(sockfd, message, peer);
            };

            if (!pool->Enqueue(task)) {
                std::cerr << "enqueue route task failed" << std::endl;
            }
        });

    if (!server.Init()) {
        return 2;
    }

    server.Start();
    return 0;
}

这里捕获 shared_ptr 是为了保证任务在线程池中排队时,路由对象仍然存在。message 和 peer 继续按值捕获,不能引用服务器接收循环中的局部变量,否则任务真正执行时引用可能已经失效。

任务进入线程池以后,执行时间与入队时间分离。局部消息和客户端地址必须按值保存,不能把短生命周期对象的引用交给工作线程。

五、客户端为什么要两个线程

5.1 单线程顺序读写的问题

回显客户端常见流程是:

text 复制代码
输入 -> 发送 -> 等待响应 -> 打印 -> 再次输入

聊天室不能假设"只有我发送以后才会收到消息"。当程序正在等待用户输入时,其他用户可能已经发送多条内容。若收发都放在一个线程里,消息只能等到当前输入结束后才能读取。

UDP Socket 本身支持发送和接收。同一个文件描述符可以由发送线程调用 sendto,由接收线程调用 recvfrom。两条执行流互不要求按请求---响应顺序配对,更适合聊天室。

客户端拆成两个线程,不是因为 UDP 只能分别读写,而是因为聊天消息的到达与本地键盘输入没有固定先后关系。

5.2 共享线程参数

cpp 复制代码
struct ThreadData {
    int sockfd;
    sockaddr_in server;
};

两个线程都需要 Socket;发送线程还需要服务器地址。结构体在 main 返回前一直有效,线程退出前不能销毁。

5.3 接收线程

cpp 复制代码
void Receiver(ThreadData* data) {
    char buffer[4096];

    for (;;) {
        sockaddr_in peer{};
        socklen_t len = sizeof(peer);

        ssize_t n = recvfrom(
            data->sockfd,
            buffer,
            sizeof(buffer) - 1,
            0,
            reinterpret_cast<sockaddr*>(&peer),
            &len);

        if (n < 0) {
            std::cerr << "recvfrom error: "
                      << strerror(errno) << std::endl;
            break;
        }

        buffer[n] = '\0';
        std::cout << "\n" << buffer << std::endl;
        std::cout << "Please Enter# " << std::flush;
    }
}

接收线程长期阻塞在 recvfrom。消息到达后被唤醒,打印完成再继续等待。

5.4 发送线程

cpp 复制代码
void Sender(ThreadData* data) {
    for (;;) {
        std::string message;
        std::cout << "Please Enter# " << std::flush;

        if (!std::getline(std::cin, message)) {
            break;
        }

        ssize_t n = sendto(
            data->sockfd,
            message.data(),
            message.size(),
            0,
            reinterpret_cast<sockaddr*>(&data->server),
            sizeof(data->server));

        if (n < 0) {
            std::cerr << "sendto error: "
                      << strerror(errno) << std::endl;
            break;
        }

        if (message == "QUIT") {
            break;
        }
    }
}

标准输出可能同时被接收线程和发送线程使用,所以提示符偶尔会和消息穿插。这不影响网络逻辑,但正式终端程序可以加输出锁,或者使用专门的界面刷新方案。

5.5 完整客户端入口

cpp 复制代码
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>

#include <cerrno>
#include <cstring>
#include <iostream>
#include <string>
#include <thread>

struct ThreadData {
    int sockfd;
    sockaddr_in server;
};

// Receiver 与 Sender 的实现放在这里

int main(int argc, char* argv[]) {
    if (argc != 3) {
        std::cerr << "Usage: " << argv[0]
                  << " server_ip server_port" << std::endl;
        return 1;
    }

    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0) {
        std::cerr << "socket error: " << strerror(errno) << std::endl;
        return 2;
    }

    sockaddr_in server{};
    server.sin_family = AF_INET;
    server.sin_port = htons(static_cast<uint16_t>(std::stoi(argv[2])));

    if (inet_pton(AF_INET, argv[1], &server.sin_addr) != 1) {
        std::cerr << "invalid server ip" << std::endl;
        close(sockfd);
        return 3;
    }

    ThreadData data{sockfd, server};
    std::thread receiver(Receiver, &data);
    std::thread sender(Sender, &data);

    sender.join();

    // 让阻塞在recvfrom中的线程退出。
    shutdown(sockfd, SHUT_RDWR);
    receiver.join();

    close(sockfd);
    return 0;
}

UDP Socket 没有 TCP 连接,但 Linux 上对 Socket 调用 shutdown 可以让阻塞操作返回。也可以把接收线程设计成可取消任务,或者在退出时发送专门唤醒报文。这里重点是:不能让主线程永远卡在 receiver.join()。

六、线程池版工程怎样编译

6.1 线程池接入后多了哪些依赖

服务器入口会用到 ThreadPool.hpp,线程池继续依赖 Thread.hpp、Mutex.hpp 和 Cond.hpp。这些文件都属于头文件实现,不需要单独生成目标文件,但编译时必须链接 POSIX 线程库。

客户端使用 std::thread,底层同样需要线程库。Makefile 可以写成:

makefile 复制代码
.PHONY: all clean

all: udpclient udpserver

udpclient: UdpClient.cc
	g++ -o $@ $^ -std=c++17 -pthread

udpserver: UdpServer.cc
	g++ -o $@ $^ -std=c++17 -pthread

clean:
	rm -f udpclient udpserver

使用 -pthread 比只写 -lpthread 更完整,因为它不仅负责链接,也会启用编译阶段需要的线程相关选项。

6.2 启动顺序

编译:

bash 复制代码
make

启动服务器:

bash 复制代码
./udpserver 8888

打开两个或更多终端,分别启动客户端:

bash 复制代码
./udpclient 127.0.0.1 8888

任意客户端发送消息后,服务器把消息路由给当前在线列表中的每个地址。输入 QUIT 时,服务器先广播退出提示,再把对应端点从在线表中移除。

6.3 线程池不会自动解决线程安全

接入线程池以后,多个任务可能同时执行:

  • 两个新用户同时上线,可能同时修改 vector;
  • 一个任务正在复制在线列表,另一个任务可能删除用户;
  • 消息和客户端地址若按引用捕获,任务执行时对象可能已经失效。

因此,路由类必须保护在线表,任务对象必须保存自己需要的数据。前面的实现采用"锁内生成快照、锁外发送"的方式,既保证容器一致性,也避免长时间持锁。

线程池只负责调度任务,不负责修复业务代码中的数据竞争。共享容器、回调捕获和对象生命周期仍要由程序自己保证。

七、这一版聊天室仍然有哪些边界

这份程序能够说明 UDP 路由和全双工客户端,但它还不是生产环境聊天室。

7.1 UDP 不保证消息一定到达

消息可能丢失、重复或乱序。当前代码没有序号、确认和重传机制,所以不能把"发送成功"理解为"所有客户端都已经收到"。sendto 成功只说明数据已经交给本机内核处理。

7.2 客户端地址可能变化

客户端重启后通常会得到新的临时端口,服务器会把它当成新用户。经过 NAT 时,服务器看到的还是映射后的公网端点,不一定是客户端本机真实地址。

7.3 退出协议并不可靠

用户正常输入 QUIT 时可以删除,但进程崩溃、断网或直接关闭终端时,服务器收不到退出消息。要清理这种用户,需要心跳、最后活跃时间和超时机制。

7.4 广播复杂度会随人数增长

当前每条消息都遍历所有在线用户,时间复杂度是 O(n)。人数较少时足够直观;人数很大时,需要按房间分组、批量发送或重新设计消息分发架构。

这些限制并不妨碍当前学习目标。它们反而说明了一件事:UDP 只提供基础数据报能力,连接状态、在线管理、可靠性和业务协议都由应用层决定。

总结

本文在 UDP 请求---响应模型上继续完成了一个简单聊天室:

  1. 服务器以客户端 IP + port 作为在线用户标识;
  2. 首次收到消息视为上线,QUIT 作为应用层退出约定;
  3. 路由模块遍历在线表,通过 sendto 向多个客户端转发;
  4. 客户端用发送线程和接收线程分别处理键盘输入与网络消息;
  5. 接入线程池以后,在线用户表必须增加并发保护;
  6. 任务中的消息和客户端地址要按值保存,避免引用失效。

从回显服务器、字典服务器到聊天室,网络层的基本接口没有改变,变化的是收到数据后的业务处理方式。把网络收发和业务回调分开以后,同一个 UDP 服务器框架才能继续扩展,而不是每换一个需求就重新写一遍 socket、bind 和接收循环。

本文对应的聊天室、线程池及辅助封装可以在代码仓库中查看:Socket 编程代码。

往期博客分享

【Linux网络】Socket 编程前要搞懂什么?从 IP、端口到字节序与 sockaddr,一次理清网络通信基础

【Linux网络】UDP 服务器怎么写?从 socket、bind 到 recvfrom/sendto,手写完整回显服务器

【Linux网络】UDP 程序怎么继续封装?从 UdpSocket、回调到网络字典服务器

相关推荐
雪落漂泊1 小时前
Linux进程(三)概念:进程优先级与进程切换篇
linux·运维·服务器
拂拉氏1 小时前
【知识讲解】 Linux信号与可重入函数的认识
linux·运维·服务器
cynicism??1 小时前
Linux 与 ZXISOS 驱动开发架构对比
linux·驱动开发·架构
pikapika~~~1 小时前
计算机网络八股
服务器·网络·计算机网络
Sayai2 小时前
Kafka 跨数据中心日志同步实战:MirrorMaker 2 选型对比 + Flink 中继任务落地(双中心互备与消费者组隔离避坑)
大数据·运维·分布式·flink·kafka·消息队列·高可用
djjjx.2 小时前
Linux进程篇(十二):从零构建属于你的自定义 Shell 解释器
linux·运维·服务器
艾莉丝努力练剑2 小时前
【AI大模型接入SDK】ChatServer接口测试
网络·c++·人工智能·学习·ai·大模型·sdk
蓝祁子yoky2 小时前
Qt学习日记 Day15:网络编程(二)- UDP基础
网络·qt·学习