深蓝黑背景封面:左侧原创示意图——两条横向线程时间线:线程 A 与线程 B 各自执行「读、加一、写回」三步,两条线在「读」处交错:A 读到 tally 等于 7 后还没来得及写回,B 也读到 7,结果双方都写回 8;下方标注「两次加一只涨了一,更新丢失」。右侧大标题「两线程各 50 次 tally++,最后是多少」,副标题「答案不是一个数,是 2 到 100 的范围」;左上标注「笔试复盘 · 操作系统」,右上圆角胶囊标注「Day 09 · 11-16」 图 1:本文封面,两条时间线在「读」处交错。

先说明属性:这是一篇历史笔试题的复盘,题目出自本地历史笔试资料,与任何公司的当前招聘无关。题面很短:全局变量 tally 初值为 0,两个线程各自执行 50 次 tally++,没有任何同步措施,问最终结果是多少。很多人脱口而出 100,但正确答案是「2 到 100 之间的某个值」。把这道题讲透,就看懂了多线程为什么必须同步。

tally++ 不是一条指令。

高级语言里的一行自增,编译到机器层面是三步:把 tally 从内存读进寄存器,寄存器里的值加一,把结果写回内存。关键在这三步不是原子的——线程在任意两步之间都可能被操作系统切走,寄存器现场被保存,等再切回来时接着跑。正是「读和写之间隔着一段不确定的时间」,给了交错可乘之机。

丢失更新:两次加一,只涨了一。

设想 tally 当前是 7。线程 A 读出 7,还没写回就被切走;线程 B 也读出 7;A 醒来把 8 写回;B 接着把自己算好的 8 也写回——B 的写覆盖了 A 的写。两个线程各完成了一次自增,tally 却只从 7 涨到 8:一次更新凭空丢失。这不是小概率的理论事件,用 Java 实测一下(本机 JDK 21,两个线程各加 20 万次,连跑 5 轮):

public class LostUpdate {
    static int tally = 0;
    static volatile boolean go = false; // 发令枪:保证两个线程同时开跑、充分交错

    public static void main(String[] args) throws InterruptedException {
        final int N = 200_000;
        for (int round = 1; round <= 5; round++) {
            tally = 0;
            go = false;
            Runnable job = () -> {
                while (!go) { Thread.onSpinWait(); } // 等发令
                for (int i = 0; i < N; i++) tally++;
            };
            Thread a = new Thread(job);
            Thread b = new Thread(job);
            a.start(); b.start();
            Thread.sleep(50); // 等两个线程都进入起跑线
            go = true;
            a.join(); b.join();
            System.out.println("第 " + round + " 轮: 期望 " + (2 * N)
                + ",实际 " + tally + ",丢失 " + (2 * N - tally) + " 次更新");
        }
    }
}

实测输出:

第 1 轮: 期望 400000,实际 234748,丢失 165252 次更新
第 2 轮: 期望 400000,实际 200000,丢失 200000 次更新
第 3 轮: 期望 400000,实际 200000,丢失 200000 次更新
第 4 轮: 期望 400000,实际 200000,丢失 200000 次更新
第 5 轮: 期望 400000,实际 200000,丢失 200000 次更新

四十万次自增,最差一轮只剩二十万——整整一半的更新被覆盖丢失。

范围推导:为什么是 2 到 100。

最大值 100 好理解:两个线程的循环完全不交错(或者干脆加了锁),每次自增都基于最新值,五十加五十等于一百。

最小值 2 需要构造一个极端交错,核心思路是让一个线程的几乎全部成果,被另一个线程的「旧值写回」覆盖。完整剧本是这样的:线程 B 第一次自增读出 0,还没写回就被切走;线程 A 连续完成 49 次自增,tally 涨到 49;B 醒来把「0 加 1」写回,A 的四十九次成果瞬间被抹成 1;A 接着做第 50 次自增,读出 1,又被切走;B 继续完成自己剩下的 49 次,tally 从 1 涨到 50;最后 A 醒来,把「1 加 1」写回——tally 停在 2。两个线程恰好各完成 50 次自增,谁也没偷懒,结果却是最小值。用一段按剧本调度的模拟验证(把每个线程的自增拆成读、写两个动作,按上面的顺序手工发牌):

B 写回 49(第 49 次自增完成)
B 读 tally=49
B 写回 50(第 50 次自增完成)
A 写回 2(第 50 次自增完成)
---
A 完成 50 次,B 完成 50 次,tally 最终 = 2

至于 2 到 100 之间的值:只要调整「覆盖写回」落在对方第几次自增之后,就能让任意数量的成果被抹掉或保住,所以区间里的每个整数都能构造出来。答案不是一个数,是整个闭区间 2 到 100。

深蓝黑背景概念图,分三层。左上「tally++ 不是一条指令」:自增被拆成三步——把 tally 从内存读进寄存器、寄存器加一、把结果写回内存,三步之间随时可能被切换走。右侧「丢失更新的交错演示」是一张按时刻排序的表:时刻一线程 A 读到 7,时刻二线程 B 也读到 7,时刻三 A 把 8 写回,时刻四 B 把自己算的 8 写回覆盖了 A 的结果——两次加一只涨一。底部通栏是取值范围推导:一条 0 到 100 的数轴,2 到 100 的区间高亮;左端标注最小值 2 的构造——线程 A 读完旧值后被切走,线程 B 跑完 49 次递增,A 醒来用旧值加一写回,把 B 的成果几乎全部覆盖;右端标注最大值 100——两次循环完全没交错,或加了原子操作与锁。数轴下注明:这个区间里的每个值都可能出现 图 2:三步分解、交错演示与取值范围。

工程里这就是「永远偏少」的计数器。

多 worker 并行统计请求数、样本数时,如果共享计数器少了原子操作或锁,线上表现就是这道题:总数永远偏少,而且压力越大丢得越多——因为并发把交错窗口放大了。解法(原子类、锁、各算各的最后汇总)这里一句话带过,重要的是诊断直觉:看到统计值莫名偏小,先怀疑丢失更新,再去查自增是不是复合操作。这类 bug 最阴的地方是测试环境常常测不出来——并发度低时交错少,结果看起来是对的,一上量就露馅。

回到笔试现场,答题结构建议:先给出区间而不是单值——最小 2、最大 100;再拆「读、加一、写回」三步说明非原子性;然后讲最小值 2 的交错构造。能把构造过程讲清楚,才说明是真懂而不是背答案。你在生产代码里抓到过计数不准的问题吗,当时是怎么定位到丢失更新的?欢迎分享你的排查过程。