lyorz's blog

Lec10 Index Concurrency Control

2026/02/14
loading

索引数据结构并发控制。

Latches Overview

Latches Mode

闩锁(latch)有两种模式:简单来说就是读模式允许多个线程读,写模式允许唯一线程写。

  • Read:
    • 多个线程可以同时读取同一个对象
    • 单个线程可以在其他线程都以read mode持有对象闩锁的情况下,获取闩锁
  • Write:
    • 同一时刻只有单一线程可以持有latch
    • 若任何线程在某时刻持有该对象的任意模式闩锁,则当前线程无法以write mode获取对象闩锁

闩锁实现目标

  • 占据较小内存
  • 在没有竞争的情况下,临界区代码能够快速执行
  • 去中心化的锁管理
  • 避免代价高昂的系统调用

闩锁实现

Test-and-Set Spinlock

用法如图所示:

  1. 声明一个原子变量,例如布尔值,假设true表示占用,false表示空闲;
  2. 尝试获取闩锁时将设置该布尔值,期望值为false,目标值为true;
  3. 设置成功,则视为获取闩锁;
  4. 设置失败,证明正在被其他线程占用,自旋等待,这里while将会不断尝试。

Non-uniform Memory Access, NUMA:非统一内存访问,在多插槽CPU上,每个插槽拥有自己的本地DRAM,可以快速访问,也可以通过互连访问读取其他插槽的DRAM,但这将比直接访问本地DRAM速度慢上两倍。

在NUMA架构下,上述自旋锁就出现一个问题,获取latch时可能需要不断通过互连去读取另一个插槽的DRAM,速度较慢;此外大量自旋对CPU占用比较高。

Blocking OS Mutex

std::mutex -> pthread_mutex_t -> futex

futex:

  1. 首先通过CAS尝试获取用户态的锁:
    • 成功则直接进入临界区
    • 失败说明存在竞争,进入第二阶段
  2. 进入内核态,线程会调用futex syscall,内核将线程挂起,等待占用线程释放锁时被唤醒。

Reader-Writer Locks

std::shared_mutex -> pthread_rwlock_t -> pthread_mutex_t + pthread_cond_t

操作系统将为读锁和写锁都维护状态

  • 记录当前持有锁的线程
  • 记录正在等待该锁的线程

例如上图线程获取写锁失败(两个线程正在占用读锁),因此线程将被阻塞进入队列等待被唤醒。

Hash Table Latching

  1. 每个page / block持有自己的读写锁,保护当前page / block中的数据;
  2. 更细粒度的锁实现中,每个slot持有自己的latch

B+ Tree Latching

并发问题举例

在B+树的并发控制中需要考虑如下问题:

  1. 多线程尝试修改同一节点内容;
  2. 当一个线程正在读取B+树而另一个线程正在尝试分裂/合并节点。

例如:

T1尝试删除节点44,成功删除后导致了节点内元素数量小于一半,于是T1决定从邻居节点处借用一个元素过来,即41,但此时T1被调度器暂停。

T2被调度器允许执行,T2需要读取41,T2根据索引一路移动到41的位置,此时T2被调度器暂停。

回到T1,T1继续之前的操作,借用41,并修改了上层节点索引;回到T2,T2发现之前获取的索引指向位置已经不存在想要的值了。

Solution:Latch Crabbing/Coupling

闩锁耦合:给定一种方法指导线程在B+树中移动时,如何获取闩锁,即当前线程向下移动时,其下方不应该存在其他线程,使当前线程获取的信息可能失效。

  1. 首先获取父节点的闩锁;
  2. 获取子节点闩锁;
  3. 如果成功获取,则释放父节点闩锁。

对于读取操作,是要按照上述过程获取锁即可确保安全。

对于写入操作,从根节点开始向下,并持有所有的写锁,直到持有了子节点闩锁并确保安全,释放父节点及以上的祖先节点的锁。

当我们到达节点D,节点D当前包含两个索引条目,删除38至多导致一个索引条目被删除,节点D仍然持有半数的元素数量,因此节点D不会发生merge行为,因此此时可以判断释放父、祖先节点是安全的。

关于A和B的释放顺序:

  • 先A后B和先B后A都是正确的,并不会导致死锁;
  • 但从性能来看,先释放A受益较高,因为持有A的写锁,意味着其他线程无法在同时获取整棵树任意一个节点,相当于堵住了入口。

Better Latching Algorithm

上述方案中,任意写线程将首先从B+树根节点获取写锁,假设极端情况,所有工作线程都是写线程,那么整个工作流程将变成单线程的形式,并发性能较差。

有一种更好的方式是对写线程使用乐观并发控制:

  1. 假设合并/拆分操作发生的概率很低;
  2. 从根节点开始向下获取各个节点的读锁,在叶子节点处获取写锁;
  3. 如果写线程发现假设出错,则重新使用悲观算法自顶向下获取锁。

Leaf Node Scans

设T1正在执行写入操作,T2正在执行范围查询,此时T1持有节点C的写锁,T2持有节点B的读锁,并按照范围查询将继续读取节点C,由于C的写锁已经被T1占用,此时T2该如何选择?

T2应当选择直接自杀重来,因为在T2视角,T1正在做什么、需要多久结束是未知的。

CATALOG
  1. 1. Latches Overview
    1. 1.1. Latches Mode
    2. 1.2. 闩锁实现目标
    3. 1.3. 闩锁实现
      1. 1.3.1. Test-and-Set Spinlock
      2. 1.3.2. Blocking OS Mutex
      3. 1.3.3. Reader-Writer Locks
  2. 2. Hash Table Latching
  3. 3. B+ Tree Latching
    1. 3.1. 并发问题举例
    2. 3.2. Solution:Latch Crabbing/Coupling
    3. 3.3. Better Latching Algorithm
  4. 4. Leaf Node Scans