python标准库中的随机数种子选取策略

太长不看:python优先使用系统硬件随机噪声(urandom)作为随机数种子,如果无法获得urandom则使用系统时间;随机数序列使用梅森旋转算法(Mersenne Twister)实现,以生成足够随机的数字。


一、起因

某天在修改一个python编写的模块时,遇到了一个问题。该模块使用python标准库 random 生成随机序列,每次调用时都会使用 random.seed() 通过默认参数创建一个实例化对象。我怀疑这里通过默认参数得到的随机数不够随机,因此对python标准库中的随机数底层实现进行了探究。

二、一些需要知道的背景知识

(一)硬件随机数熵池(或urandom)

参考 : /dev/random 和 /dev/urandom 的原理 - 程序员十三的文章 - 知乎

python中相关的接口是 os.urandom 。其调用的底层函数在Linux系统上是/dev/urandom ,在windows系统上是BCryptGenRandom()

/dev/random/dev/urandom 是 Linux 上的字符设备文件,它们是 随机数生成器 ,为系统提供随机数;在Windows上,它使用的是CryptGenRandomBCryptGenRandom(取决于Windows版本)。

随机数生成器会收集系统环境中各种数据,比如:鼠标的移动,键盘的输入, 终端的连接以及断开,音视频的播放,系统中断,内存 CPU 的使用等等;生成器把收集到的各种环境数据放入一个池子 ( 熵池 ) 中,然后将这些数据进行去偏、漂白,主要目的也是使得数据更加无序,更加难以猜测或者预料得到。

有了大量的环境数据之后,每次获取随机数时,从池子中读取指定的字节序列,这些字节序列就是生成器生成的随机数。

urandom或者说熵池,存储的是真随机数。

(二)梅森旋转算法(Mersenne Twister)

参考:谈谈梅森旋转:算法及其爆破 - 孟晨的文章 - 知乎 以及 Mersenne Twister 梅森旋转算法笔记 。更通俗易懂的解释见博客 你没听过的梅森旋转算法 。以下是对算法的简单介绍,而不是详细的原理分析。

这是一种由松本眞(Makoto Matsumoto)和西村拓士(Takuji Nishimura)在 1997 年提出的伪随机数生成算法。这个旋转算法实际上是对一个19937级的二进制序列作变换。在数学上我们可以证明一个定理,即对于一个长度为n的二进制序列,它的排列长度最长为 $2^n$ (实际上可能因为某些操作不当,没挺到 $2^n$ 个就开始循环了)。那么如何将这个序列的排列撑满 $2^n$ 个,就是这个旋转算法的精髓。

以下是梅森旋转算法的步骤:

  1. 利用 seed 初始化寄存器状态
  2. 对寄存器状态进行旋转
  3. 根据寄存器状态提取伪随机数

绝大多数标准库当中内置的随机数发生器,就是对梅森旋转算法的实现。而要生成不同的随机数序列,关键问题就在于seed(随机数种子)的选择。

好了,终于到了我们关心的环节:随机数种子的选择原理。

三、python标准库中random.seed()的实现

(一)文档中的描述

为了搞清楚原理,我去查阅了官方文档和源代码。

下面是官方文档中的描述,其中提到seed()函数接受一个参数a作为随机数种子,但是用户不提供a也是可以的,python会选取一个默认值作为随机数种子。根据这里的描述,默认(a=None时),优先使用操作系统的随机熵源(如Linux的/dev/urandom),若不可用,则回退到使用系统时间。

1
2
3
4
5
6
7
random.seed(a=None, version=2)

Initialize the random number generator.

If a is omitted or None, the current system time is used. If randomness sources are provided by the operating system, they are used instead of the system time (see the os.urandom() function for details on availability).

If a is an int, it is used directly.

(二)表面上的源代码Lib/random.py

但其实这里的解释有些模糊,于是我去查询了源代码。首先查到的是 Lib/random.py (但实际上这个并非真正的源代码)。Lib/random.py 的代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
def seed(self, a=None, version=2):
if version == 1 and isinstance(a, (str, bytes)):
a = a.decode('latin-1') if isinstance(a, bytes) else a
x = ord(a[0]) << 7 if a else 0
for c in map(ord, a):
x = ((1000003 * x) ^ c) & 0xFFFFFFFFFFFFFFFF
x ^= len(a)
a = -2 if x == -1 else x

elif version == 2 and isinstance(a, (str, bytes, bytearray)):
global _sha512
if _sha512 is None:
try:
# hashlib is pretty heavy to load, try lean internal
# module first
from _sha2 import sha512 as _sha512
except ImportError:
# fallback to official implementation
from hashlib import sha512 as _sha512

if isinstance(a, str):
a = a.encode()
a = int.from_bytes(a + _sha512(a).digest())

elif not isinstance(a, (type(None), int, float, str, bytes, bytearray)):
raise TypeError('The only supported seed types are:\n'
'None, int, float, str, bytes, and bytearray.')

super().seed(a)
self.gauss_next = None

请注意,此处源代码中似乎只有将用户参数a转换为int类型的操作,并没有涉及到对当前时间的调用或对urandom的调用(甚至也没有实现梅森旋转算法)。seed函数的最后,调用了 super().seed(a) 这个函数接口,我也同样没有找到这个函数的定义。

有些沮丧,于是我去求助了chatGPT和Gemini。在它们的帮助之下,我找到了真正的源代码,并解决了最初的那个问题。

(三)真正的源代码 Modules/_randommodule.c

AI告诉我,前面查到的代码是random.Random.seed 函数的实现,而最后的 super().seed(a) 调用的是父类的seed()方法。解决问题的关键就在这个父类上。

Random 类的继承关系是这样的:

  • random.Random(Python实现)
  • random._random.Random(C语言实现,作为Python的内置模块)

大多数情况下,Python会用 C 实现的 _random.Random,而这个C语言实现的源码位置在Modules/_randommodule.c

于是我去寻找了这个源代码,其内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
static int
random_seed(RandomObject *self, PyObject *arg)
{
int result = -1; /* guilty until proved innocent */
PyObject *n = NULL;
uint32_t *key = NULL;
size_t bits, keyused;
int res;

if (arg == NULL || arg == Py_None) {
if (random_seed_urandom(self) < 0) {
PyErr_Clear();

/* Reading system entropy failed, fall back on the worst entropy:
use the current time and process identifier. */
if (random_seed_time_pid(self) < 0) {
return -1;
}
}
return 0;
}

/* This algorithm relies on the number being unsigned.
* So: if the arg is a PyLong, use its absolute value.
* Otherwise use its hash value, cast to unsigned.
*/
if (PyLong_CheckExact(arg)) {
n = PyNumber_Absolute(arg);
} else if (PyLong_Check(arg)) {
/* Calling int.__abs__() prevents calling arg.__abs__(), which might
return an invalid value. See issue #31478. */
_randomstate *state = _randomstate_type(Py_TYPE(self));
n = PyObject_CallOneArg(state->Long___abs__, arg);
}
else {
Py_hash_t hash = PyObject_Hash(arg);
if (hash == -1)
goto Done;
n = PyLong_FromSize_t((size_t)hash);
}
if (n == NULL)
goto Done;

/* Now split n into 32-bit chunks, from the right. */
bits = _PyLong_NumBits(n);
if (bits == (size_t)-1 && PyErr_Occurred())
goto Done;

/* Figure out how many 32-bit chunks this gives us. */
keyused = bits == 0 ? 1 : (bits - 1) / 32 + 1;

/* Convert seed to byte sequence. */
key = (uint32_t *)PyMem_Malloc((size_t)4 * keyused);
if (key == NULL) {
PyErr_NoMemory();
goto Done;
}
res = _PyLong_AsByteArray((PyLongObject *)n,
(unsigned char *)key, keyused * 4,
PY_LITTLE_ENDIAN,
0, /* unsigned */
1); /* with exceptions */
if (res == -1) {
goto Done;
}

#if PY_BIG_ENDIAN
{
size_t i, j;
/* Reverse an array. */
for (i = 0, j = keyused - 1; i < j; i++, j--) {
uint32_t tmp = key[i];
key[i] = key[j];
key[j] = tmp;
}
}
#endif
init_by_array(self, key, keyused);

result = 0;

Done:
Py_XDECREF(n);
PyMem_Free(key);
return result;
}

其中即涉及到seed默认值的选取,又涉及到梅森旋转算法的实现。我们要关注的是前者,对应的代码片段如下:

1
2
3
4
5
6
7
8
9
10
11
12
if (arg == NULL || arg == Py_None) {
if (random_seed_urandom(self) < 0) {
PyErr_Clear();

/* Reading system entropy failed, fall back on the worst entropy:
use the current time and process identifier. */
if (random_seed_time_pid(self) < 0) {
return -1;
}
}
return 0;
}

这段代码的意思是这样:

  • 程序首先尝试调用 random_seed_urandom(self)。这个函数内部会尝试从 os.urandom()读取足够多的(通常是32字节)高质量随机字节来初始化Mersenne Twister(梅森旋转算法)的状态。
  • 如果 os.urandom() 不可用或读取失败(< 0),程序会清除这个错误,然后fall back到 random_seed_time_pid(self) ,即获取当前的高精度时间戳和当前进程的ID(Process ID),将它们混合起来作为种子。这是一种质量较低但兼容性很强的后备方案。

因此,我们的最终结论是:默认种子的选择策略是 os.urandom 优先,失败则回退到 时间 + 进程ID