Memcached 多线程模型:监听、CQ、Worker 与共享缓存
JotaroRD

Memcached 不是「一连接一线程」。它用一个监听线程接连接,用 Connection Queue 把连接交给多个 worker,每个 worker 用自己的 libevent 管理大量连接,并在本线程里直接执行 GET/SET。所有 worker 共享同一份 hash、slab 和 LRU,再用按 hash 分片的 item lock 保护这份缓存。

Memcached = 多 worker 线程 + 每 worker 一个 event loop + 每 worker 管多个连接 + 所有 worker 共享同一份缓存。

依据是已经对过的线程模型,以及 Memcached 源码里能直接对上的结构:独立监听线程、worker 自己的 libevent、连接队列 CQ、item locks,以及基于 hash 的锁分片。继续读源码时,优先看 thread.c:worker 初始化、CQ、wakeup、item locks 都在这里。不同版本的源码结构略有演进,基本思想不变。微信公众号原文当时没有逐段对照,下面按源码能确认的模型来记。

目录

  1. 整体模型
  2. 连接怎么进来
  3. Worker 怎么处理请求
  4. 共享缓存与锁
  5. 业务原子性、CAS、Slab、LRU
  6. 为什么适合简单高并发缓存
  7. 两条必背调用链
  8. Java / Netty 类比
  9. 十条结论

1. 整体模型

官方资料的描述是:有一个专门线程监听新 socket,再把客户端连接 round-robin 分配给各 worker。连接交出去之后,GET/SET 不再回到监听线程。

flowchart TB Client(["Client"]) subgraph Listen["Main / Listener"] Accept["accept()"] end Client --> Accept Accept --> RR{"round-robin 选择 worker"} subgraph Queues["Connection Queue:交接的是连接"] direction LR CQ0["CQ0"] CQ1["CQ1"] CQ2["CQ2"] end RR --> CQ0 RR --> CQ1 RR --> CQ2 subgraph Workers["每个 Worker:一个 libevent"] direction LR W0["Worker0
多个 conn"] W1["Worker1
多个 conn"] W2["Worker2
多个 conn"] end CQ0 --> W0 CQ1 --> W1 CQ2 --> W2 W0 --> Shared W1 --> Shared W2 --> Shared subgraph Shared["进程内共享缓存"] direction LR Hash["Hash Table"] ~~~ Slab["Slab"] Slab ~~~ LRU["LRU / item"] end classDef client fill:#f1f5f9,stroke:#64748b,color:#1e293b classDef listen fill:#dbeafe,stroke:#2563eb,color:#1e3a8a classDef queue fill:#ffedd5,stroke:#ea580c,color:#7c2d12 classDef worker fill:#dcfce7,stroke:#16a34a,color:#14532d classDef cache fill:#ede9fe,stroke:#7c3aed,color:#4c1d95 classDef decision fill:#fef9c3,stroke:#ca8a04,color:#713f12 class Client client class Accept listen class RR decision class CQ0,CQ1,CQ2 queue class W0,W1,W2 worker class Hash,Slab,LRU cache

四层各管一件事:

层 做什么 不做什么
Listener accept(),按 round-robin 选 worker 不执行 GET/SET
CQ 把新连接从监听线程交给某个 worker 不排队业务请求
Worker 自己的 event loop,解析协议并执行命令 不独占一份缓存
Shared Cache hash、slab、LRU,全进程一份 不按线程复制

2. 连接怎么进来

2.1 Main Thread

主线程的路径很短:listen → accept → 得到 socket fd → 选择 worker → 把连接交出去。

flowchart LR C1["conn1"] --> W0["Worker0"] C2["conn2"] --> W1["Worker1"] C3["conn3"] --> W2["Worker2"] C4["conn4"] --> W3["Worker3"] C5["conn5"] --> W0 classDef conn fill:#f8fafc,stroke:#94a3b8,color:#334155 classDef worker fill:#dcfce7,stroke:#16a34a,color:#14532d class C1,C2,C3,C4,C5 conn class W0,W1,W2,W3 worker

常见分发是 round-robin:第 5 个连接回到 Worker0。Main Thread 不负责执行每一个 GET/SET。

2.2 CQ:Connection Queue

CQ 是 Main Thread 和 Worker 之间的连接交接队列。源码里它大致带一把队列自己的锁:

1
2
3
4
struct conn_queue {
/* head / tail ... */
pthread_mutex_t lock;
};

CQ_ITEM 保存的是新连接,不是一条命令:

1
2
3
4
5
6
7
struct conn_queue_item {
int sfd;
enum conn_states init_state;
int event_flags;
int read_buffer_size;
/* ... */
};
flowchart TB Accept["Main Thread
accept fd = 100"] --> Item["CQ_ITEM
sfd = 100"] Item --> CQ["Worker2.CQ"] CQ --> Worker["Worker2 之后接管这条连接"] classDef listen fill:#dbeafe,stroke:#2563eb,color:#1e3a8a classDef queue fill:#ffedd5,stroke:#ea580c,color:#7c2d12 classDef worker fill:#dcfce7,stroke:#16a34a,color:#14532d class Accept listen class Item,CQ queue class Worker worker

队列里排的是 socket fd。一次 push 对应一次连接交接,例如 fd 101、fd 102、fd 103。GET a / SET b 不会逐条进入 CQ。旧版源码可以直接看到 head/tail + mutex;新版本仍然保留 connection queue 和对应的锁。

2.3 CQ 为什么自己也要锁

队列两侧是不同线程。Main 正在 cq_push(),Worker 正在 cq_pop(),两边会同时改队列,所以 queue 自己需要 mutex。

sequenceDiagram participant Main as Main Thread participant CQ as CQ.lock participant Worker as Worker Thread Main->>CQ: cq_push() 加锁 Note over CQ: 修改 head / tail Main->>CQ: 解锁 Worker->>CQ: cq_pop() 加锁 Note over CQ: 取出 CQ_ITEM Worker->>CQ: 解锁

这把锁只保护 connection queue。后面保护 KV 数据的 item lock 是另一套锁。

2.4 Worker 怎么知道 CQ 里有新连接

只做 CQ.push(conn) 不够。Worker 可能正停在 event loop 里等网络事件,根本不知道队列里多了东西。经典设计里,每个 worker 都有 wakeup pipe 或 eventfd。源码注释的意思是:每个 libevent 实例都有一个 wakeup pipe,其他线程可以通过它通知 worker,queue 里进了一个新连接。

2.5 新连接的完整调用链

sequenceDiagram participant Client participant Main as Main Thread participant CQ as Worker CQ participant Worker as Worker libevent Client->>Main: TCP connect Main->>Main: accept() 得到 fd Main->>Main: round-robin 选择 worker Main->>CQ: cq_push(CQ_ITEM) Main->>Worker: write notify fd Worker->>Worker: event loop 被唤醒 Worker->>CQ: cq_pop() Worker->>Worker: conn_new() + event_add() Note over Worker: 之后这条 conn 只由该 worker 管

连接交出去以后,后续 GET/SET 走的是该 worker 的 socket event,不再每个 request 都经过 CQ。

flowchart LR Client(["Client"]) -->|"GET / SET"| Sock["Worker 的 socket event"] Sock --> Exec["协议解析 + 命令执行"] classDef client fill:#f1f5f9,stroke:#64748b,color:#1e293b classDef worker fill:#dcfce7,stroke:#16a34a,color:#14532d class Client client class Sock,Exec worker

3. Worker 怎么处理请求

3.1 Worker 是什么

每个 Worker 是一个 pthread,加上自己的 event_base,加上挂在这个 event loop 上的多个 socket。初始化时为 worker 建自己的 event_base,然后进入 event_base_loop(...)。源码里有 worker_libevent(),核心就是进入 worker 自己的 event loop。

一个 worker 是一个单线程 event loop。

3.2 一个 Worker 管几个 Conn

很多个。4 个 worker 管 10000 个连接是正常的,因为模型是非阻塞 IO 加 event loop。

flowchart TB W0["Worker0"] --> A1["conn1"] W0 --> A5["conn5"] W0 --> A9["conn9"] W0 --> A13["conn13"] W1["Worker1"] --> B2["conn2"] W1 --> B6["conn6"] W1 --> B10["conn10"] classDef worker fill:#dcfce7,stroke:#16a34a,color:#14532d classDef conn fill:#f8fafc,stroke:#94a3b8,color:#334155 class W0,W1 worker class A1,A5,A9,A13,B2,B6,B10 conn

3.3 同一个 Worker 下多个 Conn 是否并行

同一个 worker 只有一个线程。A、B 可读、C 不可读时,event loop 先处理 A,再处理 B,然后继续循环。这是事件驱动并发,CPU 意义上的并行发生在不同 worker 之间。

flowchart TB subgraph One["同一个 Worker:一个线程串行处理事件"] direction LR A["处理 Conn A"] --> B["处理 Conn B"] B["处理 Conn B"] --> Loop["回到 event loop"] end subgraph Many["不同 Worker:多核并行"] direction LR C0["Worker0 · CPU0"] C1["Worker1 · CPU1"] C2["Worker2 · CPU2"] C3["Worker3 · CPU3"] end classDef serial fill:#fef9c3,stroke:#ca8a04,color:#713f12 classDef parallel fill:#dcfce7,stroke:#16a34a,color:#14532d class A,B,Loop serial class C0,C1,C2,C3 parallel

3.4 一个 Conn 会不会跨 Worker

正常情况下,连接建立后分配给某一个 Worker,之后一直由那个 Worker 的 event loop 管理。GET a、GET b、SET c、GET d 都由同一个 worker 执行。这样连接状态不用跨线程同步。

3.5 Worker 不只是网络 IO 线程

Memcached Worker 同时做网络 IO、协议解析、命令执行、Hash lookup、GET/SET 和结果发送。

flowchart LR W0["Worker0
GET keyA"] --> Core0["CPU0"] W1["Worker1
GET keyB"] --> Core1["CPU1"] classDef worker fill:#dbeafe,stroke:#2563eb,color:#1e3a8a classDef cpu fill:#dcfce7,stroke:#16a34a,color:#14532d class W0,W1 worker class Core0,Core1 cpu

两个 worker 可以在两个 CPU core 上同时执行 KV 操作。这是 Memcached 和 Redis 经典线程模型的一个主要差别。

3.6 一次请求在 Worker 内怎么走

客户端发 get user:100 时,全程在这个 Worker 线程里完成:读 socket、解析协议、查缓存、写回结果。锁只包住很短的共享数据临界区。

flowchart TB Read["socket readable
read"] --> Proto["解析协议,识别 GET"] Proto --> Hash["hash(user:100)"] Hash --> Lock["获取 item lock"] Lock --> Lookup["查 hash table,得到 item"] Lookup --> Ref["维护 refcount / item 状态"] Ref --> Unlock["释放 item lock"] Unlock --> Write["写 socket,返回 Client"] classDef io fill:#dbeafe,stroke:#2563eb,color:#1e3a8a classDef cmd fill:#fef9c3,stroke:#ca8a04,color:#713f12 classDef lock fill:#ffe4e6,stroke:#e11d48,color:#881337 classDef cache fill:#ede9fe,stroke:#7c3aed,color:#4c1d95 class Read,Write io class Proto,Hash cmd class Lock,Unlock lock class Lookup,Ref cache

记成一条线即可:Network → Protocol → Command → Cache → Network。

4. 共享缓存与锁

4.1 为什么多 Worker 能访问同一份数据

这些 worker 都是同一个进程里的 pthread,共享虚拟地址空间,因此共享同一份堆上的缓存。Worker0 执行 set user:1 A 之后,Worker3 执行 get user:1 可以读到。

flowchart TB subgraph Proc["同一个进程"] direction LR W0["Worker0"] W1["Worker1"] W2["Worker2"] end W0 --> Heap W1 --> Heap W2 --> Heap subgraph Heap["共享堆"] Item["Hash / Slab / LRU / Item"] end classDef worker fill:#dcfce7,stroke:#16a34a,color:#14532d classDef cache fill:#ede9fe,stroke:#7c3aed,color:#4c1d95 class W0,W1,W2 worker class Item cache

4.2 共享数据会有竞态

两个 worker 同时 SET k,没有同步就可能破坏 hash table、item metadata、LRU、refcount。源码里能看到多类锁:item_locks、lru_locks、stats_lock、conn_lock,以及前面的 CQ lock。它们保护的对象不同。

4.3 item_lock:锁分片

不是每个 key 一把 mutex。路径是 hash(key) 得到 hv,再用 hv & mask 选择 item_locks[x]。

1
mutex_lock(&item_locks[hv & hashmask(item_lock_hashpower)]);

这是 Lock Striping。例如 1024 把锁时,不同 key 可以落到不同锁上;hash 到同一槽的 key 即使不是同一个 key,也会竞争同一把锁。

flowchart TB A["key A"] --> HA["hash"] B["key B"] --> HB["hash"] C["key C"] --> HC["hash"] HA --> L21["item_locks[21]"] HC --> L21 HB --> L729["item_locks[729]"] classDef key fill:#f8fafc,stroke:#94a3b8,color:#334155 classDef hash fill:#dbeafe,stroke:#2563eb,color:#1e3a8a classDef lock fill:#ffe4e6,stroke:#e11d48,color:#881337 classDef free fill:#dcfce7,stroke:#16a34a,color:#14532d class A,B,C key class HA,HB,HC hash class L21 lock class L729 free

A 和 B 可以同时访问。A 和 C 会在 lock 21 上排队。

4.4 同一个 KV 同时 GET / SET

Worker0: GET k 和 Worker1: SET k = A 映射到同一把 item lock。临界区串行化以后,一边不会在另一边已经 free 掉对象时继续使用它。

sequenceDiagram participant W0 as Worker0 GET k participant Lock as item_lock(k) participant W1 as Worker1 SET k W0->>Lock: lock W0->>W0: 查 item,refcount++ W0->>Lock: unlock W1->>Lock: lock W1->>W1: 替换 item W1->>Lock: unlock

4.5 GET 也要加锁

关键路径上要加锁。GET 要碰到 item 生命周期、引用计数、LRU 状态,以及 hash table 里对象是否仍然安全。当前源码仍能看到 item_get_locked(...):先 hash(),再 item_lock(),再 do_item_get()。

GET 通常只保护很短的共享数据临界区,不会拿锁直到 socket 写完。

4.6 为什么不用 RWLock

理论上可以让 GET 拿读锁、SET 拿写锁,使 GET + GET 完全并发。实际 GET 还要碰 refcount、LRU、item metadata 和生命周期,本身就不是纯读。再加上 pthread rwlock 的成本、cache line contention 和实现复杂度,短临界区的 mutex 通常更实用。

Memcached 的思路是:锁很多、粒度小、持锁时间短。

4.7 item lock 和 LRU lock

两套锁保护的对象不同。

锁 主要保护
item_lock item、hash bucket、item metadata、生命周期
lru_lock 对应的 LRU 数据结构
CQ lock 连接交接队列
stats_lock / conn_lock 统计或连接相关的共享状态

源码注释的要点:修改 item / hash bucket 前需要 item lock;涉及 LRU 修改时还需要 LRU lock;LRU 访问 item 时要协调 item trylock。实际代码会固定锁顺序,否则容易死锁。

flowchart TB Worker["Worker"] --> ItemLock["item_lock
修改 item / hash bucket"] ItemLock --> LruLock["lru_lock
修改 LRU"] LruLock --> Done["按固定顺序释放"] classDef worker fill:#dcfce7,stroke:#16a34a,color:#14532d classDef item fill:#ffe4e6,stroke:#e11d48,color:#881337 classDef lru fill:#ede9fe,stroke:#7c3aed,color:#4c1d95 class Worker worker class ItemLock item class LruLock,Done lru

5. 业务原子性、CAS、Slab、LRU

5.1 线程安全不等于业务操作原子

内部锁都正确时,下面这段仍然可能丢更新:

1
2
3
GET counter
counter++
SET counter
sequenceDiagram participant A as Client A participant Cache as counter = 10 participant B as Client B A->>Cache: GET → 10 B->>Cache: GET → 10 A->>Cache: SET 11 B->>Cache: SET 11 Note over Cache: 结果是 11,丢失了一次自增

内部保证的是单次 GET 安全、单次 SET 安全。需要原子语义时用 incr、decr、cas、add。

5.2 CAS

gets 会带回 value 和 CAS token。两个客户端都读到 100, token = 51 时,先提交的 cas 成功并把 token 改成 52,后提交的 cas 因为 token 已经不是 51 而失败。这是乐观并发控制。

sequenceDiagram participant A as Client A participant Cache as value=100 token=51 participant B as Client B A->>Cache: gets → 100, token 51 B->>Cache: gets → 100, token 51 A->>Cache: cas token=51 value=110 Cache-->>A: 成功,token 变为 52 B->>Cache: cas token=51 value=120 Cache-->>B: 失败,51 != 52

5.3 Slab 在体系里的位置

value 最终落在 slab 分配器里。Item 进入合适的 slab class,例如 96B、120B、152B 这类 chunk。

flowchart TB Key["key"] --> Hash["hash table"] Hash --> Item["item *"] Item --> Chunk["chunk"] Chunk --> Page["slab page"] Page --> Class["slab class"] classDef find fill:#dbeafe,stroke:#2563eb,color:#1e3a8a classDef store fill:#ede9fe,stroke:#7c3aed,color:#4c1d95 class Key,Hash,Item find class Chunk,Page,Class store

四个概念分开记:

概念 负责什么
Hash 按 key 找到 item
Slab 决定 value 存在哪块内存
LRU 内存满时谁先被淘汰
item lock 多 worker 同时访问时的并发安全

5.4 LRU

内存满了要决定淘汰谁。Memcached 用 LRU 家族管理冷热:热数据靠近 HEAD,冷数据靠近 TAIL,压力大时从冷的一侧淘汰。GET 看起来只是读,访问模式仍可能改变 LRU 状态,所以 GET 不是纯粹的无状态读取。

flowchart TB Hot["热点"] --> Head["HEAD"] Head --> A["item A"] A --> C["item C"] C --> D["item D"] D --> B["item B"] B --> Tail["TAIL"] Tail --> Cold["冷数据,内存压力大时从这里淘汰"] classDef hot fill:#fee2e2,stroke:#dc2626,color:#7f1d1d classDef warm fill:#ffedd5,stroke:#ea580c,color:#7c2d12 classDef cold fill:#e0f2fe,stroke:#0284c7,color:#0c4a6e class Hot,Head,A hot class C,D warm class B,Tail,Cold cold

6. 为什么适合简单高并发缓存

flowchart TB CPU["多个 CPU Core"] --> Workers["多个 Worker"] Workers --> Loop["每个 Worker 一个 event loop"] Loop --> Conns["每个 Worker 管大量连接"] Conns --> Parallel["多个 Worker 并行执行 GET/SET"] Parallel --> Share["共享同一份缓存"] Share --> Lock["细粒度 item lock 保证并发安全"] classDef scale fill:#dbeafe,stroke:#2563eb,color:#1e3a8a classDef safe fill:#ede9fe,stroke:#7c3aed,color:#4c1d95 class CPU,Workers,Loop,Conns,Parallel scale class Share,Lock safe

适合的负载是:大量客户端、大量简单 GET/SET、value 是 blob、不需要复杂结构。例如 user:123 -> protobuf、product:456 -> JSON、sql:hash -> DB result、page:x -> HTML fragment。

和 Redis 的关键区别

Memcached 的多个 worker 可以真正并行执行缓存命令。Redis 经典核心更接近「IO 进来之后,命令在核心执行线程上跑」,复杂结构也放在这份核心状态里。现代 Redis 已有多线程网络 IO 等机制,核心命令执行架构仍和 Memcached 不同。

flowchart TB subgraph MC["Memcached"] direction TB M0["Worker0:IO + parse + GET/SET"] M1["Worker1:IO + parse + GET/SET"] M2["Worker2:IO + parse + GET/SET"] end subgraph RD["Redis 经典核心"] direction TB IO["网络 IO"] --> Core["核心命令执行"] Core --> Struct["Dict / List / ZSet / Hash"] end classDef mc fill:#dcfce7,stroke:#16a34a,color:#14532d classDef rd fill:#dbeafe,stroke:#2563eb,color:#1e3a8a class M0,M1,M2 mc class IO,Core,Struct rd

Memcached 特别适合多核机器上的简单 KV cache workload。

7. 两条必背调用链

第一条是新连接,只走一次。第二条是这条连接上之后的每一次 GET/SET。

flowchart TB Client(["Client"]) --> Accept["Listener accept"] Accept --> Push["cq_push + notify"] Push --> Pop["Worker cq_pop"] Pop --> New["conn_new + event_add"] New --> Owned["连接归属这个 Worker"] Owned --> Req["之后的 GET/SET"] Req --> Path["read → parse → hash → item lock → cache → write"] classDef once fill:#ffedd5,stroke:#ea580c,color:#7c2d12 classDef steady fill:#dcfce7,stroke:#16a34a,color:#14532d class Client,Accept,Push,Pop,New once class Owned,Req,Path steady

橙色是连接建立时的交接,只经过 CQ 一次。绿色是稳态:请求留在该 worker 的 socket event 上,直接打到共享缓存。

8. Java / Netty 类比

Memcached 接近的 Netty / Java 概念
Main Listener Boss EventLoop
Worker Worker EventLoop
CQ Boss 把连接注册到 Worker 的过程
libevent epoll selector / event loop
conn Channel
item_lock striped ReentrantLock

底层实现不完全一样。用这个映射记模型即可:Boss 只接连接,Worker 既做 IO 也做命令,共享缓存靠分片锁。

9. 十条结论

  1. Memcached 是多线程服务。
  2. Main Thread 主要负责监听和接受连接。
  3. 新连接通过 CQ 分发到 Worker。
  4. CQ = Connection Queue,交接的是连接。
  5. 一个 Worker 可以管理大量 Conn。
  6. Worker 使用 libevent + 非阻塞 IO。
  7. 一个 Conn 一般归属一个 Worker。
  8. Worker 不仅做网络 IO,也执行 GET/SET。
  9. 多个 Worker 共享同一份缓存,所以需要 item lock、LRU lock 等同步机制。
  10. item lock 通过 hash 分片,而不是每个 key 一把 mutex。