[Golang] 高频次和高并发下的随机数重复问题的解决方案
一、概要:
在Golang中,获取随机数的方法一般会介绍有两种,一种是基于math/rand的伪随机,一种是基于crypto/rand的真随机。其中,math/rand由于其伪随机的原理,经常会出现重复的随机数,导致在需要进行随机的业务出现较多的重复问题。在作者所经历的实际项目中便遇到在高频率和高并发下获取随机数时的重复问题,在解决该问题的道路上,尝试了几种办法,最终发现了较好的解决方案。
二、对math/rand随机数的研究:
基于math/rand获取随机数,常见的方法是:
import (
"math/rand"
"time"
)
r := rand.New(rand.NewSource(time.Now().UnixNano()))
result := r.Int31n(100)
上述代码原理,是通过纳秒时间来生成种子,然后通过该种子获取一个随机数。常用的开发者应该知道,math/rand的种子具有其固定性,同一个数作为种子时,通过New获取的实例其实是相同的,即便它们的实例地址不同,但通过同样的随机数方法,获得的值会是一样的。
这种情况是由于math/rand会根据随机种子进行一个较复杂的算法过程,从而获得一个长度为607的随机数池,由于算法过程是固定的,不存在任何的随机情况,因此同一个种子获取的随机数池是完全一致的。
此时可能会有人认为,每次取不同的纳秒时间来生成实例,不就可以解决问题?但这样的想法在实际的项目实践中是存在问题的。原因主要有两点:
1、Golang的一大优点是速度快,当一次性多次使用time.Now().UnixNano()获取纳秒时间时,会发现纳秒时间并不是不同的,而会存在重复,这使得在高频率随机下随机种子是一样的。
2、Golang的一大特点是高并发,但是在实际的运行过程中,是无法保证两个并发的协程不会在同一时间获取时间的,同样会出现重复问题。
针对这两个问题也有对应的解决方案:
1、对于第一个原因,可以在每次获取纳秒时间前,使用time.Sleep(time.Nanosecond)保证程序过一个时间再取,如此可以保证每次获取的时间是不重复的。
2、对于第二个原因,可以利用锁将获取时间的过程锁住,以保证并发时不在同一时间执行。
然而在实际项目中实践的情况并不理想。
第一个解决方案下,由于CPU处理的时间片,跳过的时间并不是1纳秒,不同的CPU会有微小差异。作者在i7-13700K的CPU下测试,跳过前后的时间差大约为0.1ms。
第二个解决方案下,倘若不结合第一个解决方案,因为无法保证锁前和锁后的时间是否完全不一的情况,因此需要保留sleep过程,然而可以计算的是,若并发量达到了10000,仅是获取种子的最长等待时间就可能达到1s,加上一般还会有其它业务逻辑,这难以保证高效的需求。
上述方案均存在隐含的问题,那么换一个解决思路,既然随机数池是固定长度,不能使用sleep或锁,为什么不让程序只保留一个随机实例并当获取次数达到一定值时更新随机实例呢?我们使用下面这个简单例子来测试可行性:
import "math/rand"
t1 := time.Now().UnixNano()
fmt.Println(t1)
r := rand.New(rand.NewSource(t1))
for i := 0; i < 600; i++ {
r.Int31n(100)
}
t2 := time.Now().UnixNano()
fmt.Println(t2)
上述代码中,我们通过t1时间生成一个随机实例,并让该实例随机600次,再获取一次时间t2。执行的结果会发现更大的问题,t1时间和t2时间是完全相同的!此时如果用t2作为种子,那么下一次随机的600次会和t1时间的一模一样。假如我们让不同实例之间做延时或锁,那么问题又会回到前面解决方案中同样的问题。
因此,可以做出如下的结论。常用的math/rand在基于获取当前时间作为种子的随机时,无法真正地解决重复问题。
三、对crypto/rand随机数的研究:
基于crypto/rand获取随机数,常见的方法是:
import (
"crypto/rand"
"math/big"
)
r, _ := rand.Int(rand.Reader, big.NewInt(100))
result := r.Int64()
crypto/rand获取的随机数可以看做是真随机,其原因是它获取种子的来源。简单来说,在各类系统中,均存在一个特定的文件专门用于存储真随机值,这些值的来源是硬件在电路上产生的各种信息、热噪声等,由于它们是不可控的且无法预测的,因此可看做是产生了真正的随机数。
在上述代码中,rand.Reader就会从系统中存储真随机数的文件或者通过调用系统级别的API获取一个真随机数。对于系统而言,当该随机数被获取后就会被销毁,不会二次使用。
然而,crypto/rand有一个缺点,它的获取效率非常低,这是因为它需要访问系统文件或者调用系统API,会产生不小的耗时。并且,每次从系统获取的随机值都会是一次性的,无法第二次使用。低效的获取方式显然并不能完全满足高频次和高并发下保持程序高效的需求。
三、最后的解决方案:
结合前面的相关内容,可以找到一个取长补短的方式,得到一个解决方案:利用crypto/rand的rand.Reader获取一个随机数,使用这个随机数作为math/rand的种子获得一个随机数池进行取值,当取值达到一定次数后,再获取一个新的种子生成新的实例。
通过rand.Reader获取一个种子数的方法如下:
import (
"crypto/rand"
"encoding/binary"
)
var seed int64
binary.Read(rand.Reader, binary.BigEndian, &seed)
使用上述方法,即便高频次或高并发地获取种子数,也能保证每次得到的种子都是不一样的数值,这样就能避免因种子相同导致的重复问题了。
最终的解决方案确定,结合后的代码如下:
import (
crand "crypto/rand"
"encoding/binary"
mrand "math/rand"
)
var seed int64
binary.Read(crand.Reader, binary.BigEndian, &seed)
source := mrand.NewSource(seed)
r := mrand.New(source)
result := r.Int31n(100)
在上述代码中,仍然需要注意一个重要问题,就是crand.Reader的耗时较长,在对取随机的过程进行封装时,需要进行进一步的改进:
1、增加一个统计阈值,每次随机后+1,并当次数达到一定值时更新随机实例;
2、对更新随机实例过程上锁,避免并发时出现重复更新;
经过改进后,可自行测试其速度。作者参与过的项目实践中,在统计阈值为300且较高的获取次数下,平均每次获取一个随机数的耗时约25~30ns。不仅满足了接近真随机的需求,并且满足了高效的需求。
四、其它参考文章:
下面是在探索该问题的过程中找到的较好的相关知识文章,在此供大家阅读参考:
知乎文章:随机数与密钥派生
CSDN文章:Go语言 crypto/rand 随机数生成方法研究草稿