计网 + epoll —— 全链路讲解 redis 的 IO 多路复用

问题:
用户向 redis 发送请求,到 redis 返回响应的过程,IO 多路复用是怎么操作的

前置背景(Redis 启动时的初始化)

在用户发送请求前,Redis 已经完成了核心准备工作,这是 epoll 能工作的基础:

  1. Redis 调用 epoll_create() 系统调用,内核创建一个 eventpoll 结构体(epoll 的核心),里面包含两个关键组件:
    • 「红黑树(rbr)」:存储 “被监听的 FD + 对应的事件类型”(比如 “FD=5,监听 EPOLLIN 可读事件”),红黑树的优势是高效插入 / 删除 / 查询(O (logN)),适配大量连接场景。
    • 「就绪列表(rdlist)」:双向链表,仅存储 “已经就绪的 FD + 事件”,供后续快速获取,避免遍历所有监听 FD。
  2. Redis 创建 ServerSocketChannel 并绑定端口(如 6379),调用 listen() 系统调用,内核开始监听该端口的 TCP 连接请求,同时创建「半连接队列」(存储未完成三次握手的连接)和「全连接队列」(存储已完成三次握手的连接)。

完整流程(用户请求→响应)

步骤 1:TCP 三次握手(计网核心)—— 连接建立,FD 诞生

这是 epoll 能监听事件的前提(没有连接就没有 FD,没有 FD 就没有监听对象):

  1. 客户端发起连接:客户端(如 redis-cli)调用 connect(),向 Redis 服务器发送 TCP 第一次握手(SYN 包),目的端口 6379。
  2. 服务器响应连接:Redis 服务器的 listen() 系统调用已经在监听端口,服务器网卡收到 SYN 包后,通过 DMA(直接内存访问) 将数据拷贝到内核的「环形缓冲区(sk_buff)」(计网数据链路层→网络层的硬件加速,无需 CPU 参与)。
    • 内核 TCP/IP 协议栈处理:解析 IP 头、TCP 头,确认是连接请求,回复 TCP 第二次握手(SYN+ACK 包),并将该连接放入「半连接队列」。
  3. 三次握手完成:客户端收到 SYN+ACK 后,回复 TCP 第三次握手(ACK 包),服务器内核收到后,将连接从「半连接队列」移到「全连接队列」
    • 同时为该连接创建一个 Socket(内核态的通信端点),并分配一个唯一的 文件描述符(FD)(比如 FD=5),FD 是用户态操作 Socket 的 “唯一标识”。

步骤 2:epoll 注册监听事件(核心!解答 “epoll 怎么知道监听什么”)

Redis 要明确告诉内核 “我要监听这个 FD 的哪些事件”,通过 epoll_ctl 完成:

  • Redis 主线程调用 accept() 系统调用,从「全连接队列」中取出刚才的连接,拿到对应的 FD(如 FD=5)。
    • 这个 FD 就是后续 epoll 监听的 “对象”,它关联着内核中该 Socket 的所有状态(如接收缓冲区、发送缓冲区、TCP 状态等)。
  • Redis 构造一个 epoll_event 结构体,里面包含:
    • 要监听的 FD(5);
    • 事件类型(比如 EPOLLIN,表示 “关注该 FD 的可读事件”—— 即客户端发送数据时触发);
    • 数据指针(可选,指向 Redis 为该客户端分配的上下文,如输入缓冲区、输出缓冲区)。
  • 注册 FD 与事件:调用 epoll_ctl(epfd, EPOLL_CTL_ADD, 5, &epoll_event) 系统调用,内核做两件关键事:
    1. 把 “FD=5 + 事件 = EPOLLIN” 插入到 eventpoll 的红黑树中,红黑树此时就记录了 “Redis 要监听 FD=5 的可读事件”—— 这就是 epoll 内核知晓 “监听规则” 的核心!
    2. 为 FD=5 对应的 Socket 绑定一个 内核回调函数(ep_poll_callback),后续该 Socket 有事件就绪时,内核会自动调用这个回调。

步骤 3:用户发送请求(计网数据传输)

  • 用户在客户端执行命令(如 GET key),客户端将命令封装成 TCP 应用数据报文(TCP 头 + 应用层 Redis 协议数据,比如 *2\r\n$3\r\nGET\r\n$3\r\nkey\r\n)。
  • 客户端调用 send() 系统调用,将报文拷贝到内核发送缓冲区,内核通过网卡将报文发送到服务器,过程中可能经过路由转发(计网网络层)、链路层帧封装等。

步骤 4:服务器接收数据(计网 + 内核事件检测)

  • 服务器网卡收到 TCP 报文后,再次通过 DMA 将数据拷贝到内核「环形缓冲区(sk_buff)」,避免 CPU 浪费。
    • 数据接收完成后,网卡触发硬中断,通知内核 TCP/IP 协议栈处理数据
  • 内核 TCP/IP 协议栈处理:
    1. 网络层:解析 IP 头,重组可能的 IP 分片;
    2. 传输层:解析 TCP 头,校验序列号、确认号,将报文段排序(处理 TCP 乱序),最终将完整的 Redis 请求数据拷贝到 FD=5 对应的 Socket「接收缓冲区(recvbuf)」。
  • 此时,Socket 的接收缓冲区有数据了,意味着 “可读事件(EPOLLIN)就绪”,内核触发之前绑定的 ep_poll_callback 回调函数。

步骤 5:epoll 更新就绪列表(内核机制)

  • 回调函数 ep_poll_callback 被调用后,会先检查:红黑树中是否存在 “FD=5 + 事件 = EPOLLIN” 的记录(确认 Redis 确实要监听这个事件)。
  • 确认后,内核将 FD=5 对应的 epitem 节点(红黑树中的节点,包含 FD 和事件信息)从红黑树中取出,加入到 eventpoll 的「就绪列表(rdlist)」中。

这里补充 Redis 默认的 水平触发(LT)模式:只要接收缓冲区有数据,就会持续将 FD 加入就绪列表,直到数据被读完;
如果是边缘触发(ET),仅在 “从无数据到有数据” 的瞬间触发一次,需要用户一次性读完所有数据,否则后续数据不会再触发通知。

步骤 6:Redis 主线程被唤醒(epoll_wait 的作用)

  • 阻塞等待就绪事件:在此之前,Redis 主线程一直阻塞在 epoll_wait(epfd, events, maxevents, -1) 系统调用上 —— 这是 “多路复用阻塞”,不是 IO 阻塞,线程休眠不占用 CPU。
  • 返回就绪列表:当就绪列表(rdlist)不为空时,epoll_wait 会立即返回:将就绪列表中的 FD 和事件(如 FD=5,EPOLLIN)拷贝到用户态的 events 数组中,同时唤醒 Redis 主线程。

步骤 7:读取请求数据(用户态 + 内核态数据拷贝)

  • Redis 主线程遍历 epoll_wait 返回的 events 数组,发现 FD=5 的可读事件。
  • 调用 recv(5, input_buffer, buffer_size, 0) 系统调用,将内核中 FD=5 对应的「接收缓冲区(recvbuf)」的数据,拷贝到 Redis 用户态的「输入缓冲区(input_buffer)」。
  • 这一步是 “数据拷贝阻塞”,但因为数据已经在接收缓冲区就绪,所以耗时极短(微秒级),不会长时间阻塞线程。

步骤 8:命令解析与执行(Redis 核心逻辑)

  • Redis 主线程解析输入缓冲区的 Redis 协议数据,识别出命令是 GET key。
  • 从 Redis 的内存数据库(如跳表、哈希表)中查找 key 对应的 value,将执行结果(如 value=xxx)写入该客户端对应的「输出缓冲区(output_buffer)」。

步骤 9:注册 “可写事件” 到 epoll(避免发送阻塞)

  • 此时要向客户端发送响应,但内核的「发送缓冲区(sendbuf)」可能已满(比如瞬间发送大量数据),如果直接调用 send() 会阻塞线程。
  • 为了避免阻塞,Redis 调用 epoll_ctl(epfd, EPOLL_CTL_MOD, 5, &new_epoll_event),将 FD=5 的监听事件修改为 EPOLLOUT(可写事件)—— 告诉内核:“我要监听 FD=5 的可写事件,等发送缓冲区有空了再通知我”。
    • 这一步的目的是避免直接调用 send() 时因内核发送缓冲区满而阻塞,而是等待 epoll 通知 “发送缓冲区有空闲空间” 时再发送。
  • 内核收到后,更新红黑树中 FD=5 对应的事件记录(从 EPOLLIN 改为 EPOLLOUT)。

步骤 10:内核检测 “可写事件” 就绪(计网 + epoll)

  • 内核的「发送缓冲区(sendbuf)」会持续将数据通过 DMA 拷贝到网卡,发送给客户端,当发送缓冲区有空闲空间(能容纳输出缓冲区的数据)时,Socket 的 “可写事件(EPOLLOUT)” 就绪。
  • 内核再次触发 ep_poll_callback 回调,检查红黑树中 FD=5 的监听事件是 EPOLLOUT,于是将 FD=5 加入就绪列表(rdlist),唤醒再次阻塞在 epoll_wait 上的 Redis 主线程。

步骤 11:发送响应数据(计网传输)

  • Redis 主线程通过 epoll_wait 拿到 FD=5 的可写事件,调用 send(5, output_buffer, data_len, 0) 系统调用。
  • 将用户态「输出缓冲区(output_buffer)」的数据拷贝到内核「发送缓冲区(sendbuf)」,内核通过 DMA 将数据拷贝到网卡(减少 CPU 开销),网卡将数据封装成 TCP 报文,发送给客户端(计网链路层→网络层→传输层→应用层)。
  • 发送完成后,Redis 调用 epoll_ctl(epfd, EPOLL_CTL_MOD, 5, &back_epoll_event),将 FD=5 的监听事件改回 EPOLLIN(继续监听客户端后续请求),或根据客户端是否断开连接,调用 epoll_ctl(epfd, EPOLL_CTL_DEL, 5, NULL) 从红黑树中删除该 FD。

步骤 12:客户端接收响应(计网 + 客户端处理)

  • 客户端网卡收到 TCP 响应报文,DMA 拷贝到内核缓冲区,TCP 协议栈处理后,拷贝到客户端用户态缓冲区。
  • 客户端解析 Redis 协议,展示响应结果(如 xxx),一次完整的请求 - 响应周期结束。
  • 若客户端不再发送请求,会触发 TCP 四次挥手,服务器内核关闭 Socket,Redis 删除对应的 FD 和缓冲区资源。

核心逻辑串联(计网 + epoll+Redis)

Redis初始化(epoll_create创建红黑树/就绪列表)→ 
TCP三次握手(计网)→ 内核创建Socket+分配FD → 
epoll_ctl注册FD+事件到红黑树(epoll知晓监听规则)→ 
用户发送请求(TCP报文)→ 网卡DMA→内核缓冲区→TCP协议栈→Socket接收缓冲区 → 
触发epoll回调,FD加入就绪列表 → epoll_wait唤醒Redis → 
读取数据(内核→用户态)→ 执行命令 → 注册EPOLLOUT到红黑树 → 
内核检测发送缓冲区空闲→FD加入就绪列表 → Redis发送响应(用户态→内核→网卡)→ 
客户端接收响应(计网)

关键答疑(针对 “epoll 怎么知道监听什么”)

epoll 内核知晓监听规则的核心是 “红黑树 + epoll_ctl 注册”:

  1. 红黑树是内核中存储 “监听规则” 的数据库,每个节点对应 “FD + 事件类型”;
  2. Redis 通过 epoll_ctl(ADD/MOD/DEL)修改红黑树内容,本质是向内核 “声明监听规则”;
  3. 内核检测到 Socket 事件(如接收缓冲区有数据)时,会通过回调函数校验该事件是否在红黑树中被监听,只有匹配才会加入就绪列表,避免无效通知。

而这一切的前提是 TCP 三次握手建立连接(计网层),没有连接就没有 Socket 和 FD,epoll 也就没有可监听的对象 —— 这正是计网和 epoll 多路复用的深度绑定点。

核心要点:epoll 如何支撑 Redis 高并发

  1. 单线程高效处理多连接:Redis 主线程通过 epoll 同时监听所有客户端 Socket,无需为每个连接创建线程,避免了线程上下文切换的开销。
  2. 事件驱动,无空转:主线程仅在 epoll_wait() 阻塞,有就绪事件时才被唤醒处理,不会像非阻塞 IO 那样盲目轮询,CPU 利用率极高。
  3. 就绪列表直接返回:epoll 内核直接维护就绪事件列表,主线程无需遍历所有 Socket,性能不随连接数增加而下降,这是它比 select/poll 更高效的关键。
  4. 可读 / 可写事件分离:通过注册可读 / 可写事件,Redis 可以在数据就绪时才读取,在发送缓冲区空闲时才写入,避免了 IO 阻塞。
Logo

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

更多推荐