并发跳表:标记删除、乐观加锁与 ConcurrentSkipListMap

多个线程共享一个有序集合,要支持查找、插入、删除和范围扫描。跳表常被推荐做这件事,理由是插入只改几个前驱的指针,不像平衡树那样要旋转。这个理由只说对了一半:跳表插入一个高度为 \(h\) 的节点要改 \(h\) 个指针,删除同样要改 \(h\) 个,而 CAS 一次只能改一个字。把 \(h\) 次单字修改拼成一次”要么发生、要么没发生”的操作,才是并发跳表真正要解决的问题。

关于并发跳表,常见的说法里有几处经不起核对:

  • “每层都是独立链表,每层用 CAS 插删就行”。朴素的 CAS 删除会让并发插入的节点凭空消失;本文第一节的实验在 64 万次操作里观察到七千多次。
  • “Java 的 ConcurrentSkipListMap 基于 Herlihy 等人的无锁跳表”。JDK 源码注释写的是 Harris 与 Michael 的无锁链表;Herlihy、Lev、Luchangco、Shavit 2007 年的跳表是加锁的,论文拿来对比的正是 ConcurrentSkipListMap。
  • “Pugh 的并发跳表先锁住所有前驱再修改”。Pugh 1990 年的技术报告锁的是单个指针字段,删除时让被删节点的指针反指回前驱。
  • “无锁一定比加锁快”。HLLS 论文在读多写少负载下测得两者相当;本文在每个线程独占一个 CPU 的测量里,两者都随线程数上升,差距在 11% 到 47% 之间,与负载和数据规模有关。真正拉开差距的是线程多于 CPU 的情形:8 个线程挤在 2 个 CPU 上、一半操作是更新时,加锁版本只有无锁版本的 44%。

本文只讲并发。跳表本身的期望代价、\(p\) 的取舍和 Redis 的实现,见Treap 与跳表:随机平衡的期望代价与生产参数。内容安排:第一节把问题说清楚并用实验演示朴素 CAS 删除的错误;第二节交代谱系;第三到五节分别讲 Pugh 的锁方案、无锁方案和 lazy 方案,附可运行的 C11 实现;第六、七节对照 JDK 21、LevelDB 1.23、RocksDB 9.7.4 的源码;第八节是测试、吞吐和超额订阅实验;第九节说明内存回收问题和本系列后两篇的分工;第十节是争论与开放问题。

一、问题:一次插入要改 \(h\) 个指针

集合语义与线性化

并发集合的正确性标准是线性化(linearizability,Herlihy & Wing,TOPLAS 1990):每个操作看起来在调用和返回之间的某一个瞬间原子地生效,这个瞬间叫线性化点(linearization point)。对跳表来说,要先约定”一个键在不在集合里”由什么决定,再约定插入和删除分别在哪一步生效。

所有算法都用了同一个观察,Pugh 1990 年的技术报告第 4 节写得最直白:层高分布只影响性能,不影响正确性。于是可以规定集合的内容只由第 0 层(最底层)决定,上层链表只是加速查找的提示。插入先接进第 0 层,再逐层往上接;删除先逐层从上往下摘,最后从第 0 层摘掉。这样一次插入或删除的线性化点就落在第 0 层的某一次单字修改上,其余 \(h-1\) 次修改只影响速度。Fraser 的博士论文(2004)第 4.3 节也以此为出发点:节点只要接进了最底层就可见,上层只为维持 \(O(\log n)\) 的查找。

剩下的问题出在第 0 层本身:它是一条有序单链表,而在单链表上用 CAS 做并发插删,本身就不对。

朴素 CAS 删除会丢掉并发插入

下图是 Fraser 论文图 4.4 的情形。链表是 \(1 \to 5 \to 8\)。线程 T1 要在 5 后面插入 6,它已经读到 5.next == 8,准备执行 CAS(5.next, 8, 6);线程 T2 要删除 5,它已经读到 1.next == 5、5.next == 8,准备执行 CAS(1.next, 5, 8)。

两个线程并发操作有序链表:T1 在节点 5 之后插入 6,T2 用一次 CAS 把 1 的后继从 5 改成 8。两次 CAS 比较的是不同的内存字,都成功,结果 6 挂在已经不可达的节点 5 上,插入返回成功但 6 丢失

两次 CAS 比较的是两个不同的字(5.next 和 1.next),互不干扰,都会成功。结果是 1.next 指向 8,节点 5 从链表上摘掉了,而 6 挂在 5 后面,从头节点出发再也走不到。T1 的 insert(6) 返回了成功,6 却不在集合里。对称的错误也存在:两个线程同时删除相邻的 5 和 8,一个执行 CAS(1.next, 5, 8),另一个把 5.next 从 8 改成 8 的后继,两次都成功后 1.next 仍指向 8,8 被”删除”了却仍然可达。

reproduce/lost_insert.c 把这个问题变成一个可计数的实验。4 个线程共享一条 64 个键的单层有序链表(也就是跳表的第 0 层),线程 \(i\) 只操作满足 \(k \bmod 4 = i\) 的键,所以相邻节点总是属于不同线程。每个线程随机挑一个自己的键,若它不在集合里就插入、在就删除,各做 16 万次。因为没有别的线程碰这些键,每次返回值都可以事先确定:刚插入成功的键,下一次删除必须成功,否则记一次”丢失”(lost);刚删除成功的键,下一次插入必须成功,否则记一次”幽灵”(ghost),即删掉的节点仍然可达。

每次运行共 640000 次操作,4 个线程绑在 CPU 5、14、16、15 上(环境见第八节)。数字随线程交错而变,每次运行都不同,表中是 results/lost_insert.txt 里的一次完整运行。朴素版本大约每 50 次操作出错一次;换成下一节的 Harris 标记删除,5 个种子都是 0。

这个实验还说明了一件事:朴素版本里所有共享访问都是原子操作,不存在数据竞争,ThreadSanitizer 对它一条警告都不报(results/lost_insert.txt 末尾的 TSan 运行,错误计数仍为数千)。TSan 检查的是内存模型层面的竞争,算法层面的交错错误只能靠对拍这类语义测试发现。

二、谱系:从 Pugh 的写锁到生产实现

flowchart LR
    P["Pugh 1990<br/>per-pointer write locks<br/>pointer reversal"] --> F["Fraser 2004<br/>lock-free skip list<br/>mark every level"]
    H["Harris 2001<br/>marked next pointer"] --> F
    H --> M["Michael 2002<br/>lock-free list-based sets"]
    M --> L["JDK 6 ConcurrentSkipListMap<br/>Lea: marker nodes"]
    H --> L
    F --> L
    HL["Heller et al. 2005<br/>lazy list"] --> HLLS["HLLS 2007<br/>optimistic lazy skip list"]
    L -. "compared against" .-> HLLS
    L -. "compared against" .-> C["Crain et al. 2013<br/>background index thread"]
    P --> LD["LevelDB memtable<br/>one writer, no delete"]
    LD --> RD["RocksDB InlineSkipList<br/>concurrent insert by CAS"]

图中实线表示”在其方法上发展”,虚线表示”以其为实验对照”。几个节点的依据:Fraser 论文第 4.3 节说 Pugh 基于逐指针锁的设计”显著影响”了他的方案,第 4.3.3 节把 Harris 的标记技术用到跳表的每一层;JDK 源码注释说基础链表用的是 “HM linked ordered set algorithm”(Harris 与 Michael),并把 Fraser、Fomitchev、Sundell 的论文列为有共同特征的算法;HLLS 论文说自己的算法建立在 Heller 等人的 lazy list 上,并以 Lea 的 ConcurrentSkipListMap 为对照;RocksDB 的 InlineSkipList 头注释说它派生自 SkipList(skiplist.h),文件保留了 LevelDB 作者的版权声明。

表里有两个分叉值得注意。第一个是删除的线性化点放在哪个字上:Pugh 和 lazy 方案用锁保护、放在一个标志或一次摘除上;Fraser 和 JDK 放在节点的 value 字段上,我们的实现(第四节)是纯集合、没有 value,就放在第 0 层 next 指针的标记位上。第二个是要不要支持删除:LevelDB 和 RocksDB 的 memtable 干脆不删节点(删除写一条墓碑记录,本身也是插入),于是标记、帮助和内存回收问题全部消失,只剩插入的发布顺序要处理(第七节)。

三、Pugh 1990:只用写锁,靠指针反转让查找走回来

Pugh 的技术报告 Concurrent Maintenance of Skip Lists(UMD CS-TR-2222.1,也编号 UMIACS-TR-90-80)先在单链表上给出算法和不变量证明,再推广到跳表。它的模型有三条约定:

  1. 锁加在字段上而不是节点上,lock(x, forward[i]) 只锁节点 \(x\) 的第 \(i\) 层指针;一个线程只在修改”别人也可能要改”的字段时才加锁。
  2. 查找不加任何锁。前提是读指针相对于对同一指针的写是原子的,读到的要么是旧值、要么是新值。报告明确说,没有这个假设就得给每次读加读锁。
  3. 删除节点 \(x\) 时,除了让前驱跳过 \(x\),还把 \(x\) 自己的指针改成指回原来的前驱(pointer reversal)。正在经过 \(x\) 的查找沿着反向指针退回到链表里正确的位置,继续往前走。

第三条是 Pugh 方案的核心。它在单链表层面解决了第一节的问题:删除要同时持有前驱的指针锁和 \(x\) 自己的指针锁(这是算法里唯一要持有两把锁的地方,第二把锁的节点键更大,所以加锁顺序全序、不会死锁),于是”在 \(x\) 后面插入”与”删除 \(x\)“不可能同时进行;而没有加锁的查找即使停在已删除的 \(x\) 上,也能沿反向指针回来。

推广到跳表时,Pugh 把删除看成”逐层降级”:先把元素从最高层摘掉,降成低一层的元素,一直降到只剩第 1 层(本文编号的第 0 层),再从第 1 层摘掉,元素在这一刻被删除。插入反过来,先接入第 1 层,再逐层升高。每个元素额外有一把锁保护它的层数,用来排除”两个线程同时删同一个元素”和”删除一个正在插入的元素”。最高层数也不精确维护,而是一个 levelHint,“很少偏离一到两层”。

没有锁争用时,插入平均获取 \(1 + \frac{1}{1-p}\) 把锁,删除平均获取 \(1 + \frac{2}{1-p}\) 把锁:\(\frac{1}{1-p}\) 是节点的期望层数,前面的 1 是元素层数锁,删除要锁前驱和自身两组指针所以乘 2。报告没有给实机测量,而是做了锁争用的模拟(表 2,\(p = 1/2\)),只统计指针锁,无争用时插入 2 把、删除 4 把,平均 3 把;100 个写者在 900 到 1000 个元素的表上时,锁请求被阻塞的比例是 1%。

删除的节点也不能立刻释放:报告把它放进一个 garbage queue,“在把它放进队列时正在进行的所有查找、插入、删除都结束之后”才能回收。这正是后来基于纪元的回收(epoch-based reclamation)的思路,第九节再回到这一点。

HLLS 论文对这套方案的评价是”由于使用了指针反转而相当复杂,据我们所知从未被证明正确”。Pugh 的报告对单链表版本给了带不变量的证明,对跳表版本只给了算法和论述;HLLS 说的”从未被证明”指的是后者。

四、无锁跳表:把 Harris 标记用到每一层

Harris 标记

Harris 在 DISC 2001 的论文里给第一节的问题一个不用锁的解:删除分两步,先用 CAS 在被删节点的 next 指针上置一个标记位(逻辑删除),再用另一次 CAS 让前驱跳过它(物理删除)。节点按字对齐,指针最低位恒为 0,可以借来当标记。标记之后,任何以”未标记的旧值”为期望值去 CAS 这个 next 指针的插入都会失败,于是不可能再有节点挂到被删节点后面。

Harris 标记删除的三步:先在节点 5 的 next 指针上置标记位,这是删除生效的时刻;此前读到 5.next 为 8 的插入,其 CAS 因期望值不含标记而失败,转而重新查找;查找顺手把 5 摘掉,插入把 6 接在 1 之后

图中第 2 步是关键:插入者读到的 5.next 是未标记的 8,而现在内存里是”8 加标记位”,CAS 比较不等而失败。插入者重新查找时会看到 5 已被标记,先把它摘掉(第 3 步),再在正确的前驱 1 之后插入。任何线程在查找途中遇到被标记的节点都会帮忙摘掉,所以删除者即使在标记后停住,也不会阻塞其他线程。

Fraser 的论文第 4.3.3 节把这个技术用到跳表:每一层当作一条独立的 Harris 链表,删除时从高到低逐层标记。本文 reproduce/lf.c 按这个结构实现一个 64 位整数键的集合,层高按 \(p = 1/4\) 抽取、上限 16 层。查找函数返回每一层的前驱和后继,途中摘掉遇到的已标记节点:

/* reproduce/lf.c: find(),把每层 key 两侧的未标记节点填进 preds/succs */
static int find(lf_t *s, ctx_t *c, int64_t key, lf_node_t **preds, lf_node_t **succs)
{
retry:;
    lf_node_t *pred = s->head;
    for (int lv = MAX_LEVEL - 1; lv >= 0; lv--) {
        lf_node_t *curr = UNMARK(ld(&pred->next[lv]));
        for (;;) {
            uintptr_t succ = ld(&curr->next[lv]);
            while (IS_MARKED(succ)) {
                /* curr is deleted at this level: swing pred past it. */
                if (!cas(c, &pred->next[lv], (uintptr_t)curr, (uintptr_t)UNMARK(succ))) {
                    c->retry++;
                    goto retry;       /* pred changed or got marked itself */
                }
                curr = UNMARK(succ);
                succ = ld(&curr->next[lv]);
            }
            if (curr->key < key) {
                pred = curr;
                curr = UNMARK(succ);
            } else {
                break;
            }
        }
        preds[lv] = pred;
        succs[lv] = curr;
    }
    return succs[0]->key == key;
}

摘除用的 CAS 以”未标记的 curr“为期望值。如果 pred 自己也被标记了,它的 next 带着标记位,CAS 失败,整个查找从头重来;这保证不会从一个已删除的前驱上摘节点。

删除:先标上层,最后标第 0 层

/* reproduce/lf.c: lf_remove(),省略开头的 find() */
    lf_node_t *victim = succs[0];

    /* Mark upper levels top-down; they only affect speed, not membership. */
    for (int lv = victim->top - 1; lv >= 1; lv--) {
        uintptr_t nx = ld(&victim->next[lv]);
        while (!IS_MARKED(nx)) {
            cas(c, &victim->next[lv], nx, MARK(nx));
            nx = ld(&victim->next[lv]);
        }
    }
    /* Marking level 0 is the linearization point of a successful remove. */
    uintptr_t nx = ld(&victim->next[0]);
    for (;;) {
        if (IS_MARKED(nx))
            return 0;                 /* another thread removed it first */
        if (cas(c, &victim->next[0], nx, MARK(nx)))
            break;
        nx = ld(&victim->next[0]);
    }
    find(s, c, key, preds, succs);    /* physically unlink at every level */
    return 1;

上层可以被多个删除者重复尝试标记,谁标上都行;第 0 层的标记只有一个线程能成功,成功者的 remove 返回 1,其余返回 0,这就是删除的线性化点。标记完再调用一次 find,由它在每一层摘掉节点。插入的线性化点是第 0 层那次 CAS(preds[0]->next[0], succs[0], n)。查找(lf_contains)只读不写,跳过被标记的节点,在第 0 层停下时后继未被标记即说明键在集合里。

先标上层、后标第 0 层的顺序保证:一旦第 0 层被标记,所有上层也已被标记,上层残留的这个节点在下一次路过的查找里就会被摘掉。

插入:第 0 层之后,上层与删除赛跑

插入在第 0 层成功之后,节点已经是集合成员,接下来逐层往上接。这时可能有删除者正在删它:

/* reproduce/lf.c: lf_insert() 的上层部分 */
    for (int lv = 1; lv < top; lv++) {
        for (;;) {
            uintptr_t nx = ld(&n->next[lv]);
            if (IS_MARKED(nx))
                return 1;             /* removed while we were linking: stop */
            if (succs[lv]->key == key)
                return 1;             /* a newer node with our key: ours is gone */
            if (UNMARK(nx) != succs[lv] &&
                !cas(c, &n->next[lv], nx, (uintptr_t)succs[lv]))
                continue;             /* got marked meanwhile; loop re-checks */
            if (cas(c, &preds[lv]->next[lv], (uintptr_t)succs[lv], (uintptr_t)n))
                break;
            c->retry++;
            find(s, c, key, preds, succs);
        }
    }
    return 1;

三处检查的含义:新节点在第 \(lv\) 层的 next 已被标记,说明删除者已经开始删它,停止往上接;重新查找后第 \(lv\) 层的后继恰好是同键的另一个未标记节点,说明我们的节点已被删掉、别人又插入了同一个键,同样停止;新节点自己的 next 指针过时了,要先用 CAS 更新,CAS 失败说明它刚被标记。第一和第三处与 Fraser 论文 list_update 第 23 到 26 行的作用相同;同键节点的情形 Fraser 在第 28 行的处理是跳过它继续接,本实现选择直接停止,因为这时我们的节点已经不在集合里,接不接上层只影响一个死节点。

下面的时序是一种合法交错:插入者 T1 在第 0 层成功之后、接第 1 层之前,删除者 T2 完成了整个删除。

sequenceDiagram
    participant T1 as T1 insert(6), top = 2
    participant L as skip list
    participant T2 as T2 remove(6)
    T1->>L: CAS level-0 pred.next: 8 to 6 (ok)
    Note over L: 6 is in the set
    T2->>L: find(6): 6 found at level 0
    T2->>L: CAS 6.next[1]: set mark
    T2->>L: CAS 6.next[0]: set mark (ok)
    Note over L: 6 is not in the set
    T2->>L: find(6): unlink 6 at level 0
    T1->>L: load 6.next[1]: marked
    Note over T1: stop linking, insert returns true

两个操作都返回成功,线性化顺序是先插入后删除,与集合语义一致。更麻烦的是另一种交错:T1 读 6.next[1] 时还没被标记,随后 T2 标记、删除、清理,T1 的第 1 层 CAS 再成功,于是一个已删除的节点被接进了第 1 层。它的 next 带标记,查找会跳过它,下一次路过的 find 会摘掉它,所以集合语义不受影响;但”每层都是下一层的子列表”这条跳表不变量被暂时破坏了。HLLS 论文批评 ConcurrentSkipListMap 时说的正是这类情况:“某些交错会让通常的跳表不变量被违反,有时是暂时的,有时是永久的”,它们不影响正确性,却让证明变难。在有内存回收的实现里,这种交错决定了一个节点什么时候才算真正不可达,第九节会再提到。

五、Lazy 跳表:乐观查找、加锁验证

Herlihy、Lev、Luchangco、Shavit 在 SIROCCO 2007 的论文 A Simple Optimistic Skiplist Algorithm(下文简称 HLLS)走了另一条路:更新操作加锁,但查找不加锁,而且始终保持”每层都是下一层的子列表”这条不变量。它建立在 Heller 等人的 lazy list(OPODIS 2005)之上,把 lazy list 用到每一层。

节点状态与集合成员

每个节点除了键、层高和各层 next 指针,还有一把锁和两个标志:marked 表示已被逻辑删除,fullyLinked 表示已经在所有层都接好。论文对集合的定义是:一个键在集合里,当且仅当链表中存在一个键为它、未被标记、且已完全接入的可达节点。

这个定义把第四节的两个难题都换了个位置。插入要改 \(h\) 个指针,无法用一条原子指令完成,于是插入的线性化点不放在任何一次指针修改上,而放在接完所有层之后把 fullyLinked 置真的那一刻。删除的线性化点是把 marked 置真的那一刻,之后再慢慢摘指针。

插入:先查找,再锁住前驱并验证

findNode 与顺序跳表的查找完全相同,不加锁、不重试,返回每层的前驱和后继。插入拿到结果后:

  1. 如果找到了同键节点且未被标记,说明键已经存在;若它还没完全接入,就等到它接入再返回 false,因为在那之前键还不算在集合里。若同键节点已被标记,说明有人正在删它,重试。
  2. 否则从第 0 层往上,依次锁住各层的前驱(同一个节点作为多层前驱时只锁一次),并验证:前驱和后继都未被标记,且前驱在这一层的后继仍是查找时看到的那个节点。
  3. 验证失败就放锁重来;验证通过则分配节点、接入各层、置 fullyLinked,然后放锁。

reproduce/lazy.c 的实现(lz_insert 的加锁与接入部分):

/* reproduce/lazy.c: lz_insert(),从验证开始 */
        int highest = -1, valid = 1;
        lz_node_t *prev = NULL;
        for (int lv = 0; valid && lv < top; lv++) {
            lz_node_t *pred = preds[lv], *succ = succs[lv];
            if (pred != prev) {
                pthread_mutex_lock(&pred->lock);
                prev = pred;
            }
            highest = lv;
            valid = !atomic_load(&pred->marked) && !atomic_load(&succ->marked) &&
                    ld(&pred->next[lv]) == succ;
        }
        if (!valid) {
            unlock_preds(preds, highest);
            c->retry++;
            continue;
        }
        lz_node_t *n = node_new(c, key, top);
        for (int lv = 0; lv < top; lv++)
            atomic_store_explicit(&n->next[lv], succs[lv], memory_order_relaxed);
        for (int lv = 0; lv < top; lv++)
            st(&preds[lv]->next[lv], n);
        atomic_store(&n->fully_linked, 1);    /* linearization point */
        unlock_preds(preds, highest);
        return 1;

新节点的 next 指针用 relaxed 写,因为此时它还没有发布;随后对前驱 next 的 release 写把它发布出去,没加锁的查找用 acquire 读到新节点时,也就看得到它的 next 与键。这是论文指出的唯一一处”修改了没有加锁的节点的 next”:新节点在前驱指向它之前对别人不可见,所以安全。

删除:先标记,再摘除

删除先调用 findNode,再检查节点是否”可以删”:已完全接入、未被标记、而且是在它的最高层被找到的。论文脚注解释了最后一条:如果节点不是在最高层被找到,说明查找经过那一层时它要么还没接完、要么已经被标记且部分摘除,继续做下去验证也会失败。满足条件后锁住该节点,确认仍未被标记,置 marked,这是删除的线性化点。然后自下而上锁住各层前驱,做弱验证(前驱未被标记且仍指向被删节点,不检查后继,因为后继就是刚标记的节点),再自上而下逐层摘除。验证失败时只放前驱的锁、保留被删节点的锁,重新查找前驱。

加锁顺序决定了不会死锁。论文 4.3 节的论证是:线程总是先锁键更大的节点。删除先锁被删节点,再从第 0 层往上锁前驱,而低层的前驱离被删节点更近、键更大;插入也是从第 0 层往上锁。

查找是 wait-free 的

contains 只调用 findNode,找到未被标记且已完全接入的节点就返回 true。它不加锁、不重试、不等待,是 wait-free 的。难点在返回 false 的情形:找到的节点被标记了,而此时链表里可能已经有一个同键的新节点。论文第 4 节证明,这种情况下在 contains 执行期间必然存在一个时刻键不在集合里,因此返回 false 仍可线性化。

插入和删除则不是无锁的:持锁线程停住,等锁的线程就一起停住;插入在同键节点上等待 fullyLinked 的那个自旋,也会被一个停在接入中途的插入者拖住。这是用进度保证换取简单性。

论文的实验

HLLS 用 Java 实现,与 JDK 6 的 ConcurrentSkipListMap 在两台机器上比较:8 核 32 线程的 Sun Fire T2000(UltraSPARC T1),以及 Sun Enterprise 6500(15 块系统板、每块 2 个 400 MHz UltraSPARC II)。每个线程从空表开始执行 100 万次随机操作,键的范围是 20 万或 200 万:

  • 90% 查找、9% 插入、1% 删除(论文称这是 Lea 观察到的典型用法):两者都可扩展,性能相当。
  • 70% 查找、20% 插入、10% 删除:T2000 上两者相近。在 6500 上 64 个线程时,小键域下 JDK 快 13%,大键域下 HLLS 快 20%。
  • 50% 插入、50% 删除:线程数接近多道程序区后,HLLS 的吞吐迅速下降,T2000 上尤其明显。论文的解释是原型没有争用管理,拿不到锁时只调用 yield;计数器显示 64 线程时插入的重试次数很多。

所以 HLLS 的结论是”在最常见的负载下与无锁实现相当”,而不是”一般情况下相当”。它主张的优势是可以证明、容易修改。第八节会看到,本文在 C 里实现的两者,在 4 个线程以内差距是 11% 到 47%。

六、JDK 21 的 ConcurrentSkipListMap:marker 节点与稀疏索引

java.util.concurrent.ConcurrentSkipListMap 从 JDK 6 起由 Doug Lea 维护,本文读的是 JDK 21 的源码。类开头约 250 行的实现注释把设计讲得很完整,下面按注释的顺序对照本文的 lf.c。

索引与数据分开存放

JDK 21 ConcurrentSkipListMap 的结构:顶部 head 指向最高层 Index,各层 Index 通过 right 向右、down 向下相连,每个 Index 的 node 字段指向底层数据链表中的 Node;底层 Node 链表中键 9 的 val 已被 CAS 为 null,其后紧跟一个 key 为 null 的 marker 节点

教科书里的跳表节点带一个 next 数组,JDK 却把索引层做成单独的 Index 对象:每个 Index 有 node(指向底层数据节点)、down(下一层的 Index)和 right(同层下一个 Index)三个字段,底层是由 Node 组成的单链表。注释给出的理由有两条:基于数组的实现”似乎复杂度和开销更高”;而且频繁遍历的索引层可以用比底层更便宜的算法维护。

删除:val 置空、挂 marker、再摘除

底层链表用的是 Harris 与 Michael 的”HM linked ordered set”算法的变体,但不在指针上打标记位。注释说 Java 里用 AtomicMarkedReference 表示带标记的指针”又慢又占空间”,于是改为在被删节点后面插入一个 marker 节点来代表”这个 next 指针已被标记”。删除节点 n(前驱 b、后继 f)分三步:

  1. CAS n.val 从非 null 改为 null。遍历者遇到 val 为 null 的节点就忽略它,这是删除的线性化点。
  2. CAS n.next 指向一个新的 marker 节点(marker 的 next 是 f)。从此不可能再有节点被接到 n 后面,这一步起的正是第四节标记位的作用:任何以 f 为期望值去 CAS n.next 的插入都会失败。
  3. CAS b.next 一次越过 n 和它的 marker。从此新的遍历不会再遇到 n,它最终被 GC 回收。

第 1 步失败说明输给了另一个操作,直接重试;第 2、3 步可能失败,是因为别的线程在遍历中看到了 val 为 null 的节点,已经帮忙挂上 marker 或摘掉了它。注释强调这种帮助”保证没有线程会卡在等待删除者的进展上”。

和 lf.c 相比,差别有三处。第一,删除的线性化点放在 value 字段上,而不是第 0 层 next 的标记位上,因为 ConcurrentSkipListMap 是映射,节点本来就有一个可以 CAS 的值字段。第二,marker 节点只在删除时分配,注释的说法是这相当于”装箱”的标记指针,但只为被删节点装箱,遍历时只需多读一个节点来检查后面是不是 marker,而不必每次读指针都去掉标记位。第三,注释明说这个技巧”在没有垃圾回收的系统里效果不会好”:每次删除多分配一个节点,而且 marker 何时可以释放,又是一个回收问题。

索引层允许竞争失败

索引层用 CAS 维护 right 字段,但注释明确允许竞争:并发的索引操作”可能(很少)没能把新的索引节点接上”,数据节点绝不允许这样。即使发生,索引仍然能正确引导查找,只是争用下”有效的 \(p\) 值可能低于名义值”。这比 lf.c 更进一步:lf.c 的上层接入失败时会重新查找再试,JDK 则认为跳表本来就是概率结构,少接一个索引节点只影响速度。

索引的参数也比常见的跳表稀疏。注释写的是 \(k = 1\)、\(p = 0.5\),结果”大约四分之一的节点有索引”,有索引的节点中一半只有一层、四分之一有两层,依此类推,最多 62 层;整个映射的期望空间”略小于 java.util.TreeMap“。插入一个比当前最高层还高的索引时,用 CAS 换上新的顶层 head;大量删除之后,删除方法会试探性地降低层数,注释承认这可能在极少数情况下”丢掉”一个正要放入索引的层,但认为这比让层数无限增长更好。

索引维护有时要在底层操作完成后单独再走一遍。注释说这增加了单线程开销,却缩小了多线程下的干扰窗口,而且让删除方法在返回前确保所有索引节点都不可达,“避免不必要的垃圾滞留”。这正对应第四节末尾那个交错:lf.c 里一个已删除的节点可能被迟到的插入者接回上层,要等下一次路过的查找才摘掉。

弱一致的遍历与计数

几处接口语义值得注意:

  • 迭代器和 spliterator 是弱一致(weakly consistent)的。按 java.util.concurrent 包文档的定义,它们可以与其他操作并发进行、不抛 ConcurrentModificationException,保证把构造时已存在的元素恰好遍历一次,但构造之后的修改可能反映、也可能不反映。
  • putAll、equals、toArray、containsValue、clear 这些批量操作不保证原子执行,Javadoc 举的例子是,与 putAll 并发的迭代器可能只看到一部分新元素。
  • size() 读的是一个 LongAdder 计数器,而不是遍历链表。LongAdder 的求和不是原子快照,有并发修改时得到的只是某个近似值。

内存序方面,注释说所有发布和结构修改都用 volatile 模式的 CAS,只在少数访问方法的入口放 acquireFence,并依靠依赖读传递 acquire 语义,称这种”栅栏上提”与 RCU 类似。这与 lf.c 的做法(每次读 next 都用 acquire)目的相同,都是保证读到一个节点指针时也能看到该节点被发布时写入的字段。

七、LevelDB 与 RocksDB:不删除的跳表

LSM 树的内存表(memtable)是跳表最常见的生产用途之一。LevelDB 和 RocksDB 的跳表之所以简单,是因为它们去掉了第四到六节所有麻烦的来源:不删除节点。LSM 树里的删除写一条墓碑记录,本身就是一次插入;memtable 写满后变成只读,刷到磁盘,然后连同它的内存池整体释放。

LevelDB 1.23:单写者、读者无锁

db/skiplist.h 开头的线程安全说明只有几行:写操作需要外部同步,“很可能是一个互斥锁”;读操作只要保证跳表在读的过程中不被销毁,就不需要任何内部加锁或同步。两条不变量是:已分配的节点在跳表销毁之前永不删除;节点一旦接入,除 next 指针外的内容不再改变。层高参数是 kMaxHeight = 12、kBranching = 4,即每升一层的概率为 \(1/4\)。

Insert 的发布顺序是全部要点:

// leveldb 1.23, db/skiplist.h, SkipList::Insert()(节选)
  x = NewNode(key, height);
  for (int i = 0; i < height; i++) {
    // NoBarrier_SetNext() suffices since we will add a barrier when
    // we publish a pointer to "x" in prev[i].
    x->NoBarrier_SetNext(i, prev[i]->NoBarrier_Next(i));
    prev[i]->SetNext(i, x);
  }

NoBarrier_SetNext 是 relaxed 存储,SetNext 是 release 存储,Next 是 acquire 读取。新节点先用 relaxed 写好自己第 \(i\) 层的 next,再用 release 写让前驱指向它;读者用 acquire 读到新节点,也就看到了它的键和 next。从第 0 层往上接,所以读者一旦在某层看到新节点,它在更低的层也已经接好,“每层都是下一层的子列表”始终成立。

表的当前最高层 max_height_ 用 relaxed 读写。源码注释解释了为什么不需要同步:并发读者如果看到了新的 max_height_,那么在新增层上从 head_ 读到的要么是旧值 nullptr,要么是下面循环写入的新节点;前一种情况下 nullptr 排在所有键之后,读者立即降到下一层。

RocksDB 9.7.4:多个写者用 CAS 插入

RocksDB 的 memtable/inlineskiplist.h 头注释说 InlineSkipList 派生自 SkipList(skiplist.h),区别在于键存储必须通过跳表实例分配、紧跟在节点后面,从而每个节点省一个指针、改善缓存局部性。线程安全说明在 LevelDB 的基础上多了一句:InsertConcurrently 可以与读操作以及其他并发插入安全地同时调用。

并发插入的做法与第四节的 lf.c 插入几乎一样,只是没有删除者要防备:

  • 层高上限 kMaxPossibleHeight = 32,分支因子默认 4。max_height_ 用 compare_exchange_weak 抬高。
  • 每层用 CASNext(i, splice->next_[i], x) 接入:先 relaxed 写好新节点在这一层的 next,再对前驱做 CAS。失败时只在这一层重新查找前驱和后继,然后重试。
  • 重复键只在第 0 层检查,注释写的是”Checking for duplicate keys on the level 0 is sufficient”。
  • 插入者可以缓存一个 Splice,即上次插入时各层的前驱和后继。它的不变量只要求各层区间逐层嵌套,而不要求 prev_[i]->Next(i) == next_[i]:并发插入可能已经在中间插了节点,下次使用时再按需修正。这让顺序或局部有序的批量插入不必每次都从头查找。

没有删除,所以不需要标记位、不需要帮助,上层接入也不会与删除赛跑;节点随内存池整体释放,所以也没有第九节的回收问题。付出的代价是这个跳表只适合”只增不删、整体丢弃”的用途。

三种做法放在一起

memtable 的例子说明,“并发跳表难”主要难在删除和回收。能把删除变成插入、把回收推迟到整体丢弃的场景,并发跳表可以只用 release/acquire 和一条 CAS 实现。

八、测试与吞吐

对拍与结构检查

reproduce/ 里的三个实现(lf.c 无锁、lazy.c 乐观加锁、glock.c 一把全局互斥锁套顺序跳表)共用一个接口,由 test.c 统一测试:

  • T1:单线程,与一个位图参照逐次比对返回值。
  • T2:多线程,每个线程只操作属于自己的键(\(k \bmod T\) 等于线程号),每次返回值都与本线程私有的参照比对,而相邻的键同时被别的线程修改。第一节的”丢失插入”在这里会直接表现为返回值不符。
  • T3:多线程共享 64 个键。对每个键,成功插入次数减成功删除次数必须是 0 或 1,并且与结束时该键是否在集合中一致。

每项测试结束后,在静止状态下做结构检查:每层严格有序、没有残留的已标记节点、所有节点都已完全接入、每一层都是下一层的子列表。三个实现各跑 3 个种子,分别用 -O2、ThreadSanitizer、ASan+UBSan 构建,全部通过(results/test.txt,4 线程)。在一台 2 vCPU 的 KVM 虚拟机(AMD EPYC 9754,Linux 6.8,GCC 13.3)上,4 个线程挤在 2 个 CPU 上重跑,结果相同(results/smoke_2vcpu.txt;TSan 需要用 setarch -R 关闭地址随机化才能启动)。

同一台 2 vCPU 机器上重跑第一节的 lost_insert,朴素删除每次运行丢失 1446 到 1913 次、幽灵 1207 到 1527 次,Harris 标记仍然全是 0。错误率比 i9 上低约四倍,但没有消失。前两篇的 ABA 实验在这台机器上几乎测不出来,原因是 ABA 要求一个线程停在读与 CAS 之间的同时,别的线程恰好完成若干次操作并复用同一个地址;而这里的错误只需要两个线程在相邻节点上的读与 CAS 交叠,窗口大得多。

吞吐实验的设定

bench.c 对三个实现做固定时长的吞吐测试:

机器上同时有其他任务在运行,绝对数值只看相对趋势。结果(百万次操作每秒,results/bench_summary.txt):

四个子图分别为 1024 与 1048576 两种键域、90% 查找与 50% 查找两种负载下的吞吐随线程数变化。无锁与 lazy 两条线都随线程数上升,无锁始终略高;全局锁在 2 线程时跌到单线程的一半以下,之后继续下降

读数

全局锁从第二个线程起就崩溃。 1024 个键、90% 查找时,单线程 18.67,两线程 7.48,四线程 6.52。所有操作排在一把锁上,第二个线程带来的只有锁和缓存行在核间的搬运。这与前两篇栈和队列的情形不同:那里无锁版本也从第二个线程起下降,因为所有线程都在争同一个 top 或 Head;跳表的操作分散在不同的键上,无锁和 lazy 两个版本都随线程数近似线性上升。

冲突很少。 无锁版本在 1024 个键、50% 更新、4 线程时,每次操作平均失败 0.003 次 CAS;lazy 版本的验证失败率也在同一量级(bench_summary.txt 最后两列)。第一节 lost_insert 那种每 50 次操作出一次错的交错,是刻意让 4 个线程在 64 个相邻键上交替操作才制造出来的;均匀分布在上千个键上的负载里,两个线程同时改同一对相邻节点的机会很小。

大键域时 lazy 更慢。 百万级键域下,lazy 在单线程时就只有无锁版本的约 70%(0.99 对 1.42),4 线程时是 68% 到 74%。这时表里约有 50 万个键,按 \(p = 1/4\) 有约 \(\log_4(5 \times 10^5) \approx 9.5\) 层,每次操作要在内存里跳转几十次,吞吐由缓存缺失决定。lazy.c 的节点多了一个 pthread_mutex_t(glibc x86-64 上 40 字节)和两个标志,节点更大、同样的缓存能容纳的节点更少,这是一个可能的原因,但本文没有做隔离实验。1024 个键时整个跳表都在缓存里,两者只差 1% 到 13%。

线程多于 CPU 时,lazy 版本开始付出加锁的代价。 上面的实验每个线程独占一个逻辑 CPU。run_oversub.sh 在 2 vCPU 虚拟机上把 1、2、4、8 个线程轮流绑到两个 CPU 上,1024 个键、每点 500 毫秒、5 次取中位数(results/oversub_2vcpu.txt):

线程数超过 CPU 数之后,无锁版本的吞吐保持不变,lazy 版本则随线程数下降:50% 更新、8 个线程时只剩 2 线程时的 53%,5 次运行在 3.36 到 6.80 之间大幅波动,平均每次操作要重试 2.84 次。机制正是第五节说的弱点:持锁线程在临界区里被换出,等锁的线程只能跟着等;而拿到锁之后发现前驱已经变了,验证失败,放锁重来。无锁版本里被换出的线程不持有任何东西,别的线程照常完成操作。本文的 lazy 实现用的是 glibc 的 pthread_mutex_t,没有退避或争用管理,这与 HLLS 原型的情形相同,HLLS 在 50% 插入、50% 删除的负载下也报告了进入多道程序区后的吞吐崩溃。

这组数据不能直接对照 HLLS 的结论。 HLLS 在 Java 里用 JDK 的 ConcurrentSkipListMap 做对照,本文的无锁版本是一个没有 marker 节点、没有 value 字段的 C 实现;HLLS 测到 64 个线程,本文只有 4 个。能说的只是:在本文的实现与规模下,无锁版本在所有配置里都不慢于 lazy,差距主要来自大键域下的单线程开销,而不是争用。

九、内存回收:删掉的节点什么时候能释放

本文的三个实现在运行期间都不释放任何节点:每次分配记入线程私有的日志,所有线程结束后才统一释放(common.h 的 ctx_alloc)。这不是偷懒,而是正确性要求。一个被删除的节点,此时可能还有别的线程正在读它:lf.c 的查找可能刚读到它的地址,lazy.c 的 findNode 不加锁,照样可能停在它上面。

跳表比单链表更难回收,原因有两个。第一,一个节点同时挂在 \(h\) 层上,删除返回时它必须在所有层都已不可达。lf.c 的删除在标记第 0 层后再调用一次 find,由它逐层摘除;而第四节末尾的交错说明,一个迟到的插入者可能在删除完成之后,把已删节点重新接进上层,它要等下一次路过的查找才被摘掉。所以”删除返回了”不等于”节点已不可达”,回收方案必须能处理这种残留。JDK 的注释说它的删除会确保返回前所有索引节点都不可达,正是为了减少这类垃圾滞留,但它依然依赖 GC 兜底。第二,查找路径很长,一次操作要经过几十个节点,逐个保护的代价随之增加。

各家的选择:

  • Pugh 1990:删除的节点放入 garbage queue,等放入时所有在途的操作都结束后再回收。这就是后来基于纪元的回收(EBR)的思路。
  • Fraser 2004:论文第 5.2.3 节用基于纪元的回收。
  • HLLS 2007:论文假设有垃圾回收器,并说没有 GC 时可以用 repeat offender 问题的解法或 hazard pointers。
  • JDK:Java GC。
  • LevelDB、RocksDB:从不删除,内存池整体释放。

hazard pointers 与 EBR 各自的机制和代价,分别是本系列第 75 篇和第 76 篇的主题。对跳表来说,一个直接的推论是:hazard pointers 要求线程在解引用前公布指针,并在公布后重新确认该节点仍可达,而跳表节点的”可达”要跨多层判断;EBR 不需要逐个保护节点,更适合长查找路径,但一个停在临界区里的线程会阻止所有回收。

十、争论与开放问题

加锁还是无锁

HLLS 的立场是:在最常见的读多写少负载下,一个简单、可证明的加锁跳表可以与无锁实现相当,而简单性本身有价值,因为程序员需要理解并修改基本结构。Gramoli 在 PPoPP 2015 的 Synchrobench 论文里用 31 种数据结构、5 种同步技术、3 个多核平台做了比较,结论之一是 CAS 能做出最快的多核算法,但要做对很难;乐观加锁的表现随负载变化较大。本文第八节的数字与两者都不矛盾:在 4 个线程以内,无锁版本在所有配置里都不慢于 lazy,但在小键域下差距只有 1% 到 13%。

争论的实质不在吞吐,而在其他维度。无锁版本不怕持锁线程被抢占,但要处理标记、帮助和不变量的暂时破坏;lazy 版本的不变量始终成立、证明更直接,但插入会因为持锁线程被换出而停住:HLLS 自己报告了高争用下没有退避时的性能崩溃,第八节的超额订阅实验里,lazy 版本在 8 个线程、2 个 CPU 上的吞吐只有无锁版本的 44%。至于”加锁更容易写对”,本文两个实现的代码量相近(lf.c 与 lazy.c 都在 230 行左右),难点都在验证和加锁顺序这些细节上。

上层索引要不要同步维护

JDK 已经允许索引层竞争失败,只要求底层正确。Crain、Gramoli、Raynal 在 ICDCS 2013 的 No Hot Spot Non-blocking Skip List 走得更远:更新操作只修改底层,立即返回;索引层由一个后台线程持续调整。他们的理由是上层的少数节点是所有操作都要经过的热点,同步维护它们会造成争用。论文在 SPECjbb 与微基准上与 JDK 的跳表比较,报告自己的实现可以快一倍以上。

这把问题变成了一个取舍:索引越”懒”,争用越少,但索引与底层的偏差越大,查找可能要在底层多走几步;后台线程本身也要占用一个核。偏差多大时查找代价开始显著上升、后台维护的频率怎么定,依赖负载和机器,目前没有通用答案。

范围查询与迭代器

有序集合相对哈希表的主要优势是范围扫描,而第六节看到,JDK 的迭代器只是弱一致的,批量操作也不原子。一个与并发更新同时进行、又能线性化的迭代器,需要某种快照。Petrank 与 Timnat 在 DISC 2013 的 Lock-Free Data-Structure Iterators 给出了一种技术,为实现集合的无锁或 wait-free 数据结构加上线性化的 wait-free 迭代器,并用它为无锁链表和无锁跳表实现了迭代器。代价是有快照正在进行时,插入、删除乃至查找在完成自身操作后,都要检查快照收集器(snap-collector)并在需要时报告自己的操作,以维持线性化。如何让范围查询既可线性化、又不拖慢不做范围查询的普通操作,是有序并发数据结构至今仍在研究的问题。

不变量与证明

HLLS 批评 JDK 实现时说,某些交错会让跳表不变量”有时暂时、有时永久”地被违反,这些违反不影响正确性,却让证明变难。本文第四节的实现就存在暂时的违反。HLLS 的摘要写道,文献中已有的并发跳表实现,无论加锁还是无锁,“都没有被证明正确”,并把原因归于这些算法的复杂结构;它自己的证明(第 4 节)之所以直接,是因为不变量在任何时刻都成立,而 lazy list 的关键性质已有 Colvin 等人的形式化验证可以借用。本文的测试只能说明在测过的交错里没有发现错误:test.c 的结构检查只在静止状态下进行,第四节那种暂时的不变量破坏,它看不到。把”暂时违反不变量但仍可线性化”这类论证做成可复用的证明方法,是无锁数据结构验证要解决的问题之一。

十一、复现

代码都在 reproduce/ 目录,只依赖 GCC、pthread 和 taskset;画图需要 matplotlib。

cd reproduce
CPUS=5,14,16,15 ./run.sh
python3 plot.py
CPUS=0,1 ./run_oversub.sh > results/oversub_2vcpu.txt

CPUS 是线程依次绑定的 CPU 列表,第一个用于单线程运行,测试的线程数等于列表长度;lost_insert 要求列表至少有 4 项,CPU 不够时可以重复,例如 0,1,0,1。TSan 在地址随机化位数较大的内核上会启动即报 unexpected memory mapping,此时设 TSAN_WRAP="setarch -R"。results/smoke_2vcpu.txt 是在 2 vCPU 虚拟机上手工运行 test 与 lost_insert 的记录,文件头写明了环境和 CPU 列表。

吞吐数字受机器和同时运行的其他任务影响,重跑时看的是趋势:全局锁是否从第二个线程起下降,无锁与 lazy 是否都随线程数上升,超额订阅时 lazy 是否下降而无锁持平。lost_insert 的丢失次数也随交错变化,判断标准是朴素版本非零、Harris 版本为零。

十二、参考资料

跳表与并发跳表

  • W. Pugh,“Skip Lists: A Probabilistic Alternative to Balanced Trees”,Communications of the ACM 33(6),1990,pp. 668–676。
  • W. Pugh,“Concurrent Maintenance of Skip Lists”,University of Maryland,CS-TR-2222.1(UMIACS-TR-90-80),1989 年 4 月,1990 年 6 月修订。
  • K. Fraser,“Practical Lock-Freedom”,博士论文,University of Cambridge Computer Laboratory,UCAM-CL-TR-579,2004。
  • M. Herlihy, Y. Lev, V. Luchangco, N. Shavit,“A Simple Optimistic Skiplist Algorithm”,SIROCCO 2007,LNCS 4474,pp. 124–138。
  • T. Crain, V. Gramoli, M. Raynal,“No Hot Spot Non-blocking Skip List”,ICDCS 2013,pp. 196–205。

链表基础与正确性

  • M. Herlihy, J. M. Wing,“Linearizability: A Correctness Condition for Concurrent Objects”,ACM TOPLAS 12(3),1990,pp. 463–492。
  • T. L. Harris,“A Pragmatic Implementation of Non-blocking Linked-Lists”,DISC 2001,LNCS 2180,pp. 300–314。
  • M. M. Michael,“High Performance Dynamic Lock-Free Hash Tables and List-Based Sets”,SPAA 2002,pp. 73–82。
  • S. Heller, M. Herlihy, V. Luchangco, M. Moir, W. N. Scherer III, N. Shavit,“A Lazy Concurrent List-Based Set Algorithm”,OPODIS 2005,LNCS 3974,pp. 3–16;期刊版 Parallel Processing Letters 17(4),2007,pp. 411–424。
  • R. Colvin, L. Groves, V. Luchangco, M. Moir,“Formal Verification of a Lazy Concurrent List-Based Set Algorithm”,CAV 2006,LNCS 4144,pp. 475–488。

评测、迭代器与争论

  • V. Gramoli,“More Than You Ever Wanted to Know about Synchronization: Synchrobench, Measuring the Impact of the Synchronization on Concurrent Algorithms”,PPoPP 2015,pp. 1–10。
  • E. Petrank, S. Timnat,“Lock-Free Data-Structure Iterators”,DISC 2013,LNCS 8205,pp. 224–238。

源码(按版本核对)

  • OpenJDK 21:java.util.concurrent.ConcurrentSkipListMap 的类注释与实现注释;java.util.concurrent 包文档中”weakly consistent”的定义;java.util.concurrent.atomic.LongAdder#sum。
  • LevelDB 1.23:db/skiplist.h。
  • RocksDB 9.7.4:memtable/inlineskiplist.h。

系列导航: - 上一篇:无锁栈:Treiber 栈、ABA、指数退避与消除退避 - 下一篇:Hazard Pointers:安全内存回收的优雅方案

相关阅读: - Treap 与跳表:随机平衡的期望代价与生产参数 - 无锁队列:Michael-Scott 算法与 ABA 问题 - Epoch-Based Reclamation:Crossbeam 的实现之道 - LSM-tree Compaction 策略:leveling、tiering、lazy leveling 与 RocksDB 的实现

读完这篇,下一步读什么

优先读同系列或同问题的下一篇,把单篇消费变成主题集群。

2026-04-13 · algorithms

无锁队列:Michael-Scott 算法与 ABA 问题

按 PODC 1996 原文复原 Michael-Scott 无锁队列:线性化点、计数指针防 ABA、计数器为何不解决内存回收、每条 CAS 的 C11 内存序,并用 TSan 与线性化检查器实测。

2026-04-20 · algorithms

Treap 与跳表:随机平衡的期望代价与生产参数

用 Seidel–Aragon 的祖先引理和 Pugh 的逆向分析推导 Treap 深度、旋转次数与跳表查找路径,逐项用计数实验核对;对照 Redis、LevelDB、RocksDB、JDK 源码核对 p 与层数上限,并讨论对手看得见随机性时的退化。

2026-04-10 · algorithms

Bloom Filter 家族:从位数组到 Ribbon,每键位数离下界还差多少

用可复现实验测量 Bloom、分块 Bloom、cuckoo、xor 与 Ribbon 的每键位数和误判率,对照 log2(1/ε) 下界解释 1.44 倍从何而来、经典 FPR 公式偏在哪里,并核对 RocksDB 9.10 的 FastLocalBloom 与 Ribbon 实现。

2026-04-19 · algorithms / database

B+tree 与 LSM-tree:原地更新与异地更新的读、写、空间代价

从 O'Neil 1996 与 Dostoevsky 的代价模型推导两类结构的读写空间放大,用计数模拟器在同一负载上实测:B+tree 写放大随缓冲池从 126 降到 8.6,leveled LSM 为 14.4,并对照 RUM 猜想原文与生产数据。