1. BLPOP 是干什么的? #
普通 LPOP mylist:list 空就立刻回 (nil)。
BLPOP mylist 0 是 Blocking LPOP:
- list 里有元素 → 立刻弹出,和
LPOP一样 - list 空 → 不立刻回包,这个 TCP 连接在服务端进入阻塞态,等有人
LPUSH/RPUSH再唤醒
从网络协议栈看:
客户端 socket ──write──▶ 服务端 query buffer
│
解析出 BLPOP
│
list 空?──yes──▶ 标记 CLIENT_BLOCKED
│ 可读 handler 仍在(epoll 仍监听)
│ 但不再 parse/execute 后续命令
│ (数据可进 query buf,先不执行)
│
客户端 read() 阻塞等 RESP ◀── 暂时没有任何 reply 写出
所以 BLPOP 本质是:用一条长连接 + 服务端状态机,把“等数据”从客户端轮询变成服务端挂起。超时参数 0 表示一直等。
多个客户端对同一 key 做 BLPOP,就是多个 waiter 排队。
2. 服务端怎么记这些“等着的人”? #
每个 DB 有一张表:db->blocking_keys:
key "mylist" ──▶ list: [ client A ] → [ client B ] → ...
挂上时(blockForKeys)往链表尾插节点:
listAddNodeTail(l, c);
dictSetVal(c->bstate->keys, client_blocked_entry, listLast(l));
内存里大致是:
list 头
│
▼
┌─────────────┐ next ┌─────────────┐
│ listNode A │ ───────▶ │ listNode B │
│ value = &A │ │ value = &B │
└─────────────┘ └─────────────┘
▲ ▲
│ │
client A 还把自己的 client B 同理
bstate->keys["mylist"]
指回这个 listNode*
(方便 O(1) 摘链,而不是扫链表)
要点:链表节点 listNode 是独立 heap 对象;
客户端结构体里还握着指向它的指针。
谁把客户端拆掉,往往会顺手 listUnlink + zfree(listNode)。
3. 有人 LPUSH 之后发生什么? #
LPUSH mylist v1 执行完会 signalKeyAsReady,把 key 丢进 server.ready_keys。
当前命令的 processCommand 末尾调用 handleClientsBlockedOnKeys → handleClientsBlockedOnKey:
listRewind(clients, &li);
...
while ((ln = listNext(&li)) && count--) {
client *receiver = listNodeValue(ln);
...
unblockClientOnKey(receiver, rl->key);
对 list 类命令,唤醒不是“直接塞个回复”那么简单,而是:
- 把该 client 从
blocking_keys摘掉、清CLIENT_BLOCKED - 重新执行原来的 BLPOP(
processCommand再跑一遍) - 这时 list 里已有元素,BLPOP 同步弹出并
addReply
这就是 “reexec / pending_command” 路径。
4. Bug 的核心:listIter 提前缓存了后继节点
#
adlist 的迭代器约定写得很清楚:迭代时只允许删当前节点,不能删别的节点。
listNode *listNext(listIter *iter) {
listNode *current = iter->next;
if (current != NULL) {
if (iter->direction == AL_START_HEAD)
iter->next = current->next; /* 已经把“下一个”指针存进 iter */
else
iter->next = current->prev;
}
return current;
}
服务 A 的那一轮:
listNext(&li) 得到 nodeA
同时 li.next = nodeB ← 后继指针已经钉死在栈上的 listIter 里
然后对 A 调用 unblockClientOnKey(A)。若在这个调用返回之前,
有人把 nodeB 从链表摘掉并 free,
返回后外层再 listNext 就会读已经释放的 listNode → heap UAF。
adlist 只保证:删当前节点时,迭代器里缓存的 next 仍有效。它不保证你在回调里删掉后继。
5. 谁在回调里杀掉了后继 B?(无 module 的这条路径) #
调用栈(你 ASan 打出来的那条):
handleClientsBlockedOnKey // 正在 for 链表,li.next == nodeB
└─ unblockClientOnKey(A)
└─ processCommand(A) // 重跑 BLPOP
└─ evictClients() // processCommand 开头:客户端内存超限就踢人
└─ freeClient(B) // B 输出缓冲很大,被选中
└─ unblockClient / unblockClientWaitingData
└─ zfree(nodeB) // 正是 li.next 指向的那块
… 回到 while
└─ listNext(&li) // 读已 free 的 nodeB → UAF
从系统角度:
| 层 | 发生了什么 |
|---|---|
| 事件循环 | 仍在处理同一次 read→processCommand(EXEC/LPUSH) 的同步调用栈里,没有回到 epoll_wait |
| 客户端状态 | A 从 blocked → 重入命令执行;B 仍在同一条 blocking_keys 链表上 |
| 内存 | evictClients 同步 freeClient(B),把 B 的 listNode 还给 jemalloc/libc |
| 迭代器 | 栈上 listIter.next 仍是悬空指针 |
所以这不是“多线程 race”,而是 单线程重入(reentrancy):外层握着结构,内层同一线程把结构拆了。
和 module 版 #4198 的差别只在“谁触发 freeClient(后继)”:
- module:reply 回调里
CLIENT KILL - 这条:
processCommand→evictClients
同一条链表 + 同一种危险的 listIter 用法。
6. 为啥测试要用 MULTI 绑 CONFIG SET + LPUSH?
#
若直接:
CONFIG SET maxmemory-clients 很小
LPUSH mylist v1
则 LPUSH 自己的 processCommand 一进来就会 evictClients,B 可能在进入 handleClientsBlockedOnKey 之前就被踢掉——打不到“迭代中途 free 后继”。
MULTI/EXEC 把降水位和 LPUSH 放进同一次 EXEC:
processCommand(EXEC)开头:水位还高 → 不踢 B- 事务里
CONFIG SET把水位降下去(只走嵌套call(),不再跑一整遍外层processCommand) - 事务里
LPUSH把 key 标 ready EXEC结束后才handleClientsBlockedOnKeys- 服务 A 时嵌套
processCommand(A)才按低水位踢掉 B → 正好打在迭代窗口里
这是在人为制造“唤醒路径上、迭代中途发生同步 free”的时序,不是日常最常见写法,但证明了:普通 BLPOP 路径确实能踩到这个洞。
7. 一句话收束 #
BLPOP = list 空时把连接挂在 blocking_keys[key] 的 waiter 链表上。
Bug = 唤醒时用 listIter 扫这条链表,却在服务当前 waiter 的同步回调里(重跑命令 → evictClients)把后继 waiter 的 listNode 释放了,外层再 listNext 读悬空指针。
修复思路也因此很明确:不要在可能同步 freeClient 的回调期间,依赖“缓存了后继 listNode*”的裸迭代;改成 ID/指针快照或先收集再处理,使后继被拆掉时不会再解引用已释放节点。