概述

很多人选择 Go 语言的原因之一是因为 Go 语言拥有很方便的并发实现,例如原生的 goroutine 和 channel,让协程开发变得更加的容易。但是,因为门槛变低了,所以使用的方式很多时候看上去都是很 naive 的,这不仅仅会影响代码的美观度,其实更重要的是可能会出现隐藏得很深的坑。但是,goroutine 的使用方式不受限制,同时控制的方法也很多,所以很难直接给出一种方式吃遍天下,本文我尝试写几种常用的实践供参考,以便在遇到真实问题的时候有利于选择。

Cancel Context

例如,我尝试写一段 DEMO,我的目的是希望有几个不同的 goroutine 在输出,然后,当我不想让他们输出的时候就按下 “Ctrl + C” 关闭,那么简单的代码可能会这么写:

但是机智如你,肯定知道这段代码是不符合要求的,所以我改了一下:

这里的区别就是我加了一个 ctx,用于控制 goroutine 的生命周期,可以发现,这段代码从易用性和维护性上都还可以,但是,这段代码也暴露了一些问题,第一个是 context 的作用主要还在于继承,如果是平级之间的 goroutine 要想互相关联会比较麻烦,例如 routine-01 打印 5 次之后就所有的 routine 退出这类需求比较难;第二个就是对于网络变成,context 也比较难以适应,例如你开启一个可一个 goroutine 用于监听端口的同时,还要关注是否被按下了 Ctrl + C。

改进 Cancel Context

对于这个 context 不适合的场景,就需要使用另外一种方式了,其实关键就是我们需要关注两个 block 的消息,所以为什么不套多一层 goroutine 呢,于是乎就有了这种操作:

// created by: https://liqiang.io
func errRoutine(ctx context.Context, name string, tickCount int) (err error) {
    var count = 0
    tick := time.Tick(time.Second)
    for {
        select {
        case <-tick:
            if count > tickCount {
                return nil
            }
            count++
            fmt.Printf("i am simpleRoutine: %s\n", name)
        case <-ctx.Done():
            fmt.Printf("%s ready to exit.\n", name)
            return
        }
    }
}

// created by: https://liqiang.io
func main() {
    finish := make(chan bool, 3)
    ctx, cancel := context.WithCancel(context.Background())
    newRoutine := func(ctx context.Context, name string, tickCount int) {
        ctx2, cancel2 := context.WithCancel(context.Background())
        end := make(chan bool, 3)
        go func() {
            errRoutine(ctx2, name, tickCount)
            log.Printf("[D] cancel2.")
            end <- true
        }()
        select {
        case <-ctx.Done():
            cancel2()
            return
        case <-end:
            cancel2()
            return
        }
    }

    go func() {
        newRoutine(ctx, "simpleRoutine-01", 2)
        finish <- true
    }()
    go func() {
        newRoutine(ctx, "simpleRoutine-02", 100)
        finish <- true
    }()
    go func() {
        newRoutine(ctx, "simpleRoutine-03", 100)
        finish <- true
    }()

    go func(cancel context.CancelFunc) {
        WaitForCtrlC()
        cancel()
    }(cancel)
    <-finish
    cancel()
}

这段代码可能写得有点绕,但是呢,思路是很明确的,那就是这里在等待两个 block 的信息,任何一个有信号了,另外一个都得放弃,并且退出,因为这是一个固定的模式,所以如果借鉴别人写好的库:run的话,整个事情就变得简单了,可以这么写:

这样,整个流程就变得比较清晰和简单了,同时,你也可以写更少的代码。

小结

本文简单介绍了两个常用的 Go 语言 goroutine 的使用模式,但是,很显然丰富度还是不足的,至少还有 errgroup 和 tomb 没有介绍,但是无妨,这篇文章就当一个引子,希望对大家有所帮助。