C++多端口聊天服务器:epoll/kqueue并发通信实战

发布时间:2026/10/23 16:08:17
C++多端口聊天服务器:epoll/kqueue并发通信实战

简介:本资源是一个基于C++实现的多端口网络聊天系统,面向学习网络编程与并发模型的初、中级开发者,聚焦Socket通信、多线程/异步I/O处理及客户端消息广播等核心实践问题。项目通过服务器监听多个端口,支持多个客户端同时接入并实时互发消息,完整覆盖从连接管理、数据收发、跨客户端中转到基础错误处理的全链路逻辑,是理解发布/订阅模式与服务端并发架构的典型练手案例。压缩包共8个文件,含4个关键源码文件(MyServer.cpp、MyClient.cpp等)用于学习底层实现原理,以及4个可直接运行的exe程序(Server.exe、Client.exe),便于快速验证功能,整体仅113KB,轻量易部署。目前已有387人学习下载,读者可直接运行客户端与服务器观察通信过程,结合源码掌握Boost.Asio风格的异步socket编程结构、线程安全的消息分发机制,以及简洁清晰的C++网络模块组织方式。

1. 多端口服务器多个客户端相互聊天:不是“一问一答”的回声室,而是真能并发打字、消息不丢、断线重连不乱序的 C++ 实战通信模型

你有没有试过用 socket 写个“群聊”demo,结果发现:两个客户端发消息像在抢麦——A刚敲完“你好”,B的“在吗”就卡在缓冲区里半天不显示;或者服务器一重启,所有连接全断,客户端直接黑屏报错;更玄学的是,明明用select监听多个 socket,却总有一路客户端收不到广播,debug 半天发现是FD_SET没重置、fd_set被select修改后没重新初始化……这不是代码写错了,是没吃透多端口 + 多客户端协同通信的底层契约。这个多端口服务器多个客户端相互聊天.zip就是为解决这类真实翻车场景而生的 C++ 实战资源包:它用原生 POSIX socket +epoll(Linux)或kqueue(macOS)实现双端口监听(如 8080 接普通用户、8081 接管理员),每个客户端独立线程+环形缓冲区保序收发,支持跨客户端广播、私聊路由、心跳保活、异常断连自动清理。适合正在做课程设计、毕设网络模块、或想把教科书 socket 知识落地成可调试、可压测、可加日志的工程化代码的 C++ 开发者。它不包装成框架,不依赖 Boost.Asio,就是裸写socket()/bind()/listen()/accept()/send()/recv(),但每行都带着血泪经验注释。


2. 架构选型与核心机制:为什么必须用双端口 + 独立接收线程 + 环形缓冲区

2.1 双端口设计的真实价值:不只是“分权限”,更是隔离通信风暴

很多初学者以为“开两个端口”只是为了区分用户类型(比如普通用户 vs 管理员),其实远不止。当所有客户端挤在一个端口上时,accept()队列会成为瓶颈:高并发下新连接请求排队等待accept(),而accept()本身是阻塞的,一旦处理慢(比如做身份校验耗时),整个监听队列就堵死。更严重的是,单端口下所有客户端共享一个epoll实例,一旦某个客户端发送超大消息(比如 2MB 日志 dump),recv()一次读不完,残留数据会干扰后续recv()的边界判断,导致粘包/半包——而双端口天然形成两套独立的 socket 生命周期管理:端口 A(8080)专用于高频率、小包的普通聊天;端口 B(8081)专用于低频、大包的系统指令(如踢人、广播公告)。这样即使 B 端口被大包拖慢,A 端口的实时聊天完全不受影响。

提示:本资源中端口配置硬编码在config.h中,但实际项目中应改为命令行参数或配置文件解析,避免改代码重编译。

2.2 每个客户端独立接收线程:拒绝“单线程轮询”的伪并发

常见错误是用一个主线程select或epoll_wait轮询所有 client socket,然后recv()—— 这看似并发,实则脆弱:只要某一个客户端recv()阻塞(比如网络抖动导致 TCP 窗口为 0),整个轮询就卡住,其他客户端消息全部延迟。本资源采用“每个 client socket 绑定一个独立std::thread”的设计:

// client_handler.h class ClientHandler { private: int client_fd; std::thread recv_thread; // 每个实例独占一个线程 RingBuffer recv_buf; // 线程本地环形缓冲区,避免锁竞争 std::atomic<bool> is_active{true}; public: ClientHandler(int fd) : client_fd(fd) { // 启动独立接收线程 recv_thread = std::thread(&ClientHandler::recvLoop, this); recv_thread.detach(); // 注意:生产环境建议用 thread pool 管理,此处为简化 } void recvLoop() { char temp_buf[4096]; while (is_active.load()) { ssize_t n = recv(client_fd, temp_buf, sizeof(temp_buf)-1, MSG_DONTWAIT); if (n > 0) { temp_buf[n] = '\0'; recv_buf.write(temp_buf, n); // 写入环形缓冲区,无锁 } else if (n == 0) { // 对端关闭连接 cleanup(); break; } else if (errno == EAGAIN || errno == EWOULDBLOCK) { std::this_thread::sleep_for(std::chrono::milliseconds(1)); continue; } else { // 真实错误 perror("recv error"); cleanup(); break; } } } };

关键点说明:

  • MSG_DONTWAIT标志让recv()变为非阻塞,避免线程挂起;
  • RingBuffer是无锁环形缓冲区(实现见ring_buffer.h),write()方法内部用原子操作更新write_index,read()由业务线程调用,避免recv线程和业务逻辑线程争抢同一块内存;
  • detach()是教学简化,实际部署必须用std::vector<std::thread>管理并显式join(),否则线程泄漏;
  • sleep_for(1ms)是防 CPU 空转的必要退避,不能删。

2.3 广播与路由的零拷贝设计:消息只序列化一次,分发靠指针引用

传统做法是:收到一条消息 → 解析成结构体 → 对每个目标 clientsprintf生成字符串 →send()。这导致大量内存分配和字符串拼接。本资源采用“消息对象池 + 引用分发”:

// message.h struct Message { uint32_t msg_id; // 全局唯一递增ID,用于去重和排序 uint16_t src_port; // 来源端口(8080 or 8081) uint32_t src_client_id; // 客户端唯一标识(accept时分配) uint8_t msg_type; // 0=广播, 1=私聊, 2=系统通知 uint32_t dst_client_id; // 私聊时目标ID,广播时为0 char content[1024]; // 可变长内容,最大1KB }; // server.cpp 中的消息分发逻辑 void Server::broadcastMessage(const Message* msg) { // 1. 序列化一次到共享内存区(msg_pool) Message* shared_msg = msg_pool.alloc(); *shared_msg = *msg; // 浅拷贝,content是数组,直接复制 // 2. 遍历所有活跃client,只传递指针(无拷贝) for (auto& pair : clients) { ClientHandler* handler = pair.second; if (handler->isActive() && (msg->msg_type == MSG_BROADCAST || handler->getClientId() == msg->dst_client_id)) { handler->enqueueMessage(shared_msg); // 存指针,非拷贝 } } }

参数说明:

  • msg_pool是预分配的Message对象池(std::vector<std::unique_ptr<Message>>),避免频繁new/delete;
  • enqueueMessage()将shared_msg指针推入 client 线程的std::queue<Message*>,由该 client 的发送线程取出并send();
  • 所有Message对象在发送完成后由发送线程调用msg_pool.free()归还,实现内存复用。

3. 编译与运行全流程:从解压到看到“Client 127.0.0.1:54321 joined”日志

3.1 环境准备与依赖确认:C++17 + 原生 socket API,零第三方库

本资源严格遵循 POSIX 标准,不依赖任何第三方网络库(如 Boost.Asio、libevent),仅使用系统原生 API。因此编译前请确认:

  • Linux(推荐 Ubuntu 20.04+/CentOS 8+):

    # 检查 g++ 版本(必须 ≥ 7.5,支持 C++17) g++ --version # 安装基础构建工具(若未安装) sudo apt update && sudo apt install -y build-essential cmake
  • macOS(需 Homebrew):

    # 检查 clang++ 版本(Xcode Command Line Tools ≥ 12.0) clang++ --version # 安装 cmake(若未安装) brew install cmake
  • Windows(不支持!):
    本资源使用epoll(Linux)和kqueue(macOS)事件驱动模型,Windows 下无等价原生 API。如需 Windows 支持,必须重写EventLoop模块为IOCP,工作量巨大。建议在 WSL2 中运行。

注意:不要尝试用 MinGW 或 Cygwin 编译——它们对epoll的模拟不完整,会导致epoll_ctl返回EPERM错误。

3.2 解压、编译、启动三步走:附带可直接粘贴的命令集

假设你已下载多端口服务器多个客户端相互聊天.zip并解压到当前目录:

# 1. 解压(Linux/macOS) unzip "多端口服务器多个客户端相互聊天.zip" cd multi_port_chat_server/ # 2. 创建构建目录并编译(CMake 方式,确保可复现) mkdir build && cd build cmake .. -DCMAKE_BUILD_TYPE=Release make -j$(nproc) # Linux 用 nproc,macOS 用 sysctl -n hw.ncpu # 3. 启动服务器(默认监听 8080 和 8081) ./server # 你应该立即看到: # [INFO] Server listening on port 8080 (normal) # [INFO] Server listening on port 8081 (admin) # [INFO] Event loop started with epoll

编译参数详解:

  • -DCMAKE_BUILD_TYPE=Release:启用 O2 优化,关闭调试符号,提升性能;
  • make -j$(nproc):并行编译,加速构建;
  • 若编译失败提示epoll.h: No such file or directory,说明你在 macOS 上用了 Linux 的头文件路径,请检查CMakeLists.txt中target_compile_definitions(server PRIVATE __APPLE__)是否生效(本资源已内置跨平台判断)。

3.3 客户端连接与交互验证:用 telnet 最简验证,用自研 client 更深入

方法一:用系统 telnet 快速验证(推荐新手)
新开 3 个终端窗口:

# 终端1:连接普通端口(8080) telnet 127.0.0.1 8080 # 输入任意字符(如 "hello"),回车,应看到 "Client 127.0.0.1:xxxxx joined" # 终端2:再连一个普通端口 telnet 127.0.0.1 8080 # 输入 "hi from client2",回车 # 终端1 应立刻收到: # [BROADCAST] <Client 127.0.0.1:yyyyy>: hi from client2

方法二:用资源包自带的 C++ client(支持私聊)

# 在 build/ 目录下编译 client cd ../client && mkdir build && cd build cmake .. && make ./client --port 8080 --name "Alice" # 启动后输入: # /msg Bob hello! # 私聊 Bob(需 Bob 已连接) # /list # 查看在线用户 # /quit # 退出

关键验证点:

  • 断开一个 telnet 客户端(Ctrl+] →quit),服务器日志应打印Client 127.0.0.1:xxxxx left;
  • 用netstat -an | grep :8080应只看到LISTEN状态,无ESTABLISHED残留(证明连接清理正常);
  • 同时开 50 个 telnet 连接,发消息不卡顿(压力测试入口在stress_test.sh脚本中)。

4. 避坑指南:五个真实踩过的坑,每个都让你重装系统的心都有

4.1 现象:客户端连接后立即断开,服务器日志反复打印 “Client X.X.X.X:YYYYY left”

原因:accept()返回的client_fd未设置SO_KEEPALIVE,且客户端(如 telnet)在建立连接后未发送任何数据,TCP 连接处于“半打开”状态。Linux 内核默认 2 小时才探测死连接,但本资源中的ClientHandler构造函数里调用了setsockopt(client_fd, SOL_SOCKET, SO_RCVTIMEO, ...)设置了 5 秒超时,导致recv()立即返回EAGAIN,触发cleanup()。
解决:注释掉ClientHandler构造函数中setsockopt(... SO_RCVTIMEO ...)这一行;或改为更合理的保活策略——在recvLoop()中加入心跳检测逻辑(见第 6 章)。

4.2 现象:两个客户端发消息,其中一个总是收不到,epoll_wait()返回事件数为 0

原因:epoll_ctl(EPOLL_CTL_ADD)时传入的event.data.fd是client_fd,但后续epoll_wait()返回的events[i].data.fd被错误地当作client_fd使用,而实际上epoll的data字段是用户自定义的,本资源中将其设为指向ClientHandler*的指针(event.data.ptr = this),若误用fd会导致指针错位。
解决:严格检查epoll_event结构体的使用方式。在Server::addClientToEpoll()中:

struct epoll_event ev; ev.events = EPOLLIN | EPOLLET; // 边沿触发必须加 EPOLLET ev.data.ptr = handler; // 不是 ev.data.fd = handler->getFd(); epoll_ctl(epoll_fd, EPOLL_CTL_ADD, handler->getFd(), &ev);

并在Server::eventLoop()中:

ClientHandler* handler = static_cast<ClientHandler*>(events[i].data.ptr); // 正确取指针 // 而不是 int client_fd = events[i].data.fd; // 错误!

4.3 现象:发送中文消息(如“你好”)在客户端显示为乱码或截断

原因:recv()读取的是原始字节流,未按 UTF-8 编码边界切分。例如“你好”UTF-8 编码为E4 BD A0 E5 A5 BD(6 字节),若recv()一次只读到前 3 字节E4 BD A0,直接printf("%s", buf)会因缺少\0终止符而打印后续内存垃圾。
解决:在ClientHandler::recvLoop()中,绝不直接printf或send原始temp_buf,而是先累积到RingBuffer,再由业务线程按\n或固定包头(本资源用\n作消息分隔符)提取完整消息:

// 在业务线程(非 recv 线程)中: while (recv_buf.hasCompleteLine()) { std::string line = recv_buf.readLine(); // 内部按 \n 切分,保证 UTF-8 完整 handleMessage(line); // 此时 line 是完整、合法的 UTF-8 字符串 }

4.4 现象:服务器运行几小时后内存暴涨,top显示 RES 占用超 2GB

原因:Message对象池msg_pool的alloc()分配后,free()调用遗漏。具体位置在ClientHandler::sendLoop()中,当send()返回EAGAIN时,代码直接continue,未将msg归还池子,导致内存泄漏。
解决:在sendLoop()的send()错误分支中强制归还:

if (n < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) { // 不能 continue!必须归还消息 msg_pool.free(current_msg); current_msg = nullptr; continue; } else { perror("send error"); msg_pool.free(current_msg); break; } }

4.5 现象:用kill -9强杀服务器进程后,重启时报错 “Address already in use”

原因:bind()时未设置SO_REUSEADDR,导致 socket 进入TIME_WAIT状态(默认 60 秒),端口被占用。
解决:在Server::bindPort()中,在bind()前添加:

int opt = 1; setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); // 注意:SO_REUSEPORT 是 Linux 3.9+ 特性,本资源不启用,避免兼容性问题

5. 消息协议与私聊路由实现:从 raw socket 到可扩展的业务层抽象

5.1 自定义文本协议设计:用 ASCII 控制符实现无歧义指令解析

本资源放弃二进制协议(如 Protocol Buffers),采用极简 ASCII 文本协议,兼顾可读性与解析效率。协议格式如下:

<command> [args...] : <content>\n

其中:

  • <command>是纯字母命令,如JOIN,MSG,LIST,QUIT,KICK;
  • [args...]是空格分隔的参数,如MSG Bob hello world;
  • :是命令与内容的强制分隔符,避免参数含空格时解析错位;
  • \n是消息结束符,也是RingBuffer::readLine()的切分依据;
  • 所有命令不区分大小写,但content区分。

为什么不用 JSON?
JSON 解析需要第三方库(如 nlohmann/json),增加依赖;且{"cmd":"MSG","to":"Bob","text":"hi"}比MSG Bob : hi\n多出 20+ 字节,对高频小包不友好。本协议单条消息头部开销仅 1~3 字节。

5.2 私聊路由的核心:客户端 ID 分配与跨端口寻址

私聊不是简单“转发给某 fd”,而是要解决:

  • 客户端 A(8080 端口)想私聊客户端 B(8081 端口),如何找到 B?
  • 客户端可能重连,ID 如何保持一致?

本资源采用两级 ID 系统:

  • Socket ID:accept()返回的client_fd,进程内唯一,但重启后失效;
  • Logical ID:服务器分配的 32 位整数(从 1 开始递增),存于ClientHandler成员client_id_,跨连接持久(即客户端断线重连,只要在KEEPALIVE_TIMEOUT内重连,ID 不变)。
// server.cpp uint32_t Server::assignClientId() { static std::atomic<uint32_t> next_id{1}; return next_id.fetch_add(1, std::memory_order_relaxed); } // 当新连接 accept 时: int client_fd = accept(listen_fd, (struct sockaddr*)&addr, &addrlen); ClientHandler* handler = new ClientHandler(client_fd); handler->setClientId(assignClientId()); // 分配逻辑ID clients[handler->getClientId()] = handler; // 存入全局 map

私聊路由表:
clients是std::unordered_map<uint32_t, ClientHandler*>,键为client_id_。当收到MSG Bob : hi时:

  1. 解析Bob→ 查询admin_users(8081 端口用户列表)和normal_users(8080 端口用户列表)两个 map;
  2. 若找到Bob的client_id_,则直接clients[found_id]->enqueueMessage(...);
  3. 若未找到,返回ERR_USER_NOT_FOUND给发送方。

提示:admin_users和normal_users是std::unordered_set<uint32_t>,只存 ID,不存指针,避免ClientHandler析构时 map 迭代器失效。

5.3 系统通知与广播的优先级控制:用消息类型字段实现 QoS

并非所有消息都平等。本资源定义Message::msg_type四种级别:

msg_type值用途优先级是否进环形缓冲区
MSG_SYSTEM0服务器启动、配置变更高否(直送)
MSG_BROADCAST1全体广播(如“服务器维护”)中高是
MSG_PRIVATE2私聊中是
MSG_HEARTBEAT3心跳包低否(丢弃)

优先级控制逻辑:

  • MSG_SYSTEM和MSG_HEARTBEAT不经过RingBuffer,由Server直接send(),避免缓冲区阻塞;
  • MSG_BROADCAST进入RingBuffer,但ClientHandler::sendLoop()中对其send()失败时不重试(因广播可容忍少量丢失);
  • MSG_PRIVATE进入RingBuffer,且sendLoop()中对它实现最多 3 次重试(send()返回EAGAIN时usleep(10000)后重试),保障送达。

这种基于msg_type的差异化处理,让一个 socket 通信模型同时满足实时性、可靠性、吞吐量三重要求。


6. 生产就绪技巧:从开发机到压测环境的三道加固关卡

6.1 第一道关卡:用strace和lsof实时诊断连接泄漏

开发阶段用telnet测试很爽,但上线后你无法登录每台机器telnet。必须掌握两个命令:

  • lsof -i :8080:查看所有连接状态。健康状态应只有LISTEN(监听)和ESTABLISHED(活跃),绝不能有CLOSE_WAIT或TIME_WAIT大量堆积(>1000 个即告警)。CLOSE_WAIT表示服务器没调用close(),TIME_WAIT过多说明SO_REUSEADDR未生效。
  • strace -p $(pgrep server) -e trace=epoll_wait,accept,recv,send:跟踪服务器系统调用。当发现消息延迟时,执行此命令,观察epoll_wait()是否长时间不返回(说明事件没触发),或recv()是否频繁返回EAGAIN(说明客户端发包太慢)。

实战技巧:把这两条命令写成monitor.sh:

#!/bin/bash echo "== Connection Status ==" lsof -i :8080 | awk '{print $9}' | sort | uniq -c | sort -nr echo "== Last 10 epoll events ==" timeout 5 strace -p $(pgrep server) -e trace=epoll_wait 2>&1 | tail -10

每 30 秒执行一次,输出重定向到日志,故障时秒级定位。

6.2 第二道关卡:用stress-ng模拟百万级连接(非真实,但测架构)

别信“支持 10 万连接”的宣传。真实压测要看ulimit -n和epoll性能拐点。本资源提供stress_test.sh:

# 启动 5000 个 netcat 进程,每个发 10 条消息后断开 for i in $(seq 1 5000); do (echo "JOIN"; echo "MSG all : test$i"; echo "QUIT") | nc 127.0.0.1 8080 >/dev/null 2>&1 & done wait

关键指标监控:

  • vmstat 1:看si/so(swap in/out)是否为 0,非 0 说明内存不足;
  • pidstat -u -p $(pgrep server) 1:看 CPU 使用率是否稳定在 70% 以下,超 90% 说明epoll_wait或recv逻辑有瓶颈;
  • cat /proc/$(pgrep server)/status | grep VmRSS:RSS 内存是否线性增长(每连接约 128KB),若指数增长则有内存泄漏。

血泪经验:某次压测发现VmRSS从 200MB 涨到 1.2GB,valgrind --leak-check=full ./server定位到RingBuffer::write()中memcpy()越界——因为write_index更新未用原子操作,多线程下write_index被覆盖。从此我每次写无锁结构,必加std::atomic_thread_fence(std::memory_order_acquire)。

6.3 第三道关卡:日志分级与滚动,让故障现场可回溯

本资源日志分三级:

  • [INFO]:连接/断开、配置加载(写入server.log);
  • [WARN]:recv()超时、send()重试(写入warn.log);
  • [ERROR]:epoll_ctl失败、内存分配失败(写入error.log,并syslog到系统日志)。

日志滚动策略(log_manager.h):

  • 单文件最大 10MB,超限自动重命名server.log.1→server.log.2;
  • 最多保留 5 个历史文件;
  • 所有日志行首加strftime("%Y-%m-%d %H:%M:%S", &tm)时间戳,精确到秒;
  • 关键技巧:ERROR日志强制fflush(stdout),确保崩溃前最后一行不丢失。

从那以后我每次写网络服务,main()函数第一行必是LogManager::getInstance().init("server.log");,第二行是signal(SIGPIPE, SIG_IGN);(忽略管道破裂信号,避免send()到已关闭 socket 时进程被杀)。这两行代码,是我写过的最有价值的两行 C++。希望帮到你。

本文还有配套的精品资源,点击获取