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 / lazyfree | 否 | bioProcessBackgroundJobs() |
设计动机可以压缩成三句话:
- 命令串行执行:键空间数据结构无需细粒度锁,语义简单,CPU cache 友好。
- Reactor + 多路复用:单线程监听大量 fd,避免为每个连接维护独立阻塞线程。
- 慢路径外移:IO 线程并行 syscall,BIO 线程处理磁盘与内存回收,主循环只做「快路径」。

主线程始终是命令执行的最终归宿;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 主循环:aeMain 与 aeProcessEvents
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 合并成一条物理连接。
可以把一次等待过程理解成三步:
- 注册兴趣:告诉内核「我关心哪些 fd 的读/写事件」。
- 阻塞等待:线程调用
select/poll/epoll_wait等,在内核里挂起;直到至少有一个 fd 就绪,或超时。 - 分发处理:内核返回就绪 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 多路复用 | 内核机制:一次等待、多个 fd | epoll_wait / kqueue 等 |
| Reactor 模式 | 应用架构:事件循环 + 回调分发 | aeMain → aeProcessEvents |
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.c 的 aeApiAddEvent,注册事件时 没有设置 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 的网络回调模型相匹配:
readQueryFromClient不一定一次读完整条命令——multibulk 大包可能分多次 epoll 回调才凑齐;LT 保证「缓冲区还有数据」时下一轮会继续通知。- 回调逻辑更简单——不必在每次 readable 回调里非阻塞循环读到
EAGAIN,与 ae 现有的同步connRead路径兼容。 - 代价是可接受的——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 --> waitfd 到回调的分发靠 fd 作为数组下标:epoll_wait 返回 fd 后,直接取 eventLoop->events[fd],无需额外哈希或查找。这是 Redis 选择「按 fd 索引数组」而非「fd → callback 映射表」的原因——在 maxclients 限定下,O(1) 索引比通用容器更省 CPU。
4.5 select / poll / epoll 在 Redis 语境下的差异
| 机制 | 等待就绪的方式 | Redis 中的影响 |
|---|---|---|
| select | 内核返回后用户态扫描 fd_set | fd 上限小,扫描 O(n),仅作兜底 |
| poll | 用户态扫描 pollfd 数组 | 无 1024 上限,但仍 O(n) 扫描 |
| epoll | epoll_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 event | fd 就绪(内核通知) | 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 来源:
- 客户端 socket:
connSetReadHandler(..., readQueryFromClient) - BIO 完成 pipe:
aeCreateFileEvent(job_comp_pipe[0], AE_READABLE, bioPipeReadJobCompList, ...) - Module 唤醒 pipe:
aeCreateFileEvent(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.");
}
serverCron 的 timeProc 返回值决定下次触发间隔:返回正数表示「N 毫秒后再来」;返回 AE_NOMORE(-1)表示不再重复。这是 ae 实现 周期性定时器 的方式——没有单独的 timerfd,全靠回调返回值重新调度。
到期处理在 processTimeEvents 中完成:遍历链表,对 when <= now 的节点调用 timeProc;已标记删除的节点(AE_DELETED_EVENT_ID)在 refcount 归零后释放。
两类事件如何在一次迭代里协作
关键衔接点在 aeProcessEvents:file 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 请求,凑齐一条完整命令后调用 processCommandAndResetClient → processCommand,修改键空间并构造回复。
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。命令执行被推迟到主线程在 beforeSleep 或 handleClientsWithPendingReadsUsingThreads 的后处理阶段。
5.3 写路径:回复如何发回客户端
命令执行完后,回复写入 client 的 buf 或 reply 链表。_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: connWritesendReplyToClient 是 writable 事件的回调入口,内部同样调用 writeToClient(c, 1)。读回调负责「收 + 执行」,写回调负责「发」;读写分离在不同阶段完成,而不是两个线程同时操作同一 client 的状态机。
6 线程模型:主线程、IO 线程、BIO
6.1 主线程 = 唯一命令执行者
Redis 的 dict、sds、过期字典等核心结构 没有为并发访问设计锁。所有会修改键空间的逻辑——SET、DEL、事务、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 协调:
- 把待处理 client 按 round-robin 分到
io_threads_list[j]; - 设置全局
io_threads_op(READ或WRITE); - 对各 IO 线程调用
setIOPendingCount(j, count)唤醒; - 主线程自己也处理
io_threads_list[0]; - spin-wait 直到所有 pending count 归零;
- 将
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 | 线程名 | 典型任务 |
|---|---|---|
| 0 | bio_close_file | 后台 close,避免 unlink 大文件阻塞主线程 |
| 1 | bio_aof | AOF fsync |
| 2 | bio_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 --> cbBIO 的设计要点是:慢操作在后台线程执行,但 completion 回调仍回到主线程。这样键空间的最终变更仍由 Reactor 线程串行完成,不会引入新的竞态。
7 设计动机与工程取舍
单 Reactor 的收益
- 键空间无锁,命令原子性由「天然串行」保证,无需再讨论「这条命令和那条命令会不会交错修改同一 key」。
- 数据结构实现简单,调试路径清晰:一条命令从
readQueryFromClient到processCommand都在同一线程栈上。 - 事件驱动避免阻塞:网络 IO 等 fd 就绪,定时任务按超时注册,主线程不会在单个
read(2)上卡死。
代价
- 命令处理受单核 CPU 限制;计算密集型命令(
SORT、SUNION大集合)会拉长 event loop 周期,影响所有连接的延迟。 - 大对象删除、AOF fsync 若放在主路径会阻塞;因此需要 BIO lazyfree 和异步 fsync。
beforeSleep任务堆积会直接转化为 event loop latency——监控上体现为latency doctor或INFO里的 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 主线程 + 可插拔多路复用 + 分层辅助线程:
- ae 事件库 提供统一的 file/time event 抽象;Linux 上由 epoll 驱动,
events[fd]实现 O(1) 回调查找。 aeMain循环 在每次阻塞前调用beforeSleep批量处理 IO、持久化、cron;在epoll_wait返回后分发 fd 回调,再处理定时器。- 命令执行 只在主线程、
io_threads_op == IO_THREADS_OP_IDLE时发生;IO 线程最多完成读缓冲和协议解析,然后打CLIENT_PENDING_COMMAND标记。 - BIO 线程 处理 close/fsync/lazyfree,通过 pipe 唤醒 ae,completion 仍回主线程。
- 理解这套分工,是分析 Redis 延迟、吞吐和「该不该开 IO 线程」的基础——瓶颈在哪一层,就该在哪一层优化,而不是笼统地重复「Redis 单线程所以慢/快」。