CMU15-445:Lecture #09 笔记

Lecture #09: Index Concurrency Control


本文是对CMU15-445课程第9节笔记的一个粗略总结和翻译。仅供个人(M1kanN)复习使用。


1. Index Concurrency Control

  • 目前讨论的数据结构都是单线程的,但大多数DBMS都需要允许多线程安全访问数据结构,去最大化了利用CPU核心或者隐藏I/O stall。

  • 并行控制协议是DBMS用于确保对共享对象进行并发操作保持正确结果的方法。

  • 一个协议的正确性标准可以有所不同:

    • 逻辑正确性:
      即线程能够读取它应该期望读取的值。
      例如:一个线程应该读回它之前写的值。
    • 物理正确性:
      即对象的内部是健全的(sound)。
      例如:在数据结构中没有会导致线程读取无效内存位置的指针。
  • 本次课程中,只关心物理正确性的问题。

2. Locks vs. Latches

  • 讨论DBMS如何保护其内部元素的时候,lock和latch之间有重要区别。

Locks

  • Lock是一个更高层次的逻辑基元(logical primitive), 它用来保护数据库的内容(如tuples、tables、databases)不受其他事务(transactions)的影响。事务将在整个持续时间内持有一个lock。数据库系统可以在运行查询的过程中向用户暴露正在持有的lock。lock需要能够回滚(rollback changes)

Latches

  • Latch是用于DBMSD内部数据结构(例如:数据结构、内存区域)的关键部分的低级保护基元,不受其他线程影响。Latch只在正在进行的操作的持续时间内保持。Latch不需要回滚变化(rollback change)。有两种模式的latch:
    • READ:
      多个线程可以允许读同一项。一个线程可以获取read latch,即使另一个线程已经持有。
    • WRITE:
      只有一个线程才能允许访问该项。如果另一个线程已经持有write latch,则另一个不能获得。

3. Latch Implementations

这块基本就是操作系统的知识复习罢了。

  • 用来实现latch的基本原理是通过现代CPU提供的atomic compare-and-swap (CAS)指令。借此,一个线程可以检查一个内存位置的内容,看它是否用某个值。如果有,则CPU将用一个新的值来交换旧的值,否则,该内存位置将保持不被修改。
  • 在DBMS实现latch用几种方法:每种都有不同的权衡。工程复杂性和运行时性能都有不同权衡。这些test-and-set是以原子方式进行的。

Blocking OS Mutex

  • Latch的一个可能的实现是OS的内置互斥机制。Linux提供了Futex(快速用户控件互斥)。由(1) 用户空间的自旋latch和(2) OS级互斥组成。如果DBMS能够获得用户空间的latch,那么latch就会被设置。在DBMS看来,它是一个单一的latch,尽管内部其实有2个。如果DBMS不能获取用户空间的latch,则会进入内核并试图获取更加昂贵的mutex。如果又不能获得,则线程会通知OS它在mutex被阻塞,然后取消调度。
  • OS mutex在DBMS中,一般是不好的决定。因为它有OS控制,而且开销大。
    • 例子:std::mutex
    • 优点:使用简单
    • 缺点:消耗大。不可扩展。因为OS的调度。

Test-and-Set Spin Latch(TAS)

  • 自旋锁比OS mutex更有效。因为它被DBMS控制。自旋锁基本上是线程试图更新的内存位置(比如将一个bool设为true)。一个线程执行CAS来尝试更新内存位置。DBMS可以控制如果不能获得latch会发生什么。比如可以选择尝试再次尝试(while循环)或允许操作系统取消调度。所以这种方式给了DBMS更多控制权。
    • 例子:std::atomic<T>
    • 优点:上锁解锁更高效。单一指令即可。
    • 缺点:不具有扩展性,也不适合缓存。因为在多线程中,CAS指令将在不同指令多次执行。浪费的指令会在高竞争环境中堆积起来。比较浪费CPU。(一直停在while循环上)

Reader-Writer Latches

  • Mutex和自旋锁不区分读写。DBMS需要一种允许并发读取的方法,所以如果用程序大量读取,就会有更好的性能。因为读可以共享,而不是等待。
  • 读写锁允许latch以读或者写的模式保持。可以跟踪有多少线程持有该latch,并在每种模式下等待获取latch。读写锁使用前两种latch实现的一种作为基础,并用额外的逻辑来处理读写队列。不同的DBMS可以有不同的策略来处理。
    • 例子:std::shared_mutex
    • 优点:并发读取
    • 缺点:必须维护读写队列。以防饥饿。所以内存开销更大。

4. Hash Table Latching

  • 由于线程访问的数据结构优先,因此在静态哈希表中支持并发访问很简单。例如:所有线程从槽中移动到下一个槽的时候,都自上而下移动。线程每次也只访问一个页面/槽。这种情况下死锁是不可能的,因为没有两个线程可以争夺对方的latch。当需要调整表的大小的时候,我们可以直接对整个表进行全局锁存。
  • 动态散列方案的锁存更复杂。因为有更多的共享状态需要更新,一般的方法是一样的。
  • 哈希表的锁存有两种方法:在锁存的粒度上有所不同。
    • Page Latches:
      每个页有自己的读写锁来保护全部内容。线程在访问一个页面之前,获得一个读写锁。这降低了并行性。但访问一个页面中的多个槽对一个线程来说是非常快的。因为只需要获取一个锁存器。
    • Slot Latches:
      每个槽都有自己的锁存器。增加并行性,因为两个线程可以访问不同槽位。但也增加了存储和计算开销。因为线程必须为它们访问的每个槽获得一个锁存器,而每个槽都必须存储锁存器的数据。
      DBMS可以使用单模式锁存器(自旋锁)来减少元数据和计算开销,但需要付出一些并行性的代价。
  • 也可以直接使用CAS指令创建一个latch-free 的线性探测哈希表。通过尝试将一个“null”值与我们希望插入的tuple进行比较和交换,可以实现在一个槽中的插入。与我们想要插入的tuple进行CAS。如果失败可以探测下一个槽,直到成功。

5. B+Tree Latching

  • B+树的锁存主要用于防止以下问题:
    • 不同线程试图同时修改同一个结点的内容
    • 当一个线程分裂/合并结点的时候,另一个线程在遍历树
  • Latch Crabbing/Coupling(锁存耦合?)是一种协议:允许多个线程同时访问/修改 B+树。基本思想是:
    1. Get latch for the parent
    2. Get latch for the child
    3. 若子结点被认为是安全的,则释放父节点的latch。一个安全结点是在更新的时候不会分裂、合并或者重新分配的结点。
  • 注意:安全的概念取决于操作是插入还是删除。一个完整的结点对于删除是安全的,因为不需要合并,但对于插入就不安全了,因为可能需要分割结点!注意:读锁存器不需要担心安全条件!

Basic Latch Crabbing Protocol

  • Search:
    从根开始往下,重复获取孩子结点的锁,并释放父结点的锁。
  • Insert/Delete:
    从根开始往下,按需获得X个latch。一旦孩子结点被锁住,检查是否安全。如果安全,则释放所有祖先结点的锁!

从正确性的角度来看,释放锁存器的顺序并不重要。然而从性能上来讲,最好是释放树中较高的锁存器,因为更高的结点锁存器阻止了较大部分叶结点的访问。

Improved Latch Crabbing Protocol

  • 基本的Latch Crabbing算法的问题是:事务在每次插入/删除操作的时候总是获得根上的独占锁存器,这限制了并行性。
    相反,我们可以假设必须调整大小(分割、合并结点)的情况很少,因此事务可以获得共享锁存器,直到叶结点。每个事务都会假设通往目标叶子结点的路径是安全的,并使用READ锁存器来抓取(Crabbing)来到达,并进行检验(是否安全)。如果不安全,则我们终止操作,并重新操作,这次用WRITE锁存器来进行。
    • Search:
      与基本算法一样。
    • Insert/Delete:
      跟Search一样,不断获取和释放READ latch。最后在叶子结点上设置WRITE latch。如果叶子不安全,则重来,这次用基本算法的思路!

Leaf Node Scans

  • 之前两个算法都是从上到下的。这意味着线程只能从低于当前结点中获取锁存器。如果想要的锁存器不能用,则线程必须等待,直到它变得可用。鉴于此,永远不可能出现死锁
  • 然而,叶子结点扫描很容易出现死锁,因为现在我们有线程从两个方向同时获得独占锁。Index latch不支持死锁检测和预防。
  • 因此,程序员唯一处理这个问题的方法就是通过code discipline。叶子结点的兄弟结点的锁存器获取协议必须支持“无等待”模式。也就是说,B+树的代码必须应对锁存器获取失败的问题。由于锁存器旨在被(相对)短暂的持有,如果一个线程试图在叶子结点上获取锁存器,但该锁存器不可用,那么它应该迅速中止其操作(释放它所持有的任何锁存器),并重新启动操作。
posted @ 2023-02-06 15:36  M1kanN  阅读(186)  评论(0编辑  收藏  举报