为什么高性能调度器都在“偷任务”?从无锁队列看懂工作窃取

本文从最基础的并发概念出发,逐步拆解无锁队列的理论基础,以及 Chase-Lev 工作窃取队列中的核心设计。


1. 为什么需要无锁?

传统互斥锁(std::mutex)简单可靠,但在高频共享数据访问场景中,如果竞争严重,线程可能发生阻塞、调度与上下文切换,从而带来额外开销。

在任务调度器这类需要频繁 Push、Pop、Steal 的场景中,如果所有线程持续竞争同一把锁,同步本身就可能成为性能瓶颈。

无锁(Lock-Free) 强调的是:即使某些线程发生竞争或暂停,系统整体仍能持续取得进展。它通常依赖原子操作和 CAS 重试完成同步。

需要注意:

Lock-Free 并不意味着每个线程都能立即完成操作。某个线程仍可能不断 CAS 失败甚至发生饥饿;如果要求每个线程都能在有限步骤内完成,则属于更严格的 Wait-Free。


2. CAS:无锁世界的基石

CAS(Compare-And-Swap,比较并交换) 是大多数无锁算法的重要原语。

它原子地完成:

  1. 读取当前值;
  2. 与期望值比较;
  3. 相等则写入新值,否则失败。

C++ 中主要通过:

cpp 复制代码
std::atomic::compare_exchange_weak()
std::atomic::compare_exchange_strong()

提供。

无锁算法中经常出现这样的模式:

text 复制代码
读取旧状态
    ↓
计算新状态
    ↓
执行 CAS
   ↙   ↘
成功    失败
结束    重新读取并重试

本质上是一种乐观并发:

先假设没有其他线程修改状态,如果发生竞争,再重新尝试。

2.1 ABA 问题

ABA 是 CAS 算法中的经典陷阱。

线程 T1 读取变量值为 A,在执行 CAS 前被暂停;线程 T2 将状态:

text 复制代码
A → B → A

当 T1 恢复时,发现当前值仍然是 A,于是 CAS 可能成功。

问题在于:

值虽然重新变成了 A,但它所代表的对象或状态可能已经发生变化。

在链表等依赖节点地址的数据结构中,这可能导致已经释放或重新使用的节点被错误访问。

常见处理方案包括:

  • Tagged Pointer:指针之外增加版本号,CAS 同时比较地址和版本。
  • Hazard Pointer:线程显式声明当前正在访问的对象,防止对象被提前释放。
  • EBR:延迟释放已经移除的对象,直到确认没有线程仍可能访问它。

3. 内存模型:原子性之外还有顺序

现代编译器和 CPU 会为了性能调整指令执行顺序。

单线程程序中,只要最终行为一致通常没有问题;但在多线程程序中,如果缺乏正确的同步,不同线程可能以不同于源码直觉的顺序观察内存操作。

因此:

使用 std::atomic 只解决了操作本身的原子性,并不意味着所有内存访问顺序都自动正确。

C++ 提供了多种内存序:

  • memory_order_relaxed:只保证原子操作本身,不建立额外同步关系。
  • memory_order_acquire:常用于读取已经发布的数据,限制后续操作越过该读取。
  • memory_order_release:常用于发布数据,保证此前相关操作不会被排到发布动作之后。
  • memory_order_acq_rel:同时具有 acquire 与 release 语义。
  • memory_order_seq_cst:提供最强的顺序一致性约束,通常也最容易推理。

需要避免一个常见误解:

seq_cst 并不能简单理解成"强制刷新 CPU 缓存"。

C++ 内存模型描述的是不同操作之间允许出现的顺序以及线程之间建立的同步关系;CPU 最终使用什么指令、Store Buffer 或 Cache 协议实现,是更底层的硬件问题。

在 Chase-Lev 的 Pop 路径中,最后一个任务可能同时被 Owner 和 Thief 竞争,因此需要非常谨慎地设计原子操作和 Fence 的顺序,避免弱内存模型下出现算法不允许的执行结果。


4. 缓存行与伪共享(False Sharing)

CPU 通常以 Cache Line 为单位维护缓存一致性。在很多现代桌面和服务器处理器上,一个 Cache Line 常见为 64 字节,但这并不是 C++ 标准规定的固定值。

假设两个高频修改的变量:

cpp 复制代码
std::atomic<size_t> top;
std::atomic<size_t> bottom;

恰好位于同一个 Cache Line。

虽然它们逻辑上完全独立,但一个核心修改 bottom 时,其他核心缓存中的整个 Cache Line 都可能受到影响。

这就是:

False Sharing(伪共享)。

在 Chase-Lev 中:

  • bottom 主要由 Owner 修改;
  • top 会被多个 Thief 竞争。

如果两者位于同一个 Cache Line,Owner 高频修改 bottom 就可能干扰其他核心访问 top

工程中通常会通过 Padding 或 Alignment 将它们隔开,例如:

cpp 复制代码
alignas(64)

C++17 还提供:

cpp 复制代码
std::hardware_destructive_interference_size

用于描述可能发生破坏性缓存干扰的数据间隔。


5. Chase-Lev 双端队列:工作窃取的核心结构

Chase-Lev Work-Stealing Deque 是最经典的工作窃取数据结构之一。

它针对一种特殊的并发模型进行了优化:

  • 一个 Owner 操作自己的本地队列;
  • 多个 Thief 可以从这个队列中窃取任务。

队列使用环形数组保存任务,并维护两个关键索引:

其中:

  • bottom:主要由 Owner 修改,用于 Push / Pop;
  • top:多个 Thief 会竞争修改,用于 Steal。

这种角色分离的核心目标不是完全消灭竞争,而是:

让绝大多数操作发生在不同位置,只把竞争集中到真正无法避免的地方。

5.1 Push:Owner 通常不需要 CAS

Push 只由 Owner 执行。

基本过程是:

text 复制代码
读取 bottom
    ↓
写入 array[bottom]
    ↓
发布任务
    ↓
bottom++

由于没有其他线程同时修改 Owner 的 bottom,因此 Push 通常不需要 CAS。

但任务内容必须先正确写入,再发布新的队列边界,否则 Thief 可能先观察到队列增长,却还无法正确读取对应任务。


5.2 Steal:Thief 通过 CAS 竞争 top

Thief 从队列另一端获取任务。

基本逻辑是:

  1. 读取 top
  2. 读取 bottom
  3. 如果 top < bottom,说明队列非空;
  4. 读取 array[top]
  5. CAS 将 toptop 修改为 top + 1

例如两个 Thief 同时尝试偷取同一个任务:

text 复制代码
Thief A ─┐
         ├─ CAS(top: 0 → 1)
Thief B ─┘

只有一个能够成功

因此同一个任务不会被两个 Thief 同时成功获取。


5.3 Pop:最后一个任务才是真正的竞争点

Owner 从 bottom 一侧取任务。

如果队列中还有多个任务:

text 复制代码
top                       bottom
 ↓                           ↓

+----+----+----+----+----+----+
| T1 | T2 | T3 | T4 | T5 |    |
+----+----+----+----+----+----+

Owner 从右边拿 T5,Thief 从左边拿 T1,两者互不影响。

真正危险的是:

text 复制代码
只剩最后一个任务

此时 Owner 和 Thief 可能同时竞争同一个元素。

Owner 通常先乐观地:

text 复制代码
bottom--

再根据 top 判断当前状态。

如果发现这是最后一个任务,Owner 就必须通过 CAS 与 Thief 竞争 top

text 复制代码
CAS 成功
→ Owner 获得任务

CAS 失败
→ 某个 Thief 已经获得任务

这里正是 Chase-Lev 最关键的同步位置。

在 ARM、POWER 等弱内存模型上,如果这里缺乏正确的内存顺序约束,不同线程可能以算法没有预期的顺序观察 topbottom,从而破坏:

最后一个任务只能被一个线程成功获得。

因此 Chase-Lev 真正困难的部分,并不是 CAS 本身,而是 CAS、普通访问以及 Fence 之间正确的内存顺序设计


6. 扩容与内存回收:为什么不能直接 delete?

Chase-Lev 通常使用环形数组保存任务。

当数组容量不足时,Owner 可以:

text 复制代码
旧数组
   ↓
创建更大的新数组
   ↓
复制仍然有效的任务
   ↓
发布新数组

但是这里存在一个问题:

某个 Thief 可能早已读取了旧数组指针,此时仍然正在访问旧数组。

如果 Owner 在切换到新数组后立即:

cpp 复制代码
delete old_array;

Thief 随后继续访问旧数组,就会产生:

Use-After-Free。

因此在无锁结构中:

"对象已经从数据结构中移除"并不等于"现在就可以安全释放内存"。

6.1 shared_ptr 为什么不一定合适?

一种简单方案是使用:

cpp 复制代码
std::shared_ptr<Array>

这样只要仍有 Thief 持有旧数组,它就不会被释放。

正确性比较容易保证,但引用计数本身通常需要原子更新。

如果每次 Steal 都频繁修改同一个共享引用计数,这个计数器就可能重新成为并发热点。

因此在极高频无锁数据结构中,shared_ptr 往往不是最理想的方案。

这并不代表它一定比互斥锁慢,最终仍然需要 Benchmark 判断。

6.2 EBR:延迟回收

EBR(Epoch-Based Reclamation) 使用的是另一种思路:

被淘汰的旧对象不立即释放,而是先进入待回收集合。

线程进入无锁数据结构的临界区时,会记录自己的活动状态或当前 Epoch。

当回收者确认所有可能仍然看到旧数组的线程都已经离开对应 Epoch 后,旧数组才能真正释放。

可以简单理解为:

text 复制代码
旧数组被替换
    ↓
进入 Retire List
    ↓
等待仍可能访问它的线程离开
    ↓
确认安全
    ↓
delete

EBR 不需要像引用计数那样在每次对象访问时修改对象级共享计数,因此很适合读操作非常频繁的无锁结构。

代价是回收存在延迟,而且必须正确维护各线程的 Epoch 状态。


结语

此无锁队列的设计思想是:

通过 Owner 与 Thief 的角色分离,让绝大多数操作根本不发生竞争,只在最后一个任务这样的关键位置进行必要的同步。

这也是工作窃取调度器能够获得良好多核扩展性的核心原因之一。

理解这些机制之后,再看到线程池和任务调度器源码中的 atomic、CAS 和 Fence,就不再只是记住这些 API,而是能够理解:

这个同步操作究竟在保护什么。

相关推荐
Leo2821 小时前
异步任务链路如何不断链:基于 OpenTelemetry 的 Trace 设计
后端
技术长镜头1 小时前
别再死记 Record、Gap、Next-Key:沿一条 SQL 看懂 InnoDB 锁
后端·mysql
晚安code1 小时前
Java四大函数式接口一篇讲透:配上Stream流式计算处理集合
后端
(Charon)1 小时前
【C++】线程安全队列(三):MPSC无锁队列、atomic与链表节点实现
c++·安全·链表
(Charon)1 小时前
【C++】:使用 mutex + deque 实现一个简单的 LockedQueue
开发语言·c++
晴殇i1 小时前
最近在 Github 名字叫“马尾辫”,这个真的很有趣看到头像
前端·后端·开源
星轨初途1 小时前
LeetCode 热题 100——day10 和为 K 的子数组
开发语言·c++·算法·leetcode
有点。2 小时前
C++广度优先搜索(二)-练习题
c++·算法·宽度优先
Q741_1472 小时前
TcpDump 使用笔记
网络·c++·笔记·测试工具·tcpdump