1 问题背景

Redis 以内存存储和极低延迟著称,单机往往要同时承载成千上万条 TCP 连接。一个自然的工程问题是:为什么不采用「每个连接一个线程」的模型?

传统的一线程一连接方案,在连接数上升后会带来明显的线程切换和锁竞争成本。Redis 选择了另一条路:用少量线程配合 Reactor 事件循环,在一个主线程里串行执行命令,同时把 socket 读写和慢 IO 尽量移出热路径。

这里真正需要关注的是「单线程」这个说法的精确含义。很多人把 Redis 理解成「整个进程只有一个线程」,这与源码不符。Redis 7.4.9 中,进程内至少存在:

  • 主线程:运行 aeMain(),处理命令、维护键空间、驱动事件循环;
  • IO 线程(可选,io-threads 配置):分担 socket 读写与 RESP 协议解析;
  • BIO 后台线程(固定 3 个 worker):处理文件 close、AOF fsync、lazyfree 等慢操作。

下文以源码为依据,把这三层如何协作、事件循环如何运转、epoll 如何接入,串成一条完整链路。

2 核心结论

可以把 Redis 的网络与调度模型概括成下面这张分工表:

角色核心职责是否访问键空间(dict 等)典型入口
主 Reactor 线程事件循环、命令执行、定时任务是,且独占aeMain()processCommand()
IO 线程socket read/write、协议解析否(解析完只打标记)IOThreadMain()
BIO 线程close / fsync / lazyfreebioProcessBackgroundJobs()

设计动机可以压缩成三句话:

  1. 命令串行执行:键空间数据结构无需细粒度锁,语义简单,CPU cache 友好。
  2. Reactor + 多路复用:单线程监听大量 fd,避免为每个连接维护独立阻塞线程。
  3. 慢路径外移:IO 线程并行 syscall,BIO 线程处理磁盘与内存回收,主循环只做「快路径」。

image-20260626181219429

主线程始终是命令执行的最终归宿;IO 线程和 BIO 线程都是围绕 Reactor 主循环做辅助,而不是并行修改共享键空间。

3 Reactor 全景:从 main()aeMain()

3.1 启动与注册

Redis 启动时,在 initServer() 中创建事件循环,注册 sleep 钩子,最后进入无限循环:

// server.c
server.el = aeCreateEventLoop(server.maxclients + CONFIG_FDSET_INCR);
// ...
aeSetBeforeSleepProc(server.el, beforeSleep);
aeSetAfterSleepProc(server.el, afterSleep);
// ...
aeMain(server.el);

aeCreateEventLoop 分配 aeEventLoop 结构,并按 fd 编号预分配 events[]fired[] 数组。每个 fd 对应一个槽位,这是 Redis 以 O(1) 查找回调的基础。

// ae.h
typedef struct aeEventLoop {
    int maxfd;
    int setsize;
    aeFileEvent *events;   /* 按 fd 索引的注册事件 */
    aeFiredEvent *fired;   /* epoll_wait 返回的就绪事件 */
    aeTimeEvent *timeEventHead;
    aeBeforeSleepProc *beforesleep;
    aeBeforeSleepProc *aftersleep;
    void *apidata;         /* epoll/kqueue 等平台私有数据 */
    int flags;
} aeEventLoop;

3.2 主循环:aeMainaeProcessEvents

aeMain 本身非常短,所有细节都在 aeProcessEvents

// ae.c
void aeMain(aeEventLoop *eventLoop) {
    eventLoop->stop = 0;
    while (!eventLoop->stop) {
        aeProcessEvents(eventLoop, AE_ALL_EVENTS |
                                   AE_CALL_BEFORE_SLEEP |
                                   AE_CALL_AFTER_SLEEP);
    }
}

一次 aeProcessEvents 迭代可以分成五个阶段:

sequenceDiagram
    participant Main as aeProcessEvents
    participant BS as beforeSleep
    participant Poll as aeApiPoll
    participant AS as afterSleep
    participant CB as fileCallbacks
    participant TE as timeEvents

    Main->>BS: AE_CALL_BEFORE_SLEEP
    BS->>BS: pending IO / AOF / expire
    Main->>Poll: epoll_wait(timeout)
    Poll-->>Main: numevents
    Main->>AS: AE_CALL_AFTER_SLEEP
    loop each fired fd
        Main->>CB: rfileProc / wfileProc
    end
    Main->>TE: processTimeEvents()

对应源码中的关键顺序:

// ae.c — aeProcessEvents 核心片段
if (eventLoop->beforesleep != NULL && (flags & AE_CALL_BEFORE_SLEEP))
    eventLoop->beforesleep(eventLoop);

/* 计算 timeout:AE_DONT_WAIT 或最近定时器 */
numevents = aeApiPoll(eventLoop, tvp);

if (eventLoop->aftersleep != NULL && flags & AE_CALL_AFTER_SLEEP)
    eventLoop->aftersleep(eventLoop);

for (j = 0; j < numevents; j++) {
    /* 按 mask 调用 rfileProc / wfileProc */
}
if (flags & AE_TIME_EVENTS)
    processed += processTimeEvents(eventLoop);

这里有两个值得单独说明的机制:

beforeSleep 是批处理窗口。 在进入 epoll_wait 阻塞之前,主线程会集中处理 pending reads/writes、AOF 刷盘、过期键扫描、客户端回收等。很多「不必等 fd 就绪才能做」的工作都塞在这里,以减少 syscall 次数、提高吞吐。

AE_BARRIER 控制读写回调顺序。 默认先触发 AE_READABLE 再触发 AE_WRITABLE,同一轮迭代里读、写可能接连发生。appendfsync always 时,installClientWriteHandler 会给写 handler 加上 AE_BARRIER,保证同一 fd 在同一轮 aeProcessEvents 迭代中不会既读又写——命令在 readable 回调里处理完后,回复写回被推迟到下一轮 beforeSleep,中间先完成 AOF fsync:

// networking.c — installClientWriteHandler
/* For the fsync=always policy, we want that a given FD is never
 * served for reading and writing in the same event loop iteration,
 * so that in the middle of receiving the query, and serving it
 * to the client, we'll call beforeSleep() that will do the
 * actual fsync of AOF to disk. the write barrier ensures that. */
if (server.aof_state == AOF_ON && server.aof_fsync == AOF_FSYNC_ALWAYS)
    ae_barrier = 1;

3.3 beforeSleep 做了什么

beforeSleep() 是理解 Redis 线程分工的关键枢纽。节选其主干逻辑:

// server.c — beforeSleep
void beforeSleep(struct aeEventLoop *eventLoop) {
    /* 1. 处理 IO 线程读完后待执行的客户端 */
    handleClientsWithPendingReadsUsingThreads();

    /* 2. TLS 等连接的 pending 数据 */
    connTypeProcessPendingData();

    /* 3. 过期键、阻塞客户端、AOF 刷盘 */
    if (server.active_expire_enabled && iAmMaster())
        activeExpireCycle(ACTIVE_EXPIRE_CYCLE_FAST);
    blockedBeforeSleep();

    if (server.aof_state == AOF_ON || server.aof_state == AOF_WAIT_REWRITE)
        flushAppendOnlyFile(0);

    /* 4. 批量写回客户端 */
    handleClientsWithPendingWritesUsingThreads();

    /* 5. 异步释放客户端、内存驱逐等 */
    freeClientsInAsyncFreeQueue();
    evictClients();

    /* 6. 有 pending 数据则本轮不 sleep */
    aeSetDontWait(server.el, dont_sleep);
}

注意执行顺序:先处理 pending reads,再 AOF flush,再 pending writes。这与 AE_BARRIER 的设计意图一致——持久化动作优先于网络回复。

afterSleep 则在 epoll_wait 返回后、分发 fd 回调之前运行,主要处理 Module GIL 的重新获取等,保证模块线程不会在主线程处理 IO 事件时并发访问键空间。

4 IO 多路复用:ae 的可插拔后端

第 3 节讲的是 Reactor 主循环如何运转;其中「阻塞等待 IO」这一步,依赖的是操作系统提供的 IO 多路复用 机制。Redis 通过 ae 库把这一层抽象成统一接口,在 Linux 上具体落地为 epoll。本节先交代这两个概念本身,再进入 ae 源码。

4.1 什么是 IO 多路复用

IO 多路复用(I/O multiplexing)解决的是这样一个问题:一台服务器上有大量 TCP 连接,如果为每条连接单独开一个阻塞线程去 read(2),连接数一涨,线程切换和内存开销就会迅速失控。

更可行的做法是:一个线程同时监视多个文件描述符(fd),等其中任意一个「就绪」——例如 socket 缓冲区里有数据可读、或发送缓冲区有空间可写——再去做对应的读写。这里的「复用」,复用的是 同一个线程的等待能力,而不是把多个 fd 合并成一条物理连接。

可以把一次等待过程理解成三步:

  1. 注册兴趣:告诉内核「我关心哪些 fd 的读/写事件」。
  2. 阻塞等待:线程调用 select / poll / epoll_wait 等,在内核里挂起;直到至少有一个 fd 就绪,或超时。
  3. 分发处理:内核返回就绪 fd 列表,用户态程序逐个调用事先注册的回调(Redis 里就是 rfileProc / wfileProc)。
sequenceDiagram
    participant App as 用户态_Reactor
    participant Kernel as 内核
    participant FDs as 多个_fd

    App->>Kernel: 注册 fd1 fd2 ... fdN 的读/写兴趣
    App->>Kernel: multiplex_wait(timeout)
    Note over App,Kernel: 线程阻塞,不占 CPU
    FDs->>Kernel: 某 fd 数据到达
    Kernel-->>App: 返回就绪 fd 列表
    App->>App: 按 fd 调用回调处理

IO 多路复用 不等于 多线程并行 IO。Redis 主线程在 epoll_wait 返回后,仍然是 串行 处理每个就绪 fd 的回调;它的价值在于 用一次 syscall 覆盖大量连接,避免「N 个连接 N 次无效轮询或 N 个线程」。

另外需要区分两个层次:

层次含义Redis 中的对应
IO 多路复用内核机制:一次等待、多个 fdepoll_wait / kqueue
Reactor 模式应用架构:事件循环 + 回调分发aeMainaeProcessEvents

ae 库处在中间:对上层提供 Reactor 语义(file event + time event),对下层封装具体的多路复用 API。

4.2 什么是 epoll

epoll 是 Linux 2.6 起提供的 IO 多路复用接口,也是 Redis 在 Linux 上的默认后端(ae_epoll.c)。与更早的 select / poll 相比,epoll 针对 「连接数多、但每次就绪的只占少数」 这一典型服务器场景做了优化。

epoll 的三个核心系统调用:

系统调用作用
epoll_create创建一个 epoll 实例(在 ae 里对应 state->epfd
epoll_ctl向实例 注册 / 修改 / 删除 某个 fd 的监视项(EPOLL_CTL_ADD/MOD/DEL
epoll_wait阻塞等待,返回 当前就绪 的 fd 列表

select / poll 的关键差异在于 等待阶段的工作方式

  • select / poll:每次等待都要把完整的 fd 集合传给内核;返回后,用户态还要 遍历整个集合 找出谁就绪——连接数为 N 时,扫描成本是 O(N)。
  • epoll:通过 epoll_ctl 把 fd 持久注册 到 epoll 实例上;epoll_wait 只返回 就绪的那几个 fd——返回结果的大小取决于活跃连接数,而不是总连接数。

Redis 源码里的对应关系很直接:

// ae_epoll.c — 创建实例
state->epfd = epoll_create(1024);

// ae_epoll.c — 注册 fd(aeApiAddEvent 内部)
epoll_ctl(state->epfd, op, fd, &ee);

// ae_epoll.c — 等待就绪(aeApiPoll 内部)
retval = epoll_wait(state->epfd, state->events, eventLoop->setsize, timeout_ms);

epoll_event 结构里有一个 data 字段。Redis 把客户端 socket 的 fd 写进 ee.data.fd,这样 epoll_wait 返回后,可以直接用这个 fd 去索引 eventLoop->events[fd],找到对应的读/写回调——这是 ae 按 fd 建数组的设计与 epoll 的天然契合点。

4.2.1 水平触发与边缘触发

epoll 在注册 fd 时,可以通过 EPOLLET 标志选择 触发模式。两种模式回答的是同一个问题:epoll_wait 返回后,什么条件下会再次通知这个 fd?

水平触发(Level-Triggered,LT) —— epoll 的 默认模式,行为与 select / poll 一致。

  • 只要 fd 仍然满足 就绪条件,下一次 epoll_wait 就会 继续报告 它。
  • 读场景:socket 接收缓冲区里 还有未读数据,就会一直被视为「可读」。
  • 写场景:发送缓冲区 还有空间,就会一直被视为「可写」。

因此,如果回调里只 read 了一部分数据、缓冲区里仍有剩余,下一轮事件循环会再次收到可读通知——不会因为没有「新数据到达」就漏掉剩余字节。

边缘触发(Edge-Triggered,ET) —— 注册时显式加上 EPOLLET

  • 只在就绪状态 发生边沿变化 时通知一次:例如从「不可读」变为「可读」。
  • 若回调没有把数据 一次性读完,剩余数据留在缓冲区里,但状态没有再次「跳变」,epoll 不会再次提醒——直到有新数据到达,才会触发下一次通知。
  • 使用 ET 通常要求 fd 设为非阻塞,并在回调里 循环 read/write 直到返回 EAGAIN,否则容易漏事件或饿死连接。

可以用一个简化的读场景来对比:

时刻缓冲区状态LT:epoll_wait 是否报告可读ET:epoll_wait 是否报告可读
T1
T2客户端发来 1KB 数据(状态变为可读)(边沿:不可读→可读)
T3回调只读了 512B,还剩 512B(仍然可读)(没有新的边沿)
T4客户端再发来 100B(新的边沿)

Redis 为什么用 LT

查看 ae_epoll.caeApiAddEvent,注册事件时 没有设置 EPOLLET,即沿用默认的水平触发:

// ae_epoll.c — aeApiAddEvent(节选)
ee.events = 0;
if (mask & AE_READABLE) ee.events |= EPOLLIN;
if (mask & AE_WRITABLE) ee.events |= EPOLLOUT;
/* 无 EPOLLET → 水平触发 */
epoll_ctl(state->epfd, op, fd, &ee);

这与 Redis 的网络回调模型相匹配:

  1. readQueryFromClient 不一定一次读完整条命令——multibulk 大包可能分多次 epoll 回调才凑齐;LT 保证「缓冲区还有数据」时下一轮会继续通知。
  2. 回调逻辑更简单——不必在每次 readable 回调里非阻塞循环读到 EAGAIN,与 ae 现有的同步 connRead 路径兼容。
  3. 代价是可接受的——LT 可能在条件持续满足时多次唤醒,但 Redis 在 readQueryFromClient 里会尽量多读(sdsMakeRoomFor 扩大 buffer),且连接数虽大、单次活跃比例通常不高,LT 的重复通知在实践中可控。

ET 的优势在于 减少 epoll_wait 返回次数、适合极端高并发且愿意承担「非阻塞 + 读写到尽」编程约束的服务。Redis 选择了 正确性优先、实现简单 的 LT,与 Reactor + 协议解析的分次读取模型更合拍。

4.3 编译期选型

ae 库通过 #include 直接编译进不同平台的后端实现:

// ae.c
#ifdef HAVE_EVPORT
#include "ae_evport.c"
#else
    #ifdef HAVE_EPOLL
    #include "ae_epoll.c"
    #else
        #ifdef HAVE_KQUEUE
        #include "ae_kqueue.c"
        #else
        #include "ae_select.c"
        #endif
    #endif
#endif

优先级为 evport > epoll > kqueue > select。Linux 生产环境走 ae_epoll.c

4.4 ae 对 epoll 的封装

在 4.2 的三组 syscall 之上,ae 再包一层平台无关的接口:aeApiCreate / aeApiAddEvent / aeApiDelEvent / aeApiPoll。上层 aeCreateFileEvent 只操作 events[fd],不必关心底层是 epoll 还是 kqueue。

注册 fd 时,ae 维护 events[fd].mask,并合并新旧 mask 后调用 epoll_ctl

// ae_epoll.c — aeApiAddEvent
int op = eventLoop->events[fd].mask == AE_NONE ?
         EPOLL_CTL_ADD : EPOLL_CTL_MOD;

mask |= eventLoop->events[fd].mask;  /* 合并旧事件 */
if (mask & AE_READABLE) ee.events |= EPOLLIN;
if (mask & AE_WRITABLE) ee.events |= EPOLLOUT;
ee.data.fd = fd;
epoll_ctl(state->epfd, op, fd, &ee);

等待就绪时:

// ae_epoll.c — aeApiPoll
retval = epoll_wait(state->epfd, state->events, eventLoop->setsize,
                    tvp ? (tvp->tv_sec*1000 + (tvp->tv_usec + 999)/1000) : -1);
/* 将 EPOLLIN/EPOLLOUT 映射为 AE_READABLE/AE_WRITABLE,写入 eventLoop->fired[] */
flowchart LR
    reg["aeCreateFileEvent(fd, mask, proc)"]
    add["aeApiAddEvent → epoll_ctl"]
    wait["aeApiPoll → epoll_wait"]
    fired["eventLoop->fired[]"]
    dispatch["events[fd].rfileProc / wfileProc"]

    reg --> add
    wait --> fired
    fired --> dispatch
    add --> wait

fd 到回调的分发靠 fd 作为数组下标epoll_wait 返回 fd 后,直接取 eventLoop->events[fd],无需额外哈希或查找。这是 Redis 选择「按 fd 索引数组」而非「fd → callback 映射表」的原因——在 maxclients 限定下,O(1) 索引比通用容器更省 CPU。

4.5 select / poll / epoll 在 Redis 语境下的差异

机制等待就绪的方式Redis 中的影响
select内核返回后用户态扫描 fd_setfd 上限小,扫描 O(n),仅作兜底
poll用户态扫描 pollfd 数组无 1024 上限,但仍 O(n) 扫描
epollepoll_ctl 持久注册 + epoll_wait 返回就绪 fd活跃连接多时,等待与分发均高效

上表是对 4.1、4.2 的横向对照。Redis 不需要把 epoll 的内核实现(红黑树、就绪链表等)讲透,但需要明确:ae 的价值不在于「调用 epoll」,而在于把 epoll 封装成统一的 file event + time event 模型,并和 beforeSleep / AE_BARRIER 配合,形成完整的 Reactor 语义。

下面这一节说明这个「双事件模型」具体长什么样,以及两类事件在一次 aeProcessEvents 迭代里如何衔接。

4.6 两类事件:file event 与 time event

epoll 只能回答一个问题:哪些 fd 现在可读/可写。但 Redis 还需要在固定间隔执行后台任务——过期键扫描、客户端超时、统计信息更新等。这些任务没有对应的 socket fd,不能靠 epoll_wait 触发。

ae 的解法是维护 两套并行的事件体系

类型触发条件数据结构典型用途
File eventfd 就绪(内核通知)events[fd] 数组客户端连接、BIO pipe、Module pipe
Time event单调时钟到期timeEventHead 单向链表serverCron、Module 定时器

两者最终都在 aeProcessEvents 里被处理,但等待机制不同:file event 靠 epoll_wait 阻塞,time event 靠给 epoll_wait 设置 超时时间 来「准时醒来」。

File event:fd 就绪驱动

每个 fd 在 aeEventLoop.events[] 中占一个槽位,记录关心的事件类型和回调:

// ae.h
typedef struct aeFileEvent {
    int mask;              /* AE_READABLE | AE_WRITABLE | AE_BARRIER */
    aeFileProc *rfileProc; /* 可读回调,如 readQueryFromClient */
    aeFileProc *wfileProc; /* 可写回调,如 sendReplyToClient */
    void *clientData;
} aeFileEvent;

注册时,aeCreateFileEvent 写入 events[fd],并调用 aeApiAddEvent 把 fd 挂到 epoll 实例上。4.4 节已经讲过分发路径:epoll_wait 返回 fd → 查 events[fd] → 调 rfileProc / wfileProc

Redis 里常见的 file event 来源:

  • 客户端 socketconnSetReadHandler(..., readQueryFromClient)
  • BIO 完成 pipeaeCreateFileEvent(job_comp_pipe[0], AE_READABLE, bioPipeReadJobCompList, ...)
  • Module 唤醒 pipeaeCreateFileEvent(server.module_pipe[0], AE_READABLE, modulePipeReadable, ...)

它们的共同点是:有 fd,等内核说「可以读/写了」再动

Time event:定时到期驱动

Time event 不依赖 fd,而是挂在 timeEventHead 链表上,每个节点记录 何时触发触发后做什么

// ae.h
typedef struct aeTimeEvent {
    long long id;
    monotime when;           /* 到期的单调时钟时间点 */
    aeTimeProc *timeProc;    /* 到期回调 */
    aeEventFinalizerProc *finalizerProc;
    void *clientData;
    struct aeTimeEvent *prev;
    struct aeTimeEvent *next;
    int refcount;
} aeTimeEvent;

创建时指定「多少毫秒后首次触发」:

// ae.c — aeCreateTimeEvent
te->when = getMonotonicUs() + milliseconds * 1000;
te->next = eventLoop->timeEventHead;
eventLoop->timeEventHead = te;

Redis 启动时注册的第一个 time event 就是 serverCron——每 1ms 触发一次,承担过期键、客户端超时、统计刷新等增量后台工作:

// server.c — initServer
if (aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL) == AE_ERR) {
    serverPanic("Can't create event loop timers.");
}

serverCrontimeProc 返回值决定下次触发间隔:返回正数表示「N 毫秒后再来」;返回 AE_NOMORE(-1)表示不再重复。这是 ae 实现 周期性定时器 的方式——没有单独的 timerfd,全靠回调返回值重新调度。

到期处理在 processTimeEvents 中完成:遍历链表,对 when <= now 的节点调用 timeProc;已标记删除的节点(AE_DELETED_EVENT_ID)在 refcount 归零后释放。

两类事件如何在一次迭代里协作

关键衔接点在 aeProcessEventsfile event 的等待超时,由最近的 time event 决定

// ae.c — aeProcessEvents
} else if (flags & AE_TIME_EVENTS) {
    usUntilTimer = usUntilEarliestTimer(eventLoop);
    if (usUntilTimer >= 0) {
        tv.tv_sec = usUntilTimer / 1000000;
        tv.tv_usec = usUntilTimer % 1000000;
        tvp = &tv;   /* epoll_wait 最多睡这么久 */
    }
}
numevents = aeApiPoll(eventLoop, tvp);

/* ... 分发 file event 回调 ... */

if (flags & AE_TIME_EVENTS)
    processed += processTimeEvents(eventLoop);

usUntilEarliestTimer 扫描链表,找出最近要到期的 timer,把剩余微秒数传给 epoll_wait 的 timeout 参数。这样:

  • 没有 fd 就绪、也没有 timer 到期 → 线程在内核里睡到二者中较早的那个;
  • timer 先到期epoll_wait 超时返回,numevents == 0,随后 processTimeEvents 执行 serverCron 等回调;
  • fd 先就绪 → 正常分发 file event;本轮末尾仍可能顺带处理已到期的 time event。
flowchart TB
    start["aeProcessEvents 开始"]
    before["beforeSleep()"]
    calcTimeout["usUntilEarliestTimer() 计算 epoll 超时"]
    epoll["aeApiPoll / epoll_wait"]
    fileCb["分发 file event 回调"]
    timeCb["processTimeEvents() 处理到期 timer"]

    start --> before --> calcTimeout --> epoll
    epoll -->|"fd 就绪"| fileCb
    epoll -->|"超时或无 fd"| timeCb
    fileCb --> timeCb

注意处理顺序:先 file event,后 time event。file event 回调里可能产生新的回复、修改客户端状态;time event(如 serverCron)在这之后运行,避免和同一轮 IO 处理交错。

为什么需要两套事件

  • File event 负责 外部驱动的 IO:网络数据到达、pipe 可读,由 epoll 通知。
  • Time event 负责 内部驱动的调度:周期性 cron、延迟任务,不占用 fd,通过超时唤醒。
  • 二者共用同一个 aeMain 循环,保证 Redis 单线程内 IO 处理和定时任务不会各自阻塞在独立的等待上。

理解这个双事件模型,再回头看第 3 节的 aeProcessEvents 时序图,「processTimeEvents」那一步就不再是孤立的尾巴——它是与 epoll 超时机制绑在一起的定时调度出口。下一节进入具体场景:一条客户端命令如何走完 file event 的读、写路径。

5 一次客户端请求的完整路径

5.1 连接建立与读事件注册

新连接 accept 后,createClient 立刻为 socket 注册读回调:

// networking.c — createClient
if (conn) {
    connEnableTcpNoDelay(conn);
    connSetReadHandler(conn, readQueryFromClient);
    connSetPrivateData(conn, c);
}

底层会通过 aeCreateFileEvent 把该 fd 的 AE_READABLE 事件挂到 server.el 上。此后,客户端发送的数据由 epoll 通知,触发 readQueryFromClient

5.2 读路径:从 socket 到命令

// networking.c — readQueryFromClient 主干
void readQueryFromClient(connection *conn) {
    client *c = connGetPrivateData(conn);

    if (postponeClientRead(c)) return;  /* IO 线程模式下推迟到 beforeSleep */

    nread = connRead(c->conn, c->querybuf + qblen, readlen);
    /* ... 错误处理 ... */

    sdsIncrLen(c->querybuf, nread);

    if (processInputBuffer(c) == C_ERR)
        c = NULL;

    beforeNextClient(c);
}

processInputBuffer 循环解析 querybuf:识别 inline / multibulk 请求,凑齐一条完整命令后调用 processCommandAndResetClientprocessCommand,修改键空间并构造回复。

IO 线程模式下的分叉——这是理解「Redis 单线程」边界的关键:

// networking.c — processInputBuffer
if (io_threads_op != IO_THREADS_OP_IDLE) {
    serverAssert(io_threads_op == IO_THREADS_OP_READ);
    c->flags |= CLIENT_PENDING_COMMAND;
    break;
}
/* 只有主线程、IO 空闲时才真正执行命令 */
if (processCommandAndResetClient(c) == C_ERR)
    return C_ERR;

IO 线程可以把数据读进 querybuf、甚至解析出完整 argv,但 不会调用 processCommand(),只设置 CLIENT_PENDING_COMMAND。命令执行被推迟到主线程在 beforeSleephandleClientsWithPendingReadsUsingThreads 的后处理阶段。

5.3 写路径:回复如何发回客户端

命令执行完后,回复写入 client 的 bufreply 链表。_addReply* 系列函数会尝试直接写 socket;若一次写不完或希望批量写,则把 client 放入 server.clients_pending_write

在下一轮 beforeSleep 中,handleClientsWithPendingWritesUsingThreads() 批量调用 writeToClient。若仍有剩余数据,再通过 installClientWriteHandler 注册 AE_WRITABLE,等 epoll 通知后继续写。

sequenceDiagram
    participant Client
    participant Epoll as epoll
    participant Read as readQueryFromClient
    participant Proc as processCommand
    participant BS as beforeSleep
    participant Write as writeToClient

    Client->>Epoll: 发送命令
    Epoll->>Read: AE_READABLE
    Read->>Read: connRead → querybuf
    Read->>Proc: processInputBuffer → processCommand
    Proc->>Proc: 修改键空间,构造 reply
    Proc->>BS: putClientInPendingWriteQueue
    BS->>Write: handleClientsWithPendingWritesUsingThreads
    Write->>Client: connWrite

sendReplyToClient 是 writable 事件的回调入口,内部同样调用 writeToClient(c, 1)。读回调负责「收 + 执行」,写回调负责「发」;读写分离在不同阶段完成,而不是两个线程同时操作同一 client 的状态机。

6 线程模型:主线程、IO 线程、BIO

6.1 主线程 = 唯一命令执行者

Redis 的 dict、sds、过期字典等核心结构 没有为并发访问设计锁。所有会修改键空间的逻辑——SETDEL、事务、MULTI/EXEC、Lua 脚本中的 Redis 调用——都在主线程、且 io_threads_op == IO_THREADS_OP_IDLE 时执行。

这不是历史遗留,而是刻意的设计:用单线程串行换无锁数据结构。IO 线程和 BIO 线程都不触碰键空间,因此也不需要和命令路径抢锁。

6.2 IO 线程:Fan-out / Fan-in

Redis 6 引入 io-threads 配置。initThreadedIO() 创建 io_threads_num - 1 个工作线程(编号 0 保留给主线程),每个线程在 IOThreadMain 中循环等待任务:

// networking.c — IOThreadMain
while (1) {
    /* 自旋等待 pending count > 0,或通过 mutex 挂起 */
    if (getIOPendingCount(id) == 0) {
        pthread_mutex_lock(&io_threads_mutex[id]);
        pthread_mutex_unlock(&io_threads_mutex[id]);
        continue;
    }

    listRewind(io_threads_list[id], &li);
    while ((ln = listNext(&li))) {
        client *c = listNodeValue(ln);
        if (io_threads_op == IO_THREADS_OP_WRITE)
            writeToClient(c, 0);
        else if (io_threads_op == IO_THREADS_OP_READ)
            readQueryFromClient(c->conn);
    }
    listEmpty(io_threads_list[id]);
    setIOPendingCount(id, 0);
}

主线程通过 Fan-out → Fan-in 协调:

  1. 把待处理 client 按 round-robin 分到 io_threads_list[j]
  2. 设置全局 io_threads_opREADWRITE);
  3. 对各 IO 线程调用 setIOPendingCount(j, count) 唤醒;
  4. 主线程自己也处理 io_threads_list[0]
  5. spin-wait 直到所有 pending count 归零;
  6. io_threads_op 设回 IDLE,主线程做后处理(执行 pending command、安装 write handler 等)。

这段协作的关键是 谁先动、谁等谁、何时回到主线程。下面用时序图表达 Fan-out / Fan-in(以写路径为例,读路径结构相同,只是 IO 线程调用 readQueryFromClient,主线程后处理改为 processCommand):

sequenceDiagram
    participant Main as 主线程
    participant List0 as io_threads_list[0]
    participant IO1 as IOThread_1
    participant IOn as IOThread_N-1

    Main->>Main: 收集 clients_pending_write
    Main->>Main: round-robin 分配到各 io_threads_list
    Main->>Main: io_threads_op = WRITE

    par Fan-out 唤醒
        Main->>IO1: setIOPendingCount(1, count)
        Main->>IOn: setIOPendingCount(N-1, count)
    end

    par 并行 writeToClient
        Main->>List0: 主线程处理 list[0](含 replica)
        IO1->>IO1: writeToClient(clients...)
        IOn->>IOn: writeToClient(clients...)
    end

    IO1->>Main: setIOPendingCount(1, 0)
    IOn->>Main: setIOPendingCount(N-1, 0)

    Main->>Main: spin-wait 全部 pending == 0
    Note over Main: Fan-in 完成
    Main->>Main: io_threads_op = IDLE
    Main->>Main: installWriteHandler / updateClientMemUsage

图中三个要点:

  • Fan-out:主线程一次性分任务并 setIOPendingCount,IO 线程才被唤醒;在 pending 归零前,主线程不会碰各 io_threads_list[j] 里的 client。
  • 并行段:主线程处理 list[0] 与 IO 线程处理 list[1..N-1] 同时进行,并行的是 syscall,不是命令执行。
  • Fan-in:主线程 busy-wait 所有 pending count 归零后,才把 io_threads_op 设回 IDLE,再安全地做 processCommand 或安装 write handler。

几个重要的边界条件:

  • Replica 客户端的写必须走主线程io_threads_list[0]),因为复制缓冲区是全局共享的,IO 线程写 replica 会破坏线程安全。
  • 默认只线程化写io-threads-do-reads no)。读线程化需要显式开启,且 postponeClientRead 仅在 io_threads_active && io_threads_do_reads 时生效。
  • 自适应启停stopThreadedIOIfNeeded() 检查 clients_pending_write 长度,若小于 io_threads_num * 2,则停止 IO 线程,退回主线程同步写——避免少量 pending 时线程唤醒开销大于收益。

6.3 BIO 后台线程

BIO(Background I/O)在 bioInit() 中启动 3 个 worker,分别处理:

Worker线程名典型任务
0bio_close_file后台 close,避免 unlink 大文件阻塞主线程
1bio_aofAOF fsync
2bio_lazy_free大 key 异步释放(UNLINK、过期删除等)

每个 worker 维护一个 FIFO 任务队列,由 pthread_cond 唤醒。任务完成后,BIO 线程向 pipe 写入通知,而 pipe 的读端已在 ae 中注册:

// bio.c — bioInit
anetPipe(job_comp_pipe, O_CLOEXEC|O_NONBLOCK, O_CLOEXEC|O_NONBLOCK);

aeCreateFileEvent(server.el, job_comp_pipe[0], AE_READABLE,
                  bioPipeReadJobCompList, NULL);

for (j = 0; j < BIO_WORKER_NUM; j++)
    pthread_create(&thread, &attr, bioProcessBackgroundJobs, arg);
flowchart LR
    main["主线程 bioSubmitJob"]
    queue["BIO worker FIFO 队列"]
    worker["bioProcessBackgroundJobs"]
    pipe["job_comp_pipe 写端"]
    ae["ae 读 pipe → bioPipeReadJobCompList"]
    cb["主线程执行 completion 回调"]

    main --> queue --> worker --> pipe --> ae --> cb

BIO 的设计要点是:慢操作在后台线程执行,但 completion 回调仍回到主线程。这样键空间的最终变更仍由 Reactor 线程串行完成,不会引入新的竞态。

7 设计动机与工程取舍

单 Reactor 的收益

  • 键空间无锁,命令原子性由「天然串行」保证,无需再讨论「这条命令和那条命令会不会交错修改同一 key」。
  • 数据结构实现简单,调试路径清晰:一条命令从 readQueryFromClientprocessCommand 都在同一线程栈上。
  • 事件驱动避免阻塞:网络 IO 等 fd 就绪,定时任务按超时注册,主线程不会在单个 read(2) 上卡死。

代价

  • 命令处理受单核 CPU 限制;计算密集型命令(SORTSUNION 大集合)会拉长 event loop 周期,影响所有连接的延迟。
  • 大对象删除、AOF fsync 若放在主路径会阻塞;因此需要 BIO lazyfree 和异步 fsync。
  • beforeSleep 任务堆积会直接转化为 event loop latency——监控上体现为 latency doctorINFO 里的 slowlog,根因往往是主线程在 sleep 前做了太多 cron 工作。

IO 线程的收益与边界

  • 收益:多个 client 的 read(2)/write(2) 并行,降低 syscall 阶段的 CPU 等待,对大包回复、高并发写放大明显。
  • 边界:不并行命令;解析与执行严格分离。增加的是 IO 并行度,不是 命令并行度

BIO 的收益

  • close(2)(可能触发慢 unlink)、fsync(2)、大内存 free 移出热路径,避免「一个慢磁盘操作拖住所有客户端」。

8 边界与常见误区

误区一:「Redis 是单线程」

更准确的说法是:Redis 的命令执行路径是单线程的。进程内还有 IO 线程、BIO 线程,Module 也可以创建自己的 pthread;只是它们不并发修改键空间。

误区二:开了 IO 线程就能线性提升 QPS

IO 线程只加速网络读写。若瓶颈在命令逻辑(复杂 Lua、大 key 操作、持久化 fsync 策略),加 IO 线程帮助有限。且 pending 不足时 Redis 会主动 stopThreadedIO,退回单线程写。

误区三:epoll 通知可读就等于一条完整命令

readQueryFromClient 可能多次触发才凑齐一条 multibulk 命令;processInputBuffer 是循环解析,可能一次读事件处理多条命令。epoll 的就绪语义是「fd 可读」,不是「一条 Redis 命令就绪」。

误区四:writable 回调和 beforeSleep 写回复是重复的

两者是互补关系:beforeSleep 里批量写是「主动冲刷」;writable handler 是「写缓冲区满、一次写不完」时的兜底。AE_BARRIER 则保证同一轮迭代中,writable 不会在 readable 之前触发,避免持久化顺序问题。

Module GIL

若加载了 Module,beforeSleep 末尾会 moduleReleaseGIL()afterSleep 开头 moduleAcquireGIL()。这意味着主线程在 epoll_wait 阻塞期间,模块线程可以运行;但 fd 回调分发前 GIL 会被重新持有,避免与命令执行并发。

9 总结

Redis 的网络架构不是「一个线程打天下」,而是 单 Reactor 主线程 + 可插拔多路复用 + 分层辅助线程

  1. ae 事件库 提供统一的 file/time event 抽象;Linux 上由 epoll 驱动,events[fd] 实现 O(1) 回调查找。
  2. aeMain 循环 在每次阻塞前调用 beforeSleep 批量处理 IO、持久化、cron;在 epoll_wait 返回后分发 fd 回调,再处理定时器。
  3. 命令执行 只在主线程、io_threads_op == IO_THREADS_OP_IDLE 时发生;IO 线程最多完成读缓冲和协议解析,然后打 CLIENT_PENDING_COMMAND 标记。
  4. BIO 线程 处理 close/fsync/lazyfree,通过 pipe 唤醒 ae,completion 仍回主线程。
  5. 理解这套分工,是分析 Redis 延迟、吞吐和「该不该开 IO 线程」的基础——瓶颈在哪一层,就该在哪一层优化,而不是笼统地重复「Redis 单线程所以慢/快」。