05 单线程模型与事件驱动架构

摘要:

Redis 能够用单个线程处理每秒数十万次的命令请求——这在直觉上似乎不可能,因为我们习惯了”并发 = 多线程”的思维模式。但 Redis 的性能瓶颈不在 CPU 计算——一次 GET/SET 操作的 CPU 耗时只有几十纳秒——而在网络 IO 和内存访问。单线程模型通过彻底消除锁竞争、上下文切换和缓存行失效(cache line invalidation)的开销,将每次操作的固定成本降到最低。而事件驱动架构通过操作系统的 IO 多路复用机制(Linux 的 epoll、macOS 的 kqueue),让单个线程能够同时监控数万个 Socket 连接——哪个连接有数据到达就处理哪个,没有数据的连接不消耗任何 CPU。本文从操作系统的 IO 模型演进出发,深入 Redis 自研的 ae 事件库的实现细节,分析 serverCron 定时任务的职责划分,最后详解 Redis 6.0 多线程 IO 的分阶段执行模型。


第 1 章 IO 模型的演进

1.1 阻塞 IO——最简单也最低效

在最原始的网络编程模型中,每个客户端连接由一个专用线程处理。线程调用 read() 从 Socket 读取数据时,如果数据尚未到达,线程会阻塞等待——操作系统将线程挂起,直到数据到达后唤醒。

线程 1: accept() → read() [阻塞等待客户端 A 的数据]
线程 2: accept() → read() [阻塞等待客户端 B 的数据]
线程 3: accept() → read() [阻塞等待客户端 C 的数据]
...

问题:每个连接需要一个线程——如果有 1 万个并发连接,就需要 1 万个线程。每个线程的栈空间默认 8MB(Linux),1 万个线程仅栈空间就需要 80GB。更严重的是,大量线程的上下文切换开销(保存/恢复寄存器、刷新 TLB、切换内核栈)会消耗大量 CPU——线程越多,切换越频繁,真正用于业务处理的 CPU 时间反而越少。这就是经典的 C10K 问题

1.2 非阻塞 IO + 轮询——能用但浪费

将 Socket 设置为非阻塞模式后,read() 在没有数据时立即返回 EAGAIN 错误而非阻塞。应用程序可以用一个线程循环轮询所有连接:

while (true) {
    for (fd in all_fds) {
        ret = read(fd, buf, sizeof(buf));  // 非阻塞
        if (ret > 0) {
            process(buf);  // 有数据,处理
        }
        // ret == EAGAIN: 没有数据,继续下一个 fd
    }
}

问题:即使没有任何连接有数据到达,线程也在不停地循环检查——忙轮询(busy polling)。CPU 100% 占用但大部分是无效工作。而且每次循环都要检查所有 fd——O(N) 的扫描开销。

1.3 IO 多路复用——事件驱动的基石

IO 多路复用解决了上述两个问题——让操作系统帮我们监控所有 fd,当某个 fd 有数据到达时通知应用程序。应用程序只处理有事件的 fd——没有事件时线程休眠,不消耗 CPU。

Linux 上的 IO 多路复用经历了三代演进:

机制特点性能
selectfd 集合用 bitmap 表示,最多 1024 个 fd每次调用 O(N) 扫描
pollfd 集合用数组表示,无数量限制每次调用 O(N) 扫描
epoll内核维护就绪列表,只返回有事件的 fd就绪事件 O(1) 返回

1.4 epoll 的工作原理

epoll 由三个系统调用组成:

// 1. 创建 epoll 实例
int epfd = epoll_create(1);
 
// 2. 注册要监控的 fd 和感兴趣的事件
struct epoll_event ev;
ev.events = EPOLLIN;          // 监控可读事件
ev.data.fd = client_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);
 
// 3. 等待事件——阻塞直到有事件或超时
struct epoll_event events[MAX_EVENTS];
int nfds = epoll_wait(epfd, events, MAX_EVENTS, timeout_ms);
 
// 只处理有事件的 fd
for (int i = 0; i < nfds; i++) {
    if (events[i].events & EPOLLIN) {
        read(events[i].data.fd, buf, sizeof(buf));
        process(buf);
    }
}

epoll 的核心优势

  • O(1) 事件通知:epoll 在内核中维护一个红黑树存储所有监控的 fd,和一个就绪链表存储有事件的 fd。当网卡收到数据触发中断时,内核的中断处理程序将对应的 fd 加入就绪链表。epoll_wait 只需要检查就绪链表是否为空——非空时直接返回有事件的 fd 列表,不需要遍历所有 fd。
  • 事件注册只做一次epoll_ctl 将 fd 加入监控后,后续的 epoll_wait 不需要再传递 fd 集合——与 select/poll 每次调用都要传递完整 fd 集合形成鲜明对比。
  • 无 fd 数量限制:epoll 监控的 fd 数量只受系统文件描述符上限限制(通常可达数十万)。

1.5 kqueue——macOS/BSD 的等价物

macOS 和 BSD 系统使用 kqueue 替代 epoll——功能等价,API 略有不同。kqueue 通过 kqueue() 创建实例,kevent() 同时负责注册事件和等待事件。Redis 的 ae 库在 macOS 上自动使用 kqueue。


第 2 章 ae 事件库的实现

2.1 ae 的定位

ae(A simple event library)是 Redis 自研的事件驱动库——整个实现只有约 700 行 C 代码。它的设计目标是最小化——只提供 Redis 需要的功能,不做任何通用化的抽象。

ae 封装了底层 IO 多路复用 API 的差异,向上提供统一的事件注册和处理接口。Redis 的网络通信、定时任务、后台任务调度全部构建在 ae 之上。

2.2 核心数据结构

// 文件事件结构
typedef struct aeFileEvent {
    int mask;                       // 事件类型(AE_READABLE | AE_WRITABLE)
    aeFileProc *rfileProc;          // 可读事件回调
    aeFileProc *wfileProc;          // 可写事件回调
    void *clientData;               // 回调函数的参数(通常是 client 指针)
} aeFileEvent;
 
// 就绪事件结构(epoll_wait 返回后填充)
typedef struct aeFiredEvent {
    int fd;                         // 就绪的文件描述符
    int mask;                       // 就绪的事件类型
} aeFiredEvent;
 
// 时间事件结构(链表节点)
typedef struct aeTimeEvent {
    long long id;                   // 唯一 ID
    monotime when;                  // 触发时间(单调时钟)
    aeTimeProc *timeProc;           // 回调函数
    aeEventFinalizerProc *finalizerProc; // 清理函数
    void *clientData;               // 回调参数
    struct aeTimeEvent *prev;       // 前一个时间事件
    struct aeTimeEvent *next;       // 后一个时间事件
} aeTimeEvent;
 
// 事件循环主结构
typedef struct aeEventLoop {
    int maxfd;                      // 当前最大的文件描述符
    int setsize;                    // 可监控的最大 fd 数
    aeFileEvent *events;            // 已注册的文件事件数组(下标 = fd)
    aeFiredEvent *fired;            // 就绪事件数组
    aeTimeEvent *timeEventHead;     // 时间事件链表头
    int stop;                       // 停止标志
    void *apidata;                  // 底层 IO 多路复用的私有数据(epoll fd 等)
    aeBeforeSleepProc *beforesleep; // 每次事件循环迭代前的回调
    aeBeforeSleepProc *aftersleep;  // epoll_wait 返回后的回调
} aeEventLoop;

events 数组的设计:文件事件存储在一个以 fd 为下标的数组中——events[fd] 就是 fd 对应的文件事件。查找 O(1),比哈希表更快(数组的缓存局部性更好)。数组大小 = setsize(默认 = maxclients + 128)。

2.3 事件循环的核心流程

void aeMain(aeEventLoop *eventLoop) {
    eventLoop->stop = 0;
    while (!eventLoop->stop) {
        aeProcessEvents(eventLoop, AE_ALL_EVENTS | AE_CALL_BEFORE_SLEEP | AE_CALL_AFTER_SLEEP);
    }
}
 
int aeProcessEvents(aeEventLoop *eventLoop, int flags) {
    int processed = 0;
 
    // 1. 计算最近的时间事件还有多久触发
    struct timeval *tvp = NULL;
    if (flags & AE_TIME_EVENTS) {
        aeTimeEvent *shortest = aeSearchNearestTimer(eventLoop);
        if (shortest) {
            // 用最近时间事件的剩余时间作为 epoll_wait 的超时
            tvp = &tv;  // tv 设置为剩余时间
        }
    }
 
    // 2. beforesleep 回调
    if (eventLoop->beforesleep)
        eventLoop->beforesleep(eventLoop);
 
    // 3. 调用底层 IO 多路复用——等待文件事件或超时
    int numevents = aeApiPoll(eventLoop, tvp);
 
    // 4. aftersleep 回调
    if (eventLoop->aftersleep)
        eventLoop->aftersleep(eventLoop);
 
    // 5. 处理所有就绪的文件事件
    for (int j = 0; j < numevents; j++) {
        int fd = eventLoop->fired[j].fd;
        int mask = eventLoop->fired[j].mask;
        aeFileEvent *fe = &eventLoop->events[fd];
 
        // 先处理可读事件,再处理可写事件
        if (mask & AE_READABLE) fe->rfileProc(eventLoop, fd, fe->clientData, mask);
        if (mask & AE_WRITABLE) fe->wfileProc(eventLoop, fd, fe->clientData, mask);
        processed++;
    }
 
    // 6. 处理到期的时间事件
    if (flags & AE_TIME_EVENTS)
        processed += processTimeEvents(eventLoop);
 
    return processed;
}

关键设计点

  • epoll_wait 的超时时间由最近的时间事件决定——确保时间事件不会因为没有文件事件而被延迟。如果有一个时间事件应该在 50ms 后触发,epoll_wait 最多阻塞 50ms 就返回。
  • beforesleep 回调在每次事件循环迭代的最开始执行——Redis 在这里处理很多关键任务:将 AOF 缓冲区写入文件、处理被阻塞的客户端(BRPOP 等待的 key 有了新数据)、发送待回复的数据等。
  • 先处理可读后处理可写:如果一个 fd 同时可读可写,先读取新请求并执行,再写回响应——确保响应中包含最新的命令结果。

2.4 ae 的底层适配层

ae 通过编译时宏选择最优的 IO 多路复用实现:

// ae.c
#ifdef HAVE_EVPORT
#include "ae_evport.c"       // Solaris evport
#elif defined(HAVE_EPOLL)
#include "ae_epoll.c"        // Linux epoll
#elif defined(HAVE_KQUEUE)
#include "ae_kqueue.c"       // macOS/BSD kqueue
#else
#include "ae_select.c"       // 通用 fallback
#endif

每个适配层实现四个函数:

  • aeApiCreate:创建底层实例(epoll_create / kqueue)
  • aeApiAddEvent:注册事件(epoll_ctl ADD)
  • aeApiDelEvent:删除事件(epoll_ctl DEL)
  • aeApiPoll:等待事件(epoll_wait / kevent)

以 ae_epoll.c 为例,aeApiPoll 的实现非常简洁:

static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) {
    aeApiState *state = eventLoop->apidata;
    int retval;
 
    retval = epoll_wait(state->epfd, state->events, eventLoop->setsize,
                        tvp ? (tvp->tv_sec*1000 + (tvp->tv_usec + 999)/1000) : -1);
 
    if (retval > 0) {
        for (int j = 0; j < retval; j++) {
            struct epoll_event *e = state->events + j;
            int mask = 0;
            if (e->events & EPOLLIN)  mask |= AE_READABLE;
            if (e->events & EPOLLOUT) mask |= AE_WRITABLE;
            if (e->events & EPOLLERR) mask |= AE_WRITABLE | AE_READABLE;
            if (e->events & EPOLLHUP) mask |= AE_WRITABLE | AE_READABLE;
            eventLoop->fired[j].fd = e->data.fd;
            eventLoop->fired[j].mask = mask;
        }
    }
    return retval;
}

第 3 章 文件事件——网络通信的驱动

3.1 监听 Socket 的可读事件

Redis 启动时在监听 Socket 上注册可读事件——当有新的客户端连接到达时触发 acceptTcpHandler

事件注册:aeCreateFileEvent(el, listen_fd, AE_READABLE, acceptTcpHandler, NULL)
触发条件:有新的 TCP 连接到达
回调操作:accept() → 创建 client → 注册客户端 fd 的可读事件

3.2 客户端 Socket 的可读事件

每个客户端连接的 fd 注册可读事件——当客户端发送命令数据时触发 readQueryFromClient

事件注册:aeCreateFileEvent(el, client_fd, AE_READABLE, readQueryFromClient, client)
触发条件:客户端 Socket 有数据到达
回调操作:read() → 解析 RESP → 执行命令 → 将响应写入输出缓冲区

3.3 客户端 Socket 的可写事件

命令执行完成后,响应数据在 client 的输出缓冲区中。Redis 在 beforesleep 中检查哪些 client 有待发送的响应——对这些 client 的 fd 注册可写事件:

事件注册:aeCreateFileEvent(el, client_fd, AE_WRITABLE, sendReplyToClient, client)
触发条件:客户端 Socket 的发送缓冲区有空间
回调操作:write() → 将输出缓冲区的数据发送给客户端
发送完成后:aeDeleteFileEvent(el, client_fd, AE_WRITABLE)  // 取消可写事件注册

取消可写事件的原因:如果不取消,每次事件循环都会触发可写事件(Socket 的发送缓冲区通常都有空间)——导致无意义的空回调,浪费 CPU。只在有数据需要发送时才注册,发送完毕后立即取消。


第 4 章 时间事件——后台任务的调度

4.1 serverCron——Redis 的心跳

serverCron 是 Redis 最重要的时间事件——默认每 100ms 执行一次(由 hz 配置控制,默认 10,即每秒 10 次)。它负责 Redis 绝大多数的后台维护任务:

任务频率说明
过期 key 采样删除每次随机采样一批有过期时间的 key,删除已过期的
Rehash 推进每次对正在 Rehash 的字典执行 1ms 的渐进式迁移
客户端超时检查每次关闭超过 timeout 秒未活动的客户端
内存使用检查每次更新内存统计信息(used_memory 等)
RDB/AOF 触发检查每次检查是否满足 save 配置的条件,决定是否 BGSAVE
BGSAVE/AOF 重写结果检查每次检查子进程是否完成,处理结果
复制心跳每秒向从节点发送 PING,维持复制连接
Cluster 心跳每秒Gossip 协议的节点通信
AOF 缓冲区刷盘每秒appendfsync everysec 模式下触发 BIO 线程 fsync
Sentinel 检查每秒(Sentinel 模式)检查主节点是否存活

4.2 hz 参数的权衡

hz 控制 serverCron 的执行频率——值越大,后台任务执行越频繁:

  • hz = 10(默认):每 100ms 执行一次——过期 key 的清理有最多 100ms 的延迟
  • hz = 100:每 10ms 执行一次——过期 key 清理更及时,但 CPU 开销增加
  • hz = 500(最大):每 2ms 执行一次——适用于对延迟极度敏感的场景

hz 不宜设置过高

hz 越高,serverCron 占用的 CPU 时间越多——留给命令处理的时间越少。对于大多数场景,默认的 hz = 10 就够了。Redis 4.0 引入了 dynamic-hz(默认开启)——在客户端连接数多、命令处理繁忙时自动降低 serverCron 的执行频率,减少与命令处理的 CPU 争抢。

4.3 beforesleep——每次迭代的预处理

beforesleep 在每次事件循环迭代的 aeApiPoll 之前执行——它处理的任务比 serverCron 更紧急、频率更高:

任务说明
处理被阻塞的客户端BRPOP 等待的 key 有了新数据——唤醒阻塞的客户端
将 AOF 缓冲区写入文件write() 到 AOF 文件(不是 fsync,只是内核缓冲区)
处理待发送的响应尝试直接 write() 发送响应——小响应可以在这里直接发送完,无需注册可写事件
处理 Client Tracking 失效通知向开启 Tracking 的客户端发送 INVALIDATE 消息
处理集群消息发送 Cluster Gossip 消息

beforesleep 中的”尝试直接发送响应”是一个重要的优化——对于大多数简单命令(如 GET/SET 返回 +OK),响应很小(几十字节),直接 write() 就能发送完毕。只有当 Socket 发送缓冲区满(write 返回 EAGAIN)时,才注册可写事件等待下一次事件循环。这避免了为每个简单响应都走一遍”注册可写事件 → epoll_wait → 可写回调”的完整流程——减少了系统调用次数。


第 5 章 Redis 6.0 多线程 IO 的深入分析

5.1 瓶颈在哪里

在 Redis 6.0 之前,主线程的事件循环负责所有工作——包括网络 IO(read/write)和命令执行。性能分析表明,在高并发(数万连接、小命令)场景下:

  • 命令执行(内存操作)只占主线程 CPU 时间的 ~30%
  • 网络 IO(read/write 系统调用 + RESP 解析/序列化)占 ~70%

网络 IO 成为了单线程的瓶颈——即使命令执行极快,主线程的大部分时间都花在了 Socket 读写上。

5.2 多线程 IO 的分阶段模型

Redis 6.0 的多线程 IO 将事件循环拆分为6 个阶段,其中 IO 阶段由多线程并行执行,命令执行阶段仍然单线程串行:


graph TD
    S1["阶段 1: 主线程</br>epoll_wait 获取就绪 fd"]
    S2["阶段 2: 主线程</br>分配客户端给 IO 线程"]
    S3["阶段 3: IO 线程并行</br>read() + RESP 解析"]
    S4["阶段 4: 主线程</br>串行执行所有命令"]
    S5["阶段 5: 主线程</br>分配客户端给 IO 线程"]
    S6["阶段 6: IO 线程并行</br>RESP 序列化 + write()"]

    S1 --> S2 --> S3 --> S4 --> S5 --> S6 --> S1

    classDef main fill:#44475a,stroke:#ff79c6,color:#f8f8f2
    classDef io fill:#44475a,stroke:#50fa7b,color:#f8f8f2

    class S1,S2,S4,S5 main
    class S3,S6 io

阶段 1:主线程调用 aeApiPoll(epoll_wait)获取所有有数据到达的客户端 fd。

阶段 2:主线程将这些客户端轮询分配到 IO 线程的待处理队列中——每个 IO 线程分到一批客户端。

阶段 3:所有 IO 线程并行地从各自的客户端 Socket 中 read() 数据并解析 RESP 协议——将原始字节流解析为 client->argcclient->argv。主线程也参与读取(它也是一个”IO 线程”)。

主线程等待:主线程通过忙轮询(busy wait) 等待所有 IO 线程完成读取——不使用锁或条件变量,而是循环检查一个原子计数器。忙等的原因是 IO 线程的读取操作非常快(微秒级),使用锁反而引入更大的开销。

阶段 4:主线程串行地执行所有客户端的命令——保持了单线程模型的简单性。在这个阶段没有任何并发——所有数据结构操作都是安全的。

阶段 5:命令执行完成后,主线程将有待发送响应的客户端分配给 IO 线程。

阶段 6:IO 线程并行地将响应数据序列化(RESP 编码)并 write() 到各自的客户端 Socket。

5.3 线程间同步机制

多线程 IO 的同步非常轻量——没有使用互斥锁(mutex)或条件变量(condition variable):

  • 任务分配:主线程将客户端指针写入 IO 线程的队列(一个简单的列表)——只有主线程写,IO 线程只读——无需锁
  • 完成通知:每个 IO 线程处理完自己的客户端后,原子地将一个计数器减 1——主线程忙轮询检查计数器是否归零
  • 内存屏障:使用原子操作(atomicSet / atomicGet)确保内存可见性

这种无锁设计的延迟极低——IO 线程从启动到完成的整个周期在微秒级,远快于线程间使用 mutex + condition variable 的唤醒延迟(通常数十微秒)。

5.4 配置与调优

# 开启多线程 IO(默认关闭)
io-threads 4
 
# 开启多线程读(默认只开多线程写)
io-threads-do-reads yes

io-threads 的推荐值:通常设置为 CPU 核数的一半,且不超过 8。原因:

  1. IO 线程只负责网络 IO——不需要太多线程
  2. 主线程的命令执行是串行的——IO 线程再多也不能加速命令执行
  3. 线程过多会增加调度开销和缓存污染

效果:在大量小命令(GET/SET)、高并发(数万连接)的 benchmark 中,4 个 IO 线程可以将吞吐量从单线程的 ~15 万 QPS 提升到 ~25 万 QPS——约 60-70% 的提升。


第 6 章 单线程模型的深层优势

6.1 无锁数据结构

Redis 的所有数据结构——dictskiplist、quicklist、listpack——都不需要考虑线程安全。没有锁意味着:

  • 零锁开销:每次 mutex lock/unlock 在 Linux 上约 25ns——在每秒百万次操作的场景下,累计数百毫秒
  • 零死锁风险:多线程数据库(如 MySQL InnoDB)需要复杂的死锁检测机制
  • 无 false sharing:多线程环境下,不同线程修改同一 cache line 的不同字段会导致 cache line 在核间反复失效(ping-pong)——单线程完全不存在这个问题

6.2 原子性保证

Redis 的每条命令天然具有原子性——因为命令在单线程中串行执行,不存在并发修改。这使得 INCR(读-改-写)、GETSET(获取旧值-设置新值)等复合操作天然是原子的——不需要额外的锁或事务。

6.3 顺序一致性

所有客户端的命令在单线程中按到达顺序执行——天然满足线性一致性(Linearizability)。如果客户端 A 先执行了 SET,客户端 B 后执行 GET,B 一定能看到 A 的写入结果。在多线程数据库中,这种保证需要复杂的并发控制机制。


第 7 章 总结

本文深入分析了 Redis 的单线程模型和事件驱动架构:

  • IO 模型演进:阻塞 IO(一连接一线程,C10K 问题)→ 非阻塞 IO + 轮询(忙等浪费 CPU)→ IO 多路复用(epoll/kqueue,事件驱动)
  • ae 事件库:~700 行代码;编译时自动选择 epoll/kqueue/select;文件事件(Socket IO)+ 时间事件(定时任务);events 数组以 fd 为下标 O(1) 查找
  • 事件循环:beforesleep(AOF 写入/响应发送/阻塞客户端唤醒)→ aeApiPoll(等待事件)→ 处理文件事件 → 处理时间事件
  • serverCron:每 100ms(hz=10)执行一次;过期采样删除、Rehash 推进、RDB/AOF 触发、复制心跳、Cluster Gossip
  • 多线程 IO(6.0+):6 阶段模型——IO 线程并行 read/write,主线程串行执行命令;无锁同步(原子计数器 + 忙轮询);吞吐量提升 60-70%
  • 单线程优势:无锁数据结构、天然原子性、线性一致性、零上下文切换

下一篇 06 内存管理与过期淘汰策略 将深入 Redis 的内存管理机制——jemalloc 分配器、过期 key 的惰性删除与定期采样删除、以及八种 maxmemory 淘汰策略的实现原理。


参考资料

  1. Redis Source Code - ae.c / ae.h / ae_epoll.c:https://github.com/redis/redis/blob/unstable/src/ae.c
  2. Redis Source Code - networking.c(多线程 IO):https://github.com/redis/redis/blob/unstable/src/networking.c
  3. Redis 6.0 Threaded IO 设计文档:https://redis.io/topics/threads
  4. epoll(7) - Linux manual page:https://man7.org/linux/man-pages/man7/epoll.7.html
  5. Dan Kegel - The C10K Problem:http://www.kegel.com/c10k.html
  6. 黄健宏 - 《Redis 设计与实现》(第二版)- 第 12 章 事件

思考题

  1. hz(默认 10)控制时间事件的检查频率——每 100ms 一次。增大到 100(每 10ms)可以更快地检测过期 Key。但 CPU 开销增加——在 Redis 主要做缓存(大量 TTL Key)的场景中,增大 hz 是否值得?你如何评估 hz 调整对 CPU 使用率的影响?
  2. 大量 Key 同时过期(如缓存雪崩场景下设置了相同 TTL)时,定期删除策略可能持续执行——每次采样发现 >25% 过期则继续。这会阻塞主线程处理正常请求。除了给 TTL 加随机偏移量(如 TTL + random(0, 60)),还有什么方案避免大量 Key 同时过期?
  3. CLIENT PAUSE 暂停所有客户端请求——在主从切换前使用可以确保所有写入已复制到从节点。暂停期间 Redis 仍在运行事件循环但不处理客户端命令。CLIENT PAUSE 的最大暂停时间应该设为多少?与 WAIT 命令配合使用的故障转移流程是什么?