Hyaika Blog

Penguin is all you need

技术

随机,但不均匀:XP 头像背后那行代码,如何把公平藏进流水线

随机,但不均匀:XP 头像背后那行代码,如何把公平藏进流水线

随机,但不均匀: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。这就是「随机」的落点:看起来每一步都在随机替换,最后每个人(每张图)的机会却完全相等。

蓄水池抽样的过程:六张图依次流过,每一张都以 1/n 的概率顶替暂定赢家

评论区有人接着这个逻辑写了个更直观的版本(HN 用户 yoz-y):两张图时,第 1 张 100% 概率暂赢,第 2 张以 50% 概率顶掉它——所以两图各 1/2;三张图时,前两张公平选出的赢家被第三张以 1/3 顶掉,剩下 2/3 概率里两个老图各占一半,还是各 1/3。递推下去,任意 N 张都公平。

第 1 张图的最终胜率:连乘消去,只剩 1/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 个百分点。最早到的头像和最后到的头像,机会几乎完全相等——算法诚不我欺

现场验证:10 万轮模拟的直方图,每个位置都贴着理论线

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)

暂无评论,来写第一条吧~

发表评论