Golang Context 详细原理和使用技巧

2022-11-28  本文已影响0人  AllenWu

Golang Context 详细原理和使用技巧

Context 背景 和 适用场景

Context 的背景

Golang 在 1.6.2 的时候还没有自己的 context,在1.7的版本中就把 golang.org/x/net/context包被加入到了官方的库中。Golang 的 Context 包,中文可以称之为“上下文”,是用来在 goroutine 协程之间进行上下文信息传递的,这些上下文信息包括 kv 数据、取消信号、超时时间、截止时间等。

Context 的功能和目的

虽然我们知道了 context 上下文的基本信息,但是想想,为何 Go 里面把 Context 单独拧出来设计呢?这就和 Go 的并发有比较大的关系,因为 Go 里面创建并发协程非常容易,但是,如果没有相关的机制去控制这些这些协程的生命周期,那么可能导致协程泛滥,也可能导致请求大量超时,协程无法退出导致协程泄漏、协程泄漏导致协程占用的资源无法释放,从而导致资源被占满等各种问题。所以,context 出现的目的就是为了解决并发协程之间父子进程的退出控制。

一个常见例子,有一个 web 服务器,来一个请求,开多个协程去处理这个请求的业务逻辑,比如,查询登录状态、获取用户信息、获取业务信息等,那么如果请求的下游协程的生命周期无法控制,那么我们的业务请求就可能会一直超时,业务服务可能会因为协程没有释放导致协程泄漏。因此,协程之间能够进行事件通知并且能控制协程的生命周期非常重要,怎么实现呢? context 就是来干这些事的。

另外,既然有大量并发协程,那么各个协程之间的一些基础数据如果想要共享,比如把每个请求链路的 tarceID 都进行传递,这样把整个链路串起来,要怎么做呢? 还是要依靠 context。

总体来说,context 的目的主要包括两个:

  1. 协程之间的事件通知(超时、取消)
  2. 协程之间的数据传递键值对的数据(kv 数据)

Context 的基本使用

Go 语言中的 Context 直接使用官方的 "context"包就可以开始使用了,一般是在我们所有要传递的地方(函数的第一个参数)把 context.Context 类型的变量传递,并对其进行相关 API 的使用。context 常用的使用姿势包括但不限于:

  1. 通过 context 进行数据传递,但是这里只能传递一些通用或者基础的元数据,不要传递业务层面的数据,不是说不可以传递,是在 Go 的编码规范或者惯用法中不提倡
  2. 通过 context 进行协程的超时控制
  3. 通过 context 进行并发控制

Context 的同步控制设计

Go 里面控制并发有两种经典的方式,一种是 WaitGroup,另外一种就是 Context。

在 Go 里面,当需要进行多批次的计算任务同步,或者需要一对多的协作流程的时候;通过 Context 的关联关系(go 的 context 被设计为包含了父子关系),我们就可以控制子协程的生命周期,而其他的同步方式是无法控制其生命周期的,只能是被动阻塞等待完成或者结束。context 控制子协程的生命周期,是通过 context 的 context.WithTimeout 机制来实现的,这个是一般系统中或者底层各种框架、库的普适用法。context 对并发做一些控制包括 Context Done 取消、截止时间取消 context.WithDeadline、超时取消 context.WithTimeout 等。

比如有一个网络请求 Request,每个 Request 都需要开启一个 goroutine 做一些业务逻辑,这些 goroutine 又可能会开启其他的 goroutine。那么这样的话,我们就可以通过 Context 来跟踪并控制这些 goroutine。

另外一个实际例子是,在 Go 实现的 web server 中,每个请求都会开一个 goroutine 去处理。但是我们的这个 goroutine 请求逻辑里面, 还需继续创建goroutine 去访问后端其他资源,比如数据库、RPC 服务等。由于这些 goroutine 都是在处理同一个请求,因此,如果请求超时或者被取消后,所有的 goroutine 都应该马上退出并且释放相关的资源,这种情况也需要用 Context 来为我们取消掉所有 goroutine。

请允许我打个小广告:这篇文章首发在我微信公众号【后端系统和架构】中,点击这里可以去往公众号查看原文链接,如果对你有帮助,欢迎前往关注,更加方便快捷的接收最新优质文章

Context 的定义和实现

Context interface 接口定义

在 golang 里面,interface 是一个使用非常广泛的结构,它可以接纳任何类型。而 context 就是通过 interface 来定义的,定义很简单,一共4个方法,这也是 Go 的设计理念,接口尽量简单、小巧,通过组合来实现丰富的功能。

定义如下:

type Context interface {
    //  返回 context 是否会被取消以及自动取消的截止时间(即 deadline)
    Deadline() (deadline time.Time, ok bool)
    
    // 当 context 被取消或者到了 deadline,返回一个被关闭的 channel
    Done() <-chan struct{}
    
    // 返回取消的错误原因,因为什么 Context 被取消
    Err() error
    
    // 获取 key 对应的 value
    Value(key interface{}) interface{}
}
  1. Deadline 返回 context 是否会被取消以及自动取消的截止时间,第一个返回值是截止时间,到了这个时间点,Context 会自动发起取消请求;第二个返回值 ok==false 时表示没有设置截止时间,如果需要取消的话,需要调用取消函数进行取消。

  2. Done 方法返回一个只读的 chan,类型为 struct{},如果该方法返回的 chan 可以读取,那么就说明 parent context 已经发起了取消请求,当我们通过 Done 方法收到这个信号后,就应该做清理操作,然后退出 goroutine,释放资源。之后,Err 方法会返回一个错误,告知为什么 Context 被取消。

  3. Err 方法返回取消的错误原因,因为什么 Context 被取消。

  4. Value 方法获取该 Context 上保存的键值对,所以要通过一个 Key 才可以获取对应的值,这个值一般是线程安全(并发安全)的。虽然 context 是一个并发安全的类型,但是如果 context 中保存着 value,则这些 value 通常不是并发安全的,并发读写这些 value 可能会造成数据错乱,严重的情况下可能发生 panic,所以在并发时,如果我们的业务代码需要读写 context 中的 value,那么最好建议我们 clone 一份原来的 context 中的 value,并塞到新的 ctx 传递给各个gorouinte。当然, 如果已经明确不会有并发读取,那么可以直接使用,或者使用的时候加锁。

parent Context 的具体实现

Context 虽然是个接口,但是并不需要使用方实现,golang 内置的 context 包,已经帮我们实现了,查看 Go 的源码可以看到如下定义:

var (
    background = new(emptyCtx)
    todo = new(emptyCtx)
)

func Background() Context {
    return background
}

func TODO() Context {
    return todo
}

Background 和 TODO 两个其实都是基于 emptyCtx 来实现的,emptyCtx 类型实现了 context 接口定义的 4 个方法,它本身是一个不可取消,没有设置截止时间,没有携带任何值的 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
}

Background 方法,一般是在 main 函数的入口处(或者请求最初的根 context)就定义并使用,然后一直往下传递,接下来所有的子协程里面都是基于 main 的 context 来衍生的。TODO 这个一般不建议业务上使用,一般没有实际意义,在单元测试里面可以使用。

Context 的继承和各种 With 系列函数

查看官方文档 https://pkg.go.dev/golang.org/x/net/context,看到有如下函数:

// 最基础的实现,也可以叫做父 context
func Background() Context
func TODO() Context

// 在 Background() 根 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 interface{}, val interface{}) Context

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YscUL2va-1669634015262)(media/16346485078421.jpg)]

Context 的常用方法实例

1. 调用 Context Done方法取消

func ContextDone(ctx context.Context, out chan<- Value) error {

    for {
        v, err := AllenHandler(ctx)

        if err != nil {
            return err
        }
        select {
        case <-ctx.Done():
            log.Infof("context has done")
            return ctx.Err()
        case out <- v:
        }
    }
}

2. 通过 context.WithValue 来传值

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    valueCtx := context.WithValue(ctx, key, "add value from allen")

    go watchAndGetValue(valueCtx)

    time.Sleep(10 * time.Second)

    cancel()

    time.Sleep(5 * time.Second)
}

func watchAndGetValue(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            //get value
            log.Infof(ctx.Value(key), "is cancel")

            return
        default:
            //get value
            log.Infof(ctx.Value(key), "int goroutine")

            time.Sleep(2 * time.Second)
        }
    }
}
    

3. 超时取消 context.WithTimeout

    package main
    
    import (
        "fmt"
        "sync"
        "time"
    
        "golang.org/x/net/context"
    )
    
    var (
        wg sync.WaitGroup
    )
    
    func work(ctx context.Context) error {
        defer wg.Done()
    
        for i := 0; i < 1000; i++ {
            select {
            case <-time.After(2 * time.Second):
                fmt.Println("Doing some work ", i)
    
            // we received the signal of cancelation in this channel
            case <-ctx.Done():
                fmt.Println("Cancel the context ", i)
                return ctx.Err()
            }
        }
        return nil
    }
    
    func main() {
        ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
        defer cancel()
    
        fmt.Println("Hey, I'm going to do some work")
    
        wg.Add(1)
        go work(ctx)
        wg.Wait()
    
        fmt.Println("Finished. I'm going home")
    }
    

4. 截止时间取消 context.WithDeadline

    package main
    
    import (
        "context"
        "fmt"
        "time"
    )
    
    func main() {
        d := time.Now().Add(1 * time.Second)
        ctx, cancel := context.WithDeadline(context.Background(), d)
    
        // Even though ctx will be expired, it is good practice to call its
        // cancelation function in any case. Failure to do so may keep the
        // context and its parent alive longer than necessary.
        defer cancel()
    
        select {
        case <-time.After(2 * time.Second):
            fmt.Println("oversleep")
        case <-ctx.Done():
            fmt.Println(ctx.Err())
        }
    }
    

Context 使用原则 和 技巧

最后

上一篇下一篇

猜你喜欢

热点阅读