简介:这是一个基于Linux平台、用C语言从零实现的聊天室项目源码包,面向网络编程学习者、Linux C开发初学者,以及完成课程设计或毕业设计的在校生。项目采用经典的Client/Server架构,实现了用户注册登录、在线用户查看、私聊“悄悄话”、全员群发、退出保存聊天记录,以及服务端统一保存聊天日志等核心功能;同时扩展了admin管理员踢人、禁言和自定义表情、快捷短语等附加特性,还包含文件传输功能,功能链完整。资源共83个文件,压缩包仅77KB,主要包含21个C源文件、8个头文件、25个Makefile构建脚本及若干.o目标文件、可执行文件和备份文件,目录结构清晰,便于阅读与二次修改。已有1529人学习下载,适合想通过一个完整项目快速掌握Linux套接字编程、多线程及C语言工程组织方式的读者,是一份可直接编译运行的练手好素材。
1. 这就是个大三课设,却能让你把 TCP 和 IO 模型吃透
如果你在某平台下载过一个叫Linux下C实现的聊天室.rar的压缩包,大概率是个经典到不能再经典的教学项目:单机服务器、多客户端、TCP 长连接、消息广播,再加点线程或多路复用。别看它名字朴素,这几乎是 Linux 系统编程与网络编程的集合体——只要把它跑通一次,你对socket 生命周期、粘包、select 与 epoll 的差异、僵尸进程处理的理解会立刻从“背概念”变成“有肌肉记忆”。我见过不少学完《Unix 环境高级编程》但写不出一个并发 echo 服务的人,也见过靠这个项目在简历上写出“熟悉 Linux 高并发网络模型”的学生——这东西确实值得花三天时间把它从编译到调优完整走一遍。
文章的读者分两类:一类是刚学完 C 语言,想在 Linux 上挑战第一个带网络通信的项目,希望有一个能跑、能改、能看懂的代码骨架;另一类是已经会写点 socket 但总是遇到“服务器莫名其妙挂掉”“客户端掉线后服务器 CPU 100%”“消息乱码”这类坑,想找一份可对照的 checklist。这两类人的共同点是不想听空洞的原理,而是想知道最少的代码、最稳妥的编译命令和最关键的几个参数。
下面这份笔记,是拿这类项目源码做底,按我自己的课后复盘习惯整理出来的:先拆架构解决它为什么这样设计,再逐段贴出可运行的核心代码,最后把我在复现时撞过的坑一条条列出来。如果你手上那份 rar 包里的代码风格跟我这里不一样,不要急着换下载源——重点不在代码抄谁的,而在于把“多客户端通信”这条主干打通之后,你才能真正看懂别人写的每一行。
2. 聊天室架构:先定 C/S 模型与 IO 模型,再写第一行代码
2.1 为什么聊天室几乎都选 C/S,而不是 P2P
标题里的“聊天室”三个字,约束了数据流向:所有客户端把消息发给一个中心节点,再由这个节点转发给所有其他人。这就是经典的 C/S(Client/Server)模型。P2P 模型虽然在大文件共享和音视频通话里很常见,但它要求每个节点都能对外暴露端口、处理其他节点的连接请求,这对一个运行在校园网或家用路由器后面的 C 语言程序来说极不友好——NAT 穿透是个大坑,不是课设阶段该碰的东西。
C/S 模型的天然好处是:消息路由、用户列表管理、在线状态维护都集中在服务器端。聊天室需要的“全局广播”语义用 C/S 实现只需要一重 for 循环;如果换成 P2P,你要在每台客户端上保存一份全量邻居表,某个节点掉线了还要触发路由表更新,复杂度是指数级上升的。所以常见的 Linux C 聊天室项目,服务器端核心数据结构通常是一张描述符数组,外加一张客户信息表;客户端则保持一个与服务器相连的 socket 描述符,循环做两件事:读用户键盘输入然后发送,监听服务器回包然后打印。
这个模型在功能上还有一个隐蔽优势:登录通知、退出通知、用户昵称管理这类“控制消息”和普通聊天消息可以走同一条 TCP 连接,只要在应用层加一个字节或几个字节的“消息类型”字段就能区分。当你的代码里出现了MSG_LOGIN、MSG_CHAT、MSG_LOGOUT这类宏时,说明你已经在认真设计协议,而不是把字符串裸发到对端。
2.2 TCP 是唯一靠谱的选择:UDP 聊天室有四个反直觉的坑
聊天室对消息可靠性有硬要求——你不能接受自己发出去的话中间丢几个字节。UDP 虽然省去连接管理,但你得自己实现序号、ACK、超时重传、去重,这等于要在应用层再造一个 mini TCP,没必要。TCP 把这些全部内置,你只管发送字节流,内核负责分段、重传、流量控制。几乎所有 Linux 下 C 语言聊天室项目都使用 TCP 协议,原因就在这里。
用 TCP 聊天有四个容易反直觉的坑,我建议你在动手前先记住:
第一个是字节流粘包。TCP 是流协议,不保证你send一次,对端recv一次就是一条完整消息。两次send的数据可能一次性到达,或者一次send的数据分两批到达。解决办法不是去改协议,而是给每条消息定一个固定长度的头部(比如 4 字节的整数表示消息长度),接收方先收满头部再按头部长度收消息体。
第二个是write 返回不代表对端收到。write只表示数据写入内核发送缓冲区,真正到达对端是 TCP 的 ACK 机制保证的。如果对端窗口太小,write可能只写入一部分,你要记录剩余字节并在POLLOUT事件到来时继续写。
第三个是read 返回 0 与返回 -1 的语义完全不同。返回 0 表示对端已关闭,你必须清理这个连接;返回 -1 需要查errno,如果EINTR则继续读,如果ECONNRESET则说明对端崩溃过,同样要清理。这个坑我在第 4 章专门列了一条踩坑记录,很多初学者的“服务器跑着跑着 CPU 100%”就是没区分这两种情况。
第四个是TCP_NODELAY 的取舍。对聊天室这种实时交互场景,你希望每条消息都立即发送,所以要在 socket 上设置TCP_NODELAY关闭 Nagle 算法;但如果你要做压力测试、希望吞吐最大化,去掉这个选项反而更好。这个小参数会影响不小的手感差异。
2.3 select、poll、epoll 怎么选:聊天室场景下我选 select 模型
多客户端并发处理是聊天室的核心实现难点,III 种常见模型分别是:
- fork 多进程:每个客户端一个进程。隔离性好,但进程开销大,2 个 CPU 的机器拉 50 个客户端就开始费劲,而且进程间共享消息队列要靠 IPC,麻烦。
- pthread 多线程:每个客户端一个线程。共享内存方便,但线程创建和切换开销也不小,并且对临界区(全局消息队列、用户表)的保护会让代码结构变得复杂。
- IO 多路复用:单进程监听多个 fd 的事件。select、poll、epoll 都是这类,用一门心思在一个循环里处理所有请求,写得好时性能可以吊打前两者,代码也更紧凑。
聊天室场景选型,我的结论是:课设和中小型聊天室,select 足够。理由有三个:
其一,select 模型是最好理解的,它把“有没有事件”变成“哪些 fd 可读可写”,一个FD_ISSET门就能判断,适合学习;epoll 的边缘触发模式容易写出漏事件的问题。其二,聊天室并发数预期不高,几十个客户端完全在 select 的 1024 个 fd 上限内,这个上限在第三代聊天室里能撑住几百客户,很够用。其三,select 的缺点(fd 上限低、每次都要重新构建监听集合、有 O(n) 扫描成本)在这个场景里都不是致命问题,而它带来的代码简洁性对学习和维护明显更友好。
// 只维护一个监听集合,使用 select 处理所有 IO 事件 fd_set read_fds; FD_ZERO(&read_fds); FD_SET(server_fd, &read_fds); int max_fd = server_fd; for (int i = 0; i < MAX_CLIENTS; i++) { int fd = clients[i].fd; if (fd > 0) { FD_SET(fd, &read_fds); if (fd > max_fd) { max_fd = fd; } } } int ret = select(max_fd + 1, &read_fds, NULL, NULL, NULL);这段代码的逻辑是用FD_SET把服务器 socket 和所有客户端 socket 放进去,然后阻塞等待其中任意一个可读。注意 select 的参数是最大 fd 加 1,不是 fd 集合的长度。我见过有人把 MAX_CLIENTS 直接传进去,结果新来的客户端永远触发不了事件。每次循环都要重建 read_fds,这是 select 的特点,别试图保存旧集合复用——内核会改写它。
2.4 单线程还是多线程:消息广播的锁竞争问题
如果你选 select 单线程,那服务器的运行逻辑就是:一个循环,三个判断分支(有新连接、有客户端发消息、有客户端断开)。这个模型没有锁竞争概念,因为所有操作都在同一个线程里发生,天然串行。好处是简单,坏处是——如果某个客户端的消息需要发送给 100 个其他客户端,这 100 次send会阻塞整个服务器循环,一个写法不当就会拖慢所有人。
如果你用多线程(主线程 accept,工作线程 recv/广播),那就要小心锁竞争。经典做法是给客户端数组和消息队列各加一个互斥锁,广播时要先锁再遍历;但更隐蔽的问题是“某个客户端发送缓冲区满”导致 send 阻塞在锁里,其他线程全部排队等这把锁。这就是为什么很多聊天室项目宁可单线程 select 也不愿意多线程——锁的粒度和边界不好拿捏,而 select 单线程虽然吞吐上限低,但行为可预测,对聊天室教学项目来说这是巨大优势。
我的建议是:先画清楚架构图再写代码。服务器核心就两个数据结构:client_info clients[MAX_CLIENTS]和fd_set read_fds;核心操作就一个broadcast_message函数。把这三个东西搞清楚,代码 300 行以内就能跑起来。
3. 从零到跑通:服务器端三块核心代码这样写
3.1 建立监听 socket:从 socket() 到 listen() 的三个必调参数
服务器端第一步是创建监听 socket,设置地址复用,绑定端口然后是监听。这一步有四个参数值得你背下来:
int server_fd = socket(AF_INET, SOCK_STREAM, 0); if (server_fd < 0) { perror("socket create failed"); exit(EXIT_FAILURE); } int opt = 1; setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(SERVER_PORT); if (bind(server_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind failed"); close(server_fd); exit(EXIT_FAILURE); } if (listen(server_fd, BACKLOG) < 0) { perror("listen failed"); close(server_fd); exit(EXIT_FAILURE); }参数说明:socket()的第三个参数 0 表示按协议自动选择流式协议,也就是 TCP;setsockopt的SO_REUSEADDR是刚需——没有它,服务器进程崩溃后,内核里连接的 socket 还要停留一段时间,立刻重启会报 “Address already in use”,这在开发时很常见,加了这个选项就能立刻重启;listen()的第二个参数 BACKLOG 是等待服务的已完成连接队列长度,设成 64 已经很大了,再多客户端同时连进来也只是短暂排队,不丢连接。
这里容易踩的坑是忘记htons()和htonl()。如果你用一个 8888 的端口直接赋值给sin_port而不转字节序,那实际绑定的是 0x22b8 这个数对应的端口——几乎不可能等你程序去访问。所以htons是必须的,不是可省略的装饰。
3.2 accept 与 select 主循环:为什么不能在每个 accept 后单独阻塞
很多人写多客户端服务器时喜欢循环 accept,每个客户端就来一个,这样是同步阻塞模型,一次只能处理一个客户端。正确写法是在 select 循环里做 accept,这样不会阻塞住其他客户端的读写事件。
while (1) { fd_set read_fds_tmp = read_fds; int ret = select(max_fd + 1, &read_fds_tmp, NULL, NULL, NULL); if (ret < 0) { if (errno == EINTR) continue; perror("select error"); break; } if (FD_ISSET(server_fd, &read_fds_tmp)) { int client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &addr_len); if (client_fd < 0) { perror("accept failed"); continue; } // 找一个空位登记新客户端 int slot = -1; for (int i = 0; i < MAX_CLIENTS; i++) { if (clients[i].fd == 0) { slot = i; break; } } if (slot < 0) { char *msg = "Server full, try later.\n"; send(client_fd, msg, strlen(msg), 0); close(client_fd); } else { clients[slot].fd = client_fd; snprintf(clients[slot].name, NICK_LEN, "user_%d", client_fd); FD_SET(client_fd, &read_fds); if (client_fd > max_fd) max_fd = client_fd; } } // 检查所有客户端 fd 的可读事件 for (int i = 0; i < MAX_CLIENTS; i++) { int fd = clients[i].fd; if (fd > 0 && FD_ISSET(fd, &read_fds_tmp)) { handle_client(fd, i); // 收发消息、处理断开 } } }代码逻辑说明:先把read_fds复制到临时变量,因为 select 会改写传入的集合;在循环里先扫描服务器 socket 的可读事件处理新连接,再扫描所有客户端 fd 处理收发。clients[i].fd == 0表示空槽位,实际代码里要注意 socket 描述符虽然从 0 开始分配,但服务器进程启动时 stdin/stdout/stderr 占用了 0、1、2,所以fd从 0 使用是安全的。我习惯用一个伪造的空值如-1表示空槽位,避免与有效的描述符 0 冲突,这个细节在很多多客户端接管中会减少莫名其妙的 bug。
这里有个新手常写错的地方:把 accept 放在 select 之外,用 while 套 accept 再起线程去处理每个客户端。那个模型不是不能跑,但并发量上来之后线程数量不受控,或者在客户端断开后线程不回收导致资源耗尽。用 select 统一管理后,客户端数量可控,代码路径清晰,排错也容易。
3.3 recv 与消息边界:处理粘包的最小可靠方案
收到客户端消息后,最忌直接recv(buf, sizeof(buf))就以 buf 为完整消息去广播。因为 TCP 是字节流,对方可能一次只发来半个包,也可能一次发来两包。如果你的协议没有定义消息边界,客户端 1 秒钟发 10 条消息,服务器可能只收到 3 次 recv,每解密一次 buf 里面含多条消息,你按一条消息广播出去就串味了。
最常见的可靠做法是固定长度头部 + 变长消息体。这里我给出一个便于初学者理解的小协议:每条消息以“|type|length:content”形式发送,其中 type 是消息类型,length 是 content 的字节数。解析时用两个缓冲区:一个存已经到达但还没处理完的数据,一个存解析完要广播的消息。
#define BUFFER_SIZE 8192 // 每个客户端维护一个收包缓冲 typedef struct { char buffer[BUFFER_SIZE]; int len; // 当前缓冲区内有效数据长度 } recv_buffer; // 从 fd 读入数据追加到缓冲,然后尝试解析完整消息 int process_recv(int fd, recv_buffer *rb) { char tmp[BUFFER_SIZE]; ssize_t n = recv(fd, tmp, sizeof(tmp), 0); if (n <= 0) { return -1; // 0 表示对端关闭,负数是错误,统一让上层清理客户端 } if (rb->len + n >= BUFFER_SIZE) { // 缓冲区溢出风险,说明对方不按协议发数据,建议踢掉该连接 return -1; } memcpy(rb->buffer + rb->len, tmp, n); rb->len += n; // 尝试从 buff 中按 "|type|length:" 格式提取消息 int consumed = parse_and_broadcast(fd, rb); // 把剩余字节移到 buff 开头,供下次继续拆包 if (consumed > 0) { memmove(rb->buffer, rb->buffer + consumed, rb->len - consumed); rb->len -= consumed; } return 0; }这里的核心技巧是“粘包不慌”:先把数据记录下来,然后进行尝试解析,能拆几条广播几条,剩余留在缓冲里等下一次 recv。这样即使对方一次发了 100 条消息,你的缓冲也能慢慢消化。注意当recv返回 0 时不能继续往缓冲里拷贝,返回 0 说明对端已关闭,后面的数据不是空,是无效的。
3.4 广播消息的意义与实现:不要让服务器变成“单聊”
广播是整个聊天室的灵魂,也是最容易写出性能问题的地方。最简单的广播就是遍历所有客户端 fd,依次send一遍。但如果某个客户端接收窗口满了(比如对方关闭了接收先关闭了 socket),对这个 fd 的send可能阻塞或者触发 SIGPIPE 直接把服务器干掉。有经验的实现会先把客户端 fd 设置为非阻塞,然后循环发送并处理EAGAIN。
// 广播给除发送者外的所有人 void broadcast(char *msg, int len, int sender_fd) { for (int i = 0; i < MAX_CLIENTS; i++) { int fd = clients[i].fd; if (fd <= 0 || fd == sender_fd) continue; ssize_t sent = send(fd, msg, len, MSG_NOSIGNAL); if (sent < 0) { // 可能是对方已断开,也可能是内核缓冲区满 perror("send failed"); // 对于断开或者 reset 的连接,用 handle_client 收尾清理 handle_disconnect(i); } } }MSG_NOSIGNAL这个标志是必须记住的:新建 socket 默认会发送 SIGPIPE 信号,如果你不对它忽略,服务器在向一个已关闭的 socket 发送数据时会直接收到 SIGPIPE 默认终止进程。比你去捕获信号更简单的是用MSG_NOSIGNAL禁止它发信号,然后通过返回值来处理错误——这是比较标准的一种处理方式。
广播时不用加锁?只要你的服务器是单线程 select 模型,就不用加锁。应对多线程模型的锁竞争,一个常见技巧是给每个客户端做一个独立的发送队列,广播时只负责把消息拷贝到对应队列,然后唤醒各自的写线程(或通过POLLOUT事件),由目标线程自己控制发送节奏。这是从“同步发送”向“异步削峰”过渡的关键一步,但聊天室场景通常不需要这么重——除非你的目标客户过千。
4. 把客户端接进来:send 的时机、非阻塞模式与优雅退出
4.1 客户端 connect 成功后,第一件要做对的事
客户端代码比服务器简单,但坑也不少。连接时用getaddrinfo解析域名或 IP,不要直接inet_pton写死——这会让程序更难维护。connect 成功后,第一件事是设置 socket 为非阻塞还是保持阻塞,取决于你的 UI 是“终端输入”还是“有事件循环”。终端版最简单,保持阻塞即可,因为你只需要两个阻塞调用交替:scanf等待输入,recv等待接收。
但这里有个隐藏问题:scanf会阻塞在终端,你无法同时处理服务器发来的消息。比如别人朝你发了一条消息,在你还未输入时,这条消息不会被打印。你的程序卡在scanf里,收到的消息一直攒在内核缓冲区。这就是为什么很多聊天室客户端做成“双线程”或者用select同时监听 stdin 和 socket。
int server_fd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in server_addr; memset(&server_addr, 0, sizeof(server_addr)); server_addr.sin_family = AF_INET; server_addr.sin_port = htons(PORT); inet_pton(AF_INET, SERVER_IP, &server_addr.sin_addr); if (connect(server_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) { perror("connect failed"); exit(EXIT_FAILURE); } // 双通道监听:stdin 和 server_fd fd_set read_fds; while (1) { FD_ZERO(&read_fds); FD_SET(STDIN_FILENO, &read_fds); FD_SET(server_fd, &read_fds); int max_fd = server_fd > STDIN_FILENO ? server_fd : STDIN_FILENO; int ret = select(max_fd + 1, &read_fds, NULL, NULL, NULL); if (ret < 0) break; if (FD_ISSET(STDIN_FILENO, &read_fds)) { // 读一行输入,发给服务器 char msg[BUFFER_SIZE]; if (fgets(msg, sizeof(msg), stdin) == NULL) break; build_and_send_message(server_fd, MSG_CHAT, msg); } if (FD_ISSET(server_fd, &read_fds)) { // 服务器来了数据,读并打印 ssize_t n = recv(server_fd, recv_buf, sizeof(recv_buf), 0); if (n <= 0) { printf("server disconnected.\n"); break; } write(STDOUT_FILENO, recv_buf, n); } }客户端用 select 同时监听文件描述符 0(键盘)和网络描述符,这个方案确实比较可靠。有一个很常见的失误是为了省事用scanf("%s")读输入,但这样遇到空格会截断,而且客户端错误地把空格后面的内容留在缓冲区,导致下一轮读入时出现错乱。用fgets会可靠一些。
4.2 send 失败后不要只打印错误:要区分三种错误码
send 返回值小于 0 时,查errno就足够,但很多人还是不区分就把它当成“对端断开”处理,结果误把EINTR当成断线把连接关了。其实EINTR(被信号打断)只需要继续发送即可,EAGAIN/EWOULDBLOCK表示发送缓冲区满,需要等待写事件,而EPIPE/ECONNRESET才算真正的连接异常。
ssize_t send_all(int fd, const char *buf, size_t len) { size_t sent = 0; while (sent < len) { ssize_t n = send(fd, buf + sent, len - sent, MSG_NOSIGNAL); if (n > 0) { sent += n; continue; } if (errno == EINTR) { continue; // 被信号打断,重试 } else if (errno == EAGAIN || errno == EWOULDBLOCK) { usleep(1000); // 缓冲满了,稍微让出 CPU continue; } else { perror("send fatal error"); return -1; // EPIPE / ECONNRESET 等真正的错误 } } return (ssize_t)sent; }这个send_all函数就是我在聊天室项目里经常复用的工具。它保证一次send调用不完整时能续发完整个消息,在缓冲区背后重试。参数的注意点是给send的 len 必须是你尚未发送的数据长度,而不是整包大小,所以要用buf + sent定位。这同样是以防你只写了一次send(fd, buf, len)就走人,导致消息被截断。
4.3 客户端优雅退出:close 前先 shutdown,避免 RST 掉线
常见的错误写法是close(fd)之后立刻退出,但如果这个 fd 的发送缓冲区里还有没发完的数据,close会把缓冲区数据发完后再发 FIN,这本来是正常的。问题是如果你在 close 之后进程立刻退出,内核可能没来得及完成整个握手过程。更推荐的做法是用 shutdown 控制关闭方向:
void client_shutdown(int fd) { if (shutdown(fd, SHUT_WR) == 0) { // 已发 FIN,但继续读取服务器端剩余的字节 char buf[4096]; while (recv(fd, buf, sizeof(buf), 0) > 0) { // 读剩余数据,直至服务器也关闭连接或超时 } } close(fd); }先关写方向、保留读方向,把所有剩余数据消化完再 close。这样服务器端收完你发完的消息后才收到 FIN,它那边不会产生ECONNRESET,你后面要清理的“半关闭连接”就会减少很多。很多人只依靠close,会导致服务器端偶尔出现 “Connection reset by peer” 的报错,实际上那是客户端 exit 太快、内核缓冲未发完导致 RST。
5. 避坑指南:Linux 聊天室最容易翻车的 5 个细节
5.1 “服务器退出后立刻重启显示 Address already in use”
现象:服务器程序被 Ctrl+C 终止,立刻重新运行,bind 报错并显示地址已在使用。
原因:socket 默认设置了SO_REUSEADDR未开启,断开连接后,TCP 连接会进入 TIME_WAIT 状态,保留端口约 60 秒(2 MSL),在此期间无法复用地址。
解决:在bind之前给服务器 socket 设置SO_REUSEADDR,这是第 3 章代码里已经写过的。如果你的代码没写这一句,趁早加上。注意这不是银弹,如果是多个进程同时 bind 一个端口导致的冲突,SO_REUSEPORT才能解决,但聊天室场景不涉及。
5.2 “有一个客户端断开,服务器 CPU 狂转,100% 不落”
现象:某个客户端按 Ctrl+C 退出后,服务器进程 CPU 占用率飙到 100%,消息发不出来,其他客户端卡顿。
原因:最常见的是recv返回 0 后,循环不判断n <= 0,下一次又对同一个 fd 发起recv,再次返回 0,在 while(1) 里疯狂空转。还有可能是你没把该客户端的 fd 从 select 监听集合中移除,select 每次立即返回该 fd 可读,然后recv返回 0,又不清理,导致死循环。
解决:recv返回值小于等于 0 时要立即调用清理函数:将该 fd 从FD_CLR,关闭 socket,对应 slot 重置为-1,并广播退出通知。我一般还会在 recv 返回 0 时打印一条日志,方便确认是哪个客户端掉线:
if (n == 0) { printf("client %d disconnected.\n", fd); close(fd); FD_CLR(fd, &read_fds); clients[i].fd = -1; continue; }5.3 “消息广播时出现乱码或带着历史残渣”
现象:客户端收到的消息里多了一些上一次接收的内容,或者打印时尾部出现乱码。
原因:没有处理粘包,直接按一次 recv 的缓冲区内容当完整消息打印。前面说过 TCP 是流式协议,单一 send 的数据可能不完整到达;当你又正好带着上一次的残余数据做 memcpy 时,就可能在拼接时产生错位。
解决:在服务器端和客户端都实现“先收头部、再收消息体”的协议解析。如果你嫌麻烦,还有一个很土的兜底方案:每条消息末尾放一个换行符\n,接收方按\n切分消息。但这种方案在跨平台和特殊内容时也会翻车,最好的办法还是长度前置。
5.4 “客户端输入中文显示正常,发送到服务器后乱码”
现象:Linux 终端默认编码是 UTF-8,Windows 终端可能用 GBK,两端字符编码不一致导致互发后乱码。
原因:这不是 socket 问题,是编码问题。聊天室直接透传字节,不做编码转换,Windows 发来的 GBK 字节到 Linux 终端按 UTF-8 解码自然乱。
解决:如果只是课设,最简单粗暴的方案是统一要求所有客户端在 UTF-8 环境下运行,并在 GUI 或说明文档里标注只能输入 UTF-8 内容。如果要做正经兼容,就在客户端发送前把 GBK 转成 UTF-8。Linux 下可以用iconv的库函数,但前提是你知道当前终端的编码。我的建议是不要在这个问题上恋战,聊天室练的是网络通信,不是编码转换。
5.5 “服务器端send同一个 fd 已经断开,进程直接挂掉”
现象:服务器运行中突然毫无征兆地退出,终端打印 “Broken pipe”。
原因:send到一个已关闭的 socket,内核发送 SIGPIPE 信号,默认动作是终止进程。你send前没检查客户状态,或者对方已经 close 了,你还在广播。
解决:前面已经给了两个惯用做法,要么signal(SIGPIPE, SIG_IGN)忽略它,要么send时加MSG_NOSIGNAL。我推荐加MSG_NOSIGNAL,因为忽略全局信号在有多条线程时可能掩盖其他 socket 的问题。另外,在广播之前可以额外检查一下客户端 fd 是否仍然存在于自己的集合中——很多看似莫名的挂掉,不是竞态,而是你忘了移除已断开客户端的槽位。
6. 把多路复用升级到 epoll:小改动换来大吞吐
select 模型跑通后,如果你想把聊天室提升到真正能支撑数百人、甚至上千人的规模,势必要迁移到 epoll。epoll 和 select 最本质的区别是:select 每次调用都要把 fd 集合从用户态拷到内核态再拷回来,全量扫描;而 epoll 在内核维护一棵红黑树加一个就绪链表,只用epoll_wait拿已经就绪的事件。这个差异决定了 select 在 fd 数量大了以后性能下降得很明显,而 epoll 在连接数多、活跃连接少的场景下吞吐几乎线性增长。
我在自己的某个模拟项目 X 里做过一次从 select 到 epoll 的改造,核心代码改动其实不大。首先创建 epoll 实例,然后把服务器 socket 加进去:
int epoll_fd = epoll_create1(0); struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = server_fd; epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &ev);之后主循环从select换成epoll_wait。关键变化是不再需要 max_fd,不再需要每次重建集合,内核直接返回一批就绪的事件,你只需要遍历events数组:
#define MAX_EVENTS 1024 struct epoll_event events[MAX_EVENTS]; while (1) { int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1); if (nfds < 0) { if (errno == EINTR) continue; perror("epoll_wait error"); break; } for (int i = 0; i < nfds; i++) { if (events[i].data.fd == server_fd) { // 处理 accept,把新连接添加到 epoll int client_fd = accept(server_fd, NULL, NULL); ev.events = EPOLLIN | EPOLLRDHUP; ev.data.fd = client_fd; epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev); } else { // 处理客户端事件,记得判断 EPOLLRDHUP 或者 recv 返回 0 } } }一个细节是EPOLLRDHUP,这是 epoll 特有的对端关闭连接事件。加上这个标志后,即使你没有立即recv,内核也会通知你连接关闭,对及时清理无用的连接很有帮助。如果你不加,就只能在可读事件里通过recv返回 0 来判断关闭。
另外要注意的是,epoll 默认是电平触发(LT),和 select 行为一致,不容易漏事件;如果你加EPOLLET变成边缘触发(ET),事件只在状态变化时通知一次,这就要求你把数据全部读完(读到EAGAIN),否则会漏掉后续数据。初学者建议先使用 LT,等完全理解事件的来龙去脉后再切 ET。聊天室这类长连接、消息频繁且短小的场景,用 LT 写起来更稳妥。
迁移完成之后,你可以做一个简单的压测:写一个小脚本模拟 1000 个客户端连接,然后统计服务器的 CPU 占用率。select 版本在 fd 过三百之后 CPU 占用就开始明显上涨,epoll 版本在 1000 连接时还很平稳。这种一数据对比哪怕只是放在项目 README 里,都比口头上说“熟悉 epoll”有说服力得多。
我的一个习惯是,在从 select 迁移到 epoll 后,会保留一个后台日志输出当前在线客户端数,并每隔 10 秒打印一次。这个日志在做压力测试时非常有价值——能直观看到连接是否在堆积、系统能否及时回收断开连接。看起来是个小习惯,但它在排查“为什么我的服务器内存越来越大”“为什么连接数缓慢增长却不下降”这类时效性问题时,帮你节省大量时间。
如果你现在手上已经有那份.rar源码,建议按这个顺序去读:先跑起来,再加日志,再改掉粘包处理,最后试着把 select 换成 epoll。每一步都亲手改一个点,跑一次验证,再改下一步。不要一上来就读全部代码试图理解每一行,因为聊天室的复杂性不在于语法,而在于多客户端交互的时间线。希望这篇笔记能帮你少走点弯路。
本文还有配套的精品资源,点击获取