12

Go语言之Context 图解

1、为什么要引入Contextcontext.Context是Go中定义的一个接口类型,从1.7版本中开始引入。其主要作用是在一次请求经过的所有协程或函数间传递取消信号及共享数据,以达到父协程对子协程的管理和控制的目的。需要注意的是context.Context的作用范围是一次请求的生命周期,即随着请求的产生而产生,随着本次请求的结束而结束。如图所示:

基本示例

// 初始的例子

func worker() {

for {

fmt.Println("worker")

time.Sleep(time.Second)

}

// 如何接收外部命令实现退出

wg.Done()

}

func main() {

wg.Add(1)

go worker()

// 如何优雅的实现结束子goroutine

wg.Wait()

fmt.Println("over")

}


通道方式

package main

import (

"fmt"

"sync"

"time"

)

var wg sync.WaitGroup

// 管道方式存在的问题:

// 1. 使用全局变量在跨包调用时不容易实现规范和统一,需要维护一个共用的channel

func worker(exitChan chan struct{}) {

LOOP:

for {

fmt.Println("worker")

time.Sleep(time.Second)

select {

case <-exitChan: // 等待接收上级通知

break LOOP

default:

}

}

wg.Done()

}

func main() {

var exitChan = make(chan struct{})

wg.Add(1)

go worker(exitChan)

time.Sleep(time.Second * 3) // sleep3秒以免程序过快退出

exitChan <- struct{}{} // 给子goroutine发送退出信号

close(exitChan)

wg.Wait()

fmt.Println("over")

}

官方版的方案

package main

import (

"context"

"fmt"

"sync"

"time"

)

var wg sync.WaitGroup

func worker(ctx context.Context) {

LOOP:

for {

fmt.Println("worker")

time.Sleep(time.Second)

select {

case <-ctx.Done(): // 等待上级通知

break LOOP

default:

}

}

wg.Done()

}

func main() {

ctx, cancel := context.WithCancel(context.Background())

wg.Add(1)

go worker(ctx)

time.Sleep(time.Second * 3)

cancel() // 通知子goroutine结束

wg.Wait()

fmt.Println("over")

}

当子goroutine又开启另外一个goroutine时,只需要将ctx传入即可:

package main

import (

"context"

"fmt"

"sync"

"time"

)

var wg sync.WaitGroup

func worker(ctx context.Context) {

go worker2(ctx)

LOOP:

for {

fmt.Println("worker")

time.Sleep(time.Second)

select {

case <-ctx.Done(): // 等待上级通知

break LOOP

default:

}

}

wg.Done()

}

func worker2(ctx context.Context) {

LOOP:

for {

fmt.Println("worker2")

time.Sleep(time.Second)

select {

case <-ctx.Done(): // 等待上级通知

break LOOP

default:

}

}

}

func main() {

ctx, cancel := context.WithCancel(context.Background())

wg.Add(1)

go worker(ctx)

time.Sleep(time.Second * 3)

cancel() // 通知子goroutine结束

wg.Wait()

fmt.Println("over")

}


Context接口

context.Context是一个接口,该接口定义了四个需要实现的方法。具体签名如下:

type Context interface {

Deadline() (deadline time.Time, ok bool)

Done() <-chan struct{}

Err() error

Value(key interface{}) interface{}

}

其中:

Deadline方法需要返回当前Context被取消的时间,也就是完成工作的截止时间(deadline);

Done方法需要返回一个Channel,这个Channel会在当前工作完成或者上下文被取消之后关闭,多次调用Done方法会返回同一个Channel;

Err方法会返回当前Context结束的原因,它只会在Done返回的Channel被关闭时才会返回非空的值;

如果当前Context被取消就会返回Canceled错误;

如果当前Context超时就会返回DeadlineExceeded错误;

Value方法会从Context中返回键对应的值,对于同一个上下文来说,多次调用Value 并传入相同的Key会返回相同的结果,该方法仅用于传递跨API和进程间跟请求域的数据;

Background()和TODO()

Go内置两个函数:Background()和TODO(),这两个函数分别返回一个实现了Context接口的background和todo。我们代码中最开始都是以这两个内置的上下文对象作为最顶层的partent context,衍生出更多的子上下文对象。

Background()主要用于main函数、初始化以及测试代码中,作为Context这个树结构的最顶层的Context,也就是根Context。

TODO(),它目前还不知道具体的使用场景,如果我们不知道该使用什么Context的时候,可以使用这个。

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

With系列函数

此外,context包中还定义了四个With系列函数。

WithCancel

WithCancel的函数签名如下:

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

WithCancel返回带有新Done通道的父节点的副本。当调用返回的cancel函数或当关闭父上下文的Done通道时,将关闭返回上下文的Done通道,无论先发生什么情况。

取消此上下文将释放与其关联的资源,因此代码应该在此上下文中运行的操作完成后立即调用cancel。

func gen(ctx context.Context) <-chan int {

dst := make(chan int)

n := 1

go func() {

for {

select {

case <-ctx.Done():

return // return结束该goroutine,防止泄露

case dst <- n:

n++

}

}

}()

return dst

}

func main() {

ctx, cancel := context.WithCancel(context.Background())

defer cancel() // 当我们取完需要的整数后调用cancel

for n := range gen(ctx) {

fmt.Println(n)

if n == 5 {

break

}

}

}

上面的示例代码中,gen函数在单独的goroutine中生成整数并将它们发送到返回的通道。 gen的调用者在使用生成的整数之后需要取消上下文,以免gen启动的内部goroutine发生泄漏。

WithDeadline

WithDeadline的函数签名如下:

func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)

返回父上下文的副本,并将deadline调整为不迟于d。如果父上下文的deadline已经早于d,则WithDeadline(parent, d)在语义上等同于父上下文。当截止日过期时,当调用返回的cancel函数时,或者当父上下文的Done通道关闭时,返回上下文的Done通道将被关闭,以最先发生的情况为准。

取消此上下文将释放与其关联的资源,因此代码应该在此上下文中运行的操作完成后立即调用cancel。

func main() {

d := time.Now().Add(50 * time.Millisecond)

ctx, cancel := context.WithDeadline(context.Background(), d)

// 尽管ctx会过期,但在任何情况下调用它的cancel函数都是很好的实践。

// 如果不这样做,可能会使上下文及其父类存活的时间超过必要的时间。

defer cancel()

select {

case <-time.After(1 * time.Second):

fmt.Println("overslept")

case <-ctx.Done():

fmt.Println(ctx.Err())

}

}

上面的代码中,定义了一个50毫秒之后过期的deadline,然后我们调用context.WithDeadline(context.Background(), d)得到一个上下文(ctx)和一个取消函数(cancel),然后使用一个select让主程序陷入等待:等待1秒后打印overslept退出或者等待ctx过期后退出。 因为ctx50秒后就过期,所以ctx.Done()会先接收到值,上面的代码会打印ctx.Err()取消原因。

WithTimeout

WithTimeout的函数签名如下:

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

取消此上下文将释放与其相关的资源,因此代码应该在此上下文中运行的操作完成后立即调用cancel,通常用于数据库或者网络连接的超时控制。具体示例如下:

package main

import (

"context"

"fmt"

"sync"

"time"

)

// context.WithTimeout

var wg sync.WaitGroup

func worker(ctx context.Context) {

LOOP:

for {

fmt.Println("db connecting ...")

time.Sleep(time.Millisecond * 10) // 假设正常连接数据库耗时10毫秒

select {

case <-ctx.Done(): // 50毫秒后自动调用

break LOOP

default:

}

}

fmt.Println("worker done!")

wg.Done()

}

func main() {

// 设置一个50毫秒的超时

ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*50)

wg.Add(1)

go worker(ctx)

time.Sleep(time.Second * 5)

cancel() // 通知子goroutine结束

wg.Wait()

fmt.Println("over")

}

WithValue

Context的另外一个功能就是在协程间共享数据。该功能是通过WithValue函数构造的Context来实现的。我们看下WithValue的实现:


func WithValue(parent Context, key, val interface{}) Context {
    if parent == nil {
        panic("cannot create context from nil parent")
    }
    if key == nil {
        panic("nil key")
    }
    if !reflectlite.TypeOf(key).Comparable() {
        panic("key is not comparable")
    }
    return &valueCtx{parent, key, val}
}

实现代码很简短,我们看到最终返回的是一个valueCtx结构体实例。其中有两点:一是key的类型必须是可比较的。二是value是不能修改的,即具有不可变性。如果需要添加新的值,只能通过WithValue基于原有的Context再生成一个新的valueCtx来携带新的key-value。这也是Context的值在传递过程中是并发安全的原因。 从另外一个角度来说,在获取一个key的值的时候,也是递归的一层一层的从下往上查找,如下:

func (c *valueCtx) Value(key interface{}) interface{} {
    if c.key == key {
        return c.val
    }
    return c.Context.Value(key)
}

上面简单介绍了下在协程间调用的时候是如何通过Context共享数据的。

但这里讨论的重点什么样的数据需要通过Context来共享,而不是通过传参的方式?

总结下来有以下两点:

  • 携带的数据作用域必须是在请求范围内有效的。即该数据随着请求的产生而产生,随着请求的结束而结束,不会永久的保存。
  • 携带的数据不建议是关键参数,关键参数应显式的通过参数来传递。例如像trace_id之类的,用于维护作用,就适合用在Context中传递。


4.1 什么是请求范围(request-scoped)内的数据

这个没有一个明显的划定标准。一般的请求范围的数据就是用来表示该请求的元数据。比如该请求是由发出(即user id),该请求是在哪儿发出的(即user ip,请求是从该用户的ip位置发出的)。

例如,如果一个日志对象logger是一个单例那么它也不是一个请求范围内的数据。但如果该logger包含了发送请求的来源信息,以及该请求是否启动了调试功能的开关信息,那么该logger也可以被认为是一个请求范围内的数据。

4.2 使用Context.Value的缺点
使用Context.Value会对降低函数的可读性和表达性。例如,下面是使用Context.Value来携带token验证角色的示例:

func IsAdminUser(ctx context.Context) bool {
  x := token.GetToken(ctx)
  userObject := auth.AuthenticateToken(x)
  return userObject.IsAdmin() || userObject.IsRoot()
}

当用户调用该函数的时候,仅仅知道该函数带有一个Context类型的参数。但如果要判断一个用户是否是Admin必须要两部分要说明:一个是验证过的token,一个是认证服务。

我们将该函数的Context移除,然后使用参数的方式来重构,如下:

func IsAdminUser(token string, authService AuthService) bool {
  x := token.GetToken(ctx)
  userObject := auth.AuthenticateToken(x)
  return userObject.IsAdmin() || userObject.IsRoot()
}

那么这个函数的可读性和表达性就比重构前提高了很多。调用者通过函数签名就很容易知道要判断一个用户是否是AdminUser,只需要传入token和认证的服务authService即可。

4.3 context.Value的使用场景
一般复杂的项目都会有中间件层以及大量的抽象层。如果将类似token或userid这样简单的参数以参数的方式从第一个函数层层传递,那对调用者来说将会是一种噩梦。如果将这样的元数据通过Context来携带进行传递,将会是比较好的方式。在实际项目中,最常用的就是在中间件中。我们以iris为web框架,来看下在中间件中的应用:

package main

import (
    "context"
    "github.com/google/uuid"
    "github.com/kataras/iris/v12"
)

func main() {
    app := iris.New()
    app.Use(RequestIDMiddleware)

    app.Get("/hello", mainHandler)

    app.Listen("localhost:8080", iris.WithOptimizations)
}

func RequestIDMiddleware(c iris.Context) {
    reqID := uuid.New()
    ctx := context.WithValue(c.Request().Context(), "req_id", reqID)
    req := c.Request().Clone(ctx)
    c.ResetRequest(req)
    c.Next()
}

func mainHandler(ctx iris.Context) {
    req_id := ctx.Request().Context().Value("req_id")
    ctx.Writef("Hello request id:%s", req_id)
    return
}


5 总结:
context包是go语言中的一个重要的特性。要想正确的在项目中使用context,理解其背后的工作机制以及设计意图是非常重要的。context包定义了一个API,它提供对截止日期、取消信号和请求范围值的支持,这些值可以跨API以及在Goroutine之间传递。

语言   Go   Context
13
发表评论
留言与评论(共有 0 条评论) “”
昵称:
匿名发表 登录账号
         
   
验证码:

相关文章

推荐文章

10
11