context:协调多个goroutine

什么是context

context是golang在1.7版本的时候引入的标准库,从名字也知道是"上下文",不过准确的说应该是goroutine的上下文,它包含了goroutine的运行状态、环境等信息。

context主要是用来在goroutine之间传递上下文信息,包括:取消信号、超时时间、截止时间等等。

为什么会有context

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

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不断监视某个目录,如果有新文件,那么就进行相应的逻辑,但是只需要监视三天,三天之后就不需要监视这个目录了,那么我们就应该让这个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里面我们不能直接杀死协程,协程的关闭一般会使用channel+select的方式。但是在某些场景下,例如处理一个请求衍生了许多协程,这些协程之间是互联的:需要共享一些全局变量、有共同的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对象和一个函数,context.Background() 返回一个空的Context,这个空的Context一般用于整个Context树的根节点。然后我们使用context.WithCancel(parent)函数,创建一个可取消的子Context,然后当作参数传给goroutine使用,这样就可以使用这个子Context跟踪这个goroutine。

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("结束啦~~~~,通知协程退出")
    cancel()
    //可能要忙其他的事情
    for {}
    /*
    bob,别傻愣着
    bob,别傻愣着
    结束啦~~~~,通知协程退出
    bob,别傻愣着
    收到取消通知,我要退出啦,使命已经结束
     */
}

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,当然我们也可以不等到这个时候,可以提前通过取消函数进行取消。而WithTimeoutWithDeadline基本上一样,这个表示是超时自动取消,是多少时间后自动取消Context的意思。可以把前面的WithDeadline理解为到达指定的时间点取消,WithTimeout理解为达到指定的时间间隔取消。

WithValue函数和取消Context无关,它是为了生成一个绑定一个键值对数据的Context,这个绑定的数据可以通过Context.Value方法访问到,后面我们会专门讲。

大家可能留意到,前三个函数都返回一个取消函数CancelFunc,这是一个函数类型,它的定义非常简单。

type CancelFunc func()

这就是取消函数的类型,该函数可以取消一个Context,以及这个节点Context下所有的所有的Context,不管有多少层级。

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("结束啦~~~~,通知协程退出")
    //调用cancel()的时候,不光是<-ctx.Done()可以获取数据,<-valueCtx.Done()一样可以获取数据
    cancel()
    //可能要忙其他的事情
    for {}
    /*
    bob,别傻愣着
    bob,别傻愣着
    结束啦~~~~,通知协程退出
    bob,别傻愣着
    收到取消通知,我要退出啦,使命已经结束
    拿到传入的值:satori
     */
}

我们可以使用context.WithValue方法附加一对K-V的键值对,这里Key必须是等价性的,也就是具有可比性;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.
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.
3.Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.
4.The same Context may be passed to functions running in different goroutines; Contexts ar safe for simultaneous use by multiple goroutines.

1.不要将 Context 塞到结构体里。直接将 Context 类型作为函数的第一个参数,而且一般都命名为 ctx。
2.不要向函数传入一个 nil 的 context,如果你实在不知道传什么,标准库给你准备好了一个 context:todo。
3.不要把本应该作为函数参数的类型塞到 context 中,context 存储的应该是一些共同的数据。例如:登陆的 session、cookie 等。
4.同一个 context 可能会被传递到多个 goroutine,别担心,context 是并发安全的。

参考

码农桃花源:https://mp.weixin.qq.com/s/GpVy1eB5Cz_t-dhVC6BJNw

飞雪无情:https://www.flysnow.org/2017/04/29/go-in-action-go-runner.html

原文地址:https://www.cnblogs.com/traditional/p/12232156.html

时间: 2024-10-14 08:52:35

context:协调多个goroutine的相关文章

Go语言之Context

控制并发有两种经典的方式,一种是WaitGroup,另外一种就是Context,今天我就谈谈Context. 什么是WaitGroup WaitGroup以前我们在并发的时候介绍过,它是一种控制并发的方式,它的这种方式是控制多个goroutine同时完成. func main() { var wg sync.WaitGroup wg.Add(2) go func() { time.Sleep(2*time.Second) fmt.Println("1号完成") wg.Done() }(

Go语言-Context上下文实践

使用 Context 的程序包需要遵循如下的原则来满足接口的一致性以及便于静态分析 1.不要把 Context 存在一个结构体当中,显式地传入函数.Context 变量需要作为第一个参数使用,一般命名为ctx 2.即使方法允许,也不要传入一个 nil 的 Context ,如果你不确定你要用什么 Context 的时候传一个 context.TODO 3.使用 context 的 Value 相关方法只应该用于在程序和接口中传递的和请求相关的元数据,不要用它来传递一些可选的参数 4.同样的 Co

Go36-32-context.Context

context.Context sync.WaitGroup类型是一个实现一对多goroutine协作流程的同步工具.还有另一种工具也可以实现这种协作流程. 回顾sync.WaitGroup实现协作流程 在使用WaitGroup的时候,建议是用"先统一Add,再并发Done,最后Wait"的模式来构建协作流程.要避免并发的调用Add方法.这就带来一个问题,需要在一开始就能确定执行子任务的goroutine的数量,至少也是在启动goroutine之前. 下面是一个示例,稍微做了一些改造:

golang标准库 context的使用

本文索引 问题引入 context包简介 示例 问题引入 goroutine为我们提供了轻量级的并发实现,作为golang最大的亮点之一更是备受推崇. goroutine的简单固然有利于我们的开发,但简单总是有代价的,考虑如下例子: func httpDo(req *http.Request, resp *http.Response) { for { select { case <-time.After(5 * time.Second): // 从req读取数据然后发送给resp // 其他的一

golang Context for goroutines

概要 goroutine 的控制 取消控制 超时控制 goroutine 之间的传值 总结 概要 golang 的提供的 channel 机制是基于 CSP(Communicating Sequencial Processes)模型的并发模式. 通过 channel, 可以很方便的写出多个 协程 (goroutine)之间协作的代码, 将顺序的代码改成并行的代码非常简单. 改造成并行的代码之后, 虽然可以更好的利用多核的硬件, 有效的提高代码的执行效率, 但是, 也带来了代码控制的问题. 并行的

聊聊golang的context

golang的context的主要用途在于在多个goroutine之间传递数据,管理多个goroutine的生命周期.实际的应用场景有比如,在http服务中,每个请求就对应一个goroutine,而请求之中可能又会调用别的api,而产生更多的goroutine,用context来管理这些goroutine就能比较方便在这些goroutine中传递数据和管理. 主要方法 func Background() Context Background() 返回一个空的context,这是一个根节点. fu

什么是架构

什么是软件架构 前言:软体设计师中有一些技术水平较高.经验较为丰富的人,他们需要承担软件系统的架构设计,也就是需要设计系统的元件如何划分.元件之间如何发生相互作用,以及系统中逻辑的.物理的.系统的重要决定的作出.在很多公司中,架构师不是一个专门的和正式的职务.通常在一个开发小组中,最有经验的程序员会负责一些架构方面的工作.在一个部门中,最有经验的项目经理会负责一些架构方面的工作.但是,越来越多的公司体认到架构工作的重要性. 什么是软件系统的架构(Architecture)?一般而言,架构有两个要

GO语言语法入门

引言 Go Go语言是谷歌2009发布的编程语言,它是一种并发的.带垃圾回收的.快速编译的语言. 它结合了解释型语言的游刃有余,动态类型语言的开发效率,以及静态类型的安全性.它也打算成为现代的,支持网络与多核计算的语言.要满足这些目标,需要解决一些语言上的问题:一个富有表达能力但轻量级的类型系统,并发与垃圾回收机制,严格的依赖规范等等.这些无法通过库或工具解决好,因此Go也就应运而生了. 优势 语法简单,上手快: 性能高,编译快,开发效率也不低: 丰富的标准库: 原生支持并发,协程模型是非常优秀

A Tour of Golang (二)

是时候继续总结一波golang使用心得了!码的代码越多了解的go就越多,go处理问题的思路确实不一样 9. defer panic recover defer 接上次的问题继续讨论,先来看下golang blog上怎么说defer A defer statement pushes a function call onto a list. The list of saved calls is executed after the surrounding function returns. Defe