文档目录

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缓存同时工作,主存带宽是天花板。