侧边栏壁纸
博主头像
问道

问道的小花园,总能给你带来惊喜

  • 累计撰写 68 篇文章
  • 累计创建 35 个标签
  • 累计收到 6 条评论

Go 语言 Context知识详解

问道
2022-11-29 / 0 评论 / 0 点赞 / 436 阅读 / 6,524 字 / 正在检测是否收录...
温馨提示:
本文最后更新于 2022-11-29,若内容或图片失效,请留言反馈。部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

context包的起源

context是Go中用来进程通信的一种方式,其底层是借助channl与snyc.Mutex实现的。

context包是在go1.7版本中引入到标准库中的:

context可以用来在goroutine之间传递上下文信息,相同的context可以传递给运行在不同goroutine中的函数,上下文对于多个goroutine同时使用是安全的,context包定义了上下文类型,可以使用backgroundTODO创建一个上下文,在函数调用链之间传播context,也可以使用WithDeadlineWithTimeoutWithCancelWithValue 创建的修改副本替换它,听起来有点绕,其实总结起就是一句话:context的作用就是在不同的goroutine之间同步请求特定的数据、取消信号以及处理请求的截止日期。

目前我们常用的一些库都是支持context的,例如gindatabase/sql等库都是支持context的,这样更方便我们做并发控制了,只要在服务器入口创建一个context上下文,不断透传下去即可。

基本介绍

context的底层设计,我们可以概括为2个接口,4种实现与6个方法。

2个接口 :Context 接口和canceler 接口

4 种实现 :emptyCtx 实现了一个空的context,可以用作根节点 cancelCtx 实现一个带cancel功能的context,可以主动取消 timerCtx 实现一个通过定时器timer和截止时间deadline定时取消的context valueCtx 实现一个可以通过 key、val 两个字段来存数据的context

6 个方法 :Background 返回一个emptyCtx作为根节点 TODO 返回一个emptyCtx作为未知节点 WithCancel 返回一个cancelCtx WithDeadline 返回一个timerCtx WithTimeout 返回一个timerCtx WithValue 返回一个valueCtx

结构和主要方法

主要函数、结构体和变量说明:

名称类型可否导出说明
Context接口可以Context 基本接口,定义了 4 个方法
canceler接口不可以Context 取消接口,定义了 2 个方法
CancelFunc函数可以取消函数签名
Background函数可以返回一个空的 Context,常用来作为根 Context
Todo函数可以返回一个空的 context,常用于初期写的时候,没有合适的 context 可用
emptyCtx结构体不可以实现了 Context 接口,默认都是空实现,emptyCtx 是 int 类型别名
cancelCtx结构体不可以可以被取消
valueCtx结构体不可以可以存储 k-v 信息
timerCtx结构体不可以可被取消,也可超时取消
WithCancel函数可以基于父 context,创建可取消 Context
WithDeadline函数可以创建一个有 deadline 的 context
WithTimeout函数可以创建一个有 timeout 的 context
WithValue函数可以创建一个存储 k-v 的 context
newCancelCtx函数不可以创建一个可取消的 context
propagateCancel函数不可以向下传递 context 节点间的取消关系
parentCancelCtx函数不可以找到最先出现的一个可取消 Context
removeChild函数不可以将当前的 canceler 从父 Context 中的 children map 中移除
background变量不可以包级 Context,默认的 Context,常作为顶级 Context
todo变量不可以包级 Context,默认的 Context 实现,也作为顶级 Context,与 background 同类型
closedchan变量不可以channel struct{}类型,用于信息通知
Canceled变量可以取消 error
DeadlineExceeded变量可以超时 error
cancelCtxKey变量不可以int 类型别名,做标记用的

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和进程间跟请求域的数据;

Context 的四种实现

emptyCtx

从源码可以看出,emptyCtx 实际上就是个 int,其对 Context 接口的实现不是直接返回,就是返回 nil,是一个空实现。它通常用于创建 root Context,标准库中 context.Background() 和 context.TODO() 返回的就是这个 emptyCtx。emptyCtx 不能取消、不能传值且没有 deadline。

cancelCtx

Context 包的核心实现就是 cancelCtx,包括里面的构造树形结构、级联取消等。

Dono()方法

c.done 是“懒汉式”初始化,只有调用了 Done() 方法的时候才会被创建。Done() 方法用于通知该 Context 是否被取消,通过监听 channel 关闭达到被取消通知目的,c.done 没有被关闭的时候,调用 Done() 方法会 block,被关闭之后,调用 Done() 方法返回 struct{},一般通过搭配 select 使用。

Value()方法

这个方法的实现比较有意思,cancelCtxKey 是一个 Context 包内部变量,将 key 与 &cancelCtxKey 比较,相等的话就返回 *cancelCtx,即 cancelCtx 的自身地址;否则继续递归。

canceler 接口

type canceler interface {
 cancel(removeFromParent bool, err error)
 Done() <-chan struct{}
}

如果一个 Context 类型实现了上面定义的两个方法,该 Context 就是一个可取消的 Context。Context 包中 *cancelCtx 和 *timerCtx 实现了 canceler 接口,注意这里是指针类型。

第一次看到这两个接口时,我就在想为什么不把 canneler 和 Context 合并呢?况且他们定义的方法中都有 Done 方法,可以解释得通的说法是,源码作者认为 cancel 方法并不是 Context 必须的,根据最小接口设计原则,将两者分开。像 emptyCtx 和 valueCtx 不是可取消的,所以他们只要实现 Context 接口即可。cancelCtx 和 timerCtx 是可取消的 Context,他们要实现 2 个接口中的所有方法。

Background()和TODO()

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

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

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

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

With系列函数

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

WithCancel

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

WithDeadline

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

WithTimeout

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

WithValue

WithValue返回父节点的副本,其中与key关联的值为val。

仅对API和进程间传递请求域的数据使用上下文值,而不是使用它来传递可选参数给函数。

所提供的键必须是可比较的,并且不应该是string类型或任何其他内置类型,以避免使用上下文在包之间发生冲突。WithValue的用户应该为键定义自己的类型。为了避免在分配给interface{}时进行分配,上下文键通常具有具体类型struct{}。或者,导出的上下文关键变量的静态类型应该是指针或接口。

客户端超时取消示例

调用服务端API时如何在客户端实现超时控制?

client端

package main

import (
  "context"
  "fmt"
  "io/ioutil"
  "net/http"
  "sync"
  "time"
)

// 客户端

type respData struct {
  resp *http.Response
  err  error
}

func doCall(ctx context.Context) {
  transport := http.Transport{
     // 请求频繁可定义全局的client对象并启用长链接
     // 请求不频繁使用短链接
     DisableKeepAlives: true,   }
  client := http.Client{
    Transport: &transport,
  }

  respChan := make(chan *respData, 1)
  req, err := http.NewRequest("GET", "http://127.0.0.1:8000/", nil)
  if err != nil {
    fmt.Printf("new requestg failed, err:%v\n", err)
    return
  }
  req = req.WithContext(ctx) // 使用带超时的ctx创建一个新的client request
  var wg sync.WaitGroup
  wg.Add(1)
  defer wg.Wait()
  go func() {
    resp, err := client.Do(req)
    fmt.Printf("client.do resp:%v, err:%v\n", resp, err)
    rd := &respData{
      resp: resp,
      err:  err,
    }
    respChan <- rd
    wg.Done()
  }()

  select {
  case <-ctx.Done():
    //transport.CancelRequest(req)
    fmt.Println("call api timeout")
  case result := <-respChan:
    fmt.Println("call server api success")
    if result.err != nil {
      fmt.Printf("call server api failed, err:%v\n", result.err)
      return
    }
    defer result.resp.Body.Close()
    data, _ := ioutil.ReadAll(result.resp.Body)
    fmt.Printf("resp:%v\n", string(data))
  }
}

func main() {
  // 定义一个100毫秒的超时
  ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*100)
  defer cancel() // 调用cancel释放子goroutine资源
  doCall(ctx)
}

server端

package main

import (
  "fmt"
  "math/rand"
  "net/http"

  "time"
)

// server端,随机出现慢响应

func indexHandler(w http.ResponseWriter, r *http.Request) {
  number := rand.Intn(2)
  if number == 0 {
    time.Sleep(time.Second * 10) // 耗时10秒的慢响应
    fmt.Fprintf(w, "slow response")
    return
  }
  fmt.Fprint(w, "quick response")
}

func main() {
  http.HandleFunc("/", indexHandler)
  err := http.ListenAndServe(":8000", nil)
  if err != nil {
    panic(err)
  }
}

总结及注意事项

context 包的代码非常短,去掉注释的话也就 200+ 行,但却是并发控制的标准做法,比如实现 goroutine 之间传递取消信号、截止时间及传递一些 k-v 值等。

注意事项

  • 以Context作为参数的函数方法,应该把Context作为第一个参数。
  • 给一个函数方法传递Context的时候,不要传递nil,如果不知道传递什么,就使用context.TODO()
  • Context的Value相关方法应该传递请求域的必要数据,不应该用于传递可选参数
  • 推荐以参数的方式显示传递Context
  • 对第三方调用要传入 context,用于控制远程调用。
  • 不要将上下文存储在结构类型中,尽可能的作为函数第一位形参传入。
  • 函数调用链必须传播上下文,实现完整链路上的控制。
  • context 的继承和派生,保证父、子级 context 的联动。
  • 不传递 nil context,不确定的 context 应当使用 TODO。
  • context 仅传递必要的值,不要让可选参数揉在一起。

优点

  • 使用context可以更好的做并发控制,能更好的管理goroutine滥用。
  • context的携带者功能没有任何限制,这样我我们传递任何的数据,可以说这是一把双刃剑
  • 网上都说context包解决了goroutinecancelation问题,你觉得呢?

缺点

  • context可以携带值,但是没有任何限制,类型和大小都没有限制,也就是没有任何约束,这样很容易导致滥用,程序的健壮很难保证;还有一个问题就是通过context携带值不如显式传值舒服,可读性变差了。
  • 可以自定义context,这样风险不可控,更加会导致滥用。
  • 创建衍生节点实际是创建一个个链表节点,其时间复杂度为O(n),节点多了会掉支效率变低。
0

评论区