资讯动态

Linux C语言局域网聊天室:从Socket到多线程并发实现

发布时间:2026/9/10 21:40:45 来源:尧图企业网站定制
简介一份基于Linux平台、使用C语言实现的局域网聊天室源码包面向网络编程初学者以及课程设计、毕业设计的学生演示如何通过TCP/IP与多线程机制构建一个小型局域网聊天服务。源码采用客户端与服务器分离模式完整实现消息群发、历史数据查询、好友列表管理、好友上下线提醒等常用功能其中涉及套接字通信、多客户端并发处理、在线状态广播、聊天记录存储与检索等关键知识点兼具可读性与可扩展性。压缩包共七个文件主要包括三个C源文件、一个头文件、一个Makefile构建脚本、一份txt说明文档以及一份docx版程序设计文档整体大小仅六十四KB内容精炼解压后可直接阅读和编译。附带的设计文档能帮助理解各模块的设计思路便于对照源码深入调试与二次开发。目前已有100人学习下载适合通过实际项目掌握Linux网络编程、多线程协作以及基础协议应用并可根据需要进一步添加私聊、文件传输等新功能。1. 从Socket到聊天室为什么这个项目值得动手拆一遍不少人在学完Linux网络编程后卡在同一个地方read函数会阻塞、write返回值不检查、多线程下共享变量乱改这些零散知识点单独看都能理解一拼成完整的聊天室就四处漏风。这个基于Linux使用C语言实现的局域网聊天室源码恰好把网络编程的核心链路串起来了——TCP连接管理、多线程并发、消息广播、历史记录落盘、上下线状态通知。它不做花哨的界面全部走终端交互反而能让注意力集中在数据传输和处理逻辑上。适合刚啃完APUE卷socket章节的学生也适合想快速回顾select/poll之前那套经典多线程模型的在职开发。配套的docx文档把设计思路写得很直白源代码结构简单到可以直接从头读完改造成自己项目的通信底层也非常方便。下面我按实际拆源码的顺序把协议设计、服务器端并发模型、客户端交互和历史查询这几块逐一说明。2. TCP选型、数据帧格式与多线程模型2.1 为什么局域网即时通讯首选TCP而非UDP聊天室最基础的需求是“消息不能丢”。UDP虽然省去了握手和保活的麻烦但局域网环境下的丢包率虽然低客户端突然掉线时UDP根本无从感知服务器维护在线列表会变得十分不可靠。TCP提供的流式传输和连接状态检测能让服务器立刻通过read返回0或errno发现对端关闭这是实现“好友上下线提醒”的前提。另一个原因是代码复杂度TCP的accept、recv、send接口更直观多线程模型下每个连接一个fd逻辑边界清晰。如果你练习过UDP的sendto循环就知道要在业务层自己实现确认和重传那工作量已经接近重写一个迷你TCP了。局域网场景下带宽充足TCP头部开销几乎不影响体验所以这个项目选TCP是合理且务实的。2.2 自定义协议帧消息头和消息体分离源码中的common.h定义了通信双方约定的报文格式这是整个聊天室的技术地基。协议设计为固定长度头部加上变长消息体避免粘包和半包。#define MSG_MAX_LEN 1024 #define NAME_MAX_LEN 32 typedef enum { MSG_TYPE_BROADCAST 1, // 群发消息 MSG_TYPE_HISTORY, // 历史记录请求/响应 MSG_TYPE_ONLINE_LIST, // 在线列表查询 MSG_TYPE_LOGIN, // 登录通知 MSG_TYPE_LOGOUT, // 退出通知 MSG_TYPE_PRIVATE // 预留的私聊消息 } msg_type_t; typedef struct { int type; // 消息类型见msg_type_t int from_len; // 发送者名字长度 int body_len; // 消息体长度 } msg_header_t; typedef struct { msg_header_t header; char from[NAME_MAX_LEN]; // 发送者名字 char body[MSG_MAX_LEN]; // 消息内容 } chat_message_t;头部的三个int字段用定长方式传输接收方先读满12字节的msg_header_t再从from_len和body_len得知后续需要读取多少字节。这种设计的好处是解析时不需要扫描整个数据流找分隔符效率高也不会误拆中文内容。注意msg_header_t没有使用#pragma pack或__attribute__((packed))这是因为在网络传输中发送方和接收方可能在不同的Linux发行版上编译结构体默认对齐可能产生额外填充字节。更稳妥的做法是像项目里这样把各字段单独发送或者将头部字段统一为网络字节序的uint32_t。我在自己的代码里会额外增加一个magic字段用于校验防止把错误的数据流当成消息处理。2.3 多线程与多进程的选择客户端连接处理项目采用一连接一线程thread-per-connection模型服务器主线程执行accept循环每接受一个新的客户端连接就创建一个pthread_t线程专门处理该连接。相比fork子进程线程共享进程地址空间操作同一份在线链表和消息计数不需要借助IPC直接加锁读写即可。代价是一个线程栈默认约8MB如果客户端数量达到数百虚拟内存压力会比较大。对于局域网聊天室这种几十人量级的场景这是最清晰的并发方案。void *client_handler(void *arg) { int client_fd *(int *)arg; char peer_ip[INET_ADDRSTRLEN]; // ... 获取客户端IP加入在线列表 ... chat_message_t msg; while (1) { memset(msg, 0, sizeof(msg)); // 分两次读取先读头再读体 if (recv(client_fd, msg.header, sizeof(msg.header), 0) 0) break; if (recv(client_fd, msg.from, msg.header.from_len, 0) 0) break; if (recv(client_fd, msg.body, msg.header.body_len, 0) 0) break; handle_message(client_fd, msg); // 根据type分发处理 } remove_client(client_fd); pthread_detach(pthread_self()); close(client_fd); return NULL; }recv三次变长读取减少了粘包概率但没完全解决如果网络出现半包第二次recv可能只收到一部分。严谨的写法是循环读取直到收满所需字节我在改进时会封装一个readn函数。另外需要注意线程参数arg指向的client_fd是栈上变量所以建议用堆上分配的方式传参否则主线程继续循环时可能修改同一块内存。源码里用malloc分配新fd副本再传入这点值得学习。3. 服务器端消息群发、在线列表与上下线通知3.1 服务器核心数据结构与锁维护在线客户端需要一张全局链表每个节点保存fd、昵称、IP等。由于多线程同时读写这张表必须用互斥锁保护。源码中在clientlist.c里实现了这个结构插入和删除都封装成独立函数。typedef struct client_node { int fd; char name[NAME_MAX_LEN]; struct sockaddr_in addr; struct client_node *next; } client_node_t; static client_node_t *head NULL; static pthread_mutex_t clients_mutex PTHREAD_MUTEX_INITIALIZER; void add_client(client_node_t *node) { pthread_mutex_lock(clients_mutex); node-next head; head node; pthread_mutex_unlock(clients_mutex); } void remove_client(int fd) { pthread_mutex_lock(clients_mutex); client_node_t *cur head, *prev NULL; while (cur) { if (cur-fd fd) { if (prev) prev-next cur-next; else head cur-next; free(cur); break; } prev cur; cur cur-next; } pthread_mutex_unlock(clients_mutex); }加锁粒度要控制好。很多新手会把整个广播循环也放在锁内导致一次慢客户端拖住所有发送。正确做法是在遍历时先锁住链表然后逐个取出fd并调用sendsend是阻塞操作可能耗时长所以应该在取到fd后尽快解锁。比较稳妥的方式是用引用计数或浅拷贝的方式拿一份fd数组出来然后释放锁再执行send。3.2 消息广播与上下线提醒的实现广播函数遍历所有在线节点将收到的消息原样转发给除自己外的其他客户端。上下线提醒本质上是广播一条特殊类型的系统消息只是消息体写的是“xx上线了”。void broadcast_message(chat_message_t *msg, int sender_fd) { pthread_mutex_lock(clients_mutex); client_node_t *cur head; while (cur) { if (cur-fd ! sender_fd) { ssize_t sent send(cur-fd, msg, sizeof(*msg), 0); if (sent -1) { // 发送失败通常会走到remove_client流程 perror(send error); } } cur cur-next; } pthread_mutex_unlock(clients_mutex); } void notify_status(const char *name, int online) { chat_message_t sys_msg; memset(sys_msg, 0, sizeof(sys_msg)); sys_msg.header.type online ? MSG_TYPE_LOGIN : MSG_TYPE_LOGOUT; sys_msg.header.from_len strlen(name); snprintf(sys_msg.body, sizeof(sys_msg.body), %s, online ? 上线了 : 下线了); sys_msg.header.body_len strlen(sys_msg.body); strncpy(sys_msg.from, name, NAME_MAX_LEN); broadcast_message(sys_msg, -1); }注意broadcast_message用sender_fd -1来表示系统广播这样所有客户端都会收到。上下线提醒应该在客户端加入链表之前发送还是之后发送如果先发通知再插入链表其他客户端无法看到这位新用户因为发送通知时他还没在列表里。正确顺序是先插入链表再广播上线的同时广播一份在线列表给所有客户端。这样收到上线通知的客户端可以主动向新用户问好而新用户也能立刻拿到完整的成员名单。源码main.c里就是按这个顺序处理的。3.3 历史数据查询的服务端存储策略源码将聊天记录保存在一个普通文本文件chat.log中。每个广播或私聊消息在转发的同时同步追加写入文件。查询历史时客户端发送MSG_TYPE_HISTORY类型消息服务器打开文件读出全部内容通过send返回给客户端。写入时要注意多线程写文件的原子性。两个线程同时调用write到同一文件描述符如果消息长度不超过PIPE_BUFLinux下4096字节内核会保证write系统调用是原子的。这里每条消息通常不到几百字节所以直接用write追加问题不大。更稳妥的做法是给文件写操作单独加一把锁或让所有写入集中在专门的日志线程。我建议在关键函数里加锁因为如果把write放在broadcast的循环里广播期间的任何失败都会影响写日志。void append_history(chat_message_t *msg) { FILE *fp fopen(chat.log, a); if (!fp) { perror(fopen chat.log); return; } fprintf(fp, [%ld] %s: %s\n, time(NULL), msg-from, msg-body); fclose(fp); }每次打开和关闭文件会有开销但对聊天室这种低频写入完全可接受。开发环境里用fopen/fclose能保证数据立即落盘避免缓冲区滞留。查询时使用fgets逐行读取并拼接通过send一次性或分段发送给请求者。还记得MSG_MAX_LEN为1024吗如果历史文件超过这个长度服务器端要拆包发送客户端则需要循环接收。源码里直接用了定长数组返回这个在长聊天记录下会截断我实测后把发送逻辑改成了按行拆包。4. 客户端实现交互线程、好友列表与本地记录4.1 客户端主流程与双线程结构客户端程序main.c的主函数逻辑清晰创建socket、connect到服务器、启动接收线程处理来自服务器的数据同时主线程循环读取用户输入并发送。接收线程的存在是为了及时处理广播消息、上下线提醒、在线列表更新等异步事件避免用户正输入时错过消息。void *recv_thread(void *arg) { int sock_fd *(int *)arg; chat_message_t msg; while (1) { int n recv(sock_fd, msg, sizeof(msg), 0); if (n 0) { printf(服务器连接已断开\n); exit(EXIT_FAILURE); } switch (msg.header.type) { case MSG_TYPE_BROADCAST: printf(\n[%s] %s\n, msg.from, msg.body); break; case MSG_TYPE_ONLINE_LIST: printf(当前在线用户\n%s\n, msg.body); break; case MSG_TYPE_LOGIN: printf( %s 上线了\n, msg.from); break; case MSG_TYPE_LOGOUT: printf( %s 下线了\n, msg.from); break; case MSG_TYPE_HISTORY: printf(-----历史记录-----\n%s\n, msg.body); break; default: break; } printf( ); fflush(stdout); } return NULL; }注意接收线程里printf之后要重新打印提示符并刷新stdout否则用户正在输入的内容会和消息混在一起。这里有个小技巧在printf消息前输出换行消息结束后再打印“ ”提示符并fflush这样即使主线程正在等待输入用户也能看到新消息插入且自己的输入内容不会被冲掉。真正的控制台聊天室还会加ncurses库做独立输入区但这个项目的终端交互方式对学习socket更友好。4.2 好友列表查看与上下线状态维护服务器返回在线列表的方式有两种一种是指令触发时服务器遍历链表拼装字符串另一种是每次客户端登录或退出时服务器主动推送最新列表。源码采用后者好处是客户端无需主动请求就能维持较新的好友状态视图。客户端侧只需要在收到MSG_TYPE_ONLINE_LIST时用strtok按换行符拆分然后更新本地的一个char online_names[][NAME_MAX_LEN]数组即可。void update_online_list(const char *data) { memset(online_names, 0, sizeof(online_names)); online_count 0; char tmp[MSG_MAX_LEN]; strncpy(tmp, data, sizeof(tmp) - 1); char *token strtok(tmp, \n); while (token online_count MAX_CLIENTS) { strncpy(online_names[online_count], token, NAME_MAX_LEN - 1); token strtok(NULL, \n); } }注意strtok会修改原字符串所以必须先复制一份data。判断一个好友是否在线只需遍历online_names下线则意味着列表中没有对应名字。这个设计不需要客户端维护好友关系数据库一切以服务器广播的列表为准属于无状态模式。坏处是如果客户端错过了某次列表推送会导致状态不准确所以还需要一个主动查询指令也就是发送MSG_TYPE_ONLINE_LIST请求。4.3 历史记录查询与本地文件缓存客户端发送查询请求时直接把MSG_TYPE_HISTORY类型的空消息发给服务器服务器就返回整个聊天日志。这里要处理网络传输长度不稳定的情况所以客户端的接收逻辑不能只依赖一次recv。我用如下循环处理可能的多段响应void request_history(int sock_fd) { chat_message_t req; memset(req, 0, sizeof(req)); req.header.type MSG_TYPE_HISTORY; send(sock_fd, req, sizeof(req.header), 0); char buf[MSG_MAX_LEN]; int total 0; int n; while ((n recv(sock_fd, buf total, sizeof(buf) - total - 1, 0)) 0) { total n; if (total sizeof(buf) - 1 || n MSG_MAX_LEN) break; } buf[total] \0; printf(%s\n, buf); return; }这个循环要想稳定工作前提是服务器一次性发送完整数据后关闭连接或发送特定的结束标记。当前源码里服务器用一次send发送历史文件内容然后保持连接不变这样客户端会阻塞在下一个recv等待新消息。所以更好的做法是服务器将历史响应用一个独有的body长度标记客户端通过body_len判断是否收完整。我在自己改进版里是让服务器把文件内容分多次发送并在末尾发送一个“END”字符串客户端循环接收直到遇到END这样更通用。客户端也可以把收到的历史记录追加写入本地history_YYYYMMDD.log方便离线查看。写入时的打开方式用O_APPEND保证多写不覆盖。这个功能虽然不是必须但面试聊到“如何做持久化”时可以多一个加分项。5. 进阶优化与排错让聊天室从能跑到好跑5.1 用netstat和telnet直接验证服务器状态源码编译后先用最基本的方式验证网络栈是否正常。服务器运行./server后用netstat -tlnp查看监听端口应能看到LISTEN状态的IPv4 socket。然后使用telnet作为简易模拟客户端手动敲入二进制消息较麻烦但可以用printf管道配合nc工具。比如nc -v 127.0.0.1 8888如果项目源码监听的端口不是8888需要去main.c里确认#define PORT的设置。nc连接上后输入任意文本如果服务器有回显或广播其他端口说明链路通。实际排查中我发现最常见的错误是socket bind失败原因是端口被占用或没有设置SO_REUSEADDR。在bind前加一行int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt));这样可以快速重启服务器而不需要等TIME_WAIT超时。5.2 粘包与半包问题定位方法局域网高并发下多条小消息可能连续到达触发TCP的Nagle算法合并成一个大包接收方一次recv拿到多条消息这就是粘包。本项目使用固定头部加变长体的做法能区分每一条消息的边界。但前提是recv必须完整地读到头和体。我在调试中曾遇到客户端发两次send第一次发header第二次发bodybody较小时两次可能落入同一个TCP段接收方先读12字节头部没问题然后要根据header里的body_len继续读。但我在第三个recv阶段只调用一次如果body没到齐就返回0会直接break导致消息丢失。所以需要把读取操作封装成readnssize_t readn(int fd, void *buf, size_t count) { size_t left count; char *ptr (char *)buf; while (left 0) { ssize_t ret recv(fd, ptr, left, 0); if (ret 0) return ret; ptr ret; left - ret; } return count; }然后用readn替换所有直接recv。这样处理半包问题后粘包问题其实也迎刃而解因为每次都严格按照头部声明的长度读取剩下的数据会留到下次recv再解析。5.3 线程安全的日志写入与退出清理历史记录写入chat.log时如果广播线程A和线程B同时调用append_history可能发生交错写入行内容穿插。在append_history内部加一把全局日志锁static pthread_mutex_t log_mutex PTHREAD_MUTEX_INITIALIZER; void append_history_safe(chat_message_t *msg) { pthread_mutex_lock(log_mutex); FILE *fp fopen(chat.log, a); if (fp) { fprintf(fp, [%ld] %s: %s\n, time(NULL), msg-from, msg-body); fclose(fp); } pthread_mutex_unlock(log_mutex); }服务器main函数收到SIGINT退出时一定要先遍历链表并逐个close所有客户端fd再释放链表内存最后关闭监听fd。否则客户端socket不会被内核立即回收重启服务器时可能报Address already in use。可以用signal(SIGINT, handler)注册清理函数handler里将全局运行标志置为0主循环退出后执行清理。5.4 用strace快速定位临时故障如果客户端发送消息后服务器端没有转发一个高效排查手段是strace跟踪进程的系统调用。假设服务器pid是1234strace -p 1234 -e tracenetwork,write,read -o /tmp/server_trace.log然后让另一台客户端发一条消息查看trace日志里recv返回的字节数、send调用的目标fd和返回值。如果send返回-1且errno为EPIPE说明对端已关闭连接如果recv返回0说明客户端主动断开了。这样能快速判断问题在网络层还是业务逻辑。这个技巧比打印log更快尤其适合排查只在特定网络场景下才出现的偶发问题。5.5 扩展方向select/poll多路复用与private消息当前项目是线程阻塞模型客户端数量增加后在大量线程切换上会有开销。作为进阶练习可以用select或poll重写服务器事件循环将listenfd和所有客户端fd放入fd_set统一处理可读事件。这样单线程就能支撑上百连接。另外协议里预留了MSG_TYPE_PRIVATE实现私聊很简单消息体里包含“目标名字:内容”服务器解析后转发给对应fd即可。这些改动都在可控范围内建议把源码复制一份出来先备份再逐项重构每次都能跑通再继续下一步。最后有个实用小技巧把makefile里的CFLAGS改为-Wall -Wextra -g编译时把警告全部暴露出来。我看到源码Makefile里只有一行gcc -o这在实际工作中不够用。加上-fno-stack-protector调试栈问题时关闭保护但上线前务必恢复。多读几遍clientlist.c里链表插入删除的逻辑把它画成图理解指针操作后再去改比我在这里写一千字都有效。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价