使用信息穿透上下文 context 对 goroutine 进行级联管理

楔子

context 是 Go 在 1.7 版本的时候引入的标准库,从名字也知道是和 "上下文" 相关,不过准确的说应该是 goroutine 的上下文,它包含了 goroutine 的运行状态、环境等信息。context 主要是用来在 goroutine 之间传递上下文信息,包括:取消信号、超时时间、截止时间等等。

为什么要有 context

我们在 context 之前一般会使用 WaitGroup 来协调多个 goroutine,但 WaitGroup 要求多个 goroutine 必须都完成,那么才算完成,否则就会一直阻塞。

package main
 
import (
    "fmt"
    "sync"
    "time"
)
 
func main() {
    wg := new(sync.WaitGroup)
    wg.Add(3)
    go func() {
        time.Sleep(time.Second)
        fmt.Println("任务1完成")
        wg.Done()
    }()
    
    go func() {
        time.Sleep(time.Second * 3)
        fmt.Println("任务2完成")
        wg.Done()
    }()
    
    go func() {
        time.Sleep(time.Second * 2)
        fmt.Println("任务3完成")
        wg.Done()
    }()
    
    wg.Wait()
    fmt.Println("任务都完成了,收工")
    /*
    	任务1完成
    	任务3完成
    	任务2完成
    	任务都完成了,收工
    */
}

我们看到只有当 3 个任务都完成才算完成,否则 wg.Wait() 就会一直阻塞在那里,所以 WaitGroup 就是控制一组 goroutine。但实际上我们会碰到这样一种场景,我们需要主动地通知某个 goroutine 让其退出。比如有一个 goroutine 一直在监视某个资源的变化,但是在某一时刻不需要了,那么我们就应该主动地通知它,让其退出,否则很容易造成内存泄露。

但是现在问题来了,我们要如何通知一个 goroutine 退出呢?比如有一个 goroutine 不断监视某个目录,如果有新文件,那么就执行相应的逻辑。但是只需要监视三天,三天之后就不需要监视这个目录了,那么就应该让这个 goroutine 停掉,如何办到呢?就目前来说可以使用 chan + select:

package main
 
import (
    "fmt"
    "time"
)
 
func main() {
    
    go func() {
        for {
            select {
            //其他逻辑
            
            //这里假设是 3s 吧,3s 后 goroutine 就退出了
            case <-time.After(time.Second * 3):
                fmt.Println("3s已到,这个goroutine已经退出")
                return
            }
        }
    }()
    
    for {}
    /*
    	3s已到,这个goroutine已经退出
    */
}

再比如说,可以在满足指定的条件之后,让goroutine退出。

package main
 
import (
    "fmt"
)
 
func main() {
    quitCh := make(chan int)
    go func(quitCh chan int) {
        for {
            select {
            //其他逻辑
            
            //我们看到,如果我们想让 goroutine 退出,那么就给 quitCh 这个 channel 发送一个值即可
            case <-quitCh:
                fmt.Println("这个goroutine已经退出")
                return
            }
        }
    }(quitCh)
    
    fmt.Println("程序执行中······")
    //退出goroutine
    quitCh <- 0
    for {}
    /*
    	程序执行中······
    	这个goroutine已经退出
    */
}

我们看到这种 chan + select 是一种比较优雅地结束一个 goroutine 的方式,但是它有一个缺点,如果有很多的 goroutine 的结束都需要控制该怎么办?另外这些 goroutine 又衍生了其它的 goroutine 怎么办?即使我们定义了很多 chan 也很难解决这些问题,因为 goroutine 的关系链就导致了这种场景十分复杂。

所以我们才需要有 context。

初识 context

上面说的那种场景是真实存在的,比如网络请求 Request,每个 Request 都需要开启一个 goroutine 做一些事情,而这些 goroutine 又需要开启其它的 goroutine,所以我们就需要一种可以跟踪 goroutine 的方案,才可以达到控制它们的目的。context 包就是为了解决上面的问题而开发的:使用 context 可以在一组 goroutine 之间传递共享的值、取消信号、deadline、......。

总结一下就是:在 Go 里面我们不能直接杀死 goroutine,goroutine 的关闭一般会使用 channel + select 的方式。但是在某些场景下,例如处理一个请求衍生了许多 goroutine,这些 goroutine 之间是互联的:需要共享一些全局变量、有共同的 deadline 等等,而且可以同时被关闭。此时再用 channel + select 的话就会比较麻烦,因此建议使用 context 来实现。

一句话:context 用来解决 goroutine 之间退出通知、 元数据传递的功能。

package main

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

func goroutine1(ctx context.Context){
    for {
        select {
        case <-ctx.Done():
            fmt.Println("goroutine1完成任务,退出")
            return
        default:
            fmt.Println("goroutine1工作中")
            time.Sleep(time.Second * 3)
        }
    }
}

func goroutine2(ctx context.Context){
    for {
        select {
        case <-ctx.Done():
            fmt.Println("goroutine2完成任务,退出")
            return
        default:
            fmt.Println("goroutine2工作中")
            time.Sleep(time.Second * 2)
        }
    }
}

func goroutine3(ctx context.Context){
    for {
        select {
        case <-ctx.Done():
            fmt.Println("goroutine3完成任务,退出")
            return
        default:
            fmt.Println("goroutine3工作中")
            time.Sleep(time.Second * 4)
        }
    }
}


func main() {
    ctx, cancel := context.WithCancel(context.Background())
    go goroutine1(ctx)
    go goroutine2(ctx)
    go goroutine3(ctx)
    time.Sleep(time.Second * 10)
    fmt.Println("时间到,通知所有goroutine退出")
    cancel()
    /*
    	goroutine1工作中
    	goroutine3工作中
    	goroutine2工作中
    	goroutine2工作中
    	goroutine1工作中
    	goroutine3工作中
    	goroutine2工作中
    	goroutine1工作中
    	goroutine2工作中
    	goroutine3工作中
    	goroutine2工作中
    	goroutine1工作中
    	时间到,通知所有goroutine退出
    */
}

仔细观察代码的话,应该不难理解。ctx.Done() 是一个 channel,当我们调用 cancel() 的时候,是可以从里面读取数据的。但是我们发现,所有的 goroutine 都退出了,这就是 Context 的一个特点,会对每一个 goroutine 都进行跟踪,当我们使用 cancel 函数通知取消的时候,Context 跟踪的 goroutine 都会被结束。这就是 Context 的控制能力,它就像一个控制器一样,按下开关后,所有基于这个 Context 或者衍生的子 Context 都会收到通知,这时就可以进行清理操作了,最终释放 goroutine,这就优雅的解决了 goroutine 启动后不可控的问题。

下面我们再来看看 context.WithCancel(context.Background()) 这一句是干嘛的,首先 context.Background() 返回一个空的 Context 对象,这个空的 Context 对象一般用于整个 Context 树的根节点。然后我们使用 context.WithCancel() 函数,返回了一个可取消的子 Context 对象和一个 cancel 函数,然后将子 Context 对象当作参数传给 goroutine 使用,也就是进行跟踪,一旦调用 cancel 函数的时候,所有的子 Context 对象都能收到信息。

Context 接口

Context 是一个接口,里面定义了四个方法,并且它们都是幂等的,也就是说连续调用多次得到的结果都是相同的。

type Context interface {
    // 返回一个截止时间和一个布尔值。
    // 到达指定的时间点,Context 会自动发起取消请求,此时 ok 是 true。
    // 如果 ok==false,那么表示没有设置截止时间,如果需要取消的话,就需要手动调用取消函数进行取消
    Deadline() (deadline time.Time, ok bool)
    // 返回一个只读的 channel,类型为 struct{}
    // 在 goroutine 中,如果该方法返回的 channel 可以读取,则意味着 parent context 已经发起了取消请求
    // 然后通过 Done 方法收到这个信号之后,可以做一些清理操作,然后退出 goroutine,释放资源
    Done() <-chan struct{}
    //返回一个错误原因,因为什么导致 Context 被取消
    Err() error
    // 获取该 Context 上绑定的值,是一个键值对,所以要通过一个 Key 才可以获取对应的值,这个值是线程安全的
    Value(key interface{}) interface{}
}

以上四个方法中常用的就是 Done 了,如果 Context 取消的时候,我们就可以得到一个关闭的 chan,关闭的 chan 是可以读取的;所以只要可以读取的时候,就意味着收到 Context 取消的信号了,那么 Context 监视的所有 goroutine 都会接收到信号。以下是这个方法的经典用法:

package main
 
import (
    "context"
    "fmt"
    "time"
)
 
func goroutine1(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            //等待退出的信号
            fmt.Println("收到取消通知,我要退出啦,使命已经结束")
            return
        default:
            //什么也不做,为了select不阻塞
        }
        
        // todo: 努力工作
        func() {
            time.Sleep(time.Second * 3)
            fmt.Println("bob,别傻愣着")
        }()
    }
}
 
func main() {
    ctx, cancel := context.WithCancel(context.Background())
    go goroutine1(ctx)
    time.Sleep(time.Second * 9)
    fmt.Println("结束啦~~~~,通知goroutine退出")
    cancel()
    // 可能要忙其他的事情
    for {}
    /*
    	bob,别傻愣着
    	bob,别傻愣着
    	结束啦~~~~,通知goroutine退出
    	bob,别傻愣着
    	收到取消通知,我要退出啦,使命已经结束
    */
}

以上只是一个 goroutine,多个也是一样的,Context 具有级联效果。然后我们看看 Context 的创建,当然不需要我们实现,Go 内置了两个。

var (
    background = new(emptyCtx)
    todo       = new(emptyCtx)
)
 
func Background() Context {
    return background
}
 
func TODO() Context {
    return todo
}

一个是 Background,主要用于 main 函数、初始化以及测试代码中,作为 Context 这个树结构的最顶层的 Context,也就是根 Context。一个是 TODO,它表示目前还不知道具体的使用场景,如果我们不知道该使用什么 Context 的时候,可以使用这个。不过从定义上来看的话,这两个是没有任何区别的,真的只有名字不一样而已。

他们两个本质上都是 emptyCtx 结构体类型,是一个不可取消,没有设置截止时间,没有携带任何值的 Context。

type emptyCtx int
 
func (*emptyCtx) Deadline() (deadline time.Time, ok bool) {
    return
}
 
func (*emptyCtx) Done() <-chan struct{} {
    return nil
}
 
func (*emptyCtx) Err() error {
    return nil
}
 
func (*emptyCtx) Value(key interface{}) interface{} {
    return nil
}

这就是 emptyCtx 实现 Context 接口的方法,可以看到,这些方法什么都没做,返回的都是 nil 或者零值。

context 的继承衍生

有了如上的根 Context,那么如何衍生更多的子 Context 的呢?这就要靠 context 包为我们提供的 With 系列的函数了。

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithValue(parent Context, key, val interface{}) Context

这四个 With 函数,都接收了一个 parent 参数,也就是父 Context,我们要基于这个父 Context 创建出子 Context;这种方式可以理解为子 Context 对父 Context 的继承,也可以理解为基于父 Context 的衍生。通过这些函数,就创建了一颗 Context 树,树的节点都可以有任意多个子节点,节点层级可以有任意多个。

WithCancel 函数,传递一个父 Context 作为参数,返回子 Context,以及一个取消函数用来取消 Context。 WithDeadline 函数,和 WithCancel 差不多,它会多传递一个截止时间参数,意味着到了这个时间点,会自动取消 Context,当然我们也可以不等到这个时候,提前通过调用取消函数进行取消。而 WithTimeout 和 WithDeadline 基本上一样,可以把前面的 WithDeadline 理解为到达指定的时间点取消,WithTimeout 理解为经过指定的时间间隔后取消。

WithValue 函数和取消 Context 无关,它是为了生成一个绑定 "一个键值对数据" 的 Context,这个绑定的数据可以通过 Context.Value 方法访问到,后面会专门说。此外前三个函数除了 Context 对象之外,还返回了一个取消函数 CancelFunc。这是一个函数类型,它的定义非常简单:type CancelFunc func(),这就是取消函数的类型,该函数可以取消一个 Context,以及这个 Context 节点下的所有 Context,不管有多少层级。

WithValue 传递元数据

我们上面演示了 WithCancel,WithDeadline 和 WithTimeout 的使用方式也是类似的,区别就是到了规定时间会自动取消,可以自己尝试一下。然后重点是 WithValue,通过 Context 我们也可以传递一些必须的元数据,这些数据会附加在 Context 上以供使用。

package main
 
import (
    "context"
    "fmt"
    "time"
)
 
func goroutine1(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            // 等待退出的信号
            fmt.Println("收到取消通知,我要退出啦,使命已经结束")
            fmt.Printf("拿到传入的值:%s", ctx.Value("name"))
            return
        default:
            // 什么也不做,为了select不阻塞
        }
        
        // todo: 努力工作
        func() {
            time.Sleep(time.Second * 3)
            fmt.Println("bob,别傻愣着")
        }()
    }
}
 
func main() {
    ctx, cancel := context.WithCancel(context.TODO())
    // 这里是基于返回的 ctx 进行创建的,接收三个参数:Context,interface{},interface{}
    valueCtx := context.WithValue(ctx, "name", "satori")
    // 传入的不再是ctx,而是基于 ctx 新创建的 valueCtx
    go goroutine1(valueCtx)
    time.Sleep(time.Second * 9)
    fmt.Println("结束啦~~~~,通知goroutine退出")
    // 调用 cancel() 的时候,不光是 <-ctx.Done() 可以获取数据,<-valueCtx.Done() 一样可以获取数据
    cancel()
    // 可能要忙其他的事情
    for {}
    /*
    	bob,别傻愣着
    	bob,别傻愣着
    	结束啦~~~~,通知goroutine退出
    	bob,别傻愣着
    	收到取消通知,我要退出啦,使命已经结束
    	拿到传入的值:satori
    */
}

我们可以使用 context.WithValue 方法附加一个键值对,这里的键必须是等价性的,也就是具有可比性;Value 值也要是线程安全的,这样我们就生成了一个新的 Context,这个新的 Context 带有这个键值对。在使用的时候,可以通过 Value 方法读取对应的值,即:ctx.Value(key)。记住,使用 WithValue 传值,一般是必须的值,不要什么值都传递。

context 使用原则

1. Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it. The Context should be the first parameter, typically named ctx.

1. 不要将 Context 塞到结构体里。直接将 Context 类型作为函数的第一个参数,而且一般都命名为 ctx。

2. Do not pass a nil Context, even if a function permits it. Pass context.TODO if you are unsure about which Context to use.

2. 不要向函数传入一个 nil 的 context,如果你实在不知道传什么,标准库给你准备好了一个 context:todo。

3. Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.

3. 不要把本应该作为函数参数的类型塞到 context 中,context 存储的应该是一些共同的数据。例如:登陆的 session、cookie 等。

4. The same Context may be passed to functions running in different goroutines; Contexts ar safe for simultaneous use by multiple goroutines.

4. 同一个 context 可能会被传递到多个 goroutine,但是不用担心,context 是并发安全的。

小结

并发是 go 语言的一大卖点,而实现高并发的关键就在于 goroutine 和 channel。但是多个 goroutine 之间的通信,以及在并发时所面临的问题就是我们需要解决的了。而幸运的时,Go 提供了相应的标准库帮助我们解决了这一点。

posted @ 2020-03-20 20:35  古明地盆  阅读(4016)  评论(1编辑  收藏  举报