阻塞与非阻塞算法 & 数据结构¶
约 1674 个字 25 行代码 预计阅读时间 9 分钟
- 阻塞的算法和数据结构使用 mutex、条件变量、期值来同步数据,但非阻塞不等价于 lock-free,比如自旋锁没有使用任何阻塞函数的调用,是非阻塞的,但并非 lock-free
- 非阻塞数据结构由松到严可分为三个等级:
- obstruction-free(无障碍):如果其他线程都暂停了,任何一个给定的线程都会在有限步数内完成操作。上例就是这种情况,但这种情况很少见,所以满足这个条件只能算一个失败的 lock-free 实现。
- lock-free(无锁):如果多线程在同一个数据结构上操作,其中一个将在有限步数内完成操作。满足 lock-free 必定满足 obstruction-free。
- wait-free(无等待):如果多线程在同一个数据结构上操作,每个线程都会在有限步数内完成操作。满足 wait-free 必定满足 lock-free,但 wait-free 很难实现,因为要保证有限步数内完成操作,就要保证操作一次通过,并且执行到某一步不能导致其他线程操作失败。
- lock-free 数据结构必须允许多线程并发访问,但它们不能做相同操作,比如一个 lock-free 的 queue 允许一个线程 push、另一个线程 pop,但不允许两个线程同时 push。此外,如果一个访问 lock-free 数据结构的线程被中途挂起,其他线程必须能完成操作而不需要等待挂起的线程。
lock-free thread-safe stack¶
- pop 返回值应该返回智能指针。
- 如果直接返回值,返回前一定会先移除元素,如果拷贝返回值时抛出异常,移除的元素就丢失了。
- 如果传引用,其他线程移除了节点,被移除的节点不能被解引用,当前线程就无法安全地拷贝数据。
Safety Memory Garbage Collection¶
在有锁的数据结构中,内存回收很简单:当一个节点被从数据结构中移除时,我们可以立即 delete 它,因为持有锁的线程是唯一能访问这个节点的线程。
但在无锁数据结构中,情况完全不同:
- 线程 A 想要从栈中 pop 一个节点(比如 Node* old_top)。
- 在线程 A 真正 delete old_top 之前,它的时间片用完了,被挂起。
- 线程 B 此时可能仍然持有一个指向 old_top 的指针,因为它在线程 A pop 操作开始时也正在访问栈顶。
- 如果线程 A 在恢复执行后立即 delete old_top,线程 B 就会访问一个已经被释放的内存(悬挂指针),导致程序崩溃。
所以我们释放内存当且仅当没有任何线程正在访问它时。
Hazard Pointers¶
每个线程都有自己的“风险指针列表”
C++
// 全局风险指针数组,存储每个线程的风险指针
static std::atomic<void *> g_hazard_pointers[MAX_THREADS];
// 全局标志,记录哪个槽被哪个线程占用
static std::atomic_flag g_hp_owner_flags[MAX_THREADS];
- 系统为每一个参与无锁操作的线程分配一个(或多个)风险指针 (Hazard Pointer)。
- 这些风险指针通常存储在一个全局的、所有线程都可以读取的列表中。
- 一个风险指针就是一个 void 或 Node,它指向一个内存地址。
“宣告风险”的操作流程¶
当一个线程(比如线程 A)要访问一个可能被其他线程删除的共享节点(比如 node_p)时,它必须遵循以下流程:
- 获取节点指针: 线程 A 首先以某种方式获取了 node_p 的指针(例如,通过原子地读取 stack.top)。
- 设置风险指针 (Set Hazard Pointer): 在真正解引用 node_p 之前,线程 A 必须“宣告”它将要访问这个节点。它会把 node_p 的地址写入到它自己的风险指针中。
- 验证 (Validation): 这是至关重要的一步,用于解决 ABA 问题。设置完风险指针后,线程 A 必须再次检查它想要访问的节点是否还是原来的那个。如果验证通过,线程 A 就可以安全地访问 node_p 了,因为它已经确保了在它宣告风险之后,这个节点没有被其他线程移除。
C++
// 再次读取栈顶,看它是否已经变了
if (stack.top.load() != node_p) {
// 如果变了,说明在我宣告风险的瞬间,节点已经被 pop 了。
// 我宣告的风险无效,必须从头再来。
thread_local_hazard_pointer.store(nullptr); // 清除风险宣告
goto retry_loop;
}
- 执行操作: 线程 A 现在可以安全地解引用 node_p 并进行操作(例如,读取它的 value 和 next 指针)。
- 清除风险指针 (Clear Hazard Pointer): 当线程 A 不再需要访问 node_p 时,它必须清除自己的风险指针,告诉大家它已经用完了。
安全删除节点的流程¶
当一个线程(比如线程 C)成功地从数据结构中移除了一个节点(比如 removed_node)后,它不能立即 delete 这个节点。
它需要做的是:
- (Retire List)”: 线程 C 将 removed_node 的指针添加到一个属于它自己的、私有的“待删除列表”中。
- 定期扫描和清理 (Scan and Reclaim): 线程 C 会在某个时刻(比如,当它的待删除列表达到一定大小时)触发一次清理操作。
- 获取所有风险指针: 它会遍历全局的风险指针列表,收集所有线程当前宣告的所有风险指针,并将它们放入一个集合中。
- 检查并删除: 然后,它遍历自己的“待删除列表”。对于列表中的每一个 node_to_delete,它检查这个 node_to_delete 是否存在于刚刚收集的风险指针集合中:
1. 如果不存在,说明当前没有任何线程正在访问这个节点,可以安全地 delete 它。
2. 如果存在,说明有其他线程正在访问它,不能删除。这个节点会被保留在待删除列表中,等待下一次清理。
Reference Count¶
为每个节点增加一个原子引用计数 (Atomic Reference Counter): std::shared_ptr 的操作是原子的,但要检查是否 lock-free
- 获取指针时增加计数: 当一个线程要访问一个节点时,它会原子地增加该节点的引用计数。
- 使用完毕后减少计数: 当线程不再需要访问该节点时,它会原子地减少该节点的引用计数。
- 删除时检查计数: 当一个节点从数据结构中被逻辑上移除(例如,从栈顶 pop 出来)后,执行移除操作的线程会减少其引用计数。如果此时引用计数变为 0,则说明没有任何线程(包括它自己)在引用这个节点,此时可以安全地 delete 它。