简介本资源是一个基于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 epollLinux或kqueuemacOS实现双端口监听如 8080 接普通用户、8081 接管理员每个客户端独立线程环形缓冲区保序收发支持跨客户端广播、私聊路由、心跳保活、异常断连自动清理。适合正在做课程设计、毕设网络模块、或想把教科书 socket 知识落地成可调试、可压测、可加日志的工程化代码的 C 开发者。它不包装成框架不依赖 Boost.Asio就是裸写socket()/bind()/listen()/accept()/send()/recv()但每行都带着血泪经验注释。2. 架构选型与核心机制为什么必须用双端口 独立接收线程 环形缓冲区2.1 双端口设计的真实价值不只是“分权限”更是隔离通信风暴很多初学者以为“开两个端口”只是为了区分用户类型比如普通用户 vs 管理员其实远不止。当所有客户端挤在一个端口上时accept()队列会成为瓶颈高并发下新连接请求排队等待accept()而accept()本身是阻塞的一旦处理慢比如做身份校验耗时整个监听队列就堵死。更严重的是单端口下所有客户端共享一个epoll实例一旦某个客户端发送超大消息比如 2MB 日志 dumprecv()一次读不完残留数据会干扰后续recv()的边界判断导致粘包/半包——而双端口天然形成两套独立的 socket 生命周期管理端口 A8080专用于高频率、小包的普通聊天端口 B8081专用于低频、大包的系统指令如踢人、广播公告。这样即使 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::atomicbool 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.hwrite()方法内部用原子操作更新write_indexread()由业务线程调用避免recv线程和业务逻辑线程争抢同一块内存detach()是教学简化实际部署必须用std::vectorstd::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::vectorstd::unique_ptrMessage避免频繁new/deleteenqueueMessage()将shared_msg指针推入 client 线程的std::queueMessage*由该 client 的发送线程取出并send()所有Message对象在发送完成后由发送线程调用msg_pool.free()归还实现内存复用。3. 编译与运行全流程从解压到看到“Client 127.0.0.1:54321 joined”日志3.1 环境准备与依赖确认C17 原生 socket API零第三方库本资源严格遵循 POSIX 标准不依赖任何第三方网络库如 Boost.Asio、libevent仅使用系统原生 API。因此编译前请确认Linux推荐 Ubuntu 20.04/CentOS 8# 检查 g 版本必须 ≥ 7.5支持 C17 g --version # 安装基础构建工具若未安装 sudo apt update sudo apt install -y build-essential cmakemacOS需 Homebrew# 检查 clang 版本Xcode Command Line Tools ≥ 12.0 clang --version # 安装 cmake若未安装 brew install cmakeWindows不支持本资源使用epollLinux和kqueuemacOS事件驱动模型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_TYPERelease make -j$(nproc) # Linux 用 nprocmacOS 用 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_TYPERelease启用 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_castClientHandler*(events[i].data.ptr); // 正确取指针 // 而不是 int client_fd events[i].data.fd; // 错误4.3 现象发送中文消息如“你好”在客户端显示为乱码或截断原因recv()读取的是原始字节流未按 UTF-8 编码边界切分。例如“你好”UTF-8 编码为E4 BD A0 E5 A5 BD6 字节若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区分。为什么不用 JSONJSON 解析需要第三方库如 nlohmann/json增加依赖且{cmd:MSG,to:Bob,text:hi}比MSG Bob : hi\n多出 20 字节对高频小包不友好。本协议单条消息头部开销仅 1~3 字节。5.2 私聊路由的核心客户端 ID 分配与跨端口寻址私聊不是简单“转发给某 fd”而是要解决客户端 A8080 端口想私聊客户端 B8081 端口如何找到 B客户端可能重连ID 如何保持一致本资源采用两级 ID 系统Socket IDaccept()返回的client_fd进程内唯一但重启后失效Logical ID服务器分配的 32 位整数从 1 开始递增存于ClientHandler成员client_id_跨连接持久即客户端断线重连只要在KEEPALIVE_TIMEOUT内重连ID 不变。// server.cpp uint32_t Server::assignClientId() { static std::atomicuint32_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_mapuint32_t, ClientHandler*键为client_id_。当收到MSG Bob : hi时解析Bob→ 查询admin_users8081 端口用户列表和normal_users8080 端口用户列表两个 map若找到Bob的client_id_则直接clients[found_id]-enqueueMessage(...)若未找到返回ERR_USER_NOT_FOUND给发送方。提示admin_users和normal_users是std::unordered_setuint32_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 traceepoll_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 traceepoll_wait 21 | 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 21 done wait关键指标监控vmstat 1看si/soswap in/out是否为 0非 0 说明内存不足pidstat -u -p $(pgrep server) 1看 CPU 使用率是否稳定在 70% 以下超 90% 说明epoll_wait或recv逻辑有瓶颈cat /proc/$(pgrep server)/status | grep VmRSSRSS 内存是否线性增长每连接约 128KB若指数增长则有内存泄漏。血泪经验某次压测发现VmRSS从 200MB 涨到 1.2GBvalgrind --leak-checkfull ./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。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?