「Redis
是单线程」在运维圈几乎成常识,却常在三个地方被误用:把
IO threads 当成「多线程 Redis」、把
后台子进程(RDB fork)算进请求路径、或忽略
beforeSleep 里 AOF fsync
与过期扫描对延迟的贡献。读 缓存服务器全景
后已知 Redis 在网络内存结构栈中的位置;本篇要钉住
一条 RESP 命令从 epoll 可读事件到
processCommand 再写回 socket
的源码路径。
本文是系列第 2 篇,回答:
aeEventLoop如何组织 文件事件 与 时间事件(serverCron)?- 客户端 读缓冲 / 写缓冲 在
networking.c里如何与命令执行衔接? io-threads配置到底改变了什么——哪些仍在主线程?
本文是「Redis / 缓存内核」系列第 2 篇(共 16 篇)。→ 系列目录
篇目 核心内容 第 1 篇 · 缓存服务器全景 四类 KV 生态位 第 2 篇 · 事件循环与 I/O ae、客户端缓冲、IO threads 边界第 3 篇 · 对象模型与 SDS redisObject、encoding
版本锚定:Redis OSS 7.4 / 8.x;下文源码路径与函数名以 7.4.x tag 为准(如
7.4.1的src/ae.c、src/networking.c、src/server.c)。环境说明:本篇引用源码片段为仓库内核对过的摘录,非完整文件;不粘贴未在本机执行的
LATENCY DOCTOR输出。
一、Reactor 谱系与
Redis 的 ae 库
1.1 模式来源
Reactor 模式(事件 demultiplex +
同步事件分发)在 Schmidt, Pattern-Oriented Software
Architecture, Vol. 2 中形式化:单线程(或每线程一个
loop)在
select/poll/epoll
上阻塞,就绪 fd 回调
handler。Nginx、libevent、Node.js(libuv)与 Redis
均属这一族,差异在 多路复用 API 封装 与
是否把业务突变放在 callback 里。
Redis 自带
ae(src/ae.c、src/ae.h),注释写明源自
Jim Tcl
解释器事件循环的移植。编译期按性能顺序选择后端:ae_epoll.c
/ ae_kqueue.c / ae_evport.c /
ae_select.c(见 ae.c 顶部
#include 链)。
1.2 与 libevent redis-lite 的分工
libevent
redis-lite 用 bufferevent 在回调里直接
handle_cmd;生产 Redis 把
协议解析 与
processCommand 突变 拆在
networking.c,并用
beforeSleep 批量处理 AOF、复制
ACK、客户端写队列——这是 toy
实现与生产路径的核心差距(对象层见第 3 篇)。
二、aeEventLoop:文件事件与时间事件
2.1 核心结构
ae.h 定义三类回调:
- 文件事件:
AE_READABLE/AE_WRITABLE;可选AE_BARRIER控制同一 fd 上读/写触发顺序。 - 时间事件:单向链表,按
when毫秒时间戳排序;serverCron即其一。 - 钩子:
beforesleep/aftersleep,在aeApiPoll前后执行。
/* src/ae.h — 文件事件 mask(节选) */
#define AE_READABLE 1
#define AE_WRITABLE 2
#define AE_BARRIER 4 /* 与 WRITABLE 同用:同轮迭代先处理读再写,或反向 */2.2 主循环 aeMain
/* src/ae.c — aeMain(节选) */
void aeMain(aeEventLoop *eventLoop) {
eventLoop->stop = 0;
while (!eventLoop->stop) {
aeProcessEvents(eventLoop, AE_ALL_EVENTS|
AE_CALL_BEFORE_SLEEP|
AE_CALL_AFTER_SLEEP);
}
}aeProcessEvents 在阻塞前调用
beforesleep(Redis 里绑定
beforeSleep),在 aeApiPoll
返回后调用 aftersleep,再依次触发就绪 fd 的
rfileProc /
wfileProc。AE_BARRIER
注释说明典型用途:先 fsync
AOF,再向客户端写回复(与 beforeSleep
里 flushAppendOnlyFile 顺序一致)。
2.3
时间事件:serverCron
initServer() 注册:
/* src/server.c — initServer(节选) */
aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL);serverCron(同文件)按
server.hz 周期执行:更新 近似 LRU
时钟 server.lruclock、增量
rehash、过期采样触发、复制心跳、统计瞬时指标等。注意:cron
与命令执行同在主线程;长耗时 cron
任务会占用事件循环(具体任务见 serverCron 内
run_with_period 块)。
stateDiagram-v2
direction LR
[*] --> Poll: aeProcessEvents
Poll --> BeforeSleep: aeApiPoll returned
BeforeSleep --> Handlers: afterSleep + fd callbacks
Handlers --> Poll: next iteration
note right of BeforeSleep
AOF flush
IO thread fan-in
activeExpireCycle fast
end note
三、连接建立到命令执行的路径
3.1 监听与可读事件
新连接在 conn.c / networking.c
侧创建 client,为 socket 注册
readQueryFromClient 为可读
handler(经
aeCreateFileEvent)。默认路径下,可读事件
同步 调用
readQueryFromClient;开启 IO threads 读时,可读
handler 可能只把 client 挂入
clients_pending_read(postponeClientRead),真正读
socket 在 beforeSleep →
handleClientsWithPendingReadsUsingThreads
中完成。
3.2
读路径:readQueryFromClient →
processInputBuffer
readQueryFromClient(networking.c)把字节追加到
c->querybuf(SDS),再调用
processInputBuffer:
- 识别 RESP 内联或多
bulk(
c->reqtype)。 - 解析完整命令到
c->argv/c->argc。 - 调用
processCommandAndResetClient→processCommand执行命令突变。
关键分支(IO threads 读解析):
/* src/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;
}
/* 主线程路径 */
if (processCommandAndResetClient(c) == C_ERR)
return C_ERR;结论(A 级,源码直接支持):IO 线程可以
读 socket + 解析
RESP,但一旦遇到完整命令,只打
CLIENT_PENDING_COMMAND;processCommand
仍在主线程(经
processPendingCommandAndInputBuffer)。
3.3
写路径:reply 与 clients_pending_write
命令处理通过 addReply* 把 RESP
回复追加到客户端输出结构;需要写 socket 时进入
clients_pending_write。beforeSleep
末尾调用
handleClientsWithPendingWritesUsingThreads:默认
IO threads 只并行
writeToClient;复制连接(CLIENT_TYPE_SLAVE)的写
强制留在主线程(注释:全局 replication
buffer 线程安全)。
3.4
beforeSleep 在请求路径上的位置
beforeSleep(server.c)在
每轮 poll 之前 运行,与单次命令处理交织。与
I/O 相关的顺序(简化):
sequenceDiagram
participant EL as aeEventLoop
participant BS as beforeSleep
participant Main as main thread
participant IOT as IO threads
EL->>BS: before poll
BS->>IOT: handleClientsWithPendingReadsUsingThreads (optional)
BS->>Main: processPendingCommand on fan-in
BS->>BS: flushAppendOnlyFile / blocked clients
BS->>IOT: handleClientsWithPendingWritesUsingThreads
EL->>Main: readable/writable callbacks
Main->>Main: readQueryFromClient / processCommand
因此 「单线程 Redis」
更精确的说法是:共享数据集上的命令执行与大部分 cron
逻辑在主线程;IO threads 与 BIO 线程(AOF fsync
等,bio.c)是辅助。RDB
fork 发生在 BGSAVE
路径,不在每条 GET 的热路径上(第 9 篇)。
四、客户端缓冲与内存边界
每个
client(server.h)至少包含:
| 字段 / 结构 | 作用 |
|---|---|
querybuf |
累积 socket 读入的 RESP
字节;qb_pos 标记已消费偏移 |
reply / reply
链表 |
待发送的 RESP
回复;大回复可能引用
clientReplyBlock |
flags |
CLIENT_PENDING_COMMAND、CLIENT_PENDING_WRITE、CLIENT_BLOCKED
等 |
readQueryFromClient 会根据
client_max_querybuf_len
等配置拒绝过大输入;maxmemory 压力下还有
客户端连接级
eviction(evictClients() 在
beforeSleep 调用,networking.c /
evict.c 协作)——这与第 8 篇 key
淘汰不同层。
Pipeline 行为:processInputBuffer 的
while 循环在 同一次可读事件
中连续解析多条命令,减少 poll 次数;这与 libevent
redis-lite
提到的「批量回复」思想一致,但生产实现还要处理
阻塞命令(CLIENT_BLOCKED
时停止消费 querybuf)、MULTI/EXEC 与
ACL。
五、IO threads:配置、fan-out/fan-in 与不变量
5.1 相关配置
| 配置 | 含义(官方文档 + 源码) |
|---|---|
io-threads |
IO 线程总数;1 表示关闭(仅主线程) |
io-threads-do-reads yes |
是否让 IO 线程参与 读+解析;默认 often 仅写线程 |
io-threads-active |
运行时是否已
startThreadedIO()(INFO
可见) |
initThreadedIO(networking.c):io_threads_num == 1
时 不创建 额外线程;否则 spawn
IOThreadMain,主线程 id 为 0。
5.2 IOThreadMain
做什么
/* src/networking.c — IOThreadMain(节选) */
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);
}
}主线程通过 io_threads_op 与
setIOPendingCount 做
fan-out,busy-wait fan-in 直至 pending
归零(同文件注释:thread safety 靠主线程在 fan-in
前不碰队列)。
5.3 设计不变量(7.4/8.x)
- 命令突变:
processCommand只在io_threads_op == IO_THREADS_OP_IDLE的主线程上下文执行(见processInputBuffer断言与processPendingCommandAndInputBuffer)。 - 复制输出:replica 客户端写回放在
io_threads_list[0](主线程处理)。 - 动态启停:待写客户端过少时
stopThreadedIOIfNeeded停线程,避免线程切换开销超过收益(阈值与io_threads_num*2相关)。
5.4 与 Memcached 多线程对比(预告第 14 篇)
Memcached worker 线程直接处理 get/set;Redis 刻意保持单线程突变 以回避细粒度锁与结构体并发安全。NSDI’13 的 Memcached 扩展讨论 多核线性扩展;Redis 7.x 选择 IO 并行 + 命令串行,是工程上不同的分叉点(争论见第 16 篇)。
六、开放问题:多线程 Redis 往哪走?
- IO 并行是否够? 当命令本身 CPU 重(大
key、复杂
SUNION、模块)或单实例 QPS 顶满 单核,IO threads 无法扩展 突变 吞吐——社区出现过多线程 fork 与 Redis 7+ 仍坚持主线程命令 的对比(选型见第 16 篇;Valkey 等 fork 的线程模型需单独读 release note,本篇不列未核对数字)。 - 读解析 offload
的代价:
io-threads-do-reads增加 fan-out/fan-in 与CLIENT_PENDING_COMMAND状态机复杂度;短连接、小 payload 场景下是否默认开启,需结合 实际 RTT 与 pipeline 比例 评估(官方文档建议与INFO配合,无 universal 开关)。 - 与持久化 /
模块的交互:
beforeSleep里 AOF、模块 GIL(moduleReleaseGIL/moduleAcquireGIL)与 IO threads 的时序约束,是 延迟尾刺 排查入口(第 15 篇LATENCY DOCTOR)。
这些问题没有单一 peer-reviewed「最终答案」;可跟踪 Redis/Valkey release notes 与 OSDI/SOSP 系 multicore KV 论文(如早期 parallel KV 研究)作对照,但 生产结论必须以具体 tag 源码为准。
七、常见误解
- 「开了 io-threads 就是多线程
Redis」。错。默认只并行 写
socket;命令仍在主线程。读线程还需
io-threads-do-reads yes,且解析后执行仍回主线程。 - 「单线程 = 只有一个 OS 线程」。错。除
IO threads 外,还有 BIO(AOF
fsync)、jemalloc
后台线程(若启用)、模块自建线程等;它们
不直接并发修改
dbdict。 - 「慢是因为 epoll
慢」。多数生产慢查询来自 命令复杂度、大
key、fork、AOF rewrite,而非
aeApiPoll本身(第 15 篇分解)。 - 「可读 callback 里一定执行
processCommand」。IO 读路径下 callback 可能只
postpone;执行点在
beforeSleepfan-in 之后。 - 「redis-lite 用了 libevent,所以 Redis 也依赖
libevent」。错。Redis 使用自研
ae,不依赖 libevent;redis-lite 是教学对照。
八、小结
三句话小结
- Redis 请求路径 =
aeMain驱动 epoll/kqueue +beforeSleep批量 I/O 与 AOF +processInputBuffer→processCommand。 - IO threads 仅 offload
readQueryFromClient/writeToClient;CLIENT_PENDING_COMMAND保证突变回到主线程。 - 「单线程 Redis」描述的是
数据面突变模型,不是 OS 线程数;理解
fan-out/fan-in 才能正确解读
INFO里的io_threads_active与延迟 profile。
下一篇进入内存对象层:对象模型与
SDS——redisObject 的 type/encoding
如何承接本篇命令路径上的 lookupKey /
setKey。
参考资料
模式 / 论文
- Schmidt, Douglas C. et al., Pattern-Oriented Software Architecture, Volume 2: Patterns for Concurrent and Networked Objects, Wiley(Reactor 模式;B 级)。
- Nishtala, Rajesh et al., Scaling Memcache at Facebook, NSDI 2013(多线程纯缓存对照;B 级)。
源码(Redis OSS 7.4.x,A 级)
src/ae.c—aeCreateEventLoop、aeProcessEvents、aeMain。src/ae.h— 文件/时间事件 mask 与回调类型。src/server.c—initServer、beforeSleep、afterSleep、serverCron、aeMain入口。src/networking.c—readQueryFromClient、processInputBuffer、processCommandAndResetClient、initThreadedIO、IOThreadMain、handleClientsWithPendingReadsUsingThreads、handleClientsWithPendingWritesUsingThreads。src/server.h—client结构、io_threads_num、CLIENT_*标志。
官方文档
- Redis Documentation, IO Threads / Latency(redis.io,7.4 / 8.x)。
站内
返回 系列目录 | 上一篇:缓存服务器全景 | 下一篇:对象模型与 SDS
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【Redis / 缓存内核】缓存服务器全景:HashMap · Memcached · Redis · 嵌入 KV
定位进程内 HashMap、Memcached、Redis 与 RocksDB 嵌入 KV 在内存缓存栈中的生态位;划分与 architecture/17、storage/66、linux/redis-source 的分工,并给出本系列 16 篇阅读地图。
【Redis / 缓存内核】对象模型与 SDS:redisObject · type · encoding
拆解 Redis 7.4/8.x 的 redisObject 多态层与 SDS 二进制安全字符串:type/encoding/refcount 如何分工、字符串四种编码的升级路径、OBJECT ENCODING 能读到什么,以及紧凑表示与统一抽象之间的工程争论。
【Redis / 缓存内核】Dict 与渐进 rehash:双 ht 与摊销扩容
从 Redis 7.4/8.x 的 dict 源码拆解链式哈希、双表渐进 rehash、rehashidx 步进与 DICT_RESIZE_AVOID 在 fork 期的行为,并对照站内哈希表内部文章说明与教科书一次性扩容的差异。
【Redis / 缓存内核】紧凑编码:listpack · quicklist · intset
Redis 7.4/8.x 如何用 listpack、quicklist、intset 为小集合省内存:ziplist 退场原因、各 type 的 encoding 升级阈值与配置项默认值,以及紧凑表示在 CPU 与内存之间的工程争论。