第一章 综合实战:高并发服务器与进程通信

各位同学,欢迎来到这门实战课的第一章。

说实话,很多人在学完C语言基础后,都会卡在一个地方——怎么把零散的知识点串起来?你会用epoll,会用线程池,也懂共享内存和信号量,但真要你写一个能扛住高并发的服务器,可能就懵了。

这一章,我们就来解决这个问题。

1.1 整体架构:我们要做什么?

先看看我们要搭建的系统长什么样。我画了一张图,帮你快速建立全局认知:

高并发服务器架构总览 客户端1 客户端2 客户端N epoll 事件循环(主线程) 监听新连接 + 分发已就绪的IO事件 线程池(工作线程) 线程1 线程2 线程3 线程4 共享内存(数据交换) + 信号量(同步互斥) TCP连接 事件驱动 任务处理 进程间通信

这张图其实就讲了三件事:谁来了、谁处理、数据怎么共享

客户端连上来,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模式讲透,再手写一个完整的线程池实现。到时候,你会看到这些组件是怎么在代码层面真正协作的。


公众号:蓝海资料掘金营,微信deep3321