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

golang 常见坑(1)-select

  •  
  •   guonaihong ·
    guonaihong · 2019-10-02 10:57:57 +08:00 · 5552 次点击
    这是一个创建于 1661 天前的主题,其中的信息可能已经有所发展或是发生改变。

    这个系列会介绍 golang 常见的坑。当然很多坑是由于对 golang 理解不到位引起的。

    猜测下如下代码的输出

    这是一段很简单的代码,生产者 go 程打印数字,结束之后发送 cancel 信号。 是不是认为会打印 0-999。如果是这样想的可以继续往下看。

    package main
    
    import (
            "context"
            "fmt"
    )
    
    func main() {
            ctx, cancel := context.WithCancel(context.Background())
    
            data := make(chan int, 1000)
    
            go func() {
                    for i := 0; i < 1000; i++ {
                            data <- i
                    }
                    cancel()
            }()
    
            for {
                    select {
                    case <-ctx.Done():
                            return
                    case v := <-data:
                            fmt.Printf("%d\n", v)
                    }
            }
    }
    
    

    问题分析

    你以为会打印 0-999 ?其实不是。。运行下代码你会发现。输出是随机的。what? 这其实和 select 的机制有关系。当 case 条件有多个为真,就想象成随机函数从 case 里面选择一个执行 。上面的代码是两个条件都满足,调用 cancel 函数,有些数据还缓存在 data chan 里面,ctx.Done()条件也为真。选择到 ctx.Done()的时候,这里很可能 case v:=<-data 都没打印全。

    解决问题

    刚刚聊了 case 的内部逻辑。再聊下如何解决这个问题。data 每个发送的数据都确保消费掉,最后再调用 cancel 函数就可解决这个问题。做法把带缓冲的 chan 修改为不带缓冲。

    // data := make(chan int, 1000)
    data := make(chan int)
    

    最佳实践

    如果不是必须的理由要用带缓冲的 chan。推荐使用无缓冲的 chan。至于担心的性能问题,他们性能差距不大。后面会补上 benchmark。

    我的 github

    https://github.com/guonaihong/gout

    第 1 条附言  ·  2019-10-02 14:53:13 +08:00
    ### 后续
    给位 v 友都很热情。其实上面的例子,只想表达一些观点。
    * select 监听多个 case 的时候,有多个 case 条件都满足的话,是随机选择一个执行的。这里再叨叨下,是从满足的条件里面。。。
    * 无缓冲 chan 是生产者消费者同步的,这点很多人会忽略,细细品味,所以例子最后的改法才是这样的。
    * 为什么用 context,其实也可以用 done := make(chan struct{}),代替 context。只是 go1.7 之后 context 就被扶正,使用 context 可以和标准库里面很多函数打通,如果一开始用 done,后面还要修改参数。
    * 为啥不直接一个 data,生产者不停 data<- i。for 循环结束,直接 close(data)。这种方式是对的,有什么好讲的。。。。
    24 条回复    2019-10-03 17:41:47 +08:00
    petelin
        1
    petelin  
       2019-10-02 11:24:45 +08:00 via iPhone
    这样最后一个 data 不还是有可能打不出来么
    petelin
        2
    petelin  
       2019-10-02 11:25:46 +08:00 via iPhone
    看错了..
    useben
        3
    useben  
       2019-10-02 11:28:16 +08:00
    这是你使用有误。一般不是用 context 来通知 chan 写完的,而是关闭 chan,不然可能会造成泄漏。写端应在写完 close chan,读端应检测 chan 再读 chan,chan 返回 false 表明已被关闭,就退出 for
    guonaihong
        4
    guonaihong  
    OP
       2019-10-02 11:33:30 +08:00
    @useben useben 兄,用的方式是 data chan 既要当数据通道,又要当结束控制通道。上面的例子是控制和数据分离的作用。有些场景只能用控制和数据分离的写法,个人觉得没有对错之分。
    guonaihong
        5
    guonaihong  
    OP
       2019-10-02 11:35:08 +08:00
    写错两个字,纠正下。
    @useben useben 兄,用的方式是 data chan 既要当数据通道,又要当结束控制通道。上面的例子是控制和数据分离的写法。有些场景只能用控制和数据分离的方法,个人觉得没有对错之分。
    heimeil
        6
    heimeil  
       2019-10-02 12:27:31 +08:00
    context 的设计目的就是尽早结束、释放资源的,你想要保证 channel 被读完的话,就需要再做一些处理

    https://play.golang.org/p/jKLArlvONhM
    znood
        7
    znood  
       2019-10-02 12:55:05 +08:00
    这不能叫 Golang 有坑,只能叫你 Golang 没学好

    context 对 select 做中断处理不管你有没有执行完才是正常情况,如果想要处理完就用其他方法比如三楼的方法

    再说你对这个处理的问题
    // data := make(chan int, 1000)
    data := make(chan int)
    你以为这样就能保证万无一失了吗?你也说了 select 是随机的,但是如果把 fmt.Printf("%d\n", v)换成处理时间长的,这个时候 data <- 999 放进去了,cancel()也执行了,你觉得 select 是一定会选择从 data 读数据吗?
    lishunan246
        8
    lishunan246  
       2019-10-02 12:55:38 +08:00
    所以为啥这里要用 context 呢
    guonaihong
        9
    guonaihong  
    OP
       2019-10-02 14:20:55 +08:00
    @lishunan246 也可以用 done := make(chan struct{}) 这种方式。自从 go1.7 引入 context 之后,现在都用 context 代替 done 的做法。因为很多标准库的参数是 context,后面如果遇到 done 结束还要控制标准库的函数,就不需要修改了。
    guonaihong
        10
    guonaihong  
    OP
       2019-10-02 14:23:13 +08:00
    @znood 你没有明白代码。无缓存 chan 是生产者,消费者同步的。data<-999 写进入 并且返回了。代表消费者已经消费调了。这时候调用 cancel 是安全的。
    guonaihong
        11
    guonaihong  
    OP
       2019-10-02 14:30:49 +08:00
    @heimeil 兄弟,我假期用的这台电脑不能翻墙。可否贴下代码,学习下。
    Nitroethane
        12
    Nitroethane  
       2019-10-02 14:57:00 +08:00 via Android
    cancel 不能在这个协程函数中调用吧,因为你不能保证在调用 cancel 之前 select 中的第二个 case 把数据读完啊,虽然无缓冲能解决这个问题,但是在实际业务中肯定要用到有缓冲的 channel 吧
    znood
        13
    znood  
       2019-10-02 15:06:46 +08:00
    好吧,献丑了,忘了无缓存 channel 是阻塞的了

    不过这里用 cancel 肯定是不合适的,因为你想把队列读取完,又不想关闭 channel,这个时候用 time.After,ctx 无条件返回,读取 channel 超时(队列空)返回
    for {
    select {
    case <-ctx.Done():
    return
    case <-time.After(time.Second):
    return
    case v := <-data:
    fmt.Printf("%d\n", v)
    }
    }
    guonaihong
        14
    guonaihong  
    OP
       2019-10-02 15:59:10 +08:00
    @znood 这个例子里面不需要 time.After。data chan 消费完。生产者调用 cancel,这时候消费者的 case <- ctx.Done() 就可以返回了。
    heimeil
        15
    heimeil  
       2019-10-02 16:03:15 +08:00
    package main

    import (
    "context"
    "fmt"
    "time"
    )

    func main() {
    ctx, cancel := context.WithCancel(context.Background())

    data := make(chan int, 10)

    go func() {
    for i := 0; i < 10; i++ {
    data <- i
    }
    cancel()
    fmt.Println("cancel")
    }()

    for {
    select {
    case <-ctx.Done():
    fmt.Println("Done")
    return
    case v := <-data:
    doSomething(v)
    RL:
    for {
    select {
    case v := <-data:
    doSomething(v)
    default:
    break RL
    }
    }
    }
    }
    }

    func doSomething(v int) {
    time.Sleep(time.Millisecond * 100)
    fmt.Println(v)
    }
    guonaihong
        16
    guonaihong  
    OP
       2019-10-02 16:49:10 +08:00
    @znood 你是想说,如果不用无缓冲 chan。用超时退出?
    reus
        17
    reus  
       2019-10-02 17:08:53 +08:00   ❤️ 2
    如果看过 go tour 应该都会知道: https://tour.golang.org/concurrency/5
    such
        18
    such  
       2019-10-02 17:52:51 +08:00 via iPhone
    context 有点滥用了,context 的设计初衷应该是做协程的上下文透传和串联,但是这个例子不涉及到这种场景,都是同一个协程,感觉还是去用另一个 chan 传递退出的信号量
    guonaihong
        19
    guonaihong  
    OP
       2019-10-02 21:39:55 +08:00
    @such 和 such 兄想得相反,我倒是不觉得滥用。很多时候一个技术被滥用是带来了性能退化,这里没有性能退化。再者 context 源码里面也是 close chan 再实现通知的。和自己 close chan 来没啥区别。
    guonaihong
        20
    guonaihong  
    OP
       2019-10-02 21:40:19 +08:00
    @reus 感谢分享。
    guonaihong
        21
    guonaihong  
    OP
       2019-10-02 21:51:37 +08:00
    @heimeil 如果 chan 是带缓冲的,并且因为某些原因不能修改为无缓冲的,可以用下面的该法。你的代码我看了,用两层 for 循环的做法,本质还是想知道 chan 有没有空。直接用个判断就行。

    ```go
    ackage main

    import (
    "context"
    "fmt"
    "time"
    )

    func main() {
    ctx, cancel := context.WithCancel(context.Background())

    data := make(chan int, 10)

    go func() {
    for i := 0; i < 10; i++ {
    data <- i
    }
    cancel()
    fmt.Println("cancel")
    }()

    for {
    select {
    case <-ctx.Done():
    if len(data) == 0 {
    fmt.Println("Done")
    return
    }
    case v := <-data:
    fmt.Printf("v = %d\n", v)
    }
    }
    }

    ```
    heimeil
        22
    heimeil  
       2019-10-02 22:40:32 +08:00
    并不是判断为空的意思,你可以这样试试看:
    case <-ctx.Done():
    if len(data) == 0 {
    fmt.Println("Done")
    return
    } else {
    fmt.Println("--------------")
    }
    znood
        23
    znood  
       2019-10-03 16:45:50 +08:00
    肯定要用带缓冲的,不带缓冲的两遍阻塞用两个协程没有意义,用一个协程就处理了
    codehz
        24
    codehz  
       2019-10-03 17:41:47 +08:00
    @znood #23 不不不,有意义,chan 的重点是对程序逻辑的拆解(或者说通过加一层抽象解决复杂问题),而且很多时候并非性能热点,chan 的阻塞操作性能还比有缓冲的高不少)虽然肯定没直接一个的快)
    就像很多时候明明可以复制粘贴,为啥要写一个函数呢,这里 chan 的作用就在于此,在合适的地方拆分模块,复用代码,降低耦合性,并不是所有场景都能用回调解决
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   2766 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 28ms · UTC 12:24 · PVG 20:24 · LAX 05:24 · JFK 08:24
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.