一、前言:历史的起点

在探讨 Redis 高性能网络模型时,我们常常会聚焦于 epoll 这个现代利器。然而,任何伟大的技术都有其演进的源头。select 作为 IO 多路复用技术的开山鼻祖,虽然在今天看来已显“古老”,但它奠定了整个事件驱动模型的基础。

Redis 的代码库中至今仍保留着对 select 的支持,这不仅是为了兼容那些不支持 epoll 或 kqueue 的老旧系统,更是对这段技术历史的致敬。

💡 核心价值
理解 select 的工作原理和局限性,不仅能让我们明白为何 epoll 是更好的选择,更能深刻体会到 Redis 事件循环抽象层(aeEventLoop)设计的精妙之处——它让底层实现的切换变得透明而优雅

本文将带你:

  • 彻底搞懂 select 系统调用的工作机制
  • 剖析其在高并发场景下的三大致命缺陷
  • 追踪 Redis 源码,看它是如何封装 select 以兼容不同平台的

二、select 是什么?同步阻塞的“轮询官”

2.1 核心思想

select 允许一个进程(或线程)通过一次系统调用,同时监视多个文件描述符(fd),并阻塞等待,直到其中至少有一个 fd 变得“就绪”(例如,socket 接收缓冲区有数据可读,或发送缓冲区有空间可写)。

你可以把它想象成一位勤恳但效率不高的“轮询官”。你交给他一份长长的名单(所有要监听的 socket),他拿着这份名单进入内核,然后挨个检查名单上的每一个人。只要发现有一个人“有事”(就绪),他就会立刻跑出来向你报告。

2.2 函数原型与参数详解

#include <sys/select.h>

int select(int nfds, 
           fd_set *readfds, 
           fd_set *writefds,
           fd_set *exceptfds, 
           struct timeval *timeout);
  • nfds: 要监视的最大文件描述符值 + 1select 只会检查从 0 到 nfds-1 的 fd。
  • readfds: 指向一个 fd_set 类型的指针,用于传入关心“可读”事件的 fd 集合。调用返回后,该集合会被内核修改,只保留就绪的 fd
  • writefds: 同上,用于“可写”事件。
  • exceptfds: 同上,用于“异常”事件(如带外数据)。
  • timeout: 超时时间。设为 NULL 表示永久阻塞;设为 {0, 0} 表示非阻塞轮询;其他值表示等待指定时间。

✅ 关键点fd_set 是一个位图(bitmap)。每个 bit 代表一个 fd 的状态(1 表示关心,0 表示不关心)。


三、select 的工作流程

让我们通过一个简单的服务器伪代码来理解:

// 1. 初始化 fd_set
fd_set read_fds;
FD_ZERO(&read_fds); // 清空集合
FD_SET(server_sock, &read_fds); // 将监听socket加入集合

int max_fd = server_sock;

while (1) {
    // 2. 必须每次都重新准备 fd_set!
    fd_set tmp_fds = read_fds; 

    // 3. 阻塞在此,等待事件
    int activity = select(max_fd + 1, &tmp_fds, NULL, NULL, NULL);

    if (activity < 0) {
        // 错误处理
        continue;
    }

    // 4. 检查监听socket是否有新连接
    if (FD_ISSET(server_sock, &tmp_fds)) {
        int client_sock = accept(server_sock, ...);
        FD_SET(client_sock, &read_fds); // 将新客户端socket加入监听集合
        if (client_sock > max_fd) max_fd = client_sock;
    }

    // 5. 遍历所有客户端socket,检查是否有数据可读
    for (int i = 0; i <= max_fd; i++) {
        if (i != server_sock && FD_ISSET(i, &tmp_fds)) {
            // 6. 读取并处理客户端数据
            handle_client_request(i);
        }
    }
}

⚠️ 注意步骤 2select 调用会修改传入的 fd_set,所以我们必须在每次调用前保存一份原始副本。


四、select 的三大致命缺陷

尽管 select 开创了 IO 多路复用的先河,但它在高并发场景下暴露出了严重的性能瓶颈。

4.1 缺陷一:文件描述符数量限制(C10K 问题的根源)

  • 在 Linux 系统中,fd_set 的大小是固定的,通常由 __FD_SETSIZE 宏定义,默认值为 1024
  • 这意味着,一个使用 select 的程序最多只能同时监听 1024 个连接
  • 对于现代动辄数万并发的 Web 服务或数据库(如 Redis),这个限制是完全不可接受的。

4.2 缺陷二:O(N) 的线性扫描开销

  • 用户态开销:每次调用 select 前,应用程序都必须重新构建整个 fd_set 位图。调用返回后,又必须线性遍历 0 到 max_fd 的所有 fd,以找出哪些是就绪的。
  • 内核态开销:内核收到 select 调用后,同样需要线性扫描传入的 fd_set 中的每一个 bit,去检查对应 fd 的状态。
  • 后果:无论有多少个 fd 是活跃的,这个 O(N) 的扫描过程都无法避免。当 N 很大(如 10,000)而活跃连接很少(如 10)时,99.9% 的 CPU 时间都浪费在了无意义的扫描上。

4.3 缺陷三:巨大的内存拷贝开销

  • 每次 select 调用,都需要将整个 fd_set(可能长达 128 字节,对应 1024 个 fd)从用户空间拷贝到内核空间
  • 调用返回时,内核又需要将修改后的 fd_set 从内核空间拷贝回用户空间
  • 在高频调用的网络服务器中,这种频繁的、大规模的内存拷贝会成为显著的性能瓶颈。

总结select 的设计在连接数较少时表现尚可,但一旦面对高并发,其固有的架构缺陷就会导致性能急剧下降。


五、Redis 如何封装 select

Redis 并没有直接使用裸的 select API,而是通过其事件驱动框架对其进行了一层优雅的封装。这使得 Redis 可以在 selectpollepollkqueue 等不同后端之间无缝切换。

5.1 核心抽象:aeEventLoop

Redis 定义了一个统一的事件循环结构 aeEventLoop,它对上层业务逻辑屏蔽了底层 IO 多路复用的具体实现。

// server.h
typedef struct aeEventLoop {
    // ...
    aeFileEvent *events; /* 存储所有注册的文件事件 */
    aeFiredEvent *fired; /* 存储已触发的事件 */
} aeEventLoop;

5.2 ae_select.c 的实现

当编译环境不支持更高级的 epoll 或 kqueue 时,Redis 会回退到 ae_select.c

  • aeApiCreate: 负责初始化。对于 select,它实际上不需要做太多事,因为 select 本身是无状态的。
  • aeApiAddEvent / aeApiDelEvent: 这两个函数在 select 实现中几乎是空操作。因为 select 没有“注册”的概念,所有的 fd 都是在 aeApiPoll 调用时临时传入的。
  • aeApiPoll: 这是核心函数,它实现了 select 的调用逻辑。
    static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) {
        aeApiState *state = eventLoop->apidata;
        int retval, j, numevents = 0;
    
        // 1. 清空 readset 和 writeset
        FD_ZERO(&state->rfds);
        FD_ZERO(&state->wfds);
    
        // 2. 遍历所有已注册的事件,将 fd 加入对应的 fd_set
        for (j = 0; j <= eventLoop->maxfd; j++) {
            aeFileEvent *fe = &eventLoop->events[j];
            if (fe->mask & AE_READABLE) FD_SET(j, &state->rfds);
            if (fe->mask & AE_WRITABLE) FD_SET(j, &state->wfds);
        }
    
        // 3. 调用 select!
        retval = select(eventLoop->maxfd+1,
                       &state->rfds, &state->wfds, NULL, tvp);
    
        // 4. 处理 select 返回的结果
        if (retval > 0) {
            for (j = 0; j <= eventLoop->maxfd; j++) {
                int mask = 0;
                aeFileEvent *fe = &eventLoop->events[j];
                // 5. 检查每个 fd 是否在返回的集合中
                if (fe->mask & AE_READABLE && FD_ISSET(j, &state->rfds))
                    mask |= AE_READABLE;
                if (fe->mask & AE_WRITABLE && FD_ISSET(j, &state->wfds))
                    mask |= AE_WRITABLE;
                if (mask) {
                    // 6. 将就绪事件记录到 fired 数组
                    eventLoop->fired[numevents].fd = j;
                    eventLoop->fired[numevents].mask = mask;
                    numevents++;
                }
            }
        }
        return numevents;
    }

✅ 关键洞察
这段代码完美地体现了 select 的工作模式,也暴露了它的缺点:

  • 步骤 2 和 5:清晰地展示了 O(N) 的线性扫描。
  • 步骤 3select 调用本身。
  • 整个过程:没有利用任何内核级的数据结构来优化,完全依赖用户态的遍历。

5.3 编译时的选择

Redis 通过 autoconf 脚本在编译时自动检测系统特性,并选择最优的后端:

// ae.c
#ifdef HAVE_EPOLL
#include "ae_epoll.c"
#else
    #ifdef HAVE_KQUEUE
    #include "ae_kqueue.c"
    #else
    #include "ae_select.c" // 最后的 fallback
    #endif
#endif

这意味着,在现代 Linux 服务器上,Redis 几乎永远不会使用 select,而是优先选择性能卓越的 epoll


六、结语

感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐