Go语言系列(十六):上下文context

    技术2022-07-12  75

    背景

    在 Go http包的Server中,每一个请求在都有一个对应的 goroutine去处理。请求处理函数通常会启动额外的goroutine用来访问后端服务,比如数据库和RPC服务。一个上游服务通常需要访问多个下游服务,比如终端用户的身份认证信息、验证相关的token、请求的截止时间。 当一个请求被取消或超时时,所有用来处理该请求的 goroutine 都应该迅速退出,然后系统才能释放这些 goroutine 占用的资源。

    传统方案一:使用sync.WaitGroup

    问题:只有所有的goroutine都结束了才算结束,只要有一个goroutine没有结束, 那么就会一直等,这显然对资源的释放是缓慢的

    var wg sync.WaitGroup func run(task string) { fmt.Println(task, "start。。。") time.Sleep(time.Second * 2) // 每个goroutine运行完毕后就释放等待组的计数器 wg.Done() } func main() { wg.Add(2) // 需要开启几个goroutine就给等待组的计数器赋值为多少,这里为2 for i := 1; i < 3; i++ { taskName := "task" + strconv.Itoa(i) go run(taskName) } // 等待,等待所有的任务都释放 等待组计数器值为 0 wg.Wait() fmt.Println("所有任务结束。。。") } /* -----------------------运行结果---------------------------- task2 start。。。 task1 start。。。 所有任务结束。。。 */ 上面例子中,一个任务结束了必须等待另外一个任务也结束了才算全部结束了,先完成的必须等待其他未完成的,所有的goroutine都要全部完成才OK。等待组比较适用于好多个goroutine协同做一件事情的时候,因为每个goroutine做的都是这件事情的一部分,只有全部的goroutine都完成,这件事情才算完成;缺点:实际生产中,需要我们主动的通知某一个goroutine结束。eg我们可以设置全局变量,在我们需要通知goroutine要停止的时候,我们为全局变量赋值,但是这样我们必须保证线程安全,不可避免的我们要为全局变量加锁,在便利性及性能上稍显不足

    传统方案二:使用Channel+select

    通过在main goroutine中像chan中发送关闭停止指令,并配合select,从而达到关闭goroutine的目的,这种方式显然比等待组优雅的多,但是在goroutine中在嵌套goroutine的情况就变得异常复杂。

    func main() { stop := make(chan bool) // 开启goroutine go func() { for { select { case <- stop: fmt.Println("任务1 结束了。。。") return default: fmt.Println(" 任务1 正在运行中。") time.Sleep(time.Second * 2) } } }() // 运行10s后停止 time.Sleep(time.Second * 10) fmt.Println("需要停止任务1。。。") stop <- true time.Sleep(time.Second * 1) } /* ------------------执行结果--------------------------------- 任务1 正在运行中... 任务1 正在运行中... 任务1 正在运行中... 任务1 正在运行中... 任务1 正在运行中... 任务1 正在运行中... 需要停止任务1... 任务1 结束了... */ 劣势:如果有很多 goroutine 都需要控制结束和如果这些 goroutine 又衍生了其它更多的goroutine比较麻烦。

    context

    context是GO1.7版本加入的一个标准库,它定义了Context类型,专门用来简化对于处理单个请求的多个goroutine之间与请求域的数据、取消信号、截止时间等相关操作.使用方式:对服务器传入的请求应该创建上下文,而对服务器的传出调用应该接收上下文。它们之间的函数调用链必须传递上下文,或者可以使用WithCancel、WithDeadline、WithTimeout或WithValue创建的派生上下文。当一个上下文被取消时,它派生的所有上下文也被取消。当一个goroutine在衍生一个goroutine时,context可以跟踪到子goroutine,从而达到控制他们的目的; func main (){ // context.Background() 返回一个空的 Context,这个空的 Context 一般用于整个 Context 树的根节点。 // context.WithCancel(parent): 创建一个可取消的子 Context,然后当作参数传给 goroutine 使用,这样就可以使用这个子 Context 跟踪这个 goroutine。 ctx,cancel:=context.WithCancel(context.Background()) // 开始goroutine ,传入ctx go func (ctx context.Context) { for { select { case <- ctx.Done(): fmt.Println("任务1 结束了....") return default: fmt.Println("任务1 正在运行中.....") time.Sleep(time.Second *2) } } }(ctx) //运行10s后停止 time.Sleep(time.Second*10) fmt.Println("需要停止任务1....") // 使用context 的cancel 函数停止goroutine cancel() // 为了检测监控过是否停止,如果没有监控输出,就表示停止了 time.Sleep(time.Second*4) } 使用select 调用<-ctx.Done()判断是否要结束,如果接收到值的话,表示结束发送结束指令:cancel 函数( CancelFunc 类型),它是我们调用context.WithCancel(parent) 函数生成子 Context 的时候返回的。我们调用它就可以发出取消指令,然后我们的监控 goroutine 就会收到信号,就会返回结束。

    多个goroutine情况

    // 使用context控制多个goroutine func watch(ctx context.Context, name string) { for { select { case <- ctx.Done(): fmt.Println(name, "退出 ,停止了。。。") return default: fmt.Println(name, "运行中。。。") time.Sleep(2 * time.Second) } } } func main() { ctx, cancel := context.WithCancel(context.Background()) go watch(ctx, "【任务1】") go watch(ctx, "【任务2】") go watch(ctx, "【任务3】") time.Sleep(time.Second * 10) fmt.Println("通知任务停止。。。。") // 当我们使用 cancel 函数通知取消时,这 3 个 goroutine 都会被结束 cancel() // 结束任务1 2 3 全部 time.Sleep(time.Second * 5) fmt.Println("真的停止了。。。") } /// // 使用channel控制多个goroutine func watch(c chan bool, name string) { for { select { case <-c: fmt.Println(name, "退出 ,停止了。。。") return default: fmt.Println(name, "运行中。。。") time.Sleep(2 * time.Second) } } } func main() { c := make(chan bool) go watch(c, "【任务1】") go watch(c, "【任务2】") go watch(c, "【任务3】") time.Sleep(time.Second * 10) fmt.Println("通知任务停止。。。。") c <- true // 结束任务1 2 3中的某一个 time.Sleep(time.Second * 5) fmt.Println("真的停止了。。。") }

    上面例子中,启动了 3 个监控 goroutine进行不断的运行任务,每一个都使用了Context进行跟踪,当我们使用cancel函数通知取消时,这 3 个 goroutine 都会被结束。canel之后,所有基于这个Context或者衍生的子Context都会收到通知,这时就可以进行清理操作了,最终释放 goroutine,这就优雅的解决了 goroutine 启动后不可控的问题。

    context接口

    type Context interface { // 获取设置的截止时间: // 第一个返回值是截止时间,到了这个时间点,Context 会自动发起取消请求; //第二个返回值 ok==false 时表示没有设置截止时间,如果需要取消的话,需要调用取消函数进行取消 Deadline() (deadline time.Time, ok bool) // 该方法返回一个只读的 chan,类型为 struct{},如果该方法返回的 chan 可以读取,则意味着parent context已经发起了取消请求,我们通过 Done 方法收到这个信号后,就应该做清理操作,然后退出 goroutine,释放资源。 Done() <- chan struct {} // 返回取消的错误原因,因为什么 Context 被取消。 Err() error // 获取该 Context 上绑定的值,是一个键值对,所以要通过一个 Key 才可以获取对应的值,这个值一般是线程安全的。 Value(key interface{}) interface{} } Done:如果 Context取消的时候,我们就可以得到一个关闭的 chan,关闭的chan是可以读取的,所以只要可以读取的时候,就意味着收到 Context取消的信号了。 func Stream (ctx context.Context, out chan <- Value)error { for { v,err:=DoSomethine(ctx) if err !=nil { return err } select { case <-ctx.Done(): return ctx.Err() case out <- v: } } }

    Go帮我们实现了2个Context接口,我们代码中最开始都是以这两个内置的作为最顶层的partent context,衍生出更多的子Context。

    var ( background = new(emptyCtx) todo = new(emptyCtx) ) func Background() Context { return background } func TODO() Context { return todo } / 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 } Background()主要用于main 函数、初始化以及测试代码中,作为 Context这个树结构的最顶层的 Context,也就是根Context。TODO(),它目前还不知道具体的使用场景…它们两个本质上都是 emptyCtx结构体类型,是一个不可取消,没有设置截止时间,没有携带任何值的Context。

    Context的继承衍生

    context 包为我们提供的 With 系列的函数,可以让我们在原来的Context上衍生出子Context。

    //返回子 Context,以及一个取消函数用来取消 Context。 func WithCancel(parent Context) (ctx Context, cancel CancelFunc) //传入截止时间参数,意味着到了这个时间点,会自动取消 Context,也可以不等到这个时候,可以提前通过取消函数进行取消。 func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc) //传入一个时间参数,多少时间之后取消Context,当然我们也可以不等到这个时候,可以提前通过取消函数进行取消。 func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) //生成一个绑定了一个键值对数据的 Context,即给context设置值,这个绑定的数据可以通过 Context.Value 方法访问到. func WithValue(parent Context, key, val interface{}) Context CancelFunc func(),该函数可以取消一个Context,以及这个节点 Context下所有的所有的 Context,不管有多少层级。context.WithValue方法附加一对 K-V 的键值对,这里 Key 必须是等价性的,也就是具有可比性;Value值要是线程安全的。在使用值的时候,可以通过 Value方法读取: ctx.Value(key)。使用WithValue 传值,一般是必须的值,不要什么值都传递。

    Context最佳实战

    不要把 Context 放在结构体中,要以参数的方式传递以 Context 作为参数的函数方法,应该把 Context 作为第一个参数,放在第一位给一个函数方法传递 Context 的时候,不要传递 nil,如果不知道传递什么,就使用 context.TODOContext 的 Value 相关方法应该传递必须的数据,不要什么数据都使用这个传递Context 是线程安全的,可以放心的在多个 goroutine 中传递

    参考

    Processed: 0.032, SQL: 9