前面 25 天我们捡了一堆"神装"(进程、线程、Socket、epoll),今天就把它们打造成一把能屠龙的"宝刀"!
前言(你已经走了很远了)
如果你能坚持到这里,请先给自己鼓个掌!过去 25 天我们干了什么:
- Day 1-13:在 Linux 下安了家(环境搭建)、学会了走路(Linux 命令)、懂得怎么造房子(GCC/Makefile 编译工具)
- Day 14-19:学会了分身术(多进程/多线程并行干活)和传声筒(管道/信号通信)
- Day 20-25:打通了任督二脉(网络 Socket 编程 + 多种并发模型)
通俗类比:前面 25 天你在捡装备,今天是第 26-27 天,你要亲手把这些装备组装成一辆能开的车。
最终效果: 你启动一个服务器程序之后,可以在多个终端窗口(甚至不同电脑)上用 telnet 连上去,输入昵称,然后任何人发消息,所有人都能实时看到------像一个极简版的微信群聊。
一、项目需求分析与架构设计
第1步:搞清楚我们要做什么
一个极简的"微信"后端,需要实现 4 个功能:
|----|----------|-----------------------|
| 序号 | 功能 | 说明 |
| ① | 多客户端同时连接 | 多个人可以同时连上你的聊天室 |
| ② | 昵称注册 | 连上后先发 NICK:张三 给自己起个名字 |
| ③ | 消息广播(群聊) | 发 SAY:大家好,服务器转发给所有人 |
| ④ | 上下线通知 | 有人进来或离开,所有人都能看到系统提示 |
第2步:理解技术选型(为什么选这些?)
|--------|--------------------------|--------------------------------|
| 组件 | 技术方案 | 一句话解释 |
| I/O 模型 | epoll(水平触发 LT) | 像一个大堂经理,同时盯着几百个客户,谁举手就去处理谁 |
| 并发处理 | 线程池 | 像银行柜台,固定 4 个窗口,来任务就分配,不临时招人 |
| 数据同步 | 互斥锁(pthread_mutex_t) | 像厕所门锁,一个人进去锁上,别人在外面等 |
| 通信协议 | 简单文本指令 | 不用复杂格式,直接发 SAY:xxx,telnet 就能调试 |
| 构建工具 | Makefile | 一键编译,不用每次手敲 gcc 命令 |
| 版本控制 | Git + GitHub | 代码存档 + 面试作品 |
第3步:看懂架构图
通俗解读:主线程是"前台接待"(epoll),接到客人后把需求写在纸条上,扔进任务队列;线程池里的"业务员"(worker)从队列里取纸条处理,处理完再通知全体客户。
二、搭建 epoll 主框架 + 连接管理
Day 26 目标:让服务器能同时接受多个客户端连上来,先把"接待大厅"搭好。
第4步:创建 server.c 文件并定义数据结构
在终端中执行:
bash
# 进入你的工作目录
cd ~/chat-server
# 创建 server.c 文件
touch server.c
用编辑器打开 server.c(推荐 vim / nano / VSCode Remote),写入以下代码:
cpp
// server.c --- 高并发聊天室服务器
#include <stdio.h> // 标准输入输出,printf用
#include <stdlib.h> // 标准库,malloc/free/exit用
#include <string.h> // 字符串处理,strcpy/strlen用
#include <unistd.h> // POSIX API,close/read/write用
#include <errno.h> // 错误码,errno用
#include <sys/socket.h> // Socket API,socket/bind/listen/accept用
#include <netinet/in.h> // Internet地址结构体,sockaddr_in用
#include <arpa/inet.h> // IP地址转换,inet_ntoa用
#include <sys/epoll.h> // epoll API,epoll_create/epoll_ctl/epoll_wait用
#include <pthread.h> // POSIX线程,互斥锁/条件变量用
// ============ 常量定义 ============
#define MAX_EVENTS 1024 // epoll 最多同时监听的事件数
#define BUFFER_SIZE 4096 // 每次读数据的缓冲区大小(4KB)
#define MAX_CLIENTS 100 // 最多同时支持的客户端数量
// ============ 数据结构 ============
// 客户端结构体 ------ 每个连上来的用户对应一条记录
typedef struct {
int fd; // 套接字描述符(可以理解为"连接编号")
char nickname[32]; // 用户昵称(最多31个字符)
int is_online; // 在线标记:1=在线,0=离线
} Client;
// ============ 全局变量 ============
Client g_clients[MAX_CLIENTS]; // 客户端池(全局共享的"花名册")
pthread_mutex_t g_clients_mutex = PTHREAD_MUTEX_INITIALIZER; // 保护花名册的锁
int g_epoll_fd; // epoll 实例的文件描述符
int g_server_fd; // 服务器监听套接字的文件描述符
第5步:编写辅助函数 ------ 设置非阻塞 + 注册到 epoll
继续在 server.c 后面追加以下代码:
cpp
// ============ 辅助函数 ============
// 将套接字设置为非阻塞模式
// 通俗解释:默认的 read/write 会"堵住"等数据,非阻塞模式下读不到数据立刻返回,
// 配合 epoll 使用才能高效处理几千个连接
void set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0); // 获取当前文件状态标志
fcntl(fd, F_SETFL, flags | O_NONBLOCK); // 在原标志上追加"非阻塞"属性
}
// 将文件描述符注册到 epoll 的监听列表
// epoll_fd:epoll 实例的标识
// fd:要被监听的文件描述符
// events:要监听的事件类型(EPOLLIN = 有数据可读)
void add_to_epoll(int epoll_fd, int fd, uint32_t events) {
struct epoll_event ev;
ev.events = events | EPOLLET; // EPOLLET 表示边缘触发模式(效率更高)
ev.data.fd = fd; // 把 fd 存起来,事件触发时能知道是哪个连接
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, &ev) == -1) {
perror("epoll_ctl add"); // 如果注册失败,打印错误并退出
exit(1);
}
set_nonblocking(fd); // 注册后立即设为非阻塞
}
⚠️ 小白预警:fcntl 是 Linux 系统调用,类似于修改文件的"开关属性"。Windows 上没有这个函数,本教程必须在 Linux 环境下操作(WSL、虚拟机或云服务器均可)。
第6步:编写 main 函数 ------ 初始化服务器 + 主循环
继续追加:
cpp
// ============ 主函数 ============
int main() {
// ── 步骤 6.1:创建 TCP 服务器套接字 ──
// 类比:先铺一条电话线,准备接听来电
g_server_fd = socket(AF_INET, SOCK_STREAM, 0);
// AF_INET = 使用 IPv4 地址
// SOCK_STREAM = 使用 TCP 协议(可靠传输,像打电话)
// 设置地址复用:程序关闭后立即重启,不会提示"端口被占用"
int opt = 1;
setsockopt(g_server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// ── 步骤 6.2:绑定地址和端口 ──
struct sockaddr_in addr;
addr.sin_family = AF_INET; // IPv4
addr.sin_port = htons(8888); // 监听 8888 端口,htons 把数字转成网络字节序
addr.sin_addr.s_addr = INADDR_ANY; // 监听本机所有 IP 地址
bind(g_server_fd, (struct sockaddr*)&addr, sizeof(addr));
// ── 步骤 6.3:开始监听 ──
// 第二个参数 10 表示"排队等待的最大连接数"
listen(g_server_fd, 10);
printf("Chat Server running on port 8888...\n");
printf("打开新终端,输入 telnet 127.0.0.1 8888 即可连入\n");
// ── 步骤 6.4:创建 epoll 实例 ──
// epoll_create1(0) 创建一个 epoll 对象,返回它的文件描述符
g_epoll_fd = epoll_create1(0);
// 把服务器监听套接字加入 epoll 监听(有新连接来时触发事件)
add_to_epoll(g_epoll_fd, g_server_fd, EPOLLIN);
// ── 步骤 6.5:初始化客户端数组 ──
for (int i = 0; i < MAX_CLIENTS; i++) {
g_clients[i].is_online = 0; // 全部标记为离线
}
// ── 步骤 6.6:进入主循环(事件驱动) ──
struct epoll_event events[MAX_EVENTS]; // 存放触发的事件
while (1) {
// epoll_wait:等待事件发生,-1 表示一直等到有事件才返回
// nfds = 本次触发了几个事件
int nfds = epoll_wait(g_epoll_fd, events, MAX_EVENTS, -1);
// 逐个处理触发的事件
for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == g_server_fd) {
// ── 情况A:有新的客户端连接 ──
struct sockaddr_in client_addr;
socklen_t len = sizeof(client_addr);
int client_fd = accept(g_server_fd,
(struct sockaddr*)&client_addr, &len);
if (client_fd < 0) continue; // accept失败就跳过
printf("新客户端连入: %s:%d (fd=%d)\n",
inet_ntoa(client_addr.sin_addr), // IP地址
ntohs(client_addr.sin_port), // 端口号
client_fd);
// 把新客户端的 fd 加入 epoll 监听
add_to_epoll(g_epoll_fd, client_fd, EPOLLIN);
// 在客户端数组中找一个空位分配给它
pthread_mutex_lock(&g_clients_mutex); // 🔒 上锁
for (int j = 0; j < MAX_CLIENTS; j++) {
if (g_clients[j].is_online == 0) {
g_clients[j].fd = client_fd;
g_clients[j].is_online = 1;
// 给一个默认昵称:User + fd编号
snprintf(g_clients[j].nickname,
sizeof(g_clients[j].nickname),
"User%d", client_fd);
break;
}
}
pthread_mutex_unlock(&g_clients_mutex); // 🔓 解锁
} else {
// ── 情况B:已连接的客户端发来了数据 ──
int client_fd = events[i].data.fd;
// Day 26 先占位,Day 27 会改成交给线程池处理
printf("收到 fd=%d 的数据,等待线程池处理...\n", client_fd);
}
}
}
return 0;
}
Day 26 验收标准:编译运行后,你可以在多个终端用 telnet 127.0.0.1 8888 连上服务器,终端上会打印"新客户端连入"的信息。
三、线程池实现 + 消息广播业务逻辑
目标:加上线程池,让多个工作线程处理消息解析和广播,实现真正的群聊。
第7步:定义任务队列和线程池结构
将以下代码追加到 server.c 的全局变量区域(g_server_fd 声明之后,main 函数之前):
cpp
// ============ 线程池相关数据结构 ============
// 任务节点 ------ 每个任务就是"某个用户发来的某条消息"
typedef struct Task {
int client_fd; // 发送消息的客户端 fd
char data[BUFFER_SIZE]; // 消息内容
int data_len; // 消息长度
struct Task *next; // 指向下一个任务(链表结构)
} Task;
// 任务队列的头尾指针(队列 = 先进先出,像排队买奶茶)
Task *g_task_head = NULL; // 队头
Task *g_task_tail = NULL; // 队尾
// 保护任务队列的互斥锁和条件变量
// 条件变量通俗解释:线程没活干的时候就在这"睡觉",有活了被叫醒
pthread_mutex_t g_task_mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t g_task_cond = PTHREAD_COND_INITIALIZER;
#define THREAD_POOL_SIZE 4 // 线程池大小:4个工作线程
第8步:编写 broadcast_message ------ 广播消息给所有人
追加以下代码:
cpp
// ============ 核心业务函数 ============
// 广播消息:把消息转发给除发送者之外的所有在线客户端
// sender_fd:发送者的 fd(不给他自己发)
// msg:消息正文
// nickname:发送者的昵称
void broadcast_message(int sender_fd, const char *msg, const char *nickname) {
char buffer[BUFFER_SIZE];
// 拼接成 "[昵称] 消息内容" 的格式
snprintf(buffer, sizeof(buffer), "[%s] %s\n", nickname, msg);
pthread_mutex_lock(&g_clients_mutex); // 🔒 上锁:保护客户端列表
for (int i = 0; i < MAX_CLIENTS; i++) {
// 跳过自己、跳过不在线的
if (g_clients[i].is_online && g_clients[i].fd != sender_fd) {
write(g_clients[i].fd, buffer, strlen(buffer));
}
}
pthread_mutex_unlock(&g_clients_mutex); // 🔓 解锁
}
⚠️ 小白预警:write 是非阻塞的。如果某个客户端的接收缓冲区满了,write 会返回 -1 并设置 errno = EAGAIN,这条消息就会丢失。这是本项目的已知缺陷,进阶扩展中会提到如何修复。
第9步:编写 handle_client_data ------ 解析指令 + 执行业务
追加以下代码:
cpp
// 处理客户端发来的数据:解析 NICK / SAY / QUIT 指令
void handle_client_data(int fd, const char *data, int len) {
char nickname[32] = {0};
char msg[BUFFER_SIZE] = {0};
// 步骤 9.1:查找该 fd 对应的昵称
pthread_mutex_lock(&g_clients_mutex);
for (int i = 0; i < MAX_CLIENTS; i++) {
if (g_clients[i].fd == fd && g_clients[i].is_online) {
strcpy(nickname, g_clients[i].nickname);
break;
}
}
pthread_mutex_unlock(&g_clients_mutex);
// 步骤 9.2:根据指令类型分发处理
if (strncmp(data, "NICK:", 5) == 0) {
// ── 指令①:设置昵称 ──
char new_name[32] = {0};
sscanf(data, "NICK:%31s", new_name); // 提取冒号后面的昵称
pthread_mutex_lock(&g_clients_mutex);
for (int i = 0; i < MAX_CLIENTS; i++) {
if (g_clients[i].fd == fd) {
strcpy(g_clients[i].nickname, new_name); // 更新昵称
break;
}
}
pthread_mutex_unlock(&g_clients_mutex);
// 广播:"XX 加入了聊天室"
broadcast_message(fd, "加入了聊天室", new_name);
} else if (strncmp(data, "SAY:", 4) == 0) {
// ── 指令②:发送群聊消息 ──
sscanf(data, "SAY:%[^\n]", msg); // 提取冒号后面的全部内容(直到换行)
broadcast_message(fd, msg, nickname);
} else if (strncmp(data, "QUIT", 4) == 0) {
// ── 指令③:退出聊天室 ──
broadcast_message(fd, "离开了聊天室", nickname);
// 关闭连接并从客户端列表移除
close(fd);
pthread_mutex_lock(&g_clients_mutex);
for (int i = 0; i < MAX_CLIENTS; i++) {
if (g_clients[i].fd == fd) {
g_clients[i].is_online = 0;
break;
}
}
pthread_mutex_unlock(&g_clients_mutex);
}
}
第10步:编写线程池核心函数
追加以下代码:
cpp
// ============ 线程池 ============
// 工作线程的主函数 ------ 每个工作线程都在死循环里等任务
// 通俗类比:银行柜员坐在窗口前,没客户就发呆(pthread_cond_wait),
// 有客户来了被叫醒(pthread_cond_signal)
void* worker_thread(void *arg) {
while (1) {
pthread_mutex_lock(&g_task_mutex); // 🔒 锁任务队列
// 队列为空 → 睡觉等待(pthread_cond_wait 会自动解锁,醒来后重新加锁)
while (g_task_head == NULL) {
pthread_cond_wait(&g_task_cond, &g_task_mutex);
}
// 从队头取出一个任务
Task *task = g_task_head;
g_task_head = g_task_head->next;
if (g_task_head == NULL) {
g_task_tail = NULL; // 队列空了,尾指针也置空
}
pthread_mutex_unlock(&g_task_mutex); // 🔓 解锁
// 处理任务:调用第9步写的业务处理函数
handle_client_data(task->client_fd, task->data, task->data_len);
// 处理完释放任务内存
free(task);
}
return NULL;
}
// 初始化线程池:创建指定数量的工作线程
// detach 表示线程结束后自动回收资源,不需要主线程调用 pthread_join
void init_thread_pool(int num) {
pthread_t tid;
for (int i = 0; i < num; i++) {
pthread_create(&tid, NULL, worker_thread, NULL);
pthread_detach(tid);
}
printf("线程池已启动,共 %d 个工作线程\n", num);
}
第11步:修改 main 函数 ------ 整合线程池
回到 main 函数,做两处修改:
修改点①:在 epoll 主循环开始前,初始化线程池。
找到 main 函数中 // ── 步骤 6.6:进入主循环 这一行,在它上面插入:
cpp
// ── 步骤 6.5.5:启动线程池(Day 27 新增)──
init_thread_pool(THREAD_POOL_SIZE);
修改点②:在 else 分支中,把收到的数据打包成任务、扔进任务队列。
找到 main 函数中的 } else { 分支("情况B:已连接的客户端发来了数据"),把里面的内容替换为:
cpp
} else {
// ── 情况B:客户端发来了数据(Day 27 完善)──
int client_fd = events[i].data.fd;
char buffer[BUFFER_SIZE];
// 非阻塞读取数据
int n = read(client_fd, buffer, sizeof(buffer) - 1);
if (n > 0) {
// 读到数据 → 创建任务节点
buffer[n] = '\0'; // 字符串结尾符
Task *t = (Task*)malloc(sizeof(Task));
t->client_fd = client_fd;
memcpy(t->data, buffer, n + 1); // 复制数据(含结尾符)
t->data_len = n;
t->next = NULL;
// 把任务放入队列尾部
pthread_mutex_lock(&g_task_mutex);
if (g_task_tail) {
g_task_tail->next = t; // 队列不空,追加到尾
} else {
g_task_head = t; // 队列为空,作为头
}
g_task_tail = t;
// 唤醒一个睡觉的工作线程
pthread_cond_signal(&g_task_cond);
pthread_mutex_unlock(&g_task_mutex);
} else if (n == 0) {
// read 返回 0 → 客户端主动断开连接(相当于自动 QUIT)
printf("客户端 fd=%d 断开连接\n", client_fd);
close(client_fd);
pthread_mutex_lock(&g_clients_mutex);
for (int j = 0; j < MAX_CLIENTS; j++) {
if (g_clients[j].fd == client_fd) {
g_clients[j].is_online = 0;
break;
}
}
pthread_mutex_unlock(&g_clients_mutex);
}
// n < 0:EAGAIN(暂时没数据,非阻塞读的正常情况),忽略即可
}
⚠️ 小白预警:read 返回 -1 的情况有两种: ① errno == EAGAIN --- 正常,数据还没到,跳过即可 ② errno == 其他值 --- 真正的错误(如连接被重置),应该关闭连接 进阶版可以加上 else if (n < 0 && errno != EAGAIN) 来处理。
四、工程化管理(Makefile + Git)
第12步:编写 Makefile
在项目目录 ~/chat-server/ 下创建 Makefile(注意大小写,M 大写):
bash
# 在终端中执行
cd ~/chat-server
touch Makefile
用编辑器打开 Makefile,写入以下内容:
bash
# Makefile --- 聊天室项目编译脚本
CC = gcc
CFLAGS = -Wall -Wextra -g -pthread
# -Wall -Wextra:打开几乎所有警告,帮你发现潜在bug
# -g:编译时加入调试信息
# -pthread:链接 POSIX 线程库
TARGET = chat_server # 最终生成的可执行文件名
OBJS = server.o # 中间目标文件
$(TARGET): $(OBJS)
$(CC) $(CFLAGS) -o $(TARGET) $(OBJS)
# 编译规则:server.o 依赖 server.c
server.o: server.c
$(CC) $(CFLAGS) -c server.c
# 清理编译产物
clean:
rm -f $(TARGET) $(OBJS)
# .PHONY 告诉 make:clean 不是一个真实文件,每次都可以执行
.PHONY: clean
⚠️ 小白预警 :Makefile 中的缩进必须是 Tab 键(不是空格)!如果用空格缩进,make 会报 missing separator 错误。
第13步:初始化 Git 仓库并提交
bash
# 在 ~/chat-server 目录下执行
# 1. 初始化 Git 仓库
git init
# 2. 创建 .gitignore(告诉 Git 哪些文件不要跟踪)
echo "build/" > .gitignore
echo "*.o" >> .gitignore
echo "chat_server" >> .gitignore
# 3. 添加文件到暂存区
git add server.c Makefile .gitignore
# 4. 第一次提交 ------ Day 26 的基础框架
git commit -m "feat: 基于 epoll 的服务器主框架,支持多连接接入"
# 5. 等你写完 Day 27 的代码后,再提交一次
git add server.c
git commit -m "feat: 实现线程池和消息广播逻辑"
# 6. 打版本标签
git tag v1.0
# 7. 推送到 GitHub(需要先在 GitHub 上创建仓库)
git remote add origin git@github.com:你的用户名/chat-server.git
git push -u origin main
git push origin v1.0
五、完整 server.c 源代码(可直接复制编译)
以下是把第4步~第11步拼装后的完整代码,顺序已调整好,可以直接保存为 server.c 并编译。
cpp
// server.c --- 高并发聊天室服务器(完整版)
// 功能:基于 epoll + 线程池,支持多客户端群聊
// 编译:make
// 运行:./chat_server
// 连接:telnet 127.0.0.1 8888
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <pthread.h>
#include <fcntl.h>
// ============ 常量定义 ============
#define MAX_EVENTS 1024
#define BUFFER_SIZE 4096
#define MAX_CLIENTS 100
#define THREAD_POOL_SIZE 4
// ============ 数据结构 ============
// 客户端结构体
typedef struct {
int fd;
char nickname[32];
int is_online;
} Client;
// 任务节点(线程池用)
typedef struct Task {
int client_fd;
char data[BUFFER_SIZE];
int data_len;
struct Task *next;
} Task;
// ============ 全局变量 ============
Client g_clients[MAX_CLIENTS];
pthread_mutex_t g_clients_mutex = PTHREAD_MUTEX_INITIALIZER;
Task *g_task_head = NULL;
Task *g_task_tail = NULL;
pthread_mutex_t g_task_mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t g_task_cond = PTHREAD_COND_INITIALIZER;
int g_epoll_fd;
int g_server_fd;
// ============ 函数声明 ============
void set_nonblocking(int fd);
void add_to_epoll(int epoll_fd, int fd, uint32_t events);
void broadcast_message(int sender_fd, const char *msg, const char *nickname);
void handle_client_data(int fd, const char *data, int len);
void* worker_thread(void *arg);
void init_thread_pool(int num);
// ============ 辅助函数 ============
void set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
void add_to_epoll(int epoll_fd, int fd, uint32_t events) {
struct epoll_event ev;
ev.events = events | EPOLLET;
ev.data.fd = fd;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, &ev) == -1) {
perror("epoll_ctl add");
exit(1);
}
set_nonblocking(fd);
}
// ============ 业务函数 ============
void broadcast_message(int sender_fd, const char *msg, const char *nickname) {
char buffer[BUFFER_SIZE];
snprintf(buffer, sizeof(buffer), "[%s] %s\n", nickname, msg);
pthread_mutex_lock(&g_clients_mutex);
for (int i = 0; i < MAX_CLIENTS; i++) {
if (g_clients[i].is_online && g_clients[i].fd != sender_fd) {
write(g_clients[i].fd, buffer, strlen(buffer));
}
}
pthread_mutex_unlock(&g_clients_mutex);
}
void handle_client_data(int fd, const char *data, int len) {
char nickname[32] = {0};
char msg[BUFFER_SIZE] = {0};
// 查找昵称
pthread_mutex_lock(&g_clients_mutex);
for (int i = 0; i < MAX_CLIENTS; i++) {
if (g_clients[i].fd == fd && g_clients[i].is_online) {
strcpy(nickname, g_clients[i].nickname);
break;
}
}
pthread_mutex_unlock(&g_clients_mutex);
// 指令分发
if (strncmp(data, "NICK:", 5) == 0) {
char new_name[32] = {0};
sscanf(data, "NICK:%31s", new_name);
pthread_mutex_lock(&g_clients_mutex);
for (int i = 0; i < MAX_CLIENTS; i++) {
if (g_clients[i].fd == fd) {
strcpy(g_clients[i].nickname, new_name);
break;
}
}
pthread_mutex_unlock(&g_clients_mutex);
broadcast_message(fd, "加入了聊天室", new_name);
} else if (strncmp(data, "SAY:", 4) == 0) {
sscanf(data, "SAY:%[^\n]", msg);
broadcast_message(fd, msg, nickname);
} else if (strncmp(data, "QUIT", 4) == 0) {
broadcast_message(fd, "离开了聊天室", nickname);
close(fd);
pthread_mutex_lock(&g_clients_mutex);
for (int i = 0; i < MAX_CLIENTS; i++) {
if (g_clients[i].fd == fd) {
g_clients[i].is_online = 0;
break;
}
}
pthread_mutex_unlock(&g_clients_mutex);
}
}
// ============ 线程池 ============
void* worker_thread(void *arg) {
(void)arg; // 显式标记未使用参数
while (1) {
pthread_mutex_lock(&g_task_mutex);
while (g_task_head == NULL) {
pthread_cond_wait(&g_task_cond, &g_task_mutex);
}
Task *task = g_task_head;
g_task_head = g_task_head->next;
if (g_task_head == NULL) g_task_tail = NULL;
pthread_mutex_unlock(&g_task_mutex);
handle_client_data(task->client_fd, task->data, task->data_len);
free(task);
}
return NULL;
}
void init_thread_pool(int num) {
pthread_t tid;
for (int i = 0; i < num; i++) {
pthread_create(&tid, NULL, worker_thread, NULL);
pthread_detach(tid);
}
printf("线程池已启动,共 %d 个工作线程\n", num);
}
// ============ 主函数 ============
int main() {
// 1. 创建 TCP 服务器
g_server_fd = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(g_server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8888);
addr.sin_addr.s_addr = INADDR_ANY;
bind(g_server_fd, (struct sockaddr*)&addr, sizeof(addr));
listen(g_server_fd, 10);
printf("Chat Server running on port 8888...\n");
printf("打开新终端,输入 telnet 127.0.0.1 8888 即可连入\n");
// 2. 创建 epoll
g_epoll_fd = epoll_create1(0);
add_to_epoll(g_epoll_fd, g_server_fd, EPOLLIN);
// 3. 初始化客户端数组
for (int i = 0; i < MAX_CLIENTS; i++) {
g_clients[i].is_online = 0;
}
// 4. 启动线程池
init_thread_pool(THREAD_POOL_SIZE);
// 5. 进入主循环
struct epoll_event events[MAX_EVENTS];
while (1) {
int nfds = epoll_wait(g_epoll_fd, events, MAX_EVENTS, -1);
for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == g_server_fd) {
// 新连接
struct sockaddr_in client_addr;
socklen_t len = sizeof(client_addr);
int client_fd = accept(g_server_fd,
(struct sockaddr*)&client_addr, &len);
if (client_fd < 0) continue;
printf("新客户端连入: %s:%d (fd=%d)\n",
inet_ntoa(client_addr.sin_addr),
ntohs(client_addr.sin_port), client_fd);
add_to_epoll(g_epoll_fd, client_fd, EPOLLIN);
pthread_mutex_lock(&g_clients_mutex);
for (int j = 0; j < MAX_CLIENTS; j++) {
if (g_clients[j].is_online == 0) {
g_clients[j].fd = client_fd;
g_clients[j].is_online = 1;
snprintf(g_clients[j].nickname,
sizeof(g_clients[j].nickname),
"User%d", client_fd);
break;
}
}
pthread_mutex_unlock(&g_clients_mutex);
} else {
// 客户端数据
int client_fd = events[i].data.fd;
char buffer[BUFFER_SIZE];
int n = read(client_fd, buffer, sizeof(buffer) - 1);
if (n > 0) {
buffer[n] = '\0';
Task *t = (Task*)malloc(sizeof(Task));
t->client_fd = client_fd;
memcpy(t->data, buffer, n + 1);
t->data_len = n;
t->next = NULL;
pthread_mutex_lock(&g_task_mutex);
if (g_task_tail) g_task_tail->next = t;
else g_task_head = t;
g_task_tail = t;
pthread_cond_signal(&g_task_cond);
pthread_mutex_unlock(&g_task_mutex);
} else if (n == 0) {
printf("客户端 fd=%d 断开连接\n", client_fd);
close(client_fd);
pthread_mutex_lock(&g_clients_mutex);
for (int j = 0; j < MAX_CLIENTS; j++) {
if (g_clients[j].fd == client_fd) {
g_clients[j].is_online = 0;
break;
}
}
pthread_mutex_unlock(&g_clients_mutex);
}
}
}
}
return 0;
}
六、运行与测试
第14步:编译项目
cpp
# 在 ~/chat-server 目录下执行
cd ~/chat-server
make clean && make
如果编译成功,你会看到目录下多了一个 chat_server 文件。
⚠️ 小白预警:如果编译报 fatal error: xxx.h: No such file or directory,说明缺少头文件或库,执行:
bash# Ubuntu/Debian sudo apt install build-essential # CentOS/RHEL sudo yum install gcc gcc-c++ glibc-devel
第15步:启动服务器
bash
./chat_server
终端会显示:
Chat Server running on port 8888... 打开新终端,输入 telnet 127.0.0.1 8888 即可连入 线程池已启动,共 4 个工作线程
第16步:模拟多个用户聊天
打开 3 个新终端窗口,分别执行:
bash
# 终端 2(用户 Alice)
telnet 127.0.0.1 8888
# 连接成功后,输入以下内容(每行回车):
NICK:Alice
SAY:大家好,我是Alice!
# 终端 3(用户 Bob)
telnet 127.0.0.1 8888
NICK:Bob
SAY:Hello Alice!
# 终端 4(用户 Charlie)
telnet 127.0.0.1 8888
NICK:Charlie
SAY:你们好啊!
此时 Alice 和 Bob 的终端会自动显示 Charlie 你们好啊!,Charlie 也会看到前两人的消息。
⚠️ 小白预警:
- 如果提示 telnet: command not found,先安装:sudo apt install telnet
- telnet 输入时不显示你打的内容属于正常现象(不回显),按回车就会发送
- 退出 telnet:按 Ctrl + ],然后输入 quit 回车
七、进阶扩展(课后作业)
目前的项目只是一个"骨架",如果你想让它更完善:
|------|---------------|---------------------------------|
| 难度 | 任务 | 说明 |
| ⭐ | 心跳机制 | 客户端每 30 秒发 PING,服务器回 PONG,超时踢下线 |
| ⭐⭐ | 私聊功能 | 支持 TO:Bob 你好 只发给 Bob |
| ⭐⭐ | 日志系统 | 所有连接/断线/错误写入 chat.log 文件 |
| ⭐⭐⭐ | 改用 CMake | 将 Makefile 替换为 CMakeLists.txt |
| ⭐⭐⭐⭐ | 修复写缓冲 Bug | 给每个客户端加发送缓冲区,处理 EAGAIN 情况 |
八、总结
- 以前我们在**"学零件"**(知道螺丝怎么拧,扳手怎么用)
- 这两天我们在**"造汽车"**(把零件组装起来,真的能跑上路)
这个聊天室项目虽然只有几百行代码,但包含了:
- 网络 I/O 管理(epoll)
- 多线程同步(互斥锁 + 条件变量)
- 生产者-消费者模型(主线程生产任务 → 线程池消费任务)
- 协议设计与解析(NICK: / SAY: / QUIT 文本指令)
- 工程化实践(Makefile + Git)
九、常见问题速查表
|---------------------------------------|------------------|---------------------------------------|
| 问题 | 原因 | 解决方法 |
| make: command not found | 未安装 make | sudo apt install make |
| Makefile:2: *** missing separator | 缩进用了空格而非 Tab | 把缩进改成 Tab 键 |
| bind: Address already in use | 上次进程未完全退出 | 等几秒重试,或用 killall chat_server |
| telnet: Unable to connect | 服务器未启动或 IP/端口错误 | 确认 ./chat_server 在运行,端口是 8888 |
| segmentation fault | 访问了空指针或越界 | 用 gdb ./chat_server 调试,检查 malloc 是否成功 |
| 客户端收不到别人发的消息 | 忘记设置昵称 | 先发 NICK:你的名字,再发 SAY:消息 |
| undefined reference to pthread_create | 编译时未链接 pthread 库 | Makefile 的 CFLAGS 必须包含 -pthread |
| telnet 输入中文乱码 | telnet 不支持 UTF-8 | 用英文测试,或用 nc(netcat)代替 |
| nc 下一行不显示 | 需要手动换行 | 输入 NICK:Alice 后手动按回车 |
今日必做:
- 把完整代码复制到 server.c,编译跑起来
- 用 3 个终端测试群聊功能
- 执行 git tag v1.0 标记你的第一个正式版本
- 推送到你的 GitHub 仓库 --- 面试时这就是你的项目作品