从朴素到高效:用 C 代码带你玩转 select、poll 和 epoll

124 阅读9分钟

从朴素到高效:用 C 代码带你玩转 select、poll 和 epoll

嗨,大家好!今天咱们要聊聊 Linux 下处理 I/O 事件的几个经典工具:selectpollepoll。这仨就像是程序员手里的“三把刷子”,从最老实的基础款到如今的高效神器,咱们会一步步写代码试试,看看它们咋工作,顺便聊聊为啥会从一个进化到另一个。不过在动手之前,咱们得先搞明白几个基本概念,不然直接上代码可能会晕头转向。放心,我尽量讲得接地气,不整那些硬邦邦的术语。


背景知识:先把基本功打扎实

  1. 啥是 fd(文件描述符)?
    fd 全称是“文件描述符”(file descriptor),简单说就是 Linux 里给每个打开的文件或者资源分配的一个编号。你可以把它想象成一个“门牌号”,不管是读文件、写文件,还是跟网络 socket 打交道,内核都靠这个数字来找到对应的东西。比如你打开一个文件,系统可能给你返回个 3,建个 socket,可能返回个 4。这些数字通常从 0 开始,0、1、2 默认是标准输入、输出和错误,之后的就看系统分配了。

  2. fds 是啥?
    fds 其实就是“文件描述符集合”(file descriptors set)的简称,也就是一堆 fd 的打包。通常在像 select 这种场景里,咱们得告诉内核:“嘿,你帮我盯着这几个 fd,看看哪个有动静。”所以 fds 就是把多个 fd 塞到一个篮子里,方便批量操作。

  3. 系统调用是啥?select、poll、epoll 是啥身份?
    系统调用(system call)是用户程序跟内核打交道的一种方式。比如你想读文件、建 socket,这些底层操作得靠内核完成,用户代码没法直接干,只能通过系统调用“拜托”内核。selectpollepoll 在 Linux 里确实是系统调用,它们是内核提供的原生接口,专门用来处理 I/O 多路复用——简单说,就是让程序同时盯着好几个 fd,看哪个有数据可读、可写啥的。至于 C 标准库(libc),它其实是对这些系统调用做了层封装,方便咱们用。比如你写 select,背后最终还是调的内核的 sys_select。所以,咱们今天玩的这仨,都是正宗的系统调用,只不过通过 C 的函数形式露出来罢了。

搞清楚这些,咱们就可以开干了!从最简单的 select 开始,一步步试着发现问题,再逼近现代最牛的方案。


第一步:最朴素的 select

咱们先拿 select 开刀,这是 I/O 多路复用的老前辈,用法简单粗暴。假设咱们要写个服务器,监听一堆客户端 socket,看谁有数据可读。代码长这样:

#include <stdio.h>
#include <stdlib.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>

#define MAX_CLIENTS 10 // 最多支持 10 个客户端连接

int main() {
    int server_fd, client_fds[MAX_CLIENTS] = {0}; // server_fd 是服务器 socket,client_fds 存客户端 fd
    fd_set readfds; // 定义一个 fd_set,装咱们要监听的 fd
    struct sockaddr_in address; // 服务器地址结构体
    int addrlen = sizeof(address); // 地址长度

    // 创建服务器 socket,AF_INET 是 IPv4,SOCK_STREAM 是 TCP
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    address.sin_family = AF_INET; // 用 IPv4
    address.sin_addr.s_addr = INADDR_ANY; // 监听所有网卡
    address.sin_port = htons(8080); // 端口 8080,htons 转成网络字节序

    // 绑定 socket 到地址和端口
    bind(server_fd, (struct sockaddr *)&address, sizeof(address));
    // 开始监听,队列长度设为 3
    listen(server_fd, 3);

    printf("Server listening on port 8080...\n"); // 提示服务器已启动

    int max_fd = server_fd; // max_fd 记录当前最大 fd,用于 select
    while (1) {
        FD_ZERO(&readfds); // 清空 readfds,select 用之前必须清零
        FD_SET(server_fd, &readfds); // 把服务器 fd 加到集合里,监听新连接

        // 遍历所有客户端 fd,把活跃的加进 readfds
        for (int i = 0; i < MAX_CLIENTS; i++) {
            if (client_fds[i] > 0) { // 如果这个 fd 有效
                FD_SET(client_fds[i], &readfds); // 加到集合里
                if (client_fds[i] > max_fd) max_fd = client_fds[i]; // 更新 max_fd
            }
        }

        // 调用 select,监听 readfds 里的 fd,max_fd+1 是必须的范围
        select(max_fd + 1, &readfds, NULL, NULL, NULL);

        // 检查服务器 fd 是否有新连接
        if (FD_ISSET(server_fd, &readfds)) {
            // 有新连接,accept 接受并返回新 fd
            int new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen);
            for (int i = 0; i < MAX_CLIENTS; i++) {
                if (client_fds[i] == 0) { // 找个空位存新 fd
                    client_fds[i] = new_socket;
                    printf("New connection, fd = %d\n", new_socket);
                    break;
                }
            }
        }

        // 检查每个客户端 fd 是否有数据可读
        for (int i = 0; i < MAX_CLIENTS; i++) {
            if (client_fds[i] > 0 && FD_ISSET(client_fds[i], &readfds)) { // 如果这个 fd 有动静
                char buffer[1024] = {0}; // 缓冲区存数据
                int valread = read(client_fds[i], buffer, 1024); // 读数据
                if (valread == 0) { // 返回 0 说明客户端断开
                    close(client_fds[i]); // 关掉 fd
                    client_fds[i] = 0; // 清空这个位置
                } else { // 有数据
                    printf("Received: %s\n", buffer); // 打印出来
                }
            }
        }
    }
    return 0;
}

跑起来后,用 telnet localhost 8080 连上试试,敲点东西,服务器会乖乖打印。看着还行,但问题很快就冒出来了。

问题在哪?

  1. 效率有点拉胯:每次 select,都要把所有 fd 塞进 fd_set,内核检查完还得把结果塞回来。比如你有 1024 个 fd,哪怕只有 1 个有数据,内核也得扫完整堆。
  2. 数量卡脖子fd_set 大小是死的,默认 1024,想监听 2000 个?得改系统配置,太麻烦。
  3. 重复劳动多:每次循环都得清空 fd_set,再一个个加回去,太费劲。

第二步:稍微聪明点的 poll

select 的毛病暴露后,程序员们推出了 poll,稍微改善了点。咱们改代码试试:

#include <stdio.h>
#include <stdlib.h>
#include <poll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>

#define MAX_CLIENTS 10 // 最多支持 10 个客户端

int main() {
    int server_fd, client_fds[MAX_CLIENTS] = {0}; // server_fd 是服务器 socket,client_fds 存客户端 fd
    struct pollfd fds[MAX_CLIENTS + 1]; // poll 用的事件数组,+1 是留给 server_fd
    struct sockaddr_in address; // 服务器地址结构体
    int addrlen = sizeof(address); // 地址长度

    // 创建服务器 socket
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    address.sin_family = AF_INET; // 用 IPv4
    address.sin_addr.s_addr = INADDR_ANY; // 监听所有网卡
    address.sin_port = htons(8080); // 端口 8080

    // 绑定和监听
    bind(server_fd, (struct sockaddr *)&address, sizeof(address));
    listen(server_fd, 3);

    printf("Server listening on port 8080...\n");

    fds[0].fd = server_fd; // 第 0 个位置放服务器 fd
    fds[0].events = POLLIN; // 监听可读事件

    int nfds = 1; // 当前监听的 fd 数量,初始只有 server_fd
    while (1) {
        // 调用 poll,nfds 是数组长度,-1 表示无限等待
        poll(fds, nfds, -1);

        // 检查服务器 fd 是否有新连接
        if (fds[0].revents & POLLIN) { // revents 是 poll 返回的事件
            int new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen);
            for (int i = 0; i < MAX_CLIENTS; i++) {
                if (client_fds[i] == 0) { // 找个空位存新 fd
                    client_fds[i] = new_socket;
                    fds[nfds].fd = new_socket; // 加到 poll 数组
                    fds[nfds].events = POLLIN; // 监听可读
                    nfds++; // fd 数量加 1
                    printf("New connection, fd = %d\n", new_socket);
                    break;
                }
            }
        }

        // 检查客户端 fd 是否有数据
        for (int i = 1; i < nfds; i++) { // 从 1 开始,第 0 个是 server_fd
            if (fds[i].revents & POLLIN) { // 如果有可读事件
                char buffer[1024] = {0}; // 缓冲区
                int valread = read(fds[i].fd, buffer, 1024); // 读数据
                if (valread == 0) { // 客户端断开
                    close(fds[i].fd); // 关闭 fd
                    client_fds[i-1] = 0; // 清空对应位置(注意 i-1,因为 client_fds 从 0 开始)
                    fds[i].fd = -1; // 标记为无效,poll 会忽略
                } else { // 有数据
                    printf("Received: %s\n", buffer);
                }
            }
        }
    }
    return 0;
}

poll 替换后,感觉比 select 顺手了些。

有啥进步?

  1. 没上限限制poll 用动态数组 struct pollfd,想加多少 fd 都行,不怕 1024 的门槛。
  2. 状态更省心:不用每次清空重设,fds 数组里的 fd 和事件可以一直用。

但还有啥不足?

  • 效率不够高:内核还是得扫完整個 fds 数组。1000 个 fd 里只有 2 个活跃,也得全检查。
  • 数据来回折腾:每次调用都得把数组传给内核,活跃 fd 少的时候有点浪费。

第三步:现代王者 epoll

终于到 epoll 了,这可是如今服务器开发的扛把子,直接把前两者的短板干掉。上代码:

#include <stdio.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>

#define MAX_EVENTS 10 // epoll_wait 一次最多返回 10 个事件
#define MAX_CLIENTS 10 // 最多支持 10 个客户端

int main() {
    int server_fd, epoll_fd, client_fds[MAX_CLIENTS] = {0}; // server_fd 是服务器,epoll_fd 是 epoll 实例
    struct epoll_event event, events[MAX_EVENTS]; // event 用于设置,events 存返回的事件
    struct sockaddr_in address; // 地址结构体
    int addrlen = sizeof(address);

    // 创建服务器 socket
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(8080);

    // 绑定和监听
    bind(server_fd, (struct sockaddr *)&address, sizeof(address));
    listen(server_fd, 3);

    // 创建 epoll 实例,0 表示默认 flags
    epoll_fd = epoll_create1(0);
    event.events = EPOLLIN; // 监听可读事件
    event.data.fd = server_fd; // 绑定 server_fd
    // 把 server_fd 加到 epoll 监听列表
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &event);

    printf("Server listening on port 8080...\n");

    while (1) {
        // 等待事件,MAX_EVENTS 是上限,-1 表示无限等待
        int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
        for (int i = 0; i < n; i++) { // 遍历返回的事件
            if (events[i].data.fd == server_fd) { // 如果是服务器 fd 有新连接
                int new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen);
                for (int j = 0; j < MAX_CLIENTS; j++) {
                    if (client_fds[j] == 0) { // 找空位存新 fd
                        client_fds[j] = new_socket;
                        event.data.fd = new_socket; // 设置新 fd
                        event.events = EPOLLIN; // 监听可读
                        // 加到 epoll 监听列表
                        epoll_ctl(epoll_fd, EPOLL_CTL_ADD, new_socket, &event);
                        printf("New connection, fd = %d\n", new_socket);
                        break;
                    }
                }
            } else { // 客户端 fd 有数据
                char buffer[1024] = {0};
                int valread = read(events[i].data.fd, buffer, 1024);
                if (valread == 0) { // 客户端断开
                    close(events[i].data.fd); // 关闭 fd
                    for (int j = 0; j < MAX_CLIENTS; j++) {
                        if (client_fds[j] == events[i].data.fd) { // 找到对应位置
                            client_fds[j] = 0; // 清空
                            break;
                        }
                    }
                } else { // 有数据
                    printf("Received: %s\n", buffer);
                }
            }
        }
    }
    return 0;
}

跑起来后,你会发现这家伙效率简直飞起!

为啥这么强?

  1. 只管有事的epoll_wait 只返回活跃的 fd,10 个 fd 里 2 个有动静,就只处理这 2 个。
  2. 内核接手:fd 交给内核管理,用户态不用每次传一堆数据。
  3. 玩法多样:支持水平触发(LT)和边缘触发(ET),随你挑。

优化方向:从 demo 到现代方案

通过这仨 demo,咱们能挖出啥改进点?这些正好跟现代高性能方案接轨:

  1. 少干无用功epoll 用事件通知跳过了全扫描,未来还能用回调或异步再提速。
  2. 省着点传数据epoll 减少了拷贝,往后可以用零拷贝(像 mmap)更省力。
  3. 灵活扩展epoll 加上线程池,能轻松应对高并发。

总结

selectpoll 再到 epoll,咱们从简单粗暴走到了高效优雅。通过代码摸索,不仅看清了每种方案的优劣,还能启发咱们在其他场景里找优化点。Linux 下搞 I/O,epoll 是目前的大赢家,但背后的思路值得咱们多琢磨。你有啥想法?欢迎留言聊聊!