Logo
Section 1 / 3

修复简单问题

在本节课中,我们将开始重新设计 Random 库,并探讨其中的性能问题。

导入

修复简单问题

在本节课中,我们将开始重新设计 Random 库,并探讨其中的性能问题。

在上一课中,我们讨论了 System.Random 的缺陷。在本课中,我们将开始重新设计 Random 库。


修复 Random 的首次尝试

我们首先需要两个静态方法:一个用于生成从 0 到 Int32.MaxValue 的真正随机整数,另一个用于生成从 0.00.0(包含)到 1.01.0(不包含)的随机双精度浮点数。不是“伪随机”,而是与真正的均匀分布随机数无法区分。

以下是我们的尝试:

1using CRNG = System.Security.Cryptography.RandomNumberGenerator; 2public static class BetterRandom 3{ 4 private static readonly ThreadLocal<CRNG> crng = new ThreadLocal<CRNG>(CRNG.Create); 5 private static readonly ThreadLocal<byte[]> bytes = 6 new ThreadLocal<byte[]>(() => new byte[sizeof(int)]); 7 public static int NextInt() 8 { 9 crng.Value.GetBytes(bytes.Value); 10 return BitConverter.ToInt32(bytes.Value, 0) & int.MaxValue; 11 } 12 public static double NextDouble() 13 { 14 while (true) 15 { 16 long x = NextInt() & 0x001FFFFF; 17 x <<= 31; 18 x |= (uint)NextInt(); 19 double n = x; 20 const double d = 1L << 52; 21 double q = n / d; 22 if (q != 1.0) 23 return q; 24 } 25 } 26}

代码结构清晰明了。让我们快速分析一下:所有状态都是线程本地的,因此我们以少量的每线程开销和每次调用的间接访问为代价,获得了线程安全性,这大概是一个合理的权衡。我们将重复使用同一个四字节缓冲区,因此缓存它并避免垃圾回收压力是值得的;不过这只是推测,我们需要通过实测来验证。

这里还可能存在其他性能问题,例如:

  • 是否值得一次性生成数千个随机字节,缓存起来,然后逐步使用?或者
  • GetBytes 的每次调用开销是否足够低,以至于无需优化?

我们必须通过实测来验证,但请注意,本课程的重点并非实现高性能优化。代码在很多地方都可以更快。我们选择的是清晰性而非性能。让一个清晰的程序变快,远比让一个快速的程序变得清晰要容易!

我们只需要正整数;通过与 0x7FFFFFFF 进行按位与操作清除最高位,可以很好地实现这一点,且不会改变分布特性。

对于随机双精度浮点数,我们知道双精度数有 52 位精度,因此我们生成一个 52 位的随机整数,并将其作为分数的分子。为了确保 1.01.0 永远不会成为输出结果,我们拒绝这种情况;大约每几十亿次调用中会多执行几次 NextInt。

另一种可能更好的方案是直接从位构建双精度浮点数,而不是进行昂贵的除法运算。

现在我们有了一个密码学强度的、线程安全的随机数库,我们可以基于它构建一个廉价的伪随机数库:

1public static class Pseudorandom 2{ 3 private readonly static ThreadLocal<Random> prng = 4 new ThreadLocal<Random>(() => new Random(BetterRandom.NextInt())); 5 6 public static int NextInt() => prng.Value.Next(); 7 public static double NextDouble() => prng.Value.NextDouble(); 8}

我们已经解决了 Random 的两个主要问题:在单线程中频繁调用时会产生高度相关的值,且不具备线程安全性。现在每个线程都有一个独立的 prng,并且其种子来自密码学强度的随机数,而不是当前时间。

我认为这已经是重大改进了——如果一开始使用的就是这个随机数库,那么 StackOverflow 上关于 Random 使用不当的 99% 的问题都将不存在。这些问题已在核心实现中得到解决。

性能问题

性能方面如何?密码学随机数生成器远慢于伪随机数生成器,且线程本地结构也带来一定开销。但大多数程序并不会明显感受到这些改进带来的延迟。对于那些确实受到影响的程序,我们可以提供一个廉价但线程不安全的版本以提升性能。

我们应该始终做出合理的权衡:如果消除一类错误的成本只是轻微的性能损失,那我们就应该接受这种权衡。

管理语言的核心理念正是:我们用少量性能换取更高的开发效率。

这就是为什么我们有垃圾回收机制、不可变字符串、数组越界检查等特性;它们都以牺牲少量性能为代价,换来更高的安全性、健壮性和开发效率。这也是为什么 C# 允许使用不安全指针——如果你需要放弃部分安全性来换取性能,语言应当支持。但我们应默认优先保证安全!

最近的 System.Random 实现也采用了类似的技术来解决这个问题。

请记住,本课程中的代码仅用于教学目的,不代表工业级高质量代码。例如,它们只对算法中很少使用的部分做了线程安全处理。这种在性能与安全之间权衡的做法是合理的。

我们已经迈出了良好的第一步,但仍有很多改进空间。这些只是基础问题。还有更多值得解决的问题,比如:

  • 当前的伪随机数生成器在随机性方面表现一般。存在许多更优的算法,在相似的状态量和性能下,能产生更难预测的结果。
  • 伪随机数的一个常见用途是生成看似随机但实际上确定性的序列,用于游戏随机化。但当前实现没有提供“将 PRNG 当前状态保存到磁盘并在之后恢复”的功能。

在本课程中,我们不会解决上述两个问题。我们将采取不同的思路来改进处理随机性的方法。我们希望从更高层次的抽象来看待问题,而不是纠结于随机数生成的具体细节或生成器的状态。为了提升 C# 中处理概率问题的水平,我们需要引入更强大的类型。

实现

以下代码实现了对 Random 类的改进:

1using System; 2 3namespace Probability 4{ 5 class Program 6 { 7 static void Main() 8 { 9 Console.WriteLine("Probability"); 10 Console.WriteLine(Pseudorandom.NextInt()); 11 } 12 } 13}

在下一课中,我们将创建一些用于常见连续概率分布的实用类。

C# 中的 System.Random 简介

连续概率分布简介