Redis网络模型-IO多路复用-select方式
一、前言:历史的起点
在探讨 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: 要监视的最大文件描述符值 + 1。select只会检查从 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);
}
}
}
⚠️ 注意步骤 2:
select调用会修改传入的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 可以在 select、poll、epoll、kqueue 等不同后端之间无缝切换。
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) 的线性扫描。
- 步骤 3:
select调用本身。- 整个过程:没有利用任何内核级的数据结构来优化,完全依赖用户态的遍历。
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。
六、结语
感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)