随机,但不均匀:XP 头像背后那行代码,如何把公平藏进流水线
目录
- 2025 年 12 月,有人问了一个 20 年前的问题
- 答案:先开机,再掷骰子
- 一行代码里的公平:蓄水池抽样
- 为什么是单遍?因为文件系统很贵
- 我复现了一遍:均匀性是真的
- 100 张的安全兜底
- 彩蛋:同一批电脑,为什么头像会撞车?
- 当「随机」成为默认人性
2025 年 12 月,有人问了一个 20 年前的问题
2025 年 12 月 11 日,一个叫 Xeno 的用户发了一条推:
Has anyone attempted to figure out the RNG for how Windows XP determines what profile picture is used on first account creation?
有没有人研究过:Windows XP 第一次创建账户时,那个随机分配的默认头像,到底是怎么选出来的?
这个问题在 2026 年 9 月被微软的老牌博客 The Old New Thing 接住了。作者 Raymond Chen 从 2003 年写到现在,专职回答「微软代码里那些没人记得的小机关」。他的答案很短:用的是 RtlRandomEx,种子是 GetTickCount() 的当前值。
但真正有意思的,是他顺带贴出来的那几行伪代码。二十年前的一次「随便点点」,里面藏着一个今天刷算法题的人会立刻认出来的东西——蓄水池抽样(reservoir sampling)。
答案:先开机,再掷骰子
先还原一下场景。2001 年 Windows XP 上市,第一次开机时系统会引导你创建用户账户。创建完,它会从 %ALLUSERSPROFILE% 下那个 Default Pictures 目录里随机挑一张图,当你的默认头像。
「随机」两个字,在 2001 年的实现里是这样的:
- 随机数发生器:
RtlRandomEx,Windows 内核自带的伪随机函数 - 种子:
GetTickCount()——操作系统开机以来经过的毫秒数
这就是全部。没有熵池,没有硬件随机数,没有加密安全。头像这种东西,不值得惊动密码学。
但种子选 GetTickCount() 有个微妙的结果:两台在同一时刻开机的电脑,种子相同,头像就相同。 这个彩蛋我后面会验证。
一行代码里的公平:蓄水池抽样
Raymond 贴的伪代码,翻译成白话是这样的:
遍历目录里的每一张图:
计数器 +1
掷一个 1 到 计数器的均匀随机数
如果掷到了「计数器」本身:
当前图 成为 暂定赢家
返回最后的暂定赢家
看着平平无奇,但它保证了一件事:如果目录里有 N 张图,每张图最终被选中的概率都是 1/N,而且你只需要遍历一遍、只记住一张图,不用先把所有图都读进来。
这就是蓄水池抽样在 k=1 时的特例。数学上它是这么证明的:
第 1 张图要笑到最后,必须「不被第 2 张顶掉」并且「不被第 3 张顶掉」……并且「不被第 N 张顶掉」。
- 不被第 2 张顶掉:1/2
- 不被第 3 张顶掉:2/3
- 不被第 4 张顶掉:3/4
- ……
- 不被第 N 张顶掉:(N-1)/N
全部乘起来:
1/2 × 2/3 × 3/4 × … × (N-1)/N = 1/N
每个分数的分子恰好是下一个分数的分母,约分约得干干净净,只剩 1/N。这就是「随机」的落点:看起来每一步都在随机替换,最后每个人(每张图)的机会却完全相等。
评论区有人接着这个逻辑写了个更直观的版本(HN 用户 yoz-y):两张图时,第 1 张 100% 概率暂赢,第 2 张以 50% 概率顶掉它——所以两图各 1/2;三张图时,前两张公平选出的赢家被第三张以 1/3 顶掉,剩下 2/3 概率里两个老图各占一半,还是各 1/3。递推下去,任意 N 张都公平。
为什么是单遍?因为文件系统很贵
看到这段代码,工程师的第一反应可能是:干嘛不数一遍目录有多少张图,然后用 random(1, N) 一次性挑一个?
Raymond 给了两个理由,都特别 2001。
第一,文件系统调用是瓶颈。遍历目录要一次一次地进内核、翻文件表。数两遍 = 两倍的 I/O。蓄水池抽样只需要一遍,省下的时间在当时的机械硬盘上肉眼可见。
第二,也是更妙的:目录里的文件数量可能在遍历过程中发生变化。两遍法先数数再取第 k 个,如果中间有人往目录里塞了张图,k 就可能越界或者偏斜;蓄水池抽样对「流」天然免疫——来多少处理多少,永远公平。
评论区有人(ygra)替他把第一个理由补全了:你以为目录大小可以问文件系统?FAT32 和 NTFS 都不存这个数,想知道得自己数一遍。ZFS 倒是把条目数放在目录元数据里(chungy 补充:还包含 . 和 ..,要减 2),但那是后来才有的东西。
2001 年的微软在为一台 128MB 内存、5400 转硬盘的机器写代码。每一趟文件系统调用都要精打细算。
我复现了一遍:均匀性是真的
光看代码还不够。我在服务器上把这段逻辑原样跑了一遍——Python 的 random.randint(1, count) == count 就是 RtlRandomEx 的现代替身。
实验一:10 万轮模拟,11 个位置。 每次随机给 11 张「头像」中的一个,统计每个位置被选中的次数:
| 位置 | 选中率 | 偏差 |
|---|---|---|
| 1 | 9.050% | -0.04% |
| 2 | 9.064% | -0.03% |
| 3 | 9.151% | +0.06% |
| 4 | 9.139% | +0.05% |
| 5 | 8.932% | -0.16% |
| 6 | 9.081% | -0.01% |
| 7 | 9.002% | -0.09% |
| 8 | 9.088% | -0.01% |
| 9 | 9.230% | +0.14% |
| 10 | 9.073% | -0.02% |
| 11 | 9.190% | +0.10% |
理论值是 9.09%,实测平均绝对偏差 0.063 个百分点。最早到的头像和最后到的头像,机会几乎完全相等——算法诚不我欺。
100 张的安全兜底
Raymond 的代码里还有一行容易被忽略的保险:采样 100 张之后,循环强制停止。
原因写得很直白:「防止有人在 Default Pictures 目录里放一百万张文件时出现病态行为。」
这个兜底本身值得琢磨:它的意思是,如果目录里文件超过 100 个,第 101 张及以后的图片永远没有机会被选中。兜底保证了性能(不会真的傻乎乎遍历一百万张),代价是牺牲了「完全公平」——但在 XP 的语境里,默认头像目录由系统自己维护,正常只有 10 张左右,这个牺牲永远碰不到。
我在模拟里验证了这一点:目录里放 10000 张图,跑 5 万轮,扫描的文件数恒定 100,被选中的位置永远不会超过 99——第 10001 张图来了也得排队,但排不上。
这种「保证单个操作可控」的防守,是 2001 年的系统代码和现在的业务代码一个很不一样的气质:它预设用户会胡来,并且提前为胡来买单。
彩蛋:同一批电脑,为什么头像会撞车?
现在回到那颗种子。GetTickCount() 是开机以来的毫秒数——不是墙上时钟,是相对开机时刻。
这意味着:如果在一条产线上,几十台机器挨个开机、挨个进系统设置界面,它们的 GetTickCount() 只差几秒甚至几百毫秒。种子足够接近,RtlRandomEx 输出的序列就会高度相关,选出来的头像也就高度雷同。
我在模拟里复现了这个场景:20 台机器,开机时刻依次错开 1 秒,用「开机秒数」做种子各选一次头像——结果 20 台机器只产生了 10 种不同的头像,好几组完全撞车。
这解释了 20 年前论坛里一个流传很广的都市传说:「为什么我们学校机房里的新电脑,默认头像全是同一只蝴蝶?」不一定是玄学,可能只是产线开机太快,骰子还没洗开。
当「随机」成为默认人性
写到最后,想起评论区 scrumper 的一句话:人类从一堆东西里随机挑一个,伸手一抓就行;而计算机没有「随便抓一个」的能力,它必须把「随机」翻译成一行行有据可循的代码。
于是「随机」变成了很多系统默认的人性:XP 给你随机头像,QQ 给每个人一只永远不会换的企鹅,微信给你一个灰色人形剪影。有的是算法,有的是符号——但默认头像这件事本身,是操作系统第一次替千千万万素未谋面的用户做「审美决定」。
我在想,如果 2001 年那个写蓄水池抽样的工程师知道:他随手埋下的公平,二十五年后会被一个推特用户翻出来,被一篇博客拆开,被我在服务器上跑了十万次模拟确认——他大概会愣了一下,然后继续改他的 bug。
算法赢了公平。至于「看起来随机」,那是另一个问题。
评论(0)
暂无评论,来写第一条吧~