注释是对的,代码是反的

代码逻辑是对的,注释写着的意图也是对的,x64 开发机上全绿——但它错了。

这是并发编程里最不显眼的一类 bug:不是算法错、不是数据结构错,而是内存可见性。这篇把 TC.Tier 里这块踩过的坑摊开——同一族问题反复返工三次,每一次都以为是最后一修。

引子:一条注释在说谎

看这段代码(块表扩容的写侧):

// ★ 先发布数组、后发布 count(读侧 acquire 见新 count ⟹ 数组必可见——旧序写反
//    = 读侧旧数组 + 新 count → chunks[count-1] 越界,8 线程实测复现 IndexOutOfRange)
Volatile.Write(ref _chunks, newChunks);
Volatile.Write(ref _chunkCount, _chunkCount + 1);

注释描述的是修复后的正确顺序。修复之前,那行注释也写着”先发布块表”——但代码做的恰好是相反的:先发布计数,后发布数组。注解是对的,意图是对的,执行是反的。

后果:读侧先用新计数拿长度、再去读数组,读到的是旧数组——下标越界。8 线程 × 300 万次分配,实测复现 3 次 IndexOutOfRangeException

这个 bug 揭示的规则很简单,也很容易被漏掉:发布共享状态时,写侧的顺序和读侧的顺序必须互为镜像。写侧”先数组后计数”配读侧”先计数后数组”才是安全的;任何一侧写反,窗口就打开了。

陷阱一:Array.Resize 是发布顺序的刺客

如果说上一个坑是”顺序写反”,下一个更隐蔽——因为顺序看起来无关紧要

段表的稀疏索引扩容,曾经长这样:

Array.Resize(ref _segIndex, newSize);   // ← 看起来无害
Array.Fill(_segIndex, -1, oldLen, ...);

Array.Resize 内部是”新建数组 + 复制 + 把字段指向新数组”。问题在于:它把字段指向新数组用的不是原子发布语义。于是无锁读者可能看到这样的中间态——字段已经是新数组,但 -1 的填充还没做完。

零初始化的元素值是 0;而 0 恰好是”下标 0 的合法段”。读者读到一个尚未填充的 0,判定”这个段存在”;于是跳过注册,尾水位推过未注册的段——永久空洞。重开时在第一个空洞处截断,复现的数据损失是 1.2MB 里丢了 ~1.1MB。

最扎心的一句记录在这里:

x64 处理器”按序可见”也救不了:读者见到字段 = 新数组时,其后的填充写入尚不可见。

也就是说,这个 bug 跟弱内存序机器没关系——在 x86 上同样成立。修复是教科书式的 build-then-publish:新建 → 拷贝 → 先填充 → 最后单点 Volatile.Write 发布;并且全仓禁用 Array.Resize 于共享数组。

陷阱二:16 字节裸读——JIT 会消掉你的判定

128 位地址带来了一条捷径:对齐的 16 字节读在很多情况下会被编译成单条 128 位加载,于是”读一个原子值”看起来不需要屏障。

“看起来”是关键词。 这条性质的正式结论是:它依赖 JIT 的具体行为,不是内存模型的形式化保证。台账里它的结案状态写得很诚实:文档化接受——不改代码,只在文档里声明”这里依赖平台/JIT 行为”。

真实伤害来自两处:一是 JIT 会把你循环里的 16 字节裸读**公共子表达式消除(CSE)**掉——循环里重读的值被优化成一个循环外的旧快照;二是撕裂。我们自己的自伤记录是:“首版裸读在恰好填满的段界产生假阳性,纯并发追加误报”。

现在的范式是这样(要不要用某个水位判定):

internal bool IsAllocatedBelow(LogicalAddress v)
{
    // 快路径:no-op CAS——原子判定,同时挡住撕裂与 CSE
    if (_allocated.TryCompareExchangeUnsafe(v, v)) return false;

    var spinner = new SpinWait();
    while (true)
    {
        Interlocked.MemoryBarrier();
        var r1 = _allocated.ReadUnsafe();
        Interlocked.MemoryBarrier();
        var r2 = _allocated.ReadUnsafe();
        if (r1 == r2) return r1 < v;   // 稳定双读:两读一致才采信
        spinner.SpinOnce();
    }
}

瓶颈判定用原子 CAS 兜底,慢路径用”屏障 + 双读一致”做稳定读。为什么明知有风险还留着裸读路径?因为账算得清楚:封装层安全路径 16.3 ns、快路径 11.06 ns(≈ 直接原生调用),而 lock 是 17.4 ns——这是性能的买路钱,只不过必须用稳定读把它包起来。

陷阱三:调换写顺序,是不够的

同一族问题里最漂亮的一课在区间投影上。有一段状态由两个字段组成,读侧要判定”区间是否可读”。

第一版修复的思路是”调换写顺序”,把窗口压小。然后是核查结论:

“调换写顺序”不足以修复:两个独立字段无论怎么排写顺序,读侧的两个独立判断总能读到不一致的中间组合,误判窗口只在两个分支之间转移——快路径误判可读会读到在途未提交数据。

窗口不是被消除,只是从一个分支搬到了另一个分支。 最终方案是上个年代的老手艺:seqlock——加一个版本号,读侧读前读后各校验一次,一致才采信。

同族的第二个例子在时钟缓存:修复的第一条是”写入顺序改成先值后键”(读者见到新键更可能见到新值),第二条才是关键——淘汰时置墓碑后不许清键值。因为清场会抹掉并发写入刚抢到槽的新条目:条目消失、计数漂移、淘汰回调不触发(池化缓冲被错归)。教训是:多字段不变量在无锁读侧不能靠”排序”近似

最容易被忽略的那件事:CI 里已经没有 ARM 了

前面那些铁律(build-then-publish、读写对称、16 字节裸读不得用于判定)全是为弱内存序机器准备的——x64 的强内存模型恰好替我们挡住了很多错误。

而 CI 现状是:ARM 腿已经被移除(成本裁定),常设的只剩两条 x64 腿;ARM 验证降级为”main 发布时手动跑一次”。也就是说,所有”弱序合规”类铁律,在开发期没有任何自动化守卫

加上另两条:x64 是”善意的骗子”(仓库里至少两处注释明写”x64 恰好安全,不依赖”);失败形态往往是静默数据损坏而不是崩溃(丢 1.1MB 不会抛异常)。

三条叠加起来,就是”最容易忽略”的完整配方:本地跑不出来、CI 测不出来、错了也不报错。

让机器替你记住协议

既然人和 CI 都靠不住,能靠的只剩”把纪律变成机器检查”。这块有三种做法:

常设绊线与示波器。 内存回收组件里内置协议违反绊线(未配对的进入/退出、跨线程使用、重入、嵌套推进……),并且带一个 32 条环形操作历史——异常消息自动携带操作类型、线程号、当前与进入时的轮次、回收队列深度等现场。这条设计的由来写得很直白:“挂死期间发布版零检测,只能靠 hang dump 逐线程考古”——既然要靠 dump 考古,不如让运行时自己记现场。

探针复现。 每个高危缺陷配一个独立复现器:分配器那个越界缺陷的探针(8 线程 × 300 万次分配,修复前复现 3 次 → 修复后 0 次)、取消语义的探针(20 万轮零伪取消)。缺陷不再是”偶发 flaky”,而是可复现、可回归的靶子。

回归门。 全量单测作为回归门(含专门为数据损坏新增的回归用例)。

一张检查表

把这篇压成几个 Code Review 时可以直接问的问题:

  1. 这份共享状态写侧的发布是单点吗?(还是散在多处普通写里)
  2. 读侧每一环都用了 acquire 吗?——字段和它指向的内容都不能漏
  3. 这个 16 字节读会不会被 JIT 消掉?它参与判定吗(水位比较 / 越界判定 / 几何决策)?——参与就上稳定读。
  4. 多字段不变量:“排序”够吗?还是需要 seqlock / 单原子打包?
  5. 这段代码只在 x64 上跑过吗
  6. 出错的形态是崩溃还是静默?——静默的那一个,必须写进铁律。

第 6 条是这篇真正的落点。崩溃会逼你修;静默不会,它只是偶尔少了一点数据。内存可见性之所以是底层最容易被忽略的维度,就因为它把正确性错误伪装成了”什么都没发生”


本文事实来自 TC.Tier 的并发规范(铁律与反模式清单)、缺陷台账与基准报告。

评论

← 全部文章