1. SPMC(单写多读)的硬件本质
场景定义
- 1个写者(Writer):生产数据(如配置更新、日志聚合、时钟信号)。
- N个读者(Reader):各自独立消费数据(如多个统计线程、监控面板、热备进程)。
硬件挑战:写者如何让所有读者看到最新数据?
方案A:共享锁(std::shared_mutex)—— 读者间乒乓
std::shared_mutex mtx;
int shared_data;
void writer() {
std::unique_lock lock(mtx);
shared_data = new_value; // 独占锁 → 缓存行 E→M
}
void reader() {
std::shared_lock lock(mtx);
int local = shared_data; // 共享锁 → 缓存行 E→S
}
硬件代价:
- 写者修改时,发 RFO(Read For Ownership),使所有读者的缓存行失效(
I状态)。 - 读者读时,重新从主存/写者缓存加载(Cache Miss + 总线流量)。
- 缓存行在N+1个核心间频繁迁移,延迟随读者数线性增加。
方案B:RCU(Read-Copy-Update)—— 读者零开销
std::atomic<int*> global_ptr; // 指向共享数据的指针
void writer() {
int* old = global_ptr.load(std::memory_order_acquire);
int* new_data = new int(*old); // 拷贝
*new_data = new_value;
global_ptr.store(new_data, std::memory_order_release); // 发布新指针
// 等待所有读者退出后,delete old (延迟回收)
}
void reader() {
int* data = global_ptr.load(std::memory_order_acquire);
int local = *data; // 完全无锁,无原子操作(除了指针load)
}
硬件行为:
- 写者:一次原子的指针发布(CAS/Store),代价 ≈ 一次缓存行同步。
- 读者:一次普通内存访问(
load),L1缓存命中率100%(指针和旧数据都在本地缓存)。 - 读者间完全不交互,缓存行在各自核心是
S(共享)状态,无冲突。
关键收益:读者数量从1增加到100,开销几乎不变(除了CPU核心数增加带来的自然调度开销)。
2. 工作窃取(Work-Stealing)的硬件亲和性
为什么"窃取"比"轮询"快?
轮询(Polling)—— 被动等待
std::atomic<size_t> global_index{0};
std::function<void()> tasks[1024];
void worker(int id) {
while (true) {
size_t idx = global_index.fetch_add(1, std::memory_order_acquire);
if (idx >= 1024) break;
tasks[idx](); // 执行任务
}
}
硬件代价:
fetch_add是原子RMW → 所有worker争抢global_index的缓存行。- 每次循环都触发RFO,缓存行在核心间乒乓 → 延迟随worker数线性增长。
工作窃取(Work-Stealing)—— 主动出击
struct Deque {
alignas(64) std::atomic<size_t> bottom{0}; // 队尾(本地操作)
alignas(64) std::atomic<size_t> top{0}; // 队首(窃取操作)
alignas(64) std::function<void()> tasks[1024];
};
void worker(int id, Deque& my_deque, std::vector<Deque>& all_deques) {
while (true) {
// 1. 本地操作:从队尾取任务(bottom--)
size_t b = my_deque.bottom.fetch_sub(1, std::memory_order_relaxed) - 1;
if (b >= my_deque.top.load(std::memory_order_acquire)) {
// 本地队列不空,执行任务(无竞争!)
my_deque.tasks[b]();
continue;
}
// 2. 本地空 → 偷窃:从随机目标的队首取任务(top++)
for (int attempt = 0; attempt < all_deques.size(); ++attempt) {
Deque& victim = all_deques[random()];
size_t t = victim.top.fetch_add(1, std::memory_order_acquire);
if (t < victim.bottom.load(std::memory_order_acquire)) {
// 偷窃成功
victim.tasks[t]();
break;
}
}
}
}
硬件行为:
- 本地操作:
bottom的fetch_sub只在当前核心的缓存行上操作,无竞争,延迟 ≈ 1ns(L1命中)。 - 窃取操作:
top的fetch_add才会触发缓存行迁移,但频率低(只在本地队列空时发生)。 - 最优情况:线程从不窃取 → 所有操作都在L1/L2缓存中完成 → 接近内存带宽极限。
3. Go 调度器的工作窃取在硬件上的"三大优化"
优化1:runq 的批量窃取
Go 调度器不偷单个G,而是一次偷走一半(runqsteal):
func (p *p) runqsteal(stealFrom *p, max int) []*g {
// 一次偷走 victim.runq 的一半任务
n := len(victim.runq) / 2
if n > max { n = max }
// 批量移动指针,减少原子操作次数
}
硬件收益:一次CAS窃取N个任务 → 原子操作次数减少N倍。
优化2:自旋 + pause 指令
Go 调度器在找不到任务时,不立即挂起线程,而是自旋几次(spinning状态):
for i := 0; i < 4; i++ {
if g := stealWork(); g != nil {
execute(g)
return
}
// 使用 CPU 的 PAUSE 指令让出流水线
asm("pause")
}
硬件收益:避免频繁的用户态↔内核态切换,自旋期间缓存保持热度。
优化3:全局队列(GRQ)作为"最后手段"
当所有本地队列都空,且窃取失败时,才访问全局队列:
if g := globalRunq.pop(); g != nil {
execute(g)
return
}
// 全局队列也空 → 进入休眠,触发系统调用
硬件收益:全局队列的CAS竞争被降到最低,只在极度空闲时才触发。
4. SPMC + 工作窃取的"理论极限"
| 场景 | 理论极限 | 硬件瓶颈 |
|---|---|---|
| SPMC (RCU) | 读者数无限(读延迟不变) | 主存带宽(读者都读同一块内存时) |
| 工作窃取 (本地队列) | 线程数 = CPU核心数 | 缓存容量(L1/L2大小限制队列长度) |
| 工作窃取 (窃取) | 窃取开销 ≈ 一次Cache Miss | 缓存一致性协议(MESI的RFO延迟) |
终极结论:
- SPMC 让数据在核心间高效共享(RCU)。
- 工作窃取让任务在核心间高效流动(本地队列 + 偷窃)。
- 两者结合 = Go调度器,它既让数据(Goroutine栈)在核心间迁移,又让任务(G)在核心间流动。
5. 硬件重压下,SPMC + 工作窃取的"脆弱点"
脆弱点1:虚假共享(False Sharing)
如果 top 和 bottom 在同一缓存行,本地操作和窃取操作会互相干扰。
// ❌ 错误布局
struct Deque {
std::atomic<size_t> bottom; // 被本地频繁修改
std::atomic<size_t> top; // 被窃取者频繁修改
// bottom 和 top 在同一个缓存行 → 伪共享!
};
// ✅ 正确布局
struct Deque {
alignas(64) std::atomic<size_t> bottom;
alignas(64) std::atomic<size_t> top; // 隔离到不同缓存行
};
脆弱点2:窃取频率过高
如果任务粒度太细(如每个任务仅1μs),窃取开销占比会上升。 解决方案:任务批量(Task Batching),让每个任务执行时间 > 100μs。
脆弱点3:NUMA(非统一内存访问)
在多插槽CPU上,跨插槽窃取会导致远距离内存访问(延迟增加2倍)。 解决方案:窃取时优先选同NUMA节点的线程。
6. 硬件视角的"总结陈词"
SPMC 的本质:
→ 让"数据"在多核间共享,代价最小化(RCU)。
→ 避免读者间相互打扰(缓存行在S状态共享)。
工作窃取的本质:
→ 让"任务"在多核间流动,负载最均衡。
→ 本地操作无锁,利用缓存局部性。
→ 窃取操作低频,承受Cache Miss成本可控。
两者的交集:
→ Go调度器:数据(Goroutine)共享,任务(G)窃取。
→ 性能极限:所有核心的L1/L2缓存同时工作,主存带宽是天花板。