Redis 的IO多路复用到底是个啥?
想必你肯定知道 redis 是单线程的,但大概率也会疑惑:一个线程既要处理客户端连接、又要读写数据、还要执行命令,凭什么能扛住 10 万 + 的并发请求?
IO 多路复用就是 redis 处理高并发的一个利器。网上关于 IO 多路复用的讲解要么太偏理论,要么满屏生涩的内核原理,今天咱们换个思路:从 Redis 的实际运行逻辑出发,用简单伪代码 + 通俗场景把 epoll 版的 IO 多路复用讲透,从初始化到事件循环,从连接处理到读写操作,一步一步拆明白
Redis 为啥非要用 IO 多路复用?
在讲原理前,先搞懂「为什么」—— 如果不用 IO 多路复用,Redis 的单线程会变成什么样?
Redis 是基于 TCP 的网络服务,和客户端的交互本质是套接字(FD)的 IO 操作,如果用最朴素的「阻塞 IO + 单线程」:
- 单线程调用
accept()阻塞等连接,此时其他客户端连不上; - 连上后调用
recv()阻塞等请求,此时其他已连接客户端的请求也处理不了;
而IO 多路复用的核心价值就是:让单线程能同时监听成千上万个 FD 的 IO 事件,内核帮忙筛选出「就绪的 FD」,线程只处理这些有事件的也就是就绪的 FD,既不阻塞,也不空转—— 这正是 Redis 单线程高并发的底层基石。
Redis 的 IO 多路复用是基于操作系统的底层实现的,Linux 下首选epoll,同时为了跨平台,还封装了 kqueue(FreeBSD/macOS)、evport(Solaris)、poll/select(Linux 的另外两个IO多路复用实现,不推荐),但核心逻辑一致,今天咱们就以最常用、最高效的epoll为例讲解。
搞懂谁在干哪件事
在正式写伪代码前,先明确 3 个核心角色,这三个角色贯穿整个 IO 多路复用流程:
- Redis 单线程:主程序,负责初始化、注册 FD、处理就绪事件(连接 / 读 / 写);
- 内核 epoll 实例:Redis 通过系统调用创建的「监听器」,由内核维护,负责监听 Redis 注册的所有 FD;
- 套接字 FD:网络通信的「管道」,分两种 —— 监听 FD(Redis 的 6379 端口,负责接连接)、已连接 FD(每个客户端一个,负责和客户端读写数据)。
简单类比:Redis 单线程是「餐厅后厨」,epoll 实例是「餐厅前台」,FD 是「餐桌」—— 后厨不用挨个盯餐桌有没有客人(轮询),也不用站在门口等客人(阻塞),前台(epoll)帮忙盯着所有餐桌,有客人来(连接事件)、客人点餐(读事件)等事件,前台(epoll)会主动通知后厨处理具体的事件,后厨只干具体的活。
重点来了:Redis IO 多路复用完整伪代码拆解
Redis 的 IO 多路复用核心是 「初始化 + 无限事件循环」:先完成所有准备工作(创建 FD、初始化 epoll、注册事件),然后进入死循环,不断「让 epoll 等事件→处理就绪事件→再等事件」,这就是 Redis 主程序的核心逻辑。
不知道有没有同学和我一样,写算法题的时候看讲解有可能看不懂,但是看代码能理解,因为代码不会说谎。然而要看 redis 的源码对于我们这种初学者可能很困难。所以我希望通过伪代码来理解主要的逻辑,接下来的伪代码是C 语言风格(Redis 本身就是 C 写的),保留核心逻辑,简化错误处理、协议解析等非关键细节,所有关键步骤都加了注释。
第一步:全局变量定义
先定义三个全局变量,贯穿整个流程:
// epoll实例的ID,Redis通过epoll_create创建,内核返回
int epoll_fd;
// 监听FD,绑定6379端口,负责接收客户端连接请求
int listen_fd;
// 最大就绪事件数,一次最多处理1024个就绪事件,可根据需求调整
#define MAX_EVENTS 1024
第二步:初始化函数 init ()—— 所有准备工作一次做完
Redis 启动时,会先执行一次初始化,完成「创建监听 FD→设置非阻塞→绑定端口→创建 epoll 实例→注册监听 FD 到 epoll」,这是所有 IO 操作的基础,伪代码如下:
// 初始化:Redis启动时执行一次,完成所有准备工作
void init() {
// 1. 通过socket()创建TCP监听套接字(IPv4 + 流式TCP)
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) exit(-1); // 简化错误处理,实际Redis会有详细日志
// 2. 设置监听FD为非阻塞模式【关键】
// 所有FD必须是非阻塞的,避免单线程被IO操作卡住
int flags = fcntl(listen_fd, F_GETFL, 0);
fcntl(listen_fd, F_SETFL, flags | O_NONBLOCK);
// 3. 绑定6379端口 + 开始监听客户端连接
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET; // IPv4协议
server_addr.sin_port = htons(6379); // 绑定默认端口6379
server_addr.sin_addr.s_addr = INADDR_ANY;// 监听本机所有IP
bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
// 第二个参数是监听队列长度,Redis默认是511,这里简化为1024
listen(listen_fd, 1024);
// 4. 创建epoll实例【epoll三大核心调用之一】
// 参数仅为历史兼容,无实际意义,Redis源码用epoll_create1(0)
epoll_fd = epoll_create(1024);
if (epoll_fd < 0) exit(-1);
// 5. 将监听FD注册到epoll实例,监听EPOLLIN事件【epoll_ctl核心用法】
// epoll_ctl是epoll的「配置工具」,负责增/删/改监听的FD和事件
struct epoll_event listen_event;
listen_event.events = EPOLLIN; // 监听读事件:对监听FD来说,EPOLLIN=有新连接请求
listen_event.data.fd = listen_fd; // 将FD和事件绑定,方便后续识别
// EPOLL_CTL_ADD:添加FD到epoll实例
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &listen_event);
printf("Redis初始化完成,监听6379端口,epoll实例创建成功\n");
}
初始化核心要点:
- 所有 FD 必须设置为非阻塞模式,这是和 epoll 配合的前提,否则单线程还是会被 IO 卡住;
- 监听 FD 的EPOLLIN 事件是「连接请求事件」,和已连接 FD 的 EPOLLIN(读数据)区分开,但内核统一用 EPOLLIN 标识;
- epoll 的三大核心系统调用:
epoll_create(创建实例)、epoll_ctl(配置监听)、epoll_wait(等待事件),这里已经用到前两个。
第三步:封装三个事件处理函数 —— 连接 / 读 / 写分开处理
Redis 的事件处理是「分类型」的:连接事件(监听 FD 的 EPOLLIN)、读事件(已连接 FD 的 EPOLLIN)、写事件(已连接 FD 的 EPOLLOUT),我们把这三个逻辑封装成独立函数,让主循环更清晰。
1. 处理新连接:handle_accept ()
当有客户端发起连接(比如 redis-cli connect),epoll 会通知 Redis 处理监听 FD 的 EPOLLIN 事件,此时调用accept()创建已连接 FD,并将其注册到 epoll,监听读事件:
// 处理新连接事件:监听FD触发EPOLLIN时调用
void handle_accept() {
// 非阻塞accept:即使无连接也不会阻塞(epoll已通知有连接,必能拿到有效FD)
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_len);
if (client_fd < 0) return;
// 关键:新创建的已连接FD也必须设置为非阻塞
int flags = fcntl(client_fd, F_GETFL, 0);
fcntl(client_fd, F_SETFL, flags | O_NONBLOCK);
// 将已连接FD注册到epoll,仅监听EPOLLIN事件(读客户端请求)
// 写事件(EPOLLOUT)按需注册,不提前注册,避免内核无效通知
struct epoll_event client_event;
client_event.events = EPOLLIN;
client_event.data.fd = client_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &client_event);
printf("新客户端连接成功,FD=%d\n", client_fd);
}
核心细节:已连接 FD只先注册 EPOLLIN,写事件 EPOLLOUT 是「按需注册」的 —— 只有当 Redis 发响应时,内核发送缓冲区满了,才会注册 EPOLLOUT,发完立刻取消,避免内核持续触发无效事件,浪费 CPU。
2. 处理客户端读事件:handle_read ()
当客户端发请求(比如 GET key、SET name 123),数据到达服务器后,内核会触发对应已连接 FD 的 EPOLLIN 事件,Redis 调用该函数读取请求、处理并尝试发送响应:
// 处理读事件:已连接FD触发EPOLLIN时调用,fd为客户端FD
void handle_read(int client_fd) {
char buf[1024]; // 接收缓冲区,Redis源码用动态缓冲区,这里简化
// 非阻塞recv:读取客户端请求数据,无数据时直接返回,不阻塞
ssize_t n = recv(client_fd, buf, sizeof(buf) - 1, 0);
// 三种情况处理:客户端断开/读失败/正常读数据
if (n == 0) {
// n=0表示客户端主动断开连接(比如redis-cli quit)
printf("客户端FD=%d主动断开连接\n", client_fd);
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client_fd, NULL); // 从epoll移除FD
close(client_fd); // 关闭FD,释放资源
return;
} else if (n < 0) {
// 非阻塞recv返回错误:EAGAIN/EWOULDBLOCK是无数据,忽略;其他是真错误,关闭FD
if (errno != EAGAIN && errno != EWOULDBLOCK) {
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client_fd, NULL);
close(client_fd);
}
return;
}
// 正常读取到数据,处理请求(Redis源码会做RESP协议解析,这里简化)
buf[n] = '\0';
printf("收到客户端FD=%d请求:%s\n", client_fd, buf);
// 生成响应,Redis会根据命令执行结果生成RESP格式响应,这里简化为"+OK\r\n"
char* response = "+OK\r\n";
// 尝试非阻塞发送响应给客户端
ssize_t send_n = send(client_fd, response, strlen(response), 0);
if (send_n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
// 发送失败,原因是内核发送缓冲区满了 → 按需注册EPOLLOUT事件
struct epoll_event client_event;
// 关键:保留EPOLLIN,新增EPOLLOUT → 同时监听读写事件,不丢读请求
client_event.events = EPOLLIN | EPOLLOUT;
client_event.data.fd = client_fd;
// EPOLL_CTL_MOD:修改已有FD的监听事件
epoll_ctl(epoll_fd, EPOLL_CTL_MOD, client_fd, &client_event);
printf("客户端FD=%d发送缓冲区满,注册EPOLLOUT事件\n", client_fd);
// 实际Redis会把未发送的响应缓存到客户端结构体,这里简化
}
}
最关键的点:修改 FD 事件时,是EPOLLIN | EPOLLOUT,而不是只写 EPOLLOUT!因为 epoll_ctl 的修改是全量覆盖,如果只写 EPOLLOUT,会丢失 EPOLLIN 监听,导致客户端新的请求无法被处理 ——Redis 永远保证「读事件常驻,写事件临时加」。
3. 处理客户端写事件:handle_write ()
当内核发送缓冲区有空位时(客户端接收了部分数据,腾出了缓冲区),会触发对应已连接 FD 的 EPOLLOUT 事件,Redis 调用该函数继续发送剩余响应,发送完成后取消 EPOLLOUT 监听:
// 处理写事件:已连接FD触发EPOLLOUT时调用,fd为客户端FD
void handle_write(int client_fd) {
// 发送缓存的响应,Redis源码会从客户端结构体取未发送的响应,这里简化
char* response = "+OK\r\n";
ssize_t send_n = send(client_fd, response, strlen(response), 0);
if (send_n > 0) {
// 发送成功,取消EPOLLOUT事件,仅保留EPOLLIN【关键】
struct epoll_event client_event;
client_event.events = EPOLLIN; // 回到初始状态,只监听读事件
client_event.data.fd = client_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_MOD, client_fd, &client_event);
printf("客户端FD=%d响应发送完成,取消EPOLLOUT事件\n", client_fd);
}
// 若仍发送失败,继续保留EPOLLOUT,等待下一次内核通知
}
核心原则:写事件是「临时的」,发送完成后立刻改回仅监听 EPOLLIN,这是 Redis 高效处理 IO 的重要细节。
第四步:核心事件循环 event_loop ()——Redis 的主程序逻辑
做完所有初始化和函数封装后,Redis 进入无限事件循环,这是 Redis 主程序的核心 —— 不断调用epoll_wait()让内核帮忙监听事件,收到就绪事件后分类型处理,处理完再继续等,周而复始。
这部分是 IO 多路复用的核心执行逻辑,一定要吃透!
// 核心事件循环:Redis启动后一直运行的主逻辑
void event_loop() {
// 定义数组,存放epoll返回的就绪事件,最多MAX_EVENTS个
struct epoll_event ready_events[MAX_EVENTS];
// 无限循环:Redis主程序一直运行,直到手动关闭
while (1) {
// 1. 调用epoll_wait等待就绪事件【epoll三大核心调用之三】
// 参数:epoll实例ID | 就绪事件数组 | 最大事件数 | 超时时间(-1=无限阻塞,直到有事件)
int nfds = epoll_wait(epoll_fd, ready_events, MAX_EVENTS, -1);
if (nfds < 0) {
continue; // 忽略中断信号,继续循环,实际Redis会做异常处理
}
// 2. 遍历所有就绪事件,分类型处理【核心】
// nfds是epoll返回的就绪事件数量,只处理这些有事件的FD,不空转
for (int i = 0; i < nfds; i++) {
int fd = ready_events[i].data.fd; // 就绪事件对应的FD
uint32_t revents = ready_events[i].events; // 就绪的事件类型(EPOLLIN/EPOLLOUT)
// 3. 区分FD类型:监听FD(处理连接)/ 已连接FD(处理读写)
if (fd == listen_fd) {
// 监听FD触发EPOLLIN:有新客户端连接,处理连接事件
handle_accept();
} else {
// 已连接FD:按事件类型处理,读事件优先于写事件
if (revents & EPOLLIN) {
handle_read(fd); // 处理读事件(客户端发请求)
}
if (revents & EPOLLOUT) {
handle_write(fd); // 处理写事件(发送响应)
}
}
}
}
}
事件循环核心要点:
epoll_wait(-1)是无限阻塞的 —— 如果没有任何就绪事件,Redis 单线程会被内核挂起,不占用 CPU 资源,这是 Redis 空闲时 CPU 使用率极低的原因;epoll_wait只返回就绪的 FD,Redis 只遍历这些就绪 FD,不用轮询所有 FD,高并发下效率拉满(O (1) 复杂度);- 事件处理读优先于写—— 客户端的请求是核心,先处理读事件,再处理写事件,符合 Redis 的业务逻辑;
- 整个循环中,Redis 单线程只做「处理就绪事件」这一件事,没有多余的空转和阻塞。
第五步:主函数 main ()—— 整合所有逻辑
最后,写一个主函数,按「初始化→进入事件循环」的顺序执行,这就是 Redis 主程序的启动流程:
// 主函数:Redis程序入口
int main() {
init(); // 执行初始化,完成所有准备工作
event_loop(); // 进入核心事件循环,一直运行
return 0;
}
就是这么简单!Redis 的 IO 多路复用核心逻辑,本质就是这几行代码的扩展 —— 源码中会增加协议解析、内存数据操作、定时任务、错误处理等,但「初始化 + 事件循环 + 分类型处理事件」的核心框架完全一致。(已经改成容易理解的样子,源码看起来不太好理解)
伪代码整体执行流程梳理
为了让你更清晰,把整个流程按「Redis 启动→客户端连接→发请求→断连接」的顺序梳理一遍,形成完整的逻辑链:
- Redis 启动,执行
main()→init(),完成监听 FD 创建、epoll 初始化、监听 FD 注册到 epoll; - 进入
event_loop(),调用epoll_wait()阻塞等待事件,Redis 单线程挂起,不占 CPU; - 客户端 A 发起连接,内核触发监听 FD 的 EPOLLIN 事件,唤醒 Redis;
- Redis 遍历就绪事件,识别出监听 FD,调用
handle_accept()创建客户端 A 的 FD4,注册到 epoll(监听 EPOLLIN); - 处理完后再次调用
epoll_wait()阻塞; - 客户端 A 发请求
GET key,内核触发 FD4 的 EPOLLIN 事件,唤醒 Redis; - Redis 调用
handle_read()读取请求,生成响应并尝试发送,若发送成功则无后续,若缓冲区满则注册 EPOLLIN|EPOLLOUT; - 若注册了 EPOLLOUT,当内核缓冲区有空位时,触发 FD4 的 EPOLLOUT 事件,Redis 调用
handle_write()发送响应,完成后取消 EPOLLOUT; - 客户端 A 执行
quit,内核触发 FD4 的 EPOLLIN 事件,Redis 调用handle_read()检测到断开,从 epoll 移除 FD4 并关闭; - 全程 Redis 单线程只处理就绪事件,处理完继续阻塞,循环往复。
那些你可能关心的关键细节
讲完伪代码,再解答几个大家最容易疑惑的问题,帮你把知识点补全:
1. epoll 为什么比 select/poll 高效?
核心是三个差异,也是 epoll 能支撑海量 FD 的原因:
- FD 管理:epoll 由内核用红黑树维护 FD,增删改查高效(O (logn)),select/poll 由用户态传数组 / 位图,内核轮询;
- 事件检测:epoll 是中断驱动,内核通过网卡硬件中断感知 FD 就绪,主动通知 Redis;select/poll 是内核轮询所有 FD,效率低(O (n));
- 事件返回:epoll 只返回就绪的 FD,Redis 直接处理;select/poll 返回所有 FD,Redis 需要自己遍历判断是否就绪,浪费资源。
- 监听数量:select 最多只能监听1024个FD(因为传递给内核的位图 fd_set 大小是 Linux 固定的),poll 虽然没有监听数量限制, 但仍需轮询所有 FD 检查就绪状态 。
2. Redis 的 IO 多路复用支持跨平台吗?
支持!Redis 封装了一套IO 多路复用抽象层(ae 层),把 epoll、kqueue、evport、poll、select 的底层差异封装成统一的接口(比如 aeApiAddEvent、aeApiPoll)。
Redis 启动时会自动检测操作系统,按「效率从高到低」择优使用:epoll/kqueue/evport → poll → select(兜底),核心事件循环逻辑无需修改,既保证了高性能,又实现了跨平台。
3. 单线程处理事件,会不会出现性能瓶颈?
Redis 的单线程只处理 IO 事件和命令执行,而 Redis 的命令都是基于内存的操作,执行速度极快(微秒级),大部分场景下,单线程完全够用。
如果真的出现性能瓶颈(比如大量慢查询、大键操作),问题不在于 IO 多路复用,而在于命令本身 —— 此时需要做慢查询优化、大键拆分,而不是质疑 IO 多路复用的设计。
总结:Redis IO 多路复用的核心精髓
看到这里,你已经吃透了 Redis IO 多路复用的核心逻辑,最后用几句话总结一下,帮你提炼精髓,加深记忆:
- 核心目的:让单线程能同时监听成千上万个 FD,高效处理 IO 事件,实现单线程高并发;
- 核心框架:初始化 + 无限事件循环,初始化做准备,事件循环是主逻辑,循环执行「epoll_wait 等事件→分类型处理就绪事件」;
- 核心原则:非阻塞 FD+epoll 监听 + 事件驱动 + 读事件常驻,所有 FD 非阻塞,epoll 帮忙筛选就绪事件,Redis 只处理有事件的 FD,读事件永远保留,写事件按需注册;
- 核心效率:epoll 的「中断驱动 + 只返回就绪 FD」,让 Redis 在高并发下的 IO 处理复杂度达到 O (1),远优于 select/poll 的 O (n);
- 源码关联:Redis 源码中,这部分逻辑在
ae.c(抽象层)、ae_epoll.c(epoll 实现)、networking.c(网络事件处理)中,看完这篇再看源码,会轻松很多。
其实 Redis 的 IO 多路复用并不复杂,核心就是「把 IO 监听的工作交给内核,Redis 只做具体的处理」—— 这也是所有高性能网络服务的设计思路。
更多推荐

所有评论(0)