Golang 如何正确使用 Context

视频信息

How to correctly use package context by Jack Lindamood at Golang UK Conf. 2017html

视频:www.youtube.com/watch?v=-_B… 博文:medium.com/@cep21/how-…git

为何须要 Context

  • 每个长请求都应该有个超时限制
  • 须要在调用中传递这个超时
    • 好比开始处理请求的时候咱们说是 3 秒钟超时
    • 那么在函数调用中间,这个超时还剩多少时间了?
    • 须要在什么地方存储这个信息,这样请求处理中间能够中止

若是进一步考虑。github

如上图这样的 RPC 调用,开始调用 RPC 1 后,里面分别调用了 RPC 2, RPC 3, RPC 4,等全部 RPC 用成功后,返回结果。

这是正常的方式,可是若是 RPC 2 调用失败了会发生什么?golang

RPC 2 失败后,若是没有 Context 的存在,那么咱们可能依旧会等全部的 RPC 执行完毕,可是因为 RPC 2 败了,因此其实其它的 RPC 结果意义不大了,咱们依旧须要给用户返回错误。所以咱们白白的浪费了 10ms,彻底不必去等待其它 RPC 执行完毕。数据库

那若是咱们在 RPC 2 失败后,就直接给用户返回失败呢?安全

用户是在 30ms 的位置收到了错误消息,但是 RPC 3 和 RPC 4 依然在没意义的运行,还在浪费计算和IO资源。

因此理想状态应该是如上图,当 RPC 2 出错后,除了返回用户错误信息外,咱们也应该有某种方式能够通知 RPC 3 和 RPC 4,让他们也中止运行,再也不浪费资源。bash

因此解决方案就是:服务器

  • 用信号的方式来通知请求该停了
  • 包含一些关于什么时间请求可能会结束的提示(超时)
  • 用 channel 来通知请求结束了

那干脆让咱们把变量也扔那吧。😈微信

  • 在 Go 中没有线程/go routine 变量
    • 其实挺合理的,由于这样就会让 goroutine 互相产生依赖
  • 很是容易被滥用

Context 实现细节

context.Context:并发

  • 是不可变的(immutable)树节点
  • Cancel 一个节点,会连带 Cancel 其全部子节点 (从上到下
  • Context values 是一个节点
  • Value 查找是回溯树的方式 (从下到上

示例 Context 链

完整代码:play.golang.org/p/ddpofBV1Q…

package main
func tree() {
  ctx1 := context.Background()
  ctx2, _ := context.WithCancel(ctx1)
  ctx3, _ := context.WithTimeout(ctx2, time.Second * 5)
  ctx4, _ := context.WithTimeout(ctx3, time.Second * 3)
  ctx5, _ := context.WithTimeout(ctx3, time.Second * 6)
  ctx6 := context.WithValue(ctx5, "userID", 12)
}
复制代码

若是这样构成的 Context 链,其形以下图:

那么当 3 秒超时到了时候:

能够看到 ctx4 超时退出了。

当 5秒钟 超时到达时:

能够看到,不只仅 ctx3 退出了,其全部子节点,好比 ctx5 和 ctx6 也都退出了。

context.Context API

基本上是两类操做:

  • 3个函数用于限定何时你的子节点退出
  • 1个函数用于设置请求范畴的变量
type Context interface {
  //  啥时候退出
  Deadline() (deadline time.Time, ok bool)
  Done() <-chan struct{}
  Err() error
  //  设置变量
  Value(key interface{}) interface{}
}
复制代码

何时应该使用 Context?

  • 每个 RPC 调用都应该有超时退出的能力,这是比较合理的 API 设计
  • 不只仅 是超时,你还须要有能力去结束那些再也不须要操做的行为
  • context.Context 是 Go 标准的解决方案
  • 任何函数可能被阻塞,或者须要很长时间来完成的,都应该有个 context.Context

如何建立 Context?

  • 在 RPC 开始的时候,使用 context.Background()
    • 有些人把在 main() 里记录一个 context.Background(),而后把这个放到服务器的某个变量里,而后请求来了后从这个变量里继承 context。这么作是不对的。直接每一个请求,源自本身的 context.Background() 便可。
  • 若是你没有 context,却须要调用一个 context 的函数的话,用 context.TODO()
  • 若是某步操做须要本身的超时设置的话,给它一个独立的 sub-context(如前面的例子)

如何集成到 API 里?

  • 若是有 Context,将其做为第一个变量
    • 如 func (d* Dialer) DialContext(ctx context.Context, network, address string) (Conn, error)
    • 有些人把 context 放到中间的某个变量里去,这很不合习惯,不要那么作,放到第一个去。
  • 将其做为可选的方式,用 request 结构体方式。
    • 如:func (r *Request) WithContext(ctx context.Context) *Request
  • Context 的变量名请用 ctx(不要起一些诡异的名字😓)

Context 放哪?

  • 把 Context 想象为一条河流流过你的程序(另外一个意思就是说不要喝河里的水……🙊)
  • 理想状况下,Context 存在于调用栈(Call Stack) 中
  • 不要把 Context 存储到一个 struct 里
    • 除非你使用的是像 http.Request 中的 request 结构体的方式
  • request 结构体应该以 Request 结束为生命终止
  • 当 RPC 请求处理结束后,应该去掉对 Context 变量的引用(Unreference)
  • Request 结束,Context 就应该结束。(这俩是一对儿,不求同年同月同日生,但求同年同月同日死……💕)

Context 包的注意事项

  • 要养成关闭 Context 的习惯
    • 特别是 超时的 Contexts
  • 若是一个 context 被 GC 而不是 cancel 了,那通常是你作错了
ctx, cancel := context.WithTimeout(parentCtx, time.Second * 2)
defer cancel()
复制代码
  • 使用 Timeout 会致使内部使用 time.AfterFunc,从而会致使 context 在计时器到时以前都不会被垃圾回收。
  • 在创建以后,当即 defer cancel() 是一个好习惯。

终止请求 (Request Cancellation)

当你再也不关心接下来获取的结果的时候,有可能会 Cancel 一个 Context?

以 golang.org/x/sync/errgroup 为例,errgroup 使用 Context 来提供 RPC 的终止行为。

type Group struct {
	cancel  func()
	wg      sync.WaitGroup
	errOnce sync.Once
	err     error
}
复制代码

建立一个 group 和 context:

func WithContext(ctx context.Context) (*Group, context.Context) {
  ctx, cancel := context.WithCancel(ctx)
  return &Group{cancel: cancel}, ctx
}
复制代码

这样就返回了一个能够被提早 cancel 的 group。

而调用的时候,并非直接调用 go func(),而是调用 Go(),将函数做为参数传进去,用高阶函数的形式来调用,其内部才是 go func() 开启 goroutine。

func (g *Group) Go(f func() error) {
  g.wg.Add(1)
  go func() {
    defer g.wg.Done()
    if err := f(); err != nil {
      g.errOnce.Do(func() {
        g.err = err
        if g.cancel != nil {
          g.cancel()
        }
      })
    }
  }()
}
复制代码

当给入函数 f 返回错误,则使用 sync.Once 来 cancel context,而错误被保存于 g.err 之中,在随后的 Wait() 函数中返回。

func (g *Group) Wait() error {
  g.wg.Wait()
  if g.cancel != nil {
    g.cancel()
  }
  return g.err
}
复制代码

注意:这里在 Wait() 结束后,调用了一次 cancel()。

package main
func DoTwoRequestsAtOnce(ctx context.Context) error {
  eg, egCtx := errgroup.WithContext(ctx)
  var resp1, resp2 *http.Response
  f := func(loc string, respIn **http.Response) func() error {
    return func() error {
      reqCtx, cancel := context.WithTimeout(egCtx, time.Second)
      defer cancel()
      req, _ := http.NewRequest("GET", loc, nil)
      var err error
      *respIn, err = http.DefaultClient.Do(req.WithContext(reqCtx))
      if err == nil && (*respIn).StatusCode >= 500 {
        return errors.New("unexpected!")
      }
      return err
    }
  }
  eg.Go(f("http://localhost:8080/fast_request", &resp1))
  eg.Go(f("http://localhost:8080/slow_request", &resp2))
  return eg.Wait()
}
复制代码

在这个例子中,同时发起了两个 RPC 调用,当任何一个调用超时或者出错后,会终止另外一个 RPC 调用。这里就是利用前面讲到的 errgroup 来实现的,应对有不少并不是请求,并须要集中处理超时、出错终止其它并发任务的时候,这个 pattern 使用起来很方便。

Context.Value - Request 范畴的值

context.Value API 的万金油(duct tape)

胶带(duct tape) 几乎能够修任何东西,从破箱子,到人的伤口,到汽车引擎,甚至到NASA登月任务中的阿波罗13号飞船(Yeah! True Story)。因此在西方文化里,胶带是个“万能”的东西。在中文里,恐怕万金油是更合适的对应词汇,从头疼、脑热,感冒发烧,到跌打损伤几乎无所不治。

固然,治标不治本,这点东西方文化中的潜台词都是同样的。这里说起的 context.Value 对于 API 而言,就是这类性质的东西,啥均可以干,可是治标不治本。

  • value 节点是 Context 链中的一个节点
package context
type valueCtx struct {
  Context
  key, val interface{}
}
func WithValue(parent Context, key, val interface{}) Context {
  //  ...
  return &valueCtx{parent, key, val}
}
func (c *valueCtx) Value(key interface{}) interface{} {
  if c.key == key {
    return c.val
  }
  return c.Context.Value(key)
}
复制代码

能够看到,WithValue() 实际上就是在 Context 树形结构中,增长一个节点罢了。

Context 是 immutable 的。

约束 key 的空间

为了防止树形结构中出现重复的键,建议约束键的空间。好比使用私有类型,而后用 GetXxx() 和 WithXxxx() 来操做私有实体。

type privateCtxType string
var (
  reqID = privateCtxType("req-id")
)
func GetRequestID(ctx context.Context) (int, bool) {
  id, exists := ctx.Value(reqID).(int)
  return id, exists
}
func WithRequestID(ctx context.Context, reqid int) context.Context {
  return context.WithValue(ctx, reqID, reqid)
}
复制代码

这里使用 WithXxx 而不是 SetXxx 也是由于 Context 其实是 immutable 的,因此不是修改 Context 里某个值,而是产生新的 Context 带某个值

Context.Value 是 immutable 的

再屡次的强调 Context.Value 是 immutable 的也不过度。

  • context.Context 从设计上就是按照 immutable (不可变的)模式设计的
  • 一样,Context.Value 也是 immutable 的
  • 不要试图在 Context.Value 里存某个可变动的值,而后改变,指望别的 Context 能够看到这个改变
    • 更别期望着在 Context.Value 里存可变的值,最后多个 goroutine 并发访问没竞争冒险啥的,由于自始至终,就是按照不可变来设计的
    • 好比设置了超时,就别觉得能够改变这个设置的超时值
  • 在使用 Context.Value 的时候,必定要记住这一点

应该把什么放到 Context.Value 里?

  • 应该保存 Request 范畴的值
    • 任何关于 Context 自身的都是 Request 范畴的(这俩同生共死)
    • 从 Request 数据衍生出来,而且随着 Request 的结束而终结

什么东西不属于 Request 范畴?

  • 在 Request 之外创建的,而且不随着 Request 改变而变化
    • 好比你 func main() 里创建的东西显然不属于 Request 范畴
  • 数据库链接
    • 若是 User ID 在链接里呢?(稍后会说起)
  • 全局 logger
    • 若是 logger 里须要有 User ID 呢?(稍后会说起)

那么用 Context.Value 有什么问题?

  • 不幸的是,好像全部东西都是由请求衍生出来的
  • 那么咱们为何还须要函数参数?而后干脆只来一个 Context 就完了?
func Add(ctx context.Context) int {
  return ctx.Value("first").(int) + ctx.Value("second").(int)
}
复制代码

曾经看到过一个 API,就是这种形式:

func IsAdminUser(ctx context.Context) bool {
  userID := GetUser(ctx)
  return authSingleton.IsAdmin(userID)
}
复制代码

这里API实现内部从 context 中取得 UserID,而后再进行权限判断。可是从函数签名看,则彻底没法理解这个函数具体须要什么、以及作什么。

代码要以可读性为优先设计考虑。

别人拿到一个代码,通常不是掉进函数实现细节里去一行行的读代码,而是会先浏览一下函数接口。因此清晰的函数接口设计,会更加利于别人(或者是几个月后的你本身)理解这段代码。

一个良好的 API 设计,应该从函数签名就清晰的理解函数的逻辑。若是咱们将上面的接口改成:

func IsAdminUser(ctx context.Context, userID string, authenticator auth.Service) bool
复制代码

咱们从这个函数签名就能够清楚的知道:

  • 这个函数极可能能够提早被 cancel
  • 这个函数须要 User ID
  • 这个函数须要一个authenticator来
  • 并且因为 authenticator 是传入参数,而不是依赖于隐式的某个东西,咱们知道,测试的时候就很容易传入一个模拟认证函数来作测试
  • userID 是传入值,所以咱们能够修改它,不用担忧影响别的东西

全部这些信息,都是从函数签名获得的,而无需打开函数实现一行行去看。

那什么能够放到 Context.Value 里去?

如今知道 Context.Value 会让接口定义更加模糊,彷佛不该该使用。那么又回到了原来的问题,到底什么能够放到 Context.Value 里去?换个角度去想,什么不是衍生于 Request?

  • Context.Value 应该是告知性质的东西,而不是控制性质的东西
  • 应该永远都不须要写进文档做为必须存在的输入数据
  • 若是你发现你的函数在某些 Context.Value 下没法正确工做,那就说明这个 Context.Value 里的信息不该该放在里面,而应该放在接口上。由于已经让接口太模糊了。

什么东西不是控制性质的东西?

  • Request ID
    • 只是给每一个 RPC 调用一个 ID,而没有实际意义
    • 这就是个数字/字符串,反正你也不会用其做为逻辑判断
    • 通常也就是日志的时候须要记录一下
      • 而 logger 自己不是 Request 范畴,因此 logger 不该该在 Context 里
      • 非 Request 范畴的 logger 应该只是利用 Context 信息来修饰日志
  • User ID (若是仅仅是做为日志用)
  • Incoming Request ID

什么显然是控制性质的东西?

  • 数据库链接
    • 显然会很是严重的影响逻辑
    • 所以这应该在函数参数里,明确表示出来
  • 认证服务(Authentication)
    • 显然不一样的认证服务致使的逻辑不一样
    • 也应该放到函数参数里,明确表示出来

例子

调试性质的 Context.Value - net/http/httptrace

medium.com/@cep21/go-1…

package main
func trace(req *http.Request, c *http.Client) {
  trace := &httptrace.ClientTrace{
    GotConn: func(connInfo httptrace.GotConnInfo) {
      fmt.Println("Got Conn")
    },
    ConnectStart: func(network, addr string) {
      fmt.Println("Dial Start")
    },
    ConnectDone: func(network, addr string, err error) {
      fmt.Println("Dial done")
    },
  }
  req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
  c.Do(req)
}
复制代码

net/http 是怎么使用 httptrace 的?

  • 若是有 trace 存在的话,就执行 trace 回调函数
  • 这只是告知性质,而不是控制性质
    • http 不会由于存在 trace 与否就有不一样的执行逻辑
    • 这里只是告知 API 的用户,帮助用户记录日志或者调试
    • 所以这里的 trace 是存在于 Context 里的
package http
func (req *Request) write(w io.Writer, usingProxy bool, extraHeaders Header, waitForContinue func() bool) (err error) {
  //  ...
  trace := httptrace.ContextClientTrace(req.Context())
  //  ...
  if trace != nil && trace.WroteHeaders != nil {
    trace.WroteHeaders()
  }
}
复制代码

回避依赖注入 - github.com/golang/oauth2

  • 这里比较诡异,使用 ctx.Value 来定位依赖
  • 不推荐这样作
    • 这里这样作基本上只是为了知足测试需求
package main
import "github.com/golang/oauth2"
func oauth() {
  c := &http.Client{Transport: &mockTransport{}}
  ctx := context.WithValue(context.Background(), oauth2.HTTPClient, c)
  conf := &oauth2.Config{ /* ... */ }
  conf.Exchange(ctx, "code")
}
复制代码

人们滥用 Context.Value 的缘由

  • 中间件的抽象
  • 很深的函数调用栈
  • 混乱的设计

context.Value 并无让你的 API 更简洁,那是假象,相反,它让你的 API 定义更加模糊。

总结 Context.Value

  • 对于调试很是方便
  • 将必须的信息放入 Context.Value 中,会让接口定义更加不透明
  • 若是能够尽可能明肯定义在接口
  • 尽可能不要用 Context.Value

总结 Context

  • 全部的长的、阻塞的操做都须要 Context
  • errgroup 是构架于 Context 之上很好的抽象
  • 当 Request 的结束的时候,Cancel Context
  • Context.Value 应该被用于告知性质的事物,而不是控制性质的事物
  • 约束 Context.Value 的键空间
  • Context 以及 Context.Value 应该是不可变的(immutable),而且应该是线程安全
  • Context 应该随 Request 消亡而消亡

Q&A

数据库的访问也用 Context 么?

以前说过长时间、可阻塞的操做都用 Context,数据库操做也是如此。不过对于超时 Cancel 操做来讲,通常不会对写操做进行 cancel;可是对于读操做,通常会有 Cancel 操做。

原文

blog.lab99.org/post/golang…

我的微信公众号:

我的github:

github.com/jiankunking

我的博客:

jiankunking.com

相关文章
相关标签/搜索