【PHP8.2】PHP的随机数会得到大大的改善
在PHP中,随机数一直以来都存在各种问题,所以它很出名。
关于随机数的质量这一大问题,通过random_int的出现,暂时看到了一种解决方法,但除此之外,还存在一些其他问题,比如状态是全局的等等。
决定在PHP8.2中引入Random\Randomizer类以解决这些问题。
下面是相关RFC,Random Extension 5.x的介绍。
5.x 随机扩展
引言
目前,PHP的随机功能仍存在一些问题。
问题
问题的主要点有以下4点。
・全球状态
・梅森旋转子
・随机性
・内部机制
全球状态
Mersenne Twister的内部状态会被隐式地保存在全局区域中,而且用户没有修改它的方法。因此,在函数内使用随机函数可能导致代码错误。
假设有以下代码。
function foo(): void {}
mt_srand(1234);
foo();
mt_rand(1, 100); // 76
我做了如下更改。
function foo(): void {
str_shuffle('abc'); // ランダム関数を入れた
}
mt_srand(1234);
foo();
mt_rand(1, 100); // 65
由于使用了str_shuffle()函数,导致mt_rand()的结果从76变成了65。
使用外部包将使得这种代码的维护变得更加困难。
甚至于,在生成器(Generator)和纤程(Fiber)中,当前状态会很容易丢失。
上述的是,mt_srand()和srand()函数无法提供可重现的随机值。
在像Swoole这样的扩展模块中,由于Mersenne Twister的内部状态也被复制到子进程中,所以会产生随机数不安全的问题。
梅森旋转器
“尽管梅森旋转器是一种优秀的伪随机数生成器,但它已经过时,不再适应现代需求。”
尽管2的19937次方减1有一个非常长的周期,它仍然在BigCrash和Crush这样的测试中失败了。
此外,梅森旋转器只能生成32位大小的值。这与许多运行环境和zend_long均为64位的现状不符。
随机性 (suí jī
PHP的内置函数shuffle、str_shuffle、array_rand使用梅森旋转器作为随机数源。
这不是密码学上安全的。
如果需要一个满足密码学安全性的类似函数,用户必须使用random_int等来实现新的函数。
内部部分
在PHP中,由于历史原因,随机数的实现在各个地方都有点混乱。它们被实现在不同的文件中,有些甚至彼此依赖,这可能让扩展模块的开发人员感到困惑。
用户态方法
我们来考虑一种通过用户自行实现来解决类似问题的方法。
首先,我们实现一个随机数生成器。
我们将比较这里使用PHP编写的实现和我们自己实现的XorShift128+。
class XorShift128Plus
{
/* constants */
protected const MASK_S5 = 0x07ffffffffffffff;
protected const MASK_S18 = 0x00003fffffffffff;
protected const MASK_S27 = 0x0000001fffffffff;
protected const MASK_S30 = 0x00000003ffffffff;
protected const MASK_S31 = 0x00000001ffffffff;
protected const MASK_LO = 0x00000000ffffffff;
protected const ADD_HI = 0x9e3779b9;
protected const ADD_LO = 0x7f4a7c15;
protected const MUL1_HILO = 0x476d;
protected const MUL1_HIHI = 0xbf58;
protected const MUL1_LO = 0x1ce4e5b9;
protected const MUL2_HIHI = 0x94d0;
protected const MUL2_HILO = 0x49bb;
protected const MUL2_LO = 0x133111eb;
/* states */
protected int $s0;
protected int $s1;
public function __construct(int $seed)
{
$s = $seed;
$this->s0 = $this->splitmix64($s);
$this->s1 = $this->splitmix64($s);
}
public function generate(): int
{
$s1 = $this->s0;
$s0 = $this->s1;
$s0h = ($s0 >> 32) & self::MASK_LO;
$s0l = $s0 & self::MASK_LO;
$s1h = ($s1 >> 32) & self::MASK_LO;
$s1l = $s1 & self::MASK_LO;
$zl = $s0l + $s1l;
$zh = $s0h + $s1h + ($zl >> 32);
$z = ($zh << 32) | ($zl & self::MASK_LO);
$this->s0 = $s0;
$s1 ^= $s1 << 23;
$this->s1 = $s1 ^ $s0 ^ (($s1 >> 18) & self::MASK_S18) ^ (($s0 >> 5) & self::MASK_S5);
return $z;
}
protected function splitmix64(int &$s): int
{
$zl = $s & self::MASK_LO;
$zh = ($s >> 32) & self::MASK_LO;
$carry = $zl + self::ADD_LO;
$z = $s = (($zh + self::ADD_HI + ($carry >> 32)) << 32) | ($carry & self::MASK_LO);
$z ^= ($z >> 30) & self::MASK_S30;
$zl = $z & self::MASK_LO;
$zh = ($z >> 32) & self::MASK_LO;
$lo = self::MUL1_LO * $zl;
$zll = $zl & 0xffff;
$zlh = $zl >> 16;
$mul1l = $zll * self::MUL1_HILO;
$mul1h = $zll * self::MUL1_HIHI + $zlh * self::MUL1_HILO + (($mul1l >> 16) & 0xffff);
$mul1 = (($mul1h & 0xffff) << 16) | ($mul1l & 0xffff);
$mul2 = ((self::MUL1_LO * $zh) & self::MASK_LO);
$carry = (($lo >> 32) & self::MASK_LO);
$hi = $mul1 + $mul2 + $carry;
$z = ($hi << 32) | ($lo & self::MASK_LO);
$z ^= ($z >> 27) & self::MASK_S27;
$zl = $z & self::MASK_LO;
$zh = ($z >> 32) & self::MASK_LO;
$lo = self::MUL2_LO * $zl;
$zll = $zl & 0xffff;
$zlh = $zl >> 16;
$mul1l = $zll * self::MUL2_HILO;
$mul1h = $zll * self::MUL2_HIHI + $zlh * self::MUL2_HILO + (($mul1l >> 16) & 0xffff);
$mul1 = (($mul1h & 0xffff) << 16) | ($mul1l & 0xffff);
$mul2 = (self::MUL2_LO * $zh) & self::MASK_LO;
$carry = ($lo >> 32) & self::MASK_LO;
$hi = $mul1 + $mul2 + $carry;
$z = ($hi << 32) | ($lo & self::MASK_LO);
return $z ^ (($z >> 31) & self::MASK_S31);
}
}
$xs128pp = new \XorShift128Plus(1234);
// ベンチマーク
for ($i = 0; $i < 1000000000; $i++) {
$xs128pp->generate();
}
当执行这些实现并对mt_rand()进行了1000000000次操作时,速度如下。
请参考邮件列表,无论使用JIT与否,原生实现都明显更快。
提案 (Tí’àn)
提供了一个名为Randomizer的类,提供各种各样的随机方法。
通过将Engine接口传递给构造函数,可以根据需要更改随机数生成器。
为了方便起见,默认情况下提供了基本引擎,但也计划提供一种能够轻松添加算法的接口。
这个建议有以下优点。
根据环境交换随机数产生器
您可以根据环境选择随机数生成器。
例如,在开发时我们可能想使用伪随机数发生器(PRNG),而在正式生产环境中则希望使用加密安全伪随机数发生器(CSPRNG)。我们可以通过以下简单的代码来实现这样的需求。
$rng = $is_production
? new Random\Engine\Secure()
: new Random\Engine\PCG64(1234);
$randomizer = new Random\Randomizer($rng);
$randomizer->shuffleString('foobar');
固定的随机数序列
有时候,像是在需要满足特定条件的情况下持续生成随机数,或者在计算负载方面比较困难。
$required_result = mt_rand(1, 100);
while (($generated = mt_rand(1, 100)) !== $required_result) {
echo "retry\n";
}
echo "done\n";
可以通过使用动态注入来改变测试时的行为。
$engine = new class () implements Random\Engine {
public function generate(): string
{
// Result must be a string.
return pack('V', 1);
}
};
$randomizer = new Random\Randomizer($engine);
$required_result = $randomizer->getInt(1, 100);
while (($generated = $randomizer->getInt(1, 100)) !== $required_result) {
echo "retry\n";
}
echo "done\n";
具有加密安全的随机操作
使用CSPRNG进行字符串和数组的洗牌,过去必须在用户端实施。
未来将不再需要用户端实施。
$engine = new Random\Engine\Secure();
$randomizer = new Random\Randomizer($engine);
$items = range(1, 10);
$items = $randomizer->shuffleArray($items);
保持安全 chí
由于范围限于实例,可以防止外部包或光纤等外部因素的变化。
方法
以下将实现以下接口和类。
随机工具引擎界面
提供随机数生成器的接口。
有一个名为generate()的方法,它返回一个二进制字符串。
返回值不能为空,否则会引发RuntimeException。
如果要在PHP中实现一个随机数发生器,需要以Little Endian格式进行打包并返回值。
由于Engine::generate()的返回值是一个字符串,所以无论在哪个执行环境中,如果种子和序列相同,随机数将返回相同的结果。
然而,在用户实现中,如果返回一个超过64位的字符串,有可能会被截断为64位。
随机\加密安全引擎的界面
乱数发生器被证明具有密码学上的安全性的标记界面。
Random\SerializableEngine 接口。
一个表示随机数发生器可序列化的接口。
需要实现以下两个方法。
__serialize(): array
__unserialize(array $data): void
随机引擎的组合线性同余发生器
使用CombinedLCG算法生成随机数。
构造函数参数作为种子值,当省略时,种子值由CSPRNG生成。
如果传递相同的种子值,结果将始终相同。
$seed = 1234;
$engine = new \Random\Engine\CombinedLCG($seed);
var_dump(bin2hex($engine->generate())); // "fc6ff102"
var_dump(bin2hex($engine->generate())); // "40e0ce05"
// 同じシード値からは同じ値になる
$engine = new \Random\Engine\CombinedLCG($seed);
var_dump(bin2hex($engine->generate())); // "fc6ff102"
var_dump(bin2hex($engine->generate())); // "40e0ce05"
随机引擎MersenneTwister类
使用梅森旋转算法生成随机数。
构造函数参数作为种子值,当省略时种子值由CSPRNG生成。
将MT_RAND_PHP常量作为第二个参数传递会使梅森旋转算法损坏。
如果传递相同的种子值,将始终得到相同的结果。
$seed = 1234;
$engine = new \Random\Engine\MersenneTwister($seed);
var_dump(bin2hex($engine->generate())); // "2f6b0731"
var_dump(bin2hex($engine->generate())); // "d3e2667f"
// 同じシード値からは同じ値になる
$engine = new \Random\Engine\MersenneTwister($seed);
var_dump(bin2hex($engine->generate())); // "2f6b0731"
var_dump(bin2hex($engine->generate())); // "d3e2667f"
随机引擎PCG64类
使用PCG64算法生成随机数。
构造函数的参数将作为种子值,如果参数被省略,则种子值将由CSPRNG生成。
如果传递相同的种子值,结果将始终相同。
$seed = 1234;
$engine = new \Random\Engine\PCG64($seed);
var_dump(bin2hex($engine->generate())); // "ecfbe5990a319380"
var_dump(bin2hex($engine->generate())); // "4f6b4a5b53b10e3f"
// same seed results in same sequence of results.
$engine = new \Random\Engine\PCG64($seed);
var_dump(bin2hex($engine->generate())); // "ecfbe5990a319380"
var_dump(bin2hex($engine->generate())); // "4f6b4a5b53b10e3f"
随机引擎安全
在Secure::generate()中,无法传递种子,并且没有可复制性。
此类使用的随机数被保证是CSPRNG。
通过Secure::generate()生成的值是无法再现的。
最终类 随机数生成器\随机化者
这是一个使用传递的随机数生成器执行随机数处理的类。
以下方法已经准备好。
__construct(Random\Engine $engine = new Random\Engine\Secure())
getInt(): int // replaces mt_rand()
getInt(int $min, int $max) // replaces mt_rand() and random_int()
getBytes(int length): string // replaces random_bytes()
shuffleArray(array $array): array // replaces shuffle()
shuffleString(string $string): string // replaces str_shuffle()
__serialize(): array
__unserialize(array $data): void
可以在构造函数中传递一个随机数生成器。
如果省略,则使用Random\Engine\Secure。
这个类本身是可以序列化的,但是可能存在引擎不可序列化的情况。
在用户层实现引擎中,我们使用$this->engine->generate()方法来生成值。
而本地引擎则不受此限制,可以以更快的方式生成值。
每个方法都可以在不同的执行环境中重现结果,并且未来的兼容性也得到了保证。
但是,如果在32位环境中使用生成大于32位值的引擎来执行Randomizer::getInt(),将会引发RuntimeException异常。
这些引擎实施了一些常见的随机数发生器。
它们保证对于给定的种子值,会返回与参考序列相同的序列。
在Randomizer中,当可以从外部观察到的值发生变化时,被认为是破坏性变更。
对于附带破坏性变更的规范更改,需要单独的RFC。
PRNG 比拼
随机数生成算法的比较。
由于前述的问题,最好回避使用MT19937(梅森旋转器)。
如果要引入新的RNG算法,选择是很重要的。
我们考虑的算法如下。
MT19937和XorShift128+是广泛使用的,但也存在测试不通过的问题,因此不适合新的使用。
因此,我们选择了最新的PCG64算法,它没有测试问题。
除了为了互通性而保留的MT19937以外,PCG64是唯一可实现可重现性的随机数生成器。
由于PCG64使用128位整数,无法在32位环境中以原生方式处理,因此需要进行模拟。但由于已经在大多数环境中使用64位,所以应该没有问题。
我們也考慮過Xoshiro256,但最終選擇了PCG64,因為它在處理提出的問題上似乎是最合適的。
有關問題的指摘和對策,可以在以下網站中查看。
https://pcg.di.unimi.it/pcg.php 的内容请移步这个链接:https://www.pcg-random.org/posts/on-vignas-pcg-critique.html
有趣的是,这些算法之间激烈互相批评。
两种观点都应受到尊重,因此选择非常困难。
我也考虑过同时实施两种方式,但增加不必要的选项只会增加混乱,所以我放弃了这个选择。
如果有人觉得需要的话,可以通过扩展模块等方式进行增加。
内部变化
以下的PHP函数已迁移到新建的ext/random扩展模块中。
・lcg_value() -> 线性同余生成器值()
・srand() -> 设置随机种子()
・rand() -> 生成随机数()
・mt_srand() -> 设置 Mersenne Twister 随机数种子()
・mt_rand() -> 生成 Mersenne Twister 随机数()
・random_int() -> 生成随机整数()
・random_bytes() -> 生成随机字节()
此外,以下的内部API也将被移动到ext/random扩展模块中。
– php_random_int_throw() – 抛出 PHP 随机整数
– php_random_int_silent() – 静默 PHP 随机整数
– php_combined_lcg() – PHP 组合线性同余生成器
– php_mt_srand() – PHP 梅森旋转算法种子初始化
– php_mt_rand() – PHP 梅森旋转算法产生随机数
– php_mt_rand_range() – PHP 梅森旋转算法产生指定范围内的随机数
– php_mt_rand_common() – PHP 梅森旋转算法常用随机数生成
– php_srand() – PHP 种子初始化
– php_rand() – PHP 生成随机数
– php_random_bytes() – PHP 随机字节生成
– php_random_int() – PHP 随机整数生成
为了标准化RNG,所有与RNG相关的实现将被整合到一个新的random扩展中。
以下的头文件保留下来是为了扩展模块的兼容性,但实际上包含的是ext/random/php_random.h。
・ext/standard/php_lcg.h – ext/standard/php_lcg.h 是一个文件。
・ext/standard/php_rand.h – ext/standard/php_rand.h 是另一个文件。
・ext/standard/php_mt_rand.h – 同样,ext/standard/php_mt_rand.h 是一个文件。
・ext/standard/php_random.h – 最后一个文件是 ext/standard/php_random.h。
未来的范围
这部分内容是关于未来展望的,不包含在这个RFC中。
・为了保持兼容性,删除保留的头文件。
・将lcg_value()、mt_srand()、srand()标记为不推荐使用。
不兼容向后的改变
不具备互换性的改变。
以下的名字将无法使用。
– 随机的
– 随机引擎
– 安全随机引擎
– 可序列化的随机引擎
– 混合线性同余随机引擎
– 梅森旋转随机引擎
– PCG64随机引擎
– 安全随机引擎
– 随机发生器
建议的 PHP 版本
PHP8.2可以直接在中国境内访问
投票
投票日程为2022年6月14日至2022年6月28日,只需要获得选民三分之二的赞成即可被接受。
这个RFC被全体一致通过,赞成票21票,反对票0票。
补丁和测试
心得
通过这个改进,PHP也可以使用可重现性的随机数。个人而言,我从未写过依赖可重现性随机数的处理,所以不太了解,但对于像游戏等需要可重现性的处理来说,这是一个重要的因素。
此外,由于此RFC,原本PHP存在的各种与随机性相关的问题也将被彻底解决。从现在开始,没有人会再说PHP的随机数有问题了。
顺便提一下,撰写本RFC的人是一位曾经为PHP的乱数实现写过一篇“一团糟”文章的人。他以一种对他人事情的态度写着“为了解决这些问题,我们针对PHP 8.2提出了Random Extension 5.x的RFC,并且已经开始投票了。”但这不就是自己给自己“鼓掌”的节奏吗?