为什么计算机里的“随机“几乎都是假的
这个系列一直在讲确定性——定点数、逐比特一致、全网同一个答案。今天岔开聊个看似无关、其实是一根线上的东西:随机数。因为你迟早会问,FPS 里霰弹枪的散射、暴击、掉落,这些随机怎么在所有客户端上还能算出一样的结果?答案是,计算机里的随机数压根就不是真随机,而这个假,恰恰是我们求之不得的。计算机没法凭空变出随机先想一个最根本的问题:随机到底从哪来?真正的随机,得来自物理世界某个本质上不可预测的过程——放射性原子什么时候衰变、电路里的热噪声、大气层的电磁扰动。这些东西背后没有公式,你没法算出来下一刻是多少,只能去测量。而计算机是什么?一台严格按指令执行的确定性机器。你给它同样的输入,它永远吐同样的输出——这本来就是我们指望它的地方,不然程序没法用了。可问题来了:一台同样输入必然同样输出的机器,你让它给我一个不可预测的数,这本身就是矛盾的。它做的每一步都是可预测的,凭空变不出不可预测来。所以计算机里绝大多数所谓的随机数,都不是真的随机,而是用一个确定的公式,算出一串看起来杂乱无章的数。这就是伪随机——pseudo,假的、伪装的。它看着随机,骨子里是被一个算法一步步推出来的,完全可复现。伪随机长什么样:一个种子,一条链理解伪随机,抓住两个词:种子(seed)和序列(sequence)。伪随机数生成器(PRNG)干的事,是从一个初始值出发,反复套一个公式,每套一次得到一个新数,同时这个新数又变成下一次的输入。像滚雪球一样,一个推一个,滚出一长串数:种子 → 公式 → 数1 → 公式 → 数2 → 公式 → 数3 → ... seed ↑______这个又喂回去当下一次的输入______↑最经典的例子是线性同余(LCG),公式简单到你会怀疑它凭什么能随机:// 线性同余生成器,教科书级别的简单classLCG{ulongstate;publicLCG(ulongseed){stateseed;// 种子决定一切}publicuintNext(){// 乘一个大数、加一个数、取模——就这么三步statestate*6364136223846793005UL1442695040888963407UL;return(uint)(state33);// 取高位,低位随机性差}}就这么个乘、加、截断的公式,反复套,吐出来的数列却是杂乱无章、看不出规律的。你盯着数1、数2、数3……根本猜不到下一个是几——除非你知道种子和公式。关键在种子。同一个种子,这条链每一步都完全一样,吐出的整串数分毫不差。换个种子,整条链就变成另一副样子。所以伪随机数不是随机产生的,是由种子唯一确定的一串数,只不过这串数的分布看起来足够均匀、足够杂乱,骗过了想找规律的眼睛。varanewLCG(12345);varbnewLCG(12345);// a 和 b 会吐出一模一样的序列,一个不差// a.Next() b.Next(),永远成立这就是伪的含义:它有随机的外表(数看起来乱),没有随机的内核(过程完全确定、可复现)。那真随机在哪:得去测物理世界伪随机之外,计算机确实也能搞到真随机,但代价是——它不能靠算,得靠采集物理世界的噪声。操作系统会去收集一堆本质上不可预测的物理事件:你敲键盘的精确时间间隔(精确到纳秒)、鼠标移动的微小抖动、硬盘寻道的时延、CPU 温度传感器的最低位、网卡收包的时间戳。这些东西背后是真实的物理混沌,攒到一起形成一个熵池(entropy pool)。Linux 上的/dev/random就是干这个的。现代 CPU 甚至内置了硬件随机数指令(比如 Intel 的 RDRAND),直接采电路热噪声。但这些真随机有几个特点:慢(得等物理事件攒够熵)、不可复现(这才是重点,它就是要不可复现)、通常只用在最需要绝对不能被预测的地方——生成加密密钥、密码盐、安全 token。也就是说,真随机是留给安全用的,那里不可预测是命根子。而日常你写的Random.Next()、游戏里的各种随机,几乎全是伪随机。这就回到标题:计算机里绝大多数随机数,都是伪随机。真随机是少数派,专供安全场景。对游戏来说,伪不是缺陷,是刚需讲到这你可能觉得,伪随机是个不得已的将就——真随机搞不到、太慢,只好用假的凑合。反了。对我们做联机游戏的,伪随机的可复现恰恰是求之不得的宝贝。你顺着这个系列一路读下来,应该已经嗅到味道了:确定性。想象霰弹枪开一枪,十几颗弹丸朝不同方向散射,每颗方向随机。在确定性同步的 FPS 里,这一枪的结果必须在所有客户端和服务器上完全一样——你屏幕上打空的那颗,队友屏幕上、服务器判定里,都得是打空的同一颗。要是各端各随机各的,你这打中了他那没打中,命中判定直接分裂,整局 desync。伪随机怎么解决?所有端用同一个种子、同一个算法。种子一样,那条随机数链每一步都一样,散射的十几颗弹丸方向,红米、iPhone、服务器算出来一模一样。真随机在这里反而是灾难——它不可复现,各端各采各的物理噪声,结果必然对不上。// 确定性同步里的随机:种子由全局状态派生,所有端一致classDeterministicRandom{ulongstate;// 种子通常来自:对局初始种子 当前逻辑帧号 某个事件序号// 这些在所有端都相同,所以派生出的 state 也相同publicDeterministicRandom(ulongsharedSeed){statesharedSeed;}publicuintNext(){statestate*6364136223846793005UL1442695040888963407UL;return(uint)(state33);}}所以在确定性系统里,随机数生成器是被当作逻辑状态的一部分严格管理的:它的 state 要跟着走帧一起推进、要能打进快照、要在回放时精确恢复。它和角色位置、着地状态是一个待遇——都是必须逐比特一致的权威状态。几条实战铁律既然把随机数当逻辑状态管,前面整个系列讲定点数时那些洁癖,原样搬过来:一,种子必须全端同源。别用本地时间、别用System.Random默认构造(它拿系统时间当种子,各端不一样)。种子要从对局共享的确定性状态派生——初始种子由服务器下发或大家约定,再混入逻辑帧号、事件序号这类全端一致的量。二,算法要自己实现,别用系统库。平台自带的Random内部算法可能因运行时、版本、平台而异,不保证跨平台逐比特一致。这跟前面别用Math.Sin,自己查表是一个道理——凡是要全端一致的,底层实现必须攥在自己手里。自己写个 LCG 或者 xorshift,行为写死,谁都改不了。三,输出走整数,别碰浮点。需要0 到 1 的随机小数时,别返回 float,返回定点数——拿整数随机值去除以范围,落到你的 Q 格式定点里。整个系列反复强调,一处浮点泄漏就满盘皆输,随机数这里也不例外。四,谁调用、调几次,也得一致。这是最隐蔽的坑。随机数是一条有顺序的链,第 N 次调用拿到第 N 个数。如果某个端因为某种分支多调了一次Next(),它的链就错位了,从此之后全端随机数全对不上。所以不光种子要一致,消耗随机数的次数和顺序也必须在所有端严格一致。这条最容易翻车——一个客户端专属的表现逻辑不小心调了一下共享 RNG,整条链就歪了。表现层要随机,得用另一个独立的、不参与同步的 RNG,井水不犯河水。收尾怎么理解计算机里绝大多数随机数都是伪随机?一句话:计算机是确定性机器,同样输入必然同样输出,它凭空变不出真正的不可预测,只能用一个确定的公式从种子出发,算出一串看起来杂乱、实则完全可复现的数——这就是伪随机。真随机得靠采集物理世界的噪声,慢、不可复现,专留给加密这种绝对不能被预测的安全场景,是少数派。而对做联机游戏的我们,伪随机的可复现不是缺陷是刚需:所有客户端和服务器用同一个种子、同一个算法,那条随机数链就在全端逐比特一致,霰弹散射、暴击、掉落,大家算出来分毫不差,命中判定才不会分裂。所以在确定性系统里,随机数生成器被当成逻辑状态的一部分严格管理——种子全端同源、算法自己实现、输出走定点、消耗顺序还得一致。绕了一圈你会发现,这跟定点数、跟自己查三角函数表、跟重力常量烘成整数,是同一件事的不同侧面:确定性是从每一个看起来该随机、该用系统库、该图省事的角落,一点点守出来的。随机数只是又一个考验你守不守得住的地方。