第一章 综合实战:高并发服务器与进程通信
各位同学,欢迎来到这门实战课的第一章。
说实话,很多人在学完C语言基础后,都会卡在一个地方——怎么把零散的知识点串起来?你会用epoll,会用线程池,也懂共享内存和信号量,但真要你写一个能扛住高并发的服务器,可能就懵了。
这一章,我们就来解决这个问题。
1.1 整体架构:我们要做什么?
先看看我们要搭建的系统长什么样。我画了一张图,帮你快速建立全局认知:
这张图其实就讲了三件事:谁来了、谁处理、数据怎么共享。
客户端连上来,epoll负责通知你「有人来了」,线程池里的工作线程接手干活,干完活如果需要和其他进程交换数据,就走共享内存+信号量这套机制。
1.2 epoll:高并发的基石
为什么选epoll而不是select或poll?
我早年做过一个项目,用select管理几百个连接,CPU直接飙到90%。后来换成epoll,同样的负载,CPU降到15%。差距就这么大。
epoll的核心优势就两个:
- O(1)的事件通知——不管你有1个连接还是10万个连接,它都能快速告诉你「哪些连接有数据可读」
- 边缘触发(ET) vs 水平触发(LT)——我个人更倾向ET模式,虽然编程麻烦点,但性能更好
核心代码骨架:
// 1. 创建epoll实例
int epfd = epoll_create1(0);
// 2. 添加监听socket
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // 边缘触发
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
// 3. 事件循环
struct epoll_event events[1024];
while (1) {
int n = epoll_wait(epfd, events, 1024, -1);
for (int i = 0; i < n; i++) {
if (events[i].data.fd == listen_fd) {
// 新连接来了
accept_and_add_to_epoll(epfd, listen_fd);
} else {
// 已有连接有数据,丢给线程池处理
thread_pool_add_task(events[i].data.fd);
}
}
}
避坑指南:使用ET模式时,一定要用非阻塞IO,并且要循环读取直到返回EAGAIN。我曾经见过有人用ET模式但只读一次,结果数据没读完就丢了,排查了一整天。
1.3 线程池:别让线程成为你的瓶颈
有了epoll,我们知道了「谁有活干」。但谁来干?
你当然可以来一个请求就创建一个线程。但你想过没有——如果每秒来1000个请求,你就创建1000个线程?线程创建销毁的开销,加上上下文切换的代价,服务器直接就挂了。
线程池的思路很简单:提前创建好一批线程,有任务就派给空闲的线程。
我习惯的线程池设计包含这几个要素:
- 一个任务队列(用互斥锁+条件变量保护)
- 一组工作线程(数量通常是CPU核心数的2倍)
- 一个管理接口(添加任务、销毁线程池)
typedef struct {
void (*function)(void*);
void* arg;
} task_t;
typedef struct {
task_t* queue; // 任务队列
int queue_size; // 队列容量
int head, tail; // 队首队尾
pthread_t* threads; // 线程数组
int thread_count; // 线程数量
pthread_mutex_t mutex;
pthread_cond_t cond;
int shutdown; // 关闭标志
} thread_pool_t;
注意:线程池的大小不是越大越好。我见过有人把线程池设成1000个线程,结果大部分时间都花在锁竞争上,性能反而下降。一般建议:CPU密集型任务用N+1个线程,IO密集型用2N+1个(N是CPU核心数)。
1.4 共享内存+信号量:进程间通信的黄金搭档
为什么需要进程间通信?
因为你的服务器可能不止一个进程。比如,你可能有一个网络处理进程和一个日志记录进程,它们需要共享一些数据。这时候,共享内存就是最高效的方式——没有之一。
但共享内存有个问题:多个进程同时读写怎么办?
这就轮到信号量出场了。信号量本质上是一个计数器,用来控制同时访问共享资源的进程数量。
// 创建共享内存
int shmid = shmget(IPC_PRIVATE, 4096, IPC_CREAT | 0666);
void* shmaddr = shmat(shmid, NULL, 0);
// 创建信号量
int semid = semget(IPC_PRIVATE, 1, IPC_CREAT | 0666);
semctl(semid, 0, SETVAL, 1); // 初始值为1,相当于互斥锁
// 使用共享内存(带信号量保护)
struct sembuf p_op = {0, -1, SEM_UNDO}; // P操作:申请资源
struct sembuf v_op = {0, 1, SEM_UNDO}; // V操作:释放资源
semop(semid, &p_op, 1); // 加锁
// 读写共享内存...
semop(semid, &v_op, 1); // 解锁
个人经验:使用共享内存时,一定要处理好同步和互斥。我曾经在一个项目中,两个进程同时写共享内存的同一个位置,结果数据全乱了。从那以后,我养成了一个习惯——任何共享内存的访问,都必须有信号量保护,哪怕你觉得「这个场景不会并发」。
1.5 调试三板斧:gdb、strace、valgrind
写代码只是第一步。调试才是真正考验功力的时候。
我常用的调试工具就三个,每个都有它的专长:
| 工具 | 用途 | 常用场景 |
|---|---|---|
| gdb | 源码级调试 | 定位段错误、查看变量值、单步跟踪 |
| strace | 系统调用追踪 | 查看程序调用了哪些系统调用、参数是什么 |
| valgrind | 内存检测 | 查找内存泄漏、越界访问、使用未初始化内存 |
举个例子。如果你的服务器莫名其妙地卡住了,你会怎么做?
- 先用strace -p <pid>看看进程卡在哪个系统调用上——是卡在read()上等数据?还是卡在lock()上等锁?
- 如果是内存问题,用valgrind --leak-check=full ./your_server跑一遍,它会告诉你哪行代码泄漏了多少字节
- 如果是逻辑问题,用gdb attach <pid>挂上去,看看各个线程的堆栈
重要提醒:valgrind会让程序运行速度慢10-20倍,所以不要在压测时用。我一般是在开发阶段,用少量数据跑一遍valgrind,确保没有内存问题后,再上压力测试。
1.6 把这些串起来:一个完整的例子
说了这么多,我们来写一个能跑起来的demo。这个例子虽然简单,但包含了本章所有的核心思想:
// 伪代码:高并发服务器核心流程
int main() {
// 1. 初始化线程池(4个工作线程)
thread_pool_t* pool = thread_pool_create(4);
// 2. 创建共享内存(用于进程间通信)
int shmid = shmget(IPC_PRIVATE, 1024, IPC_CREAT | 0666);
int* shared_counter = (int*)shmat(shmid, NULL, 0);
*shared_counter = 0;
// 3. 创建信号量(保护共享内存)
int semid = semget(IPC_PRIVATE, 1, IPC_CREAT | 0666);
semctl(semid, 0, SETVAL, 1);
// 4. 启动epoll事件循环
int epfd = epoll_create1(0);
// ... 添加监听socket ...
while (1) {
int n = epoll_wait(epfd, events, 1024, -1);
for (int i = 0; i < n; i++) {
if (is_new_connection(events[i])) {
handle_new_conn(epfd, listen_fd);
} else {
// 把任务丢给线程池
task_t task = {process_request, events[i].data.ptr};
thread_pool_add_task(pool, task);
}
}
}
// 5. 清理
thread_pool_destroy(pool);
shmdt(shared_counter);
shmctl(shmid, IPC_RMID, NULL);
semctl(semid, 0, IPC_RMID);
return 0;
}
这个例子虽然短,但你看——epoll负责通知,线程池负责干活,共享内存+信号量负责进程间数据交换。三个组件各司其职,配合得天衣无缝。
1.7 本章小结
这一章我们做了三件事:
- 搞清楚了epoll+线程池怎么搭起高并发服务器的骨架
- 学会了共享内存+信号量这套进程间通信的组合拳
- 掌握了gdb/strace/valgrind这三个调试利器
说实话,这些知识点单独拿出来都不难。难的是把它们组合在一起,形成一个能真正跑起来的系统。这也是我设计这门课的初衷——不只是教语法,而是教你怎么用C语言写出工业级的程序。
下一章,我们会深入epoll的细节,把ET和LT模式讲透,再手写一个完整的线程池实现。到时候,你会看到这些组件是怎么在代码层面真正协作的。