LevelDB源码剖析(3) Skiplist跳表

1. 背景

什么是跳表?跳表是SortedMap的一种具体实现,是一种概率性的数据结构。跳表拥有SortedMap的所有功能,定位和红黑树类似,其和红黑树的区别在于
优点:

  • 跳表的实现更加简单
  • 跳表在范围查询的时候效率是要高于红黑树
  • 红黑树的插入和删除可能引发子树的调整,逻辑比较复杂,跳表只要维护相邻节点。

缺点:

  • 跳表需要维护额外的多层链表,是一种空间换时间的做法,相比之下红黑树不需要占用多余的空间

2. 原理

跳表的每个节点有一个随机产生的层数,从1层到n层,每一层的节点单独组成一个链表。每层链表之间的节点都是按照顺序排列的,并且自底向上节点越来越稀疏,第一层包含了所有的数据,查找的时候层数自顶向下逐渐下降,直到找到对应的节点。
image.png
以上图为例,假设我们需要查找key为35的数据流程就是

  • 以HEAD开始,先从Level 3层查找,发现35 < 37, 则查找层数降为Level 2
  • 在Level 2层发现35 > 13,则跳转到13
  • 在13节点继续从Level 2层查找,发现35 < 37, 将层数降为Level 1
  • 以此类推,直至在Level 0层查找到35

3. 源码解析

跳表的原理并不复杂,所以侧重点主要放在实现上。

3.1 SkipList.h

先看跳表对外的接口,跳表提供的主要功能是对元素的插入和查询并提供了一个迭代器以便进行范围查找

template <typename Key, class Comparator>
class SkipList {
 private:
  struct Node;
 public:
  //创建一个跳表,用cmp进行比较,arena进行内存分配
  explicit SkipList(Comparator cmp, Arena* arena); //explicit的目的在于与防止类构造函数的隐式自动转换
  SkipList(const SkipList&) = delete; //跳表类禁止拷贝
  SkipList& operator=(const SkipList&) = delete;
  void Insert(const Key& key); //向跳表中插入一个key,需要保证表中没有与插入数据相同的key
  bool Contains(const Key& key) const; //判断key是否存在跳表中
  //跳表的迭代器
  class Iterator {
   public:
    explicit Iterator(const SkipList* list); //用跳表初始化迭代器
    bool Valid() const; //节点是否可用
    const Key& key() const; //返回key
    void Next(); //进入下一个节点
    void Prev(); //进入上一个节点
    void Seek(const Key& target); //进入第一个key >= target的节点
    void SeekToFirst(); //进入第一个节点
    void SeekToLast(); //进入最后一个节点
   private:
    const SkipList* list_;
    Node* node_;
  };
}

3.2 FindGreaterOrEqual

跳表内实现了多个查询函数,思路基本相同,这边选取一个比较有代表性的查询函数,即查找第一个大于等于key的节点的指针。

template <typename Key, class Comparator>
typename SkipList<Key, Comparator>::Node*
SkipList<Key, Comparator>::FindGreaterOrEqual(const Key& key,
                                              Node** prev) const {
  Node* x = head_; //使用头节点作为起点
  int level = GetMaxHeight() - 1;
  while (true) { 
    Node* next = x->Next(level);
    if (KeyIsAfterNode(key, next)) { //判断key是否大于下一个节点
      // Keep searching in this list
      x = next; //如果大于则继续前进
    } else {
      if (prev != nullptr) prev[level] = x;
      if (level == 0) { //如果到第0层说明到底了
        return next;
      } else {
        // Switch to next list
        level--;      //否则进行层数下降继续查找
      }
    }
  }
}

3.3 Insert

插入的基本逻辑是找到key大于等于的那个key的节点,并将其插入到结点之后。

template <typename Key, class Comparator>
void SkipList<Key, Comparator>::Insert(const Key& key) {
  Node* prev[kMaxHeight]; //前置node列表
  Node* x = FindGreaterOrEqual(key, prev);
  assert(x == nullptr || !Equal(key, x->key));

  int height = RandomHeight(); //随机节点的高度
  if (height > GetMaxHeight()) {
    for (int i = GetMaxHeight(); i < height; i++) {
      prev[i] = head_;
    }
    max_height_.store(height, std::memory_order_relaxed);
  }

  x = NewNode(key, height);
  for (int i = 0; i < height; i++) {
    x->NoBarrier_SetNext(i, prev[i]->NoBarrier_Next(i));
    prev[i]->SetNext(i, x);
  }
}

值得一提的是其中实现了NoBarrier_SetNext和SetNext两个功能相同但实现有区别的函数,其中NoBarrier_SetNext是没有内存屏障的操作,通过使用宽松顺序对next_n进行读写来提高性能,但代价是这是性能不安全的,那为什么仍然使用这种方式呢?
从代码中可以看出,节点的插入主要分为两步,即

  1. x->next = pre->next
  2. pre->next = x

对于第一步操作而言,该操作无需对所有内存可见,因为此时x还没有完全插入链表中,其他内存依然可以通过pre正常访问到pre->next, 其他线程依然可以正常读取。无需关注x。
而到了第二步,由于这步会对pre->next进行修改,再修改过程中如果其他内存访问pre->next, 可能会得到错误的结果,因此这步使用了线程安全的SetNext。

3.4 Node

Node是很多数据结构都会有的节点类,这这边主要关注两点

  1. 使用宽松内存顺序NoBarrier_SetNext的实现
  2. Node对象的内存分配,因为节点的高度不确定,所以next的长度也无法确定,这里采用的方式是在NewNode中连续申请一块堆内存,包含了对象以及其成员所需的内存,然后next_就可以使用动态的长度
template <typename Key, class Comparator>
struct SkipList<Key, Comparator>::Node {
  explicit Node(const Key& k) : key(k) {}
  Key const key;
  Node* Next(int n) {
    assert(n >= 0);
    return next_[n].load(std::memory_order_acquire);
  }
  void SetNext(int n, Node* x) {
    assert(n >= 0);
    next_[n].store(x, std::memory_order_release);
  }
  Node* NoBarrier_Next(int n) {
    assert(n >= 0);
    return next_[n].load(std::memory_order_relaxed); 
  }
  void NoBarrier_SetNext(int n, Node* x) {
    assert(n >= 0);
    next_[n].store(x, std::memory_order_relaxed);
  }


 private:
  std::atomic<Node*> next_[1]; //由于Node是在New Node函数中使用堆内存分配,所以next_实际可以访问数组定义之外的内存,这里用长度为[1]的数组只是为了方便理解
};


template <typename Key, class Comparator>
typename SkipList<Key, Comparator>::Node* SkipList<Key, Comparator>::NewNode(
    const Key& key, int height) {
  char* const node_memory = arena_->AllocateAligned(
      sizeof(Node) + sizeof(std::atomic<Node*>) * (height - 1)); //申请Node本身以及next_的内存
  return new (node_memory) Node(key);
}

3.5 RandomHeight

在原理中有提到,跳表的高度是越往上越稀疏的,实操的时候是怎么控制稀疏程度的呢?通过代码可以看到,实际使用的时候,通过控制每个节点Height的分布,使得RandomHeight的返回满足“高度越高,数量越少”的原则,从期望上就可以达到越往上越稀疏的结构。实际开发代码的过程中,是以每次有四分之一的概率网上累加这一原则,对Height进行随机的,就可以保证每层的节点都是上一层的三分之一左右。

template <typename Key, class Comparator>
int SkipList<Key, Comparator>::RandomHeight() {
  // Increase height with probability 1 in kBranching
  static const unsigned int kBranching = 4;
  int height = 1;
  while (height < kMaxHeight && rnd_.OneIn(kBranching)) {
    height++;
  }
  assert(height > 0);
  assert(height <= kMaxHeight);
  return height;
}
posted @ 2022-11-29 01:00  Hugh_Locke  阅读(147)  评论(0编辑  收藏  举报