V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
The Go Programming Language
http://golang.org/
Go Playground
Go Projects
Revel Web Framework
afxcn
V2EX  ›  Go 编程语言

使用 go 遇到的一个奇怪问题,求教

  •  
  •   afxcn ·
    afxcn · 235 天前 · 5091 次点击
    这是一个创建于 235 天前的主题,其中的信息可能已经有所发展或是发生改变。

    下面这段代码在长时间运行后,有一定的机率会出错,RandString(32)返回的全是 0.

    从网上查的资料全局变量应该不会被回收才对。

    package helper
    
    import (
    	"math/rand"
    	"time"
    )
    
    const _charsetRand = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@#$"
    
    var _seededRand = rand.New(rand.NewSource(time.Now().UnixNano()))
    
    // RandStringWithCharset rand string with charset
    func RandStringWithCharset(length int, charset string) string {
    	b := make([]byte, length)
    	l := len(charset)
    	for i := range b {
    		b[i] = charset[_seededRand.Intn(l)]
    	}
    	return string(b)
    }
    
    // RandString rand string
    func RandString(length int) string {
    	return RandStringWithCharset(length, _charsetRand)
    }
    
    // RandInt rand int between [min, max)
    func RandInt(min int, max int) int {
    	if min <= 0 || max <= 0 {
    		return 0
    	}
    
    	if min >= max {
    		return max
    	}
    
    	return _seededRand.Intn(max-min) + min
    }
    
    // RandMax rand int between [0, max)
    func RandMax(max int) int {
    	if max <= 1 {
    		return 0
    	}
    
    	return _seededRand.Intn(max)
    }
    
    第 1 条附言  ·  233 天前

    感谢大家的讨论,谢谢。

    #2 b[i] = charset[createRand().Intn(l)] 这种写法在出问题的测试服务器里跑了1个多月,客户没有再报告问题。没有在产品服务器上用过这样的写法。

    #41 说 RandString(32) 返回的全是 0 可能不是正确的,应该说是我们发现了大量为 32 个 0 的 token, 有可能有其他的情况我们没有注意到。

    #25 #28 #39 我们的上层代码确实有 recover, 但只用来记录日志。

    我们的 ServeHTTP 里的第一行就是:defer app.recv(w, r)

    func (app *Application) recv(w http.ResponseWriter, r *http.Request) {
    	if rcv := recover(); rcv != nil {
    		w.WriteHeader(http.StatusInternalServerError)
    		if app.panic != nil {
    			app.panic(w, r, rcv)
    		} else {
    			app.logf("%s %s %s %s rcv: %v", r.RemoteAddr, r.Host, r.Method, r.URL.Path, rcv)
    		}
    	}
    }
    

    客户的服务器和测试服务器都是在符合PCI标准的机房里的,我们做外包的很难直接接触。

    第 2 条附言  ·  211 天前

    换成 #7 下面这样的代码不会有问题,目前来说我还没办法证明出现这个问题的具体原因,可能是某些特殊的seed导致的问题。

    // RandomString random string
    func RandomString(len int) (string, error) {
    
    	b := make([]byte, len/2)
    
    	_, err := rand.Read(b)
    
    	if err != nil {
    		return "", err
    	}
    
    	return hex.EncodeToString(b), nil
    }
    
    56 条回复    2024-06-20 13:43:10 +08:00
    kneo
        1
    kneo  
       235 天前 via Android
    不是线程安全的吧。
    afxcn
        2
    afxcn  
    OP
       235 天前
    @kneo 应该和线程安全没什么关系吧,我也不确定。

    换成下面的代码这样,就不会出现返回全为 0 的情况了。

    ```go
    func createRand() *rand.Rand {

    if _seededRand == nil {
    _seededRand = rand.New(rand.NewSource(time.Now().UnixNano()))
    }

    return _seededRand
    }

    // RandStringWithCharset rand string with charset
    func RandStringWithCharset(length int, charset string) string {
    b := make([]byte, length)
    l := len(charset)
    for i := range b {
    b[i] = charset[createRand().Intn(l)]
    }
    return string(b)
    }
    ```
    AceGo
        3
    AceGo  
       235 天前   ❤️ 1
    @afxcn #2 你是怎么判断是否出现 0 的
    pathletboy
        4
    pathletboy  
       235 天前
    这么改试试
    ```go
    package helper

    import (
    "math/rand"
    "time"
    )

    const _charsetRand = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@#$"

    var _seededRand *rand.Rand

    func init() {
    _seededRand = rand.New(rand.NewSource(time.Now().UnixNano()))
    }

    // RandStringWithCharset rand string with charset
    func RandStringWithCharset(length int, charset string) string {
    b := make([]byte, length)
    l := len(charset)
    for i := range b {
    b[i] = charset[_seededRand.Intn(l)]
    }
    return string(b)
    }

    // RandString rand string
    func RandString(length int) string {
    return RandStringWithCharset(length, _charsetRand)
    }

    // RandInt rand int between [min, max)
    func RandInt(min int, max int) int {
    if min <= 0 || max <= 0 {
    return 0
    }

    if min >= max {
    return max
    }

    return _seededRand.Intn(max-min) + min
    }

    // RandMax rand int between [0, max)
    func RandMax(max int) int {
    if max <= 1 {
    return 0
    }

    return _seededRand.Intn(max)
    }
    ```
    afxcn
        5
    afxcn  
    OP
       235 天前
    @AceGo 我们在预上线的测试环境上发现的,我们用它来生成 token ,突然发现 token 表里出现大量全是 32 个 0 这样的 token 。
    AceGo
        6
    AceGo  
       235 天前
    @afxcn #5 你的意思是换成新的方法在线上没有出现过 0 了?
    afxcn
        7
    afxcn  
    OP
       235 天前
    @AceGo 换了写法后,好几年了,没出现过这种情况,不过后来我们又改了,改成用 crypto/rand 了,所以也不是 100%确定#2 的写法是不是对的。

    ```go
    package utils

    import (
    "crypto/rand"
    "encoding/hex"
    )

    // RandomString random string
    func RandomString(len int) (string, error) {

    b := make([]byte, len/2)

    _, err := rand.Read(b)

    if err != nil {
    return "", err
    }

    return hex.EncodeToString(b), nil
    }
    ```
    Citrus
        8
    Citrus  
       235 天前 via iPhone
    @afxcn crypto rand 用来生成 token 性能非常差的,建议别这么改
    yin1999
        9
    yin1999  
       235 天前 via Android
    现在最新版本 Go 里面,rand 包的全局随机数生成器的随机种子也是每次自动生成的了,而且有自带的加速特性,可以考虑切回 rand 的全局随机数生成器试试会不会有这个问题。
    R18
        10
    R18  
       235 天前
    目前最新的 Go 版本也会出错吗?
    dododada
        11
    dododada  
       235 天前
    chatgpt

    这段代码的问题在于并发访问了全局的 _seededRand 变量,导致了竞争条件( race condition )。在多个 goroutine 同时调用 RandStringWithCharset 函数时,它们可能会同时访问和修改 _seededRand ,从而导致不可预测的结果,甚至造成程序崩溃。

    在第一段代码中,_seededRand 被多个 goroutine 同时访问和修改,因为没有对其进行同步操作或者使用互斥锁。而在第二段代码中,通过在 RandStringWithCharset 函数中调用 createRand 函数,每次都创建一个新的 _seededRand 实例,避免了并发访问全局变量的问题。

    通过这样的修改,确保了在并发情况下每个 goroutine 都有自己的 _seededRand 实例,从而解决了竞争条件问题,确保了程序的稳定性。
    ixiaohei
        12
    ixiaohei  
       235 天前
    ```rand.NewSource(time.Now().UnixNano())```不是线程安全的。并发情况下会出现一些未定义的异常,比如 panic
    R18
        13
    R18  
       235 天前
    ```go
    charset[_seededRand.Intn(l)]
    ```

    我有点好奇,就算不是线程安全的,这代码也不该返回字符串"0"啊,"0"在整个 charset 里也没有处在一个特殊的位置。
    keakon
        14
    keakon  
       235 天前
    https://pkg.go.dev/math/rand#NewSource
    Unlike the default Source used by top-level functions, this source is not safe for concurrent use by multiple goroutines.

    现在不需要手动初始化 seed 的
    retanoj
        15
    retanoj  
       235 天前
    @R18 #13
    参考 #11 的回答,如果发生静态条件,那这句会报错,赋值不会被完成,所以 b 数组全零。

    感觉是这样
    R18
        16
    R18  
       235 天前   ❤️ 2
    @retanoj b 是一个 byte[], byte 的零值是 0 没错,但是经过 string() 类型转换后会变成 “ ” ,我特意试了一下。
    AceGo
        17
    AceGo  
       235 天前   ❤️ 1
    @retanoj 也应该是 panic 退出,不应该继续执行
    hxzhouh1
        18
    hxzhouh1  
       235 天前
    up 能说一下复现的方式跟 go 版本嘛? 我目前没法复现
    yianing
        19
    yianing  
       234 天前 via Android
    @ixiaohei 这个是全局变量,只会在包引入时初始化一次
    ixiaohei
        20
    ixiaohei  
       234 天前
    @yianing New 出来的 Source 不是线程安全的。如果是 rand 包导出的方法是线程安全的,因为里面的 source 是并发安全的
    yianing
        21
    yianing  
       234 天前 via Android
    @ixiaohei 看了下全局用的是 lockedSource ,new 出来的是 rngSource ,使用确实要加锁
    cloudzhou
        22
    cloudzhou  
       234 天前
    https://pkg.go.dev/math/rand#NewSource

    NewSource returns a new pseudo-random Source seeded with the given value. Unlike the default Source used by top-level functions, this source is not safe for concurrent use by multiple goroutines. The returned Source implements Source64.
    afxcn
        23
    afxcn  
    OP
       234 天前
    @hxzhouh1 很难重现了,21 年的事情,应该是当时最新版本的 go ,但我当时在自己的电脑跑,都没办法重现。

    只有在测试服务器上出现,而且不是一次;第一次出现的时候以为是服务器被黑了,后来才定位到这段代码。
    cloudzhou
        24
    cloudzhou  
       234 天前
    /*
    * Top-level convenience functions
    */

    var globalRand = New(&lockedSource{src: NewSource(1).(Source64)})
    zhaoxueqin248
        25
    zhaoxueqin248  
       234 天前   ❤️ 1
    @afxcn 这个 math/rand 的 IntN 在搞并发下运行会有概率 panic 的, 是不是上层有 recover 默认值 导致的 0 ?
    rockyliang
        26
    rockyliang  
       234 天前
    使用`go run -race`命令检测这段代码,确实是存在竞态并发安全问题,具体是在`b[i] = charset[_seededRand.Intn(l)]`这一行代码处
    sztink
        27
    sztink  
       234 天前
    靠,你这生成随机字符代码难怪看着眼熟,我前段时候从网络上 copy 的一份跟你的几乎一模一样,我得赶紧 fix 一下。copy 来源: https://github.com/BelphegorPrime/lib/blob/master/RandString.go
    hopingtop
        28
    hopingtop  
       234 天前   ❤️ 3
    这里能展示一下, 真正写入 全是 0 ,上层的函数吗?
    比如这个函数上面有 recover , 这个时候你们得到的 值可能是 空字符串, 但是在你们存入的时候, 这个空字符串经过一些 编码,比如 hex 这种, 就会变成 0000..000 但是是 64 位, 假设你存入的 varchar(32) 在不经过 mysql 严格模式下, 就能把 空字符串 变成 32 位 000... 然后误解,是这里生成的了。

    当前上面也只是我的一种 推理可能性
    0o0O0o0O0o
        29
    0o0O0o0O0o  
       234 天前
    -race
    NoobPhper
        30
    NoobPhper  
       234 天前
    想象一下同一 ns 情况下 两个 routine 调用了 你哪个不安全的 var , 得到的结果
    Karte
        31
    Karte  
       234 天前
    @dododada @Livid AI 回答.
    nuk
        32
    nuk  
       234 天前
    遇到过差不多的问题,不过因为代码不多,很快就发现是 race condition
    https://imgur.com/dp2lnEy
    hopingtop
        33
    hopingtop  
       234 天前
    @hopingtop #28 现在肯定能够确认这段代码非线程安全的,会有数据竞争,出现 Panic
    但是就是特别想知道为啥是 32 个 0 看是否是我上面描述的猜测
    AceGo
        34
    AceGo  
       234 天前
    @afxcn #2 NewSource 是线程不安全的,在并发下会 panic 。
    这两种写法都用到 NewSource ,应该会报一样的错误
    AceGo
        35
    AceGo  
       234 天前
    @yianing #19 Intn 方法会最终调用到 Source 的 Int 方法,所以最终是线程不安全的
    mightybruce
        36
    mightybruce  
       234 天前   ❤️ 1
    回答竟然都是竞态并发安全问题, 你们真的是认真的吗
    sztink
        37
    sztink  
       234 天前
    @afxcn 这样是解决不了本质问题的。因为 rand.NewSource 不是并发安全的。另外 createRand 中_seededRand 初始化的逻辑也不是并发安全的。你应该直接用全局函数 rand.Intn()。
    cloudzhou
        38
    cloudzhou  
       234 天前
    @mightybruce 这就是最大可能阿,难道不是并发问题?起码代码就不是并发安全
    你是认真的吗?
    kuanat
        39
    kuanat  
       234 天前   ❤️ 1
    对于 rand.NewSource 存在竞态导致 panic 这一点应该没有异议,而 panic 没有中断而是正常被调用,说明上层应该是存在 recover 逻辑的。结合 OP #23 指出本地正常而测试服务器异常,推测本地测试的生成逻辑,而服务器完成的是全部调用逻辑。

    @R18 #16 指出 byte 空值转换为 string 之后是空字符串,说明在可见代码的部分,只可能产生空字符串。 @AceGo 和 @hopingtop 判断出是上层调用将空字符串变成了全 0 字符串。

    我这里做一点补充,一般要保证字符串定长都会做 padding ,用字符串 0 做填充是最常见的。
    kneo
        40
    kneo  
       234 天前 via Android
    你的意思是现在重现不了了,2021 年能重现?是我理解错了吗?几年前的事情你现在才来问?
    afxcn
        41
    afxcn  
    OP
       234 天前
    @kneo 确实是几年前遇到的问题,当时解决起来也容易,换种写法就可以了,只是到目前为止还是没搞清楚是因为什么,所以来问了。

    说是返回 32 个 0 可能也不是绝对准确的,也可能只是注意到了全是 0 的 token ,因为它最明显。
    jiayiming001
        42
    jiayiming001  
       234 天前
    func main() {
    var wait sync.WaitGroup
    for i := 0; i < 100; i++ {
    wait.Add(1)
    go func() {
    defer func() {
    if err := recover(); err != nil {
    fmt.Println(err)
    }
    }()
    defer wait.Done()
    for j := 0; j < 100; j++ {
    helper.RandString(10)
    }
    }()
    }

    wait.Wait()
    }

    运行这个程序,可以看到有的 goroutine 出现了 painc
    afxcn
        43
    afxcn  
    OP
       234 天前
    @sztink 我也是从网上搜的,印像中 golang random string 它排最前面.
    kneo
        44
    kneo  
       234 天前 via Android
    @afxcn 恕我直言,我不认为几年前的问题你今天还能根据记忆还原出真相。除非你现在依然能重现它。
    zzhaolei
        45
    zzhaolei  
       234 天前
    1. 现在的 go1.22.2 版本,并发调用 Intn 并不会 panic ,源码里就没有相关的检查
    2. 当 go 协程调用 _seededRand 的时候,程序的运行时已经将 _seededRand 初始化完成,并不存在说你加了 if _seededRand == nil {...init...} 就会好的情况
    3. 几年前的问题了,你也不知道当时具体是什么 go 版本,现有的数据不足以用来判断
    Ipsum
        46
    Ipsum  
       234 天前
    rand.NewSource 记得会有读写过程,你多个协程并发会有 data race
    afxcn
        47
    afxcn  
    OP
       234 天前
    @zzhaolei 当时猜可能是因为_seededRand 被回收了,所以加了个判断。

    因为知道自己是猜的,所以心里不踏实,一段时间后改成用 crypto/rand 。

    crypto/rand 倒是没出过问题,只是生成出来的字符串中没有大写的字母,有点奇怪。

    @Citrus 说 crypto/rand 性能很差,因为只是生成 token 的时候用到,还未成为优先问题。
    yeyypp92
        48
    yeyypp92  
       234 天前
    rand 不是线程安全的
    AceGo
        49
    AceGo  
       233 天前
    @zzhaolei 所有顶层方法都是并发安全的,Source 是并发不安全的
    zzhaolei
        50
    zzhaolei  
       233 天前
    @AceGo 怎么个不安全法?我看源码也没问题啊,没有改 seed ,也没有竞争
    MrSeven7
        51
    MrSeven7  
       233 天前
    亲测并发会 panic ,楼主说的返回 32 个 0 可能是自己的业务代码做了默认值处理吧
    zzhaolei
        52
    zzhaolei  
       233 天前
    @MrSeven7 是怎么 panic 的,可以贴个代码吗
    zzhaolei
        53
    zzhaolei  
       233 天前
    https://go.dev/play/p/GaXBGyvGkEn 我试出来了,确实会 panic ,Source 的 Uint64 方法内部会索引一个定长的数组,并发的情况下可能会出现索引越界的情况

    https://imgur.com/QsyRcDU
    bv
        54
    bv  
       233 天前
    AceGo
        55
    AceGo  
       233 天前
    @zzhaolei rand 中 Source 的注释:
    // A Source represents a source of uniformly-distributed
    // pseudo-random int64 values in the range [0, 1<<63).
    //
    // A Source is not safe for concurrent use by multiple goroutines.
    NewSource 方法的
    // NewSource returns a new pseudo-random Source seeded with the given value.
    // Unlike the default Source used by top-level functions, this source is not
    // safe for concurrent use by multiple goroutines.
    // The returned Source implements Source64.
    afxcn
        56
    afxcn  
    OP
       190 天前
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2603 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 26ms · UTC 01:46 · PVG 09:46 · LAX 17:46 · JFK 20:46
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.