跳转至

C++ Primer

885 个字 17 行代码 预计阅读时间 3 分钟

Abstract

HLL 的概率计数机制

  • b: 哈希值二进制表示中初始位的数量;
  • m=2^b: 寄存器(桶)数量,可以被视为一个内存块;
  • p1 的最高有效位位置;

Task 1

实现一个基本的 HyperLogLog,把最左侧 1 bit 的位置存储在寄存器中。

实现一个基本的 HyperLogLog,把最左侧 1 bit 的位置存储在寄存器中。

  • ComputeHash(hash): 直接调用 std::bitset<64> 的构造函数返回二进制即可;
  • PositionOfLeftmostOne(bset): 取除了 n_bits 以外剩余比特位中最高的 1 bit 的位置;
  • AddElem(val): 计算哈希值 hash,将其转为二进制并计算寄存器索引 idxp 值,最后更新寄存器 R[idx]
  • ComputeCardinality: 使用公式即可 \(N = \alpha_m * m^2 * (\sum_{j=0}^{m-1} 2^{-R[j]})^{-1}\)

注意事项

  • n_bits 可能为负,需 return 处理;
  • 不可以使用 std::pow(2, -r),因为 ruint8_t,不可以变成负数,所以使用 1.0 / std::pow(2, r)

Task 2

实现 Presto HLL 密集布局,把最右侧连续 0 的计数存储在寄存器中。

  • 实现 Presto HLL 密集布局,把最右侧连续 0 的计数存储在寄存器中。
  • AddElem(bset):
    1. 计算哈希值 hash 并转为二进制;
    2. 计算寄存器索引 idx
    3. 计算最右连续 0 个数作为 p 值,分解为 dense_valueoverflow_value(如果超过了 DENSE_BUCKET_SIZE
    4. 更新 dense_bucket_[idx]overflow_buckets_[idx]
  • ComputeCardinality():
    1. 先从 dense_bucket_overflow_buckets_ 求出 bitdense_bucket_valueoverflow_bucket_value
    2. 合并这两个 bitmerge = dense_bucket_value + (overflow_bucket_value << DENSE_BUCKET_SIZE)
    3. 最后使用公式计算基数;

Spring 2023

Task 1: Copy-On-Write Trie

Copy-On-Write Trie

修改 trie.htrie.cpp 来实现一个 Copy-On-Write Trie

  • Get(key):沿着 key 逐字符向下查找节点;如果路径不存在或节点值类型不匹配则返回空,否则返回该值指针。
  • Put(key, value):持久化插入 / 更新;递归创建或克隆路径上的节点,仅替换目标分支并保留其他分支,最后返回新的 Trie 根。
  • Remove(key):持久化删除;递归移除目标值并必要时回收空节点,保持其他分支不变,最终返回新的 Trie 根(可能为空

Task 2: Concurrent Key-Value Store

Concurrent Key-Value Store

在单线程的 Copy-On-Wirte Trie 基础上,实现一个支持多线程的并发 key-value store

┌───────────────────────────────────┐
│             TrieStore             │  ← 线程安全包装层
│  ┌─────────┐        ┌──────────┐  │
│  │root_lock│        │write_lock│  │  ← 两把锁分工
│  └────┬────┘        └─────┬────┘  │
│       │                   │       │
│       ▼                   ▼       │
│  ┌─────────────────────────────┐  │
│  │        Trie (COW)           │  │
│  │   root_: shared_ptr<const   │  │  ← 不可变数据结构
│  │           TrieNode>         │  │
│  └─────────────────────────────┘  │
└───────────────────────────────────┘
  • COW Trie 本身是不可变的,所以读操作不需要锁,只需要保护根指针的读取;
  • root_lock_:保护 root_ 指针的读写(获取当前版本)
  • write_lock_:序列化所有修改操作(Put/Remove
  • Get(key)
    • root_lock_ 只保护对 root_ 的读取,获取 snapshot 后立刻释放;
    • 后续遍历无需上锁,因为 snapshot 是局部副本;
  • Put(key, value)Remove(key)::
    • write_lock_ 确保只有一个 writer 执行 PutRemove,避免并发修改;
    • 实际的 Trie::Put/Remove 不在锁内执行,避免阻塞 writer
    • 使用双锁策略,write_lock_ 保护序列化,root_lock_ 保护根指针安全;

为什么需要ValueGruad ?

  • 假设这样一个场景:Get(key) 返回指针后,另一个线程执行了 Remove(key)

    T1: auto val = trie_store.Get<int>("key");  // 获取值引用
    T2: trie_store.Remove("key");               // 删除键,COW 创建新 root
       // 旧 root 的节点如果没有其他引用会被销毁
    T1: cout << *val;  // 如果 val 只存指针,这里会悬空!
    
  • 解决方案:ValueGuard 持有 Trie 的拷贝(root_,确保节点不会被销毁,即使其他线程修改了 TrieStore root_ValueGuard 持有的旧版本仍然有效(通过 shared_ptr 的引用计数)