想必你肯定知道 redis 是单线程的,但大概率也会疑惑:一个线程既要处理客户端连接、又要读写数据、还要执行命令,凭什么能扛住 10 万 + 的并发请求?

IO 多路复用就是 redis 处理高并发的一个利器。网上关于 IO 多路复用的讲解要么太偏理论,要么满屏生涩的内核原理,今天咱们换个思路:从 Redis 的实际运行逻辑出发,用简单伪代码 + 通俗场景把 epoll 版的 IO 多路复用讲透,从初始化到事件循环,从连接处理到读写操作,一步一步拆明白

Redis 为啥非要用 IO 多路复用?

在讲原理前,先搞懂「为什么」—— 如果不用 IO 多路复用,Redis 的单线程会变成什么样?

Redis 是基于 TCP 的网络服务,和客户端的交互本质是套接字(FD)的 IO 操作,如果用最朴素的「阻塞 IO + 单线程」:

  1. 单线程调用accept()阻塞等连接,此时其他客户端连不上;
  2. 连上后调用recv()阻塞等请求,此时其他已连接客户端的请求也处理不了;

IO 多路复用的核心价值就是:让单线程能同时监听成千上万个 FD 的 IO 事件,内核帮忙筛选出「就绪的 FD」,线程只处理这些有事件的也就是就绪的 FD,既不阻塞,也不空转—— 这正是 Redis 单线程高并发的底层基石。

Redis 的 IO 多路复用是基于操作系统的底层实现的,Linux 下首选epoll,同时为了跨平台,还封装了 kqueue(FreeBSD/macOS)、evport(Solaris)、poll/select(Linux 的另外两个IO多路复用实现,不推荐),但核心逻辑一致,今天咱们就以最常用、最高效的epoll为例讲解。

搞懂谁在干哪件事

在正式写伪代码前,先明确 3 个核心角色,这三个角色贯穿整个 IO 多路复用流程:

  1. Redis 单线程:主程序,负责初始化、注册 FD、处理就绪事件(连接 / 读 / 写);
  2. 内核 epoll 实例:Redis 通过系统调用创建的「监听器」,由内核维护,负责监听 Redis 注册的所有 FD;
  3. 套接字 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");
}

初始化核心要点

  1. 所有 FD 必须设置为非阻塞模式,这是和 epoll 配合的前提,否则单线程还是会被 IO 卡住;
  2. 监听 FD 的EPOLLIN 事件是「连接请求事件」,和已连接 FD 的 EPOLLIN(读数据)区分开,但内核统一用 EPOLLIN 标识;
  3. 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); // 处理写事件(发送响应)
                }
            }
        }
    }
}

事件循环核心要点

  1. epoll_wait(-1)无限阻塞的 —— 如果没有任何就绪事件,Redis 单线程会被内核挂起,不占用 CPU 资源,这是 Redis 空闲时 CPU 使用率极低的原因;
  2. epoll_wait只返回就绪的 FD,Redis 只遍历这些就绪 FD,不用轮询所有 FD,高并发下效率拉满(O (1) 复杂度);
  3. 事件处理读优先于写—— 客户端的请求是核心,先处理读事件,再处理写事件,符合 Redis 的业务逻辑;
  4. 整个循环中,Redis 单线程只做「处理就绪事件」这一件事,没有多余的空转和阻塞。

第五步:主函数 main ()—— 整合所有逻辑

最后,写一个主函数,按「初始化→进入事件循环」的顺序执行,这就是 Redis 主程序的启动流程:

// 主函数:Redis程序入口
int main() {
    init();         // 执行初始化,完成所有准备工作
    event_loop();   // 进入核心事件循环,一直运行
    return 0;
}

就是这么简单!Redis 的 IO 多路复用核心逻辑,本质就是这几行代码的扩展 —— 源码中会增加协议解析、内存数据操作、定时任务、错误处理等,但「初始化 + 事件循环 + 分类型处理事件」的核心框架完全一致。(已经改成容易理解的样子,源码看起来不太好理解)

伪代码整体执行流程梳理

为了让你更清晰,把整个流程按「Redis 启动→客户端连接→发请求→断连接」的顺序梳理一遍,形成完整的逻辑链:

  1. Redis 启动,执行main()init(),完成监听 FD 创建、epoll 初始化、监听 FD 注册到 epoll;
  2. 进入event_loop(),调用epoll_wait()阻塞等待事件,Redis 单线程挂起,不占 CPU;
  3. 客户端 A 发起连接,内核触发监听 FD 的 EPOLLIN 事件,唤醒 Redis;
  4. Redis 遍历就绪事件,识别出监听 FD,调用handle_accept()创建客户端 A 的 FD4,注册到 epoll(监听 EPOLLIN);
  5. 处理完后再次调用epoll_wait()阻塞;
  6. 客户端 A 发请求GET key,内核触发 FD4 的 EPOLLIN 事件,唤醒 Redis;
  7. Redis 调用handle_read()读取请求,生成响应并尝试发送,若发送成功则无后续,若缓冲区满则注册 EPOLLIN|EPOLLOUT;
  8. 若注册了 EPOLLOUT,当内核缓冲区有空位时,触发 FD4 的 EPOLLOUT 事件,Redis 调用handle_write()发送响应,完成后取消 EPOLLOUT;
  9. 客户端 A 执行quit,内核触发 FD4 的 EPOLLIN 事件,Redis 调用handle_read()检测到断开,从 epoll 移除 FD4 并关闭;
  10. 全程 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 多路复用的核心逻辑,最后用几句话总结一下,帮你提炼精髓,加深记忆:

  1. 核心目的:让单线程能同时监听成千上万个 FD,高效处理 IO 事件,实现单线程高并发;
  2. 核心框架初始化 + 无限事件循环,初始化做准备,事件循环是主逻辑,循环执行「epoll_wait 等事件→分类型处理就绪事件」;
  3. 核心原则非阻塞 FD+epoll 监听 + 事件驱动 + 读事件常驻,所有 FD 非阻塞,epoll 帮忙筛选就绪事件,Redis 只处理有事件的 FD,读事件永远保留,写事件按需注册;
  4. 核心效率:epoll 的「中断驱动 + 只返回就绪 FD」,让 Redis 在高并发下的 IO 处理复杂度达到 O (1),远优于 select/poll 的 O (n);
  5. 源码关联:Redis 源码中,这部分逻辑在ae.c(抽象层)、ae_epoll.c(epoll 实现)、networking.c(网络事件处理)中,看完这篇再看源码,会轻松很多。

其实 Redis 的 IO 多路复用并不复杂,核心就是「把 IO 监听的工作交给内核,Redis 只做具体的处理」—— 这也是所有高性能网络服务的设计思路。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐