云计算百科
云计算领域专业知识百科平台

Day 24-25 并发服务器 + IO复用:让你的程序同时服务成千上万个“客人“

单连接的 Echo 服务器就像一家只有一个服务员的餐馆——客人来了得排队等。今天我们给餐厅招满"服务员"(进程/线程),再给每个人配上一部"智能对讲机"(epoll),让客人们随到随吃,同时还能监控后厨、收银台、外卖窗口,真正实现多面手。


前言

在 Day 22-23 中,我们实现了一个标准的 TCP Echo 服务器。但它有一个致命的缺陷:一次只能服务一个客户端。

通俗类比:想象你开了一家外卖店,店里只有你一个人。你接了一个电话(accept),然后开始等对方报菜名(read)。如果对方磨磨蹭蹭不说要什么,你就一直握着电话干等——后面的电话根本接不进来。这就是"阻塞"的困境。

今天,我们分三步跨越这道坎:

  • 多进程服务器(Fork):每来一个客人,就雇一个临时工去服务它。
  • 多线程服务器(Pthread):不用雇人,用"分身术"(线程),每个分身去跟一个客人聊天。
  • IO 复用技术(select / poll / epoll):不雇人、不分身,给一个人配上一台"多频道对讲机",同时监控所有客人,谁开口就跟谁聊——一个服务员搞定全场。

  • 第一部分:为什么单线程/单进程服务器不行?

    我们先回顾一下单线程服务器的核心代码:

    while (1) {
    client_fd = accept(server_fd, …); // 阻塞等待:没人来就一直等
    // ⚠️ 只有当前客户端断开后,才能接下一个!
    while ((n = read(client_fd, buffer, …)) > 0) {
    write(client_fd, buffer, n); // 阻塞等待:对方没发送数据就一直等
    }
    close(client_fd);
    }

    1.1 "阻塞"到底是什么?

    通俗类比:你去取款机取钱,前面的人操作很慢。你不能同时帮后面的人取钱,只能站着干等。这就是阻塞——你(进程)被"卡"住了。

    从操作系统角度看:

    状态 进程在干嘛 占CPU吗
    运行态 正在CPU上执行代码
    就绪态 准备好了,等CPU轮到我 不占
    阻塞态(睡眠态) 在等某个条件(数据到达、连接到来),条件满足前无法运行 不占

    关键理解:阻塞并不会浪费 CPU(CPU 会去执行其他进程),但它浪费了机会——因为该进程无法去处理其他客户端的请求。这就是并发要解决的根本矛盾。


    第二部分:多进程并发服务器(Fork 模型)

    2.1 核心思想——一张图看懂

    父进程(迎宾员)

    ├── accept() ← 来客人了!

    ├── fork() → 子进程1(服务员1)→ 服务客户A → 退出

    ├── accept() ← 又来一个!

    ├── fork() → 子进程2(服务员2)→ 服务客户B → 退出

    通俗类比:银行大堂经理(父进程)只管接待和分流,每来一个客户就喊一个柜员(子进程)专门服务。经理自己马上回头接待下一位。

    2.2 环境准备

    第 1 步 → 确认你的 Linux 环境已经装好 gcc:

    # 检查 gcc 是否安装
    gcc –version

    # 如果没装(Ubuntu/Debian)
    sudo apt install gcc -y

    # 如果没装(CentOS/RHEL)
    sudo yum install gcc -y

    第 2 步 → 创建项目目录并进入:

    mkdir -p ~/linux_study/day24-25 && cd ~/linux_study/day24-25

    2.3 编写代码

    第 3 步 → 用编辑器创建 fork_server.c:

    vim fork_server.c # 也可用 nano 或 gedit

    第 4 步 → 输入完整代码(下面每一行 // 注释都请你认真读,这些注释就是你的"中文说明书"):

    #include <stdio.h> // 标准输入输出,printf 需要
    #include <stdlib.h> // 标准库,exit 需要
    #include <string.h> // 字符串函数,memset 需要
    #include <unistd.h> // POSIX API,fork / close / read / write 需要
    #include <sys/socket.h> // 套接字函数,socket / bind / listen / accept 需要
    #include <netinet/in.h> // sockaddr_in 结构体,存储 IP 和端口
    #include <arpa/inet.h> // inet_ntoa,把IP的数字格式转成可读字符串
    #include <sys/wait.h> // waitpid,回收子进程用
    #include <signal.h> // signal,注册信号处理函数

    #define PORT 8080 // 服务器监听端口,可改成你喜欢的
    #define BUFFER_SIZE 1024 // 接收缓冲区大小,1KB 够用

    // 信号处理函数:当子进程退出时,内核发 SIGCHLD 给父进程
    // 如果不处理,子进程会变成"僵尸"(占用系统 PID 名额)
    void sigchld_handler(int sig) {
    // waitpid:回收所有已退出的子进程
    // -1 表示"回收任意子进程",WNOHANG 表示"不阻塞,没死就返回"
    while (waitpid(-1, NULL, WNOHANG) > 0);
    }

    int main() {
    int server_fd, client_fd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_len = sizeof(client_addr);
    char buffer[BUFFER_SIZE];

    // ① 注册信号处理——就像给父进程装了个"子进程去世通知铃"
    signal(SIGCHLD, sigchld_handler);

    // ② 创建套接字(socket:在系统中占一个"电话号码")
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    // AF_INET:使用 IPv4
    // SOCK_STREAM:使用 TCP(可靠、有连接)
    // 0:协议自动选择(TCP 就是 IPPROTO_TCP)

    // ③ 设置端口复用——防止重启时 "Address already in use" 报错
    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    // ④ 配置服务器地址(身份证信息)
    memset(&server_addr, 0, sizeof(server_addr)); // 先清零,防止垃圾数据
    server_addr.sin_family = AF_INET; // 地址家族:IPv4
    server_addr.sin_port = htons(PORT); // 端口号,htons 把字节序转成网络序
    server_addr.sin_addr.s_addr = INADDR_ANY; // 监听本机所有网卡(0.0.0.0)

    // ⑤ 绑定——把"电话号码"和"身份证"绑在一起
    bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));

    // ⑥ 开始监听——像餐厅打开门开始迎客,5 是排队上限
    listen(server_fd, 5);
    printf("多进程服务器启动,PID=%d,监听端口 %d\\n", getpid(), PORT);

    // ⑦ 主循环:不停接客,每接一位就喊一个新柜员(fork)
    while (1) {
    client_fd = accept(server_fd,
    (struct sockaddr *)&client_addr,
    &client_len);
    if (client_fd < 0) {
    perror("accept"); // 打印错误原因
    continue; // 出错也别崩溃,继续等下一位
    }

    pid_t pid = fork();
    if (pid == 0) {
    // ========== 子进程:我就是新柜员 ==========
    close(server_fd); // 子进程不迎客,关掉监听套接字

    printf("子进程 %d 开始服务 %s:%d\\n",
    getpid(),
    inet_ntoa(client_addr.sin_addr), // IP 地址
    ntohs(client_addr.sin_port)); // 端口号

    // Echo:收到什么就回什么(像复读机)
    int n;
    while ((n = read(client_fd, buffer, sizeof(buffer) – 1)) > 0) {
    buffer[n] = '\\0'; // 手动加字符串结尾
    printf("子进程 %d 收到: %s", getpid(), buffer);
    write(client_fd, buffer, n); // 原样发回
    }

    close(client_fd);
    printf("子进程 %d 下班\\n", getpid());
    exit(0); // 子进程任务完成,优雅退出

    } else if (pid > 0) {
    // ========== 父进程:迎宾员继续迎客 ==========
    close(client_fd); // 父进程不服务客户,关掉客户套接字
    // 继续循环,等下一位

    } else {
    perror("fork"); // fork 失败(比如系统进程数满了)
    close(client_fd);
    }
    }

    close(server_fd);
    return 0;
    }

    第 5 步 → 编译运行:

    # 编译(多线程版本需要 -lpthread,多进程不需要额外链接库)
    gcc -Wall -g fork_server.c -o fork_server

    # 运行
    ./fork_server

    看到输出 多进程服务器启动,PID=xxxxx,监听端口 8080 就说明成功。

    第 6 步 → 测试:在另一个终端用 telnet 连接:

    # 另开一个终端
    telnet 127.0.0.1 8080

    # 输入任意文字,回车,服务器会原样返回
    hello # 你会看到服务器回显 "hello"

    再开第3个终端重复上述操作——你会发现每个客户端都被独立服务,互不影响。

    2.4 多进程模型关键点速查

    关键点 为什么必须这样做 不做的后果
    注册 SIGCHLD 信号 子进程退出时通知父进程回收 僵尸进程堆积,ps aux 显示大量 <defunct>
    父进程 close(client_fd) 子进程 fork 后复制了文件描述符,父进程不关会导致引用计数 > 1 子进程退出后连接可能无法完全释放
    子进程 close(server_fd) 子进程不需要监听新连接 资源泄漏,且可能干扰父进程
    资源开销认知 每个客户端 = 一个独立进程(内存、页表、PID) 几百个客户端可能就撑不住了

    ⚠️ 预警:如果你忘了注册 SIGCHLD,运行一段时间后 ps aux | grep fork_server 会发现一堆 <defunct> 僵尸进程。它们占着 PID 不放,最终可能导致系统无法创建新进程。


    第三部分:多线程并发服务器(Pthread 模型)

    3.1 核心思想——一张图看懂

    主线程(迎宾员)

    ├── accept() ← 来客人了!

    ├── pthread_create() → 线程A(分身A)→ 服务客户A → 退出

    ├── accept() ← 又来一个!

    ├── pthread_create() → 线程B(分身B)→ 服务客户B → 退出

    通俗类比:线程就像"分身术"。你不是雇新员工,而是把自己复制出一份"投影",每个投影去服务一个客人。因为这些投影共享同一个大脑(内存空间),所以创建快、切换快,但一旦有一个投影摔倒了(崩溃),所有人都得跟着倒。

    3.2 编写代码

    第 1 步 → 创建 thread_server.c:

    vim thread_server.c

    第 2 步 → 输入完整代码:

    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <unistd.h>
    #include <sys/socket.h>
    #include <netinet/in.h>
    #include <arpa/inet.h>
    #include <pthread.h> // 多线程需要这个头文件

    #define PORT 8080
    #define BUFFER_SIZE 1024

    // 线程函数:每个线程都在这个函数里服务自己的客户
    // 参数 arg 是一个指向 int 的指针(客户端的文件描述符)
    void* client_handler(void *arg) {
    int client_fd = *(int*)arg; // 先把参数解出来
    free(arg); // 用完立刻释放,防止内存泄漏!
    char buffer[BUFFER_SIZE];
    int n;

    // 打印线程 ID(用 pthread_self() 获取)
    printf("线程 %lu 开始服务\\n", pthread_self());

    // Echo 循环:读到就回
    while ((n = read(client_fd, buffer, sizeof(buffer) – 1)) > 0) {
    buffer[n] = '\\0';
    printf("线程 %lu 收到: %s", pthread_self(), buffer);
    write(client_fd, buffer, n);
    }

    close(client_fd);
    printf("线程 %lu 下班\\n", pthread_self());
    return NULL;
    }

    int main() {
    int server_fd, *client_fd_ptr; // ⚠️ 注意:client_fd_ptr 是指针,不是普通 int
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_len = sizeof(client_addr);
    pthread_t tid;

    // 创建套接字、设置端口复用
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    // 配置服务器地址
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(PORT);
    server_addr.sin_addr.s_addr = INADDR_ANY;

    // 绑定 + 监听
    bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));
    listen(server_fd, 5);
    printf("多线程服务器启动,监听端口 %d\\n", PORT);

    // 主循环
    while (1) {
    // ⚠️ 关键:必须在堆上分配内存,不能传栈上变量!
    // 原因:如果传 &client_fd(栈变量),下一次 accept 会覆盖它的值
    // 线程可能读到错误的数据
    client_fd_ptr = malloc(sizeof(int));
    *client_fd_ptr = accept(server_fd,
    (struct sockaddr *)&client_addr,
    &client_len);

    if (*client_fd_ptr < 0) {
    perror("accept");
    free(client_fd_ptr); // 出错也别忘释放
    continue;
    }

    printf("新客户端 %s:%d 连接\\n",
    inet_ntoa(client_addr.sin_addr),
    ntohs(client_addr.sin_port));

    // 创建线程去服务
    // 参数说明:&tid=线程ID输出, NULL=默认属性,
    // client_handler=线程函数, client_fd_ptr=传给线程的参数
    pthread_create(&tid, NULL, client_handler, client_fd_ptr);

    // 分离线程:告诉系统"不用等我,我结束后自动回收"
    // 这样主线程不需要调用 pthread_join 阻塞等待
    pthread_detach(tid);
    }

    close(server_fd);
    return 0;
    }

    第 3 步 → 编译运行(注意要加 -lpthread):

    # 编译——pthread 不是标准 C 库的一部分,必须显式链接
    gcc -Wall -g thread_server.c -o thread_server -lpthread

    # 运行
    ./thread_server

    ⚠️ 最容易犯的错:编译时忘了 -lpthread,链接阶段报一堆 undefined reference to pthread_create。加上就好了。

    第 4 步 → 测试:同样用 telnet:

    # 终端2
    telnet 127.0.0.1 8080

    # 终端3
    telnet 127.0.0.1 8080

    3.3 多线程模型关键点速查

    关键点 为什么必须这样做 不做的后果
    参数用 malloc 堆分配 栈变量在循环中会被覆盖,线程读到的可能是下个客户端的 fd 两个线程服务同一个客户端,数据乱套
    pthread_detach 主线程不阻塞等子线程结束 如果不 join 也不 detach,线程资源永远不会回收(内存泄漏)
    共享数据加锁 多线程同时写全局变量会发生竞争 计数器错乱、链表断链,且极难复现调试
    崩溃传播 一个线程段错误 = 整个进程死 不像多进程那样能隔离故障

    ⚠️ 预警:如果你用 malloc 分配了 client_fd_ptr 但忘了在线程函数里 free(arg),每连接一个客户端就泄漏 4 字节(或 8 字节)。看上去很小,跑一天下来可能泄漏几百 MB。


    第四部分:多进程 vs 多线程 —— 终极对决

    维度 多进程(Fork) 多线程(Pthread)
    创建开销 高(复制页表、文件描述符表等) 低(只需新建栈和寄存器)
    切换开销 高(切换页表,TLB 刷新) 低(只切换寄存器上下文)
    内存占用 高(独立地址空间) 低(共享地址空间)
    通信方式 需要 IPC(管道、共享内存、消息队列) 直接读写全局变量(但要加锁)
    数据隔离/安全性 极高(一个崩了不影响其他) 极低(一个崩了全挂)
    最大并发数 通常几百~几千(受 PID 和内存限制) 可达数万(受内存限制)
    编程难度 较低(信号处理稍麻烦) 较高(锁、死锁、条件变量)
    现实世界谁在用 Apache Prefork、Chrome 多进程架构 Nginx、MySQL 连接池、Java 线程池

    选型口诀

    • 求稳(银行/工控/关键业务)→ 多进程。挂了可以重启,不影响其他。
    • 求快(Web 服务器/游戏服务器/API 网关)→ 多线程。能支撑更高并发。
    • 两全其美(现代主流)→ IO 复用(epoll)+ 少量线程池。详见第五部分。

    第五部分:IO 复用技术 —— 一个服务员搞定全场

    5.0 阻塞和非阻塞的局限

    在讲 IO 复用之前,我们先搞清楚两个概念:

    阻塞 I/O(Blocking I/O)——默认模式:

    read(fd, buf, size); // fd 没数据?进程就"睡着",直到有数据才醒

    通俗类比:打电话时对方不说话,你就在那头等着,不挂电话(进程挂起),当然也不能接别人的电话。

    非阻塞 I/O(Non-blocking I/O)——手动设置后:

    int flags = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flags | O_NONBLOCK); // 设为非阻塞

    int n = read(fd, buf, size); // 没数据?立刻返回 -1,errno=EAGAIN

    通俗类比:你每隔一秒看一眼对方有没有说话,没说就扭头看一眼别人。但如果用 for 循环疯狂去检查(轮询),CPU 会飙到 100%,大部分时间都在做无用功——这叫"忙等待"。

    IO 复用的解决思路:别自己挨个问了,让内核帮你盯着!你只需要告诉内核"帮我盯着这 100 个文件描述符,谁有数据了叫醒我"。这样你只需要阻塞在一个地方(比如 epoll_wait),而不是在 100 个 read 之间疲于奔命。

    一句话总结:IO 复用就是"用一个阻塞,换掉 100 个阻塞"。


    5.1 三兄弟登场:select / poll / epoll

    时间线:1983 select(老大哥)→ 1997 poll(二哥)→ 2002 epoll(三弟,王者)

    select poll epoll
    年代 1983 (BSD) 1997 (SVR4) 2002 (Linux 2.5.44)
    原理 内核轮询所有 fd 内核轮询所有 fd 事件驱动,内核主动通知
    fd 数量限制 硬限制 FD_SETSIZE(默认 1024) 无硬限制 无硬限制
    数据结构 3 个位图(fd_set) 动态数组(struct pollfd[]) 红黑树 + 就绪链表
    每次调用是否重新传入 fd 列表 是(从用户态拷贝到内核态) 是(从用户态拷贝到内核态) 否(fd 注册在内核,长期有效)
    复杂度 O(n) —— n 为最大 fd 值 O(n) —— n 为 fd 总数 O(1) —— 只遍历就绪的 fd
    适用场景 少量 fd,跨平台 中等量 fd 大规模并发(C10K+),Linux 专属

    一句话总结:select 和 poll 是"挨个点名",epoll 是"谁举手叫谁"。


    5.2 select —— 老大哥的笨办法

    5.2.1 核心 API

    #include <sys/select.h>

    int select(int nfds, // 最大 fd 值 + 1
    fd_set *readfds, // 想监控"可读"的 fd 集合
    fd_set *writefds, // 想监控"可写"的 fd 集合
    fd_set *exceptfds, // 想监控"异常"的 fd 集合
    struct timeval *timeout); // 超时时间

    // 操作 fd_set 的宏
    FD_ZERO(fd_set *set); // 清空集合
    FD_SET(int fd, fd_set *set); // 把 fd 加入集合
    FD_CLR(int fd, fd_set *set); // 把 fd 从集合移除
    FD_ISSET(int fd, fd_set *set); // 检查 fd 是否在集合中(即是否就绪)

    5.2.2 工作流程

    第 1 步:把所有想监控的 fd 塞进 fd_set
    第 2 步:调用 select(),进程阻塞,内核去轮询所有 fd
    第 3 步:有 fd 就绪 → select 返回,内核修改 fd_set(只留下就绪的 fd)
    第 4 步:遍历所有 fd,用 FD_ISSET 数哪些就绪了
    第 5 步:处理就绪的 fd,回到第 1 步

    通俗类比:select 就像一个老师,每天早晨把全班 200 个学生名单抄一遍交上去。上课时想知道谁举手——他挨个点名:"张三举手了吗?李四举手了吗?…" 每天还要把 200 人名单重新抄一遍。人少还行,人多了效率奇低。

    5.2.3 完整代码:select_server.c

    第 1 步 → 创建文件:

    vim select_server.c

    第 2 步 → 输入代码:

    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <unistd.h>
    #include <sys/socket.h>
    #include <netinet/in.h>
    #include <arpa/inet.h>
    #include <sys/select.h> // select 需要这个头文件

    #define PORT 8080
    #define BUFFER_SIZE 1024
    #define MAX_CLIENTS 1024 // FD_SETSIZE 默认就是 1024

    int main() {
    int server_fd, client_fd, max_fd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_len = sizeof(client_addr);
    char buffer[BUFFER_SIZE];

    // 存储所有客户端 fd,方便遍历(弥补 select 的不足)
    int client_fds[MAX_CLIENTS];
    for (int i = 0; i < MAX_CLIENTS; i++) client_fds[i] = -1; // -1 表示空位

    // ① 创建套接字
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    // ② 绑定 + 监听
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(PORT);
    server_addr.sin_addr.s_addr = INADDR_ANY;
    bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));
    listen(server_fd, 5);

    printf("select 服务器启动,监听端口 %d\\n", PORT);
    printf("最大支持 %d 个客户端(硬限制)\\n", MAX_CLIENTS);

    while (1) {
    fd_set read_fds; // 每次循环重新创建集合
    FD_ZERO(&read_fds); // 清空
    FD_SET(server_fd, &read_fds); // 把 server_fd 加入监控列表
    max_fd = server_fd; // 记录最大 fd 值

    // ② 把所有客户端 fd 加入监控集合
    for (int i = 0; i < MAX_CLIENTS; i++) {
    if (client_fds[i] != -1) {
    FD_SET(client_fds[i], &read_fds);
    if (client_fds[i] > max_fd) max_fd = client_fds[i];
    }
    }

    // ③ 调用 select — 内核帮你盯着所有 fd
    // 参数:max_fd+1(最大 fd 编号+1)、读集合、写集合(NULL=不监控)、
    // 异常集合(NULL=不监控)、超时(NULL=永远等)
    int ready = select(max_fd + 1, &read_fds, NULL, NULL, NULL);
    if (ready < 0) {
    perror("select");
    continue;
    }

    // ④ 检查 server_fd 是否有新连接
    if (FD_ISSET(server_fd, &read_fds)) {
    client_fd = accept(server_fd,
    (struct sockaddr *)&client_addr,
    &client_len);
    if (client_fd >= 0) {
    printf("新客户端 %s:%d 连接,fd=%d\\n",
    inet_ntoa(client_addr.sin_addr),
    ntohs(client_addr.sin_port), client_fd);

    // 找个空位存起来
    for (int i = 0; i < MAX_CLIENTS; i++) {
    if (client_fds[i] == -1) {
    client_fds[i] = client_fd;
    break;
    }
    }
    }
    ready–; // 这个事件处理完了
    }

    // ⑤ 遍历检查每个客户端是否有数据
    for (int i = 0; i < MAX_CLIENTS && ready > 0; i++) {
    int fd = client_fds[i];
    if (fd == -1) continue;

    if (FD_ISSET(fd, &read_fds)) {
    int n = read(fd, buffer, sizeof(buffer) – 1);

    if (n <= 0) {
    // 客户端断开连接(read 返回 0)或出错(返回 -1)
    if (n == 0) {
    printf("客户端 fd=%d 断开连接\\n", fd);
    } else {
    perror("read");
    }
    close(fd);
    client_fds[i] = -1; // 释放位置
    } else {
    // 正常收到数据,Echo 回去
    buffer[n] = '\\0';
    printf("客户端 fd=%d 发来: %s", fd, buffer);
    write(fd, buffer, n);
    }
    ready–;
    }
    }
    }

    close(server_fd);
    return 0;
    }

    第 3 步 → 编译运行:

    gcc -Wall -g select_server.c -o select_server
    ./select_server

    5.2.4 select 的致命缺陷
    缺陷 影响
    fd 数量上限 = FD_SETSIZE(默认 1024) 改这个值需要重新编译内核,极不友好
    每次调用都要把整个 fd_set 从用户态拷到内核态 连接数多时,拷贝开销巨大
    内核遍历所有 fd(不是只遍历就绪的) O(n),1 万个连接要遍历 1 万次
    返回后用户还要遍历一次找就绪 fd 又一遍 O(n)

    select 用于学习理解 IO 复用原理很合适,但生产环境绝不要用它处理高并发。


    5.3 poll —— 二哥的小改进

    5.3.1 核心 API

    #include <poll.h>

    struct pollfd {
    int fd; // 要监控的文件描述符
    short events; // 想监控的事件(POLLIN=可读, POLLOUT=可写)
    short revents; // 内核返回:实际发生的事件
    };

    int poll(struct pollfd *fds, // pollfd 数组
    nfds_t nfds, // 数组元素个数
    int timeout); // 超时(毫秒),-1 表示永远等

    5.3.2 poll 相比 select 的改进
    改进点 select poll
    fd 数量上限 硬编码 FD_SETSIZE=1024 无硬限制,只受内存限制
    数据结构 位图(fd_set),只能用宏操作 结构体数组(pollfd),更直观
    事件和结果分离 不分离(同一个 fd_set 改来改去) 分离:events 是你想监控的,revents 是内核返回的

    通俗类比:poll 不再用"花名册",改用"签到表"。每行写一个学生名字和想要关注的项目,老师不用全部重新抄——可以增删改。但老师还是要全体点名。

    5.3.3 完整代码:poll_server.c

    第 1 步 → 创建文件:

    vim poll_server.c

    第 2 步 → 输入代码:

    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <unistd.h>
    #include <sys/socket.h>
    #include <netinet/in.h>
    #include <arpa/inet.h>
    #include <poll.h> // poll 需要这个头文件

    #define PORT 8080
    #define BUFFER_SIZE 1024
    #define MAX_CLIENTS 10240 // poll 没有硬限制,这里设一个合理的软上限

    int main() {
    int server_fd, client_fd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_len = sizeof(client_addr);
    char buffer[BUFFER_SIZE];

    // pollfd 数组:存所有要监控的 fd
    struct pollfd fds[MAX_CLIENTS + 1]; // +1 给 server_fd
    int nfds = 0; // 当前数组中有多少个有效条目

    // ① 创建套接字
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    // ② 绑定 + 监听
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(PORT);
    server_addr.sin_addr.s_addr = INADDR_ANY;
    bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));
    listen(server_fd, 5);

    // ③ 把 server_fd 加入监控列表
    fds[0].fd = server_fd;
    fds[0].events = POLLIN; // POLLIN 表示"可读"(新连接或数据到达)
    nfds = 1;

    printf("poll 服务器启动,监听端口 %d\\n", PORT);

    while (1) {
    // ④ 调用 poll:阻塞等待,有事件时返回
    // timeout=-1 表示永远等
    int ready = poll(fds, nfds, -1);
    if (ready < 0) {
    perror("poll");
    continue;
    }

    // ⑤ 遍历检查每个 fd
    for (int i = 0; i < nfds && ready > 0; i++) {
    // revents 中是否有我们关心的事件
    if (fds[i].revents == 0) continue;

    if (fds[i].fd == server_fd) {
    // ===== server_fd:有新连接 =====
    if (fds[i].revents & POLLIN) {
    client_fd = accept(server_fd,
    (struct sockaddr *)&client_addr,
    &client_len);
    if (client_fd >= 0 && nfds < MAX_CLIENTS + 1) {
    printf("新客户端 %s:%d 连接,fd=%d\\n",
    inet_ntoa(client_addr.sin_addr),
    ntohs(client_addr.sin_port), client_fd);

    fds[nfds].fd = client_fd;
    fds[nfds].events = POLLIN; // 监控可读
    nfds++;
    } else if (client_fd >= 0) {
    printf("客户端数量已达上限,拒绝新连接\\n");
    close(client_fd);
    }
    }
    ready–;

    } else {
    // ===== 客户端 fd:有数据或断开 =====
    int fd = fds[i].fd;

    if (fds[i].revents & POLLIN) {
    int n = read(fd, buffer, sizeof(buffer) – 1);

    if (n <= 0) {
    // 断开或出错
    if (n == 0) printf("客户端 fd=%d 断开\\n", fd);
    else perror("read");
    close(fd);

    // 从数组中移除:把最后一个元素搬过来,然后 nfds–
    fds[i] = fds[nfds – 1];
    nfds–;
    i–; // 回退一步,因为当前位置现在是新的元素
    } else {
    buffer[n] = '\\0';
    printf("客户端 fd=%d 发来: %s", fd, buffer);
    write(fd, buffer, n);
    }
    }

    // 也可以检查 POLLERR(连接出错)和 POLLHUP(客户端挂断)
    if (fds[i].revents & (POLLERR | POLLHUP)) {
    printf("客户端 fd=%d 异常断开\\n", fd);
    close(fd);
    fds[i] = fds[nfds – 1];
    nfds–;
    i–;
    }

    ready–;
    }
    }
    }

    close(server_fd);
    return 0;
    }

    第 3 步 → 编译运行:

    gcc -Wall -g poll_server.c -o poll_server
    ./poll_server

    5.3.4 poll 的不足

    poll 解决了 select 的"1024 上限"问题,但遍历所有 fd 的 O(n) 问题仍在。当你有 1 万个连接但只有 3 个活跃时,poll 仍然要扫描全部 1 万个。这就是 epoll 要解决的。


    5.4 epoll —— Linux 王牌登场

    5.4.1 核心思想

    select/poll:每次调用都把"全班名单"交上去,老师说"自己去数谁举手"
    epoll: 只在学期初登记一次。之后有人举手,老师直接递给你一张
    "举手名单"——你只需要处理名单上的人!

    epoll 的三个核心 API:

    #include <sys/epoll.h>

    // ① 创建 epoll 实例(相当于在老师那里注册一个"举手监听系统")
    int epoll_create1(int flags); // flags 通常设为 0

    // ② 注册/修改/删除要监控的 fd(相当于把学生加入举手监听系统)
    int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
    // op: EPOLL_CTL_ADD(加入)/ EPOLL_CTL_MOD(修改)/ EPOLL_CTL_DEL(删除)

    // ③ 等待事件(相当于老师说:"这是举手名单,你处理吧")
    int epoll_wait(int epfd, struct epoll_event *events,
    int maxevents, int timeout);

    struct epoll_event {
    uint32_t events; // EPOLLIN(可读)/ EPOLLOUT(可写)/ EPOLLERR(错误)
    epoll_data_t data; // 可以存 fd 或自定义指针
    };

    5.4.2 epoll 为什么快?
    原因 解释
    事件驱动 就绪的 fd 会自动挂到内核的就绪链表上,epoll_wait 只返回这些就绪 fd,不扫描全量
    红黑树存储 所有注册的 fd 存在红黑树中,增删改查都是 O(log n)
    只拷贝就绪事件 epoll_wait 只把就绪的 fd 信息从内核拷到用户态,不是全部 fd
    长期注册 fd 注册一次后长期有效,不需要每次循环重新传入

    通俗类比:select/poll 像是你每天把全班名单交给老师,老师每天从头数一遍。epoll 像是老师手里有个"举手铃"——只有举手的学生(就绪 fd)才会触发铃响,老师直接把举手名单给你。

    5.4.3 两种触发模式
    模式 行为 适用场景
    LT(水平触发,默认) 只要缓冲区有数据,每次 epoll_wait 都会通知你 简单可靠,兼容 select/poll 编程模型,不易漏事件
    ET(边缘触发) 只在状态变化时通知一次(从无到有) 高性能,减少重复通知,但要求 fd 必须设为非阻塞,否则容易卡死

    ET 模式通俗解释:
    LT = "你碗里还有饭,我每次走过都提醒你一下"
    ET = "我喊一次'开饭了',你自己记得吃到碗空,我不会再喊"

    ⚠️ ET 模式的红线:使用 ET 模式时,fd 必须是非阻塞的,且必须循环 read 直到返回 EAGAIN(表示读空了),否则可能漏掉数据。

    5.4.4 完整代码:epoll_server.c(LT 模式,即默认模式,最安全)

    第 1 步 → 创建文件:

    vim epoll_server.c

    第 2 步 → 输入代码:

    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <unistd.h>
    #include <sys/socket.h>
    #include <netinet/in.h>
    #include <arpa/inet.h>
    #include <sys/epoll.h> // epoll 需要这个头文件

    #define PORT 8080
    #define BUFFER_SIZE 1024
    #define MAX_EVENTS 10240 // 一次最多处理这么多就绪事件

    int main() {
    int server_fd, client_fd, epfd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_len = sizeof(client_addr);
    char buffer[BUFFER_SIZE];

    // ① 创建套接字
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    // ② 绑定 + 监听
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(PORT);
    server_addr.sin_addr.s_addr = INADDR_ANY;
    bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));
    listen(server_fd, 5);

    // ③ 创建 epoll 实例
    // epoll_create1(0) 等价于旧版的 epoll_create(任意正数)
    epfd = epoll_create1(0);
    if (epfd == -1) {
    perror("epoll_create1");
    exit(1);
    }

    // ④ 把 server_fd 注册到 epoll
    struct epoll_event ev, events[MAX_EVENTS];
    ev.events = EPOLLIN; // 关心"可读"事件(新连接到来)
    ev.data.fd = server_fd; // 把 fd 存进去,方便后面取出来
    epoll_ctl(epfd, EPOLL_CTL_ADD, server_fd, &ev);

    printf("epoll 服务器启动(LT 模式),监听端口 %d\\n", PORT);

    while (1) {
    // ⑤ epoll_wait:阻塞等待,有事件时返回
    // 参数:epoll实例、存放事件的数组、数组大小、超时(-1=永远等)
    int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
    if (nfds < 0) {
    perror("epoll_wait");
    continue;
    }

    // ⑥ 遍历所有就绪事件——注意!只遍历"就绪"的,不是全部 fd
    for (int i = 0; i < nfds; i++) {
    int fd = events[i].data.fd;

    if (fd == server_fd) {
    // ===== server_fd:有新连接 =====
    client_fd = accept(server_fd,
    (struct sockaddr *)&client_addr,
    &client_len);
    if (client_fd >= 0) {
    printf("新客户端 %s:%d 连接,fd=%d\\n",
    inet_ntoa(client_addr.sin_addr),
    ntohs(client_addr.sin_port), client_fd);

    // 把新客户端也注册到 epoll
    ev.events = EPOLLIN;
    ev.data.fd = client_fd;
    epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);
    }

    } else {
    // ===== 客户端 fd:有数据或断开 =====
    int n = read(fd, buffer, sizeof(buffer) – 1);

    if (n <= 0) {
    // 断开或出错——从 epoll 中移除这个 fd
    if (n == 0) {
    printf("客户端 fd=%d 断开连接\\n", fd);
    } else {
    perror("read");
    }
    epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); // 从 epoll 移除
    close(fd);
    } else {
    // 收到数据,原样发回
    buffer[n] = '\\0';
    printf("客户端 fd=%d 发来: %s", fd, buffer);
    write(fd, buffer, n);
    }
    }
    }
    }

    close(server_fd);
    close(epfd); // 关闭 epoll 实例
    return 0;
    }

    第 3 步 → 编译运行:

    gcc -Wall -g epoll_server.c -o epoll_server
    ./epoll_server

    5.4.5 ET 模式版本(进阶)

    如果你想体验 ET 模式的高性能(以及它带来的复杂度),将上述代码中的核心部分改为:

    // ① 设置 server_fd 和所有 client_fd 为非阻塞
    int set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
    }
    set_nonblocking(server_fd);

    // ② 注册时加上 EPOLLET 标志
    ev.events = EPOLLIN | EPOLLET; // 边缘触发模式
    ev.data.fd = server_fd;
    epoll_ctl(epfd, EPOLL_CTL_ADD, server_fd, &ev);

    // ③ 读取数据时必须循环读到 EAGAIN
    while (1) {
    n = read(fd, buffer, sizeof(buffer) – 1);
    if (n == -1) {
    if (errno == EAGAIN || errno == EWOULDBLOCK) {
    break; // 读完了,没有更多数据
    }
    perror("read");
    break;
    }
    if (n == 0) {
    // 客户端关闭
    epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
    close(fd);
    break;
    }
    buffer[n] = '\\0';
    write(fd, buffer, n);
    }

    ⚠️ ET 模式警告:不设非阻塞 + 不循环 read 到 EAGAIN → 数据可能永远丢失。初学者建议先用 LT 模式跑通,再尝试 ET。


    5.5 三兄弟终极对决 —— 一表定乾坤

    维度 select poll epoll
    fd 上限 FD_SETSIZE(通常 1024),改它要重编译内核 无硬限制,受内存限制 无硬限制,受内存限制
    每次调用是否重传 fd 列表 是(拷贝整个 fd_set) 是(拷贝整个 pollfd 数组) 否(fd 注册在内核空间,一次注册长期有效)
    内核行为 遍历全部 fd 找到就绪的 遍历全部 fd 找到就绪的 事件驱动:fd 就绪时自动挂到就绪链表
    复杂度 O(n),n = 最大 fd 值 O(n),n = fd 总数 O(1),只关注就绪 fd 数量
    返回后的工作量 遍历所有 fd 用 FD_ISSET 检查 遍历所有 fd 检查 revents 只遍历返回的就绪事件(epoll_wait 返回的 nfds 个)
    跨平台 是(POSIX,全平台) 是(POSIX,全平台) 否(Linux 专属。BSD 有 kqueue,Windows 有 IOCP)
    C10K 问题 无法支撑 勉强支撑但效率低 轻松应对(Nginx 单机百万连接靠的就是 epoll)
    适用场景 学习原理、少量 fd、跨平台需求 中小规模、跨平台需求 高并发生产环境、Linux 服务器

    📌 术语速查:C10K 问题(1999 年提出)——即"如何在一台服务器上同时服务 1 万个客户端"。epoll 是 Linux 给出的标准答案。现代已进化到 C10M(千万级)。


    5.6 epoll 实战:压测对比

    我们来直观感受一下三种模型的差距。

    第 1 步 → 分别启动三个服务器(每个在单独的终端):

    # 终端1
    ./select_server

    # 终端2
    ./poll_server

    # 终端3
    ./epoll_server

    第 2 步 → 查看 CPU 和内存占用(当连接数增多时):

    # 新开终端,用 top 或 htop 观察
    top -p $(pgrep -d',' select_server)
    top -p $(pgrep -d',' poll_server)
    top -p $(pgrep -d',' epoll_server)

    现象:当有 1000 个空闲连接(连接了但不发数据)时:

    • select/poll 每次 select()/poll() 调用都会遍历 1000 个 fd,CPU 使用率明显上升。
    • epoll 只返回就绪的 fd 数(可能是 0),CPU 几乎不涨。

    第六部分:实战对比 —— 用 telnet 同时连接多个服务器

    6.1 模拟多个客户端

    在多个终端中同时运行客户端:

    # 终端1
    telnet 127.0.0.1 8080

    # 终端2
    telnet 127.0.0.1 8080

    # 终端3
    telnet 127.0.0.1 8080

    6.2 查看进程/线程/fd 数量变化

    # 查看多进程服务器的子进程
    ps aux | grep fork_server

    # 查看多线程服务器的线程数(-T 显示线程,-p PID 指定进程)
    ps -T -p $(pgrep thread_server)

    # 查看 epoll 服务器打开的文件描述符
    ls -la /proc/$(pgrep epoll_server)/fd | wc -l

    6.3 用 Python 脚本模拟大量连接

    以下脚本可以快速创建 500 个连接来测试你的服务器:

    # 保存为 test_client.py,运行:python3 test_client.py
    import socket
    import time

    sockets = []
    for i in range(500):
    try:
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect(('127.0.0.1', 8080))
    s.send(f"Hello from client {i}\\n".encode())
    sockets.append(s)
    print(f"Client {i} connected")
    except Exception as e:
    print(f"Client {i} failed: {e}")
    break

    print(f"Total connected: {len(sockets)}")
    print("Press Ctrl+C to disconnect all…")
    time.sleep(60) # 保持连接 60 秒


    第七部分:避坑指南(面试/实战高频)

    现象 解决方案
    进程模型僵尸泛滥 ps aux 看到大量 <defunct> 注册 SIGCHLD + waitpid 循环回收;或 signal(SIGCHLD, SIG_IGN)
    线程模型参数传递错误 两个线程服务同一个 fd 参数必须在堆上 malloc,不能用栈变量地址
    线程忘记 detach/join 内存持续增长 创建后用 pthread_detach,或在线程内 pthread_detach(pthread_self())
    select fd 超限 连接数超过 1024 后拒绝新连接 换成 poll 或 epoll
    epoll ET 模式卡死 read 返回后程序不再响应 ET 必须配合非阻塞 fd + 循环 read 到 EAGAIN
    select 每次重设 fd_set 程序行为异常,某些 fd "丢了" 每次循环必须 FD_ZERO + 重新 FD_SET(select 会修改 fd_set)
    epoll_ctl 忘记 EPOLL_CTL_DEL 关闭 fd 后 epoll 仍报告事件 关闭 fd 前先 epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL)
    非阻塞 read 误判 EAGAIN 程序频繁报错退出 EAGAIN/EWOULDBLOCK 是正常状态,不是错误,应 continue 等待

    总结

    这两天我们跨越了单用户服务的藩篱,正式进入了高并发网络编程的世界:

    技术 核心思路 一句话总结
    多进程(fork) 每个客户 = 一个子进程 稳如泰山,但扛不住海量连接
    多线程(pthread) 每个客户 = 一个线程 轻量快速,但一损俱损
    select 内核轮询 + 位图 经典入门,上限 1024,遍历全量
    poll 内核轮询 + 动态数组 去掉 1024 限制,但遍历全量问题仍在
    epoll 事件驱动 + 红黑树 + 就绪链表 Linux 王者,只关注就绪事件,O(1) 效率

    技术演进路线图

    单线程阻塞服务器(排队模式)

    ├──→ 多进程(fork)──→ 线程池(预创建线程复用)

    ├──→ 多线程(pthread)──→ 线程池 + 任务队列

    └──→ IO 复用 ──→ select(1983)

    └──→ poll(1997)

    └──→ epoll(2002)──→ 现代高性能服务器标配

    └──→ Reactor 模式
    └──→ Proactor 模式

    现实世界:Nginx 用 epoll + 少量 worker 进程,单机轻松百万并发。Redis 用 epoll 实现单线程处理数万客户端。你今天写的 epoll_server 就是这些顶级开源项目的"种子"。


    常见问题速查表(Day 24-25)

    问题现象 原因 解决办法
    fork_server 运行一段时间后 ps aux 出现大量 <defunct> 子进程退出后父进程没回收(僵尸进程) 加上 signal(SIGCHLD, sigchld_handler) 和 waitpid 循环
    thread_server 编译报 undefined reference to pthread_create 没链接 pthread 库 编译命令加上 -lpthread:gcc thread_server.c -o thread_server -lpthread
    多线程服务器行为怪异,两个客户端收到的内容互相串 参数传递用了栈变量地址 必须 malloc 在堆上分配,线程内 free
    select 服务器最多只能连 1024 个客户端 FD_SETSIZE 硬限制 换成 poll 或 epoll
    epoll_wait 返回了事件,但 read 时没有数据 可能是 ET 模式下没循环读到底 ET 模式必须循环 read 直到 errno == EAGAIN
    epoll_ctl 报 Bad file descriptor fd 已经被 close 但没从 epoll 删除 关闭 fd 前先 epoll_ctl(…, EPOLL_CTL_DEL, fd, NULL)
    bind: Address already in use 上次服务器退出后端口还处于 TIME_WAIT 状态 加上 setsockopt(…, SO_REUSEADDR, …)
    ET 模式服务器跑着跑着不动了 fd 没设非阻塞,read 阻塞在某个客户端上 所有 ET 模式下的 fd 必须先 fcntl(fd, F_SETFL, O_NONBLOCK)
    客户端断开后服务器 CPU 飙升 没有正确处理 read 返回 0 的情况,空转循环 检查 read 返回值:<=0 时关闭 fd 并从监控列表移除

    知识点关系图

    ┌─────────────────────────────────────┐
    │ 网络编程基础(Day 22-23) │
    │ socket / bind / listen / accept │
    │ TCP Echo 服务器 │
    └──────────────┬──────────────────────┘

    ┌──────────────┴──────────────────────┐
    │ 并发服务器(Day 24-25) │
    └──────┬──────────────┬───────────────┘
    │ │
    ┌────────────┴──┐ ┌──────┴───────────────┐
    │ 多进程 Fork │ │ 多线程 Pthread │
    │ 每客户=1进程 │ │ 每客户=1线程 │
    └──────┬────────┘ └──────┬───────────────┘
    │ │
    └─────────┬──────────┘
    │ 都无法高效支撑海量连接

    ┌──────────┴─────────────────────────┐
    │ IO 复用技术 │
    │ │
    │ select ──→ poll ──→ epoll(王者) │
    │ 1983 1997 2002 │
    │ O(n) O(n) O(1) │
    └──────────┬─────────────────────────┘

    ┌──────────┴──────────────────────────┐
    │ Reactor / Proactor 模式 │
    │ (进阶:框架设计模式) │
    │ │
    │ Nginx、Redis、Netty、libuv 的基石 │
    └─────────────────────────────────────┘


    📌 作业(Day 24-25 巩固)

  • 基础:分别编译运行多进程和多线程服务器,用 telnet 开 3 个客户端同时连接,用 ps 观察进程/线程数量的变化。
  • 实验:在多线程服务器中,故意不关闭 server_fd 或故意不设置线程分离,用 htop 或 ps 观察资源泄漏现象。
  • 动手:把任意一个服务器的客户端套接字用 fcntl 设为非阻塞,然后在 read 时加入对 EAGAIN 的判断,体会非阻塞读的行为。
  • 对比:分别编译运行 select、poll、epoll 三个服务器,用第六部分的 Python 脚本创建 500 个连接,用 top 对比三者的 CPU 占用。
  • 挑战 A:在多线程服务器中引入一个全局计数器 int online_clients,每连接一个客户端 +1,断开时 -1。用互斥锁 pthread_mutex_t 保护它,并让主线程每秒打印一次当前在线人数。
  • 挑战 B:把 epoll 服务器从 LT 模式改为 ET 模式,确保所有 fd 设为非阻塞,并循环 read 到 EAGAIN。用大量客户端压测,对比 LT 和 ET 模式下 epoll_wait 的返回次数。
  • 思考题:为什么 epoll 需要使用红黑树来存储 fd?用哈希表(数组+链表)行不行?从增删改查、范围查询、内存占用三个角度分析。

  • 并发之路漫漫,从 fork 到 pthread 再到 epoll,你已经掌握了 Linux 网络编程的"三把钥匙"。今天写的 epoll_server.c,就是 Nginx 和 Redis 诞生的第一行代码。我们下期项目见!

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » Day 24-25 并发服务器 + IO复用:让你的程序同时服务成千上万个“客人“
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!