走进Golang之Context的使用

Go语言精选

共 1016字,需浏览 3分钟

 ·

2020-08-05 00:44

我们为什么需要 Context 的呢?我们来看看看一个 HTTP 请求的处理:

请求示意

例子大概意思是说,有一个获取订单详情的请求,会单独起一个 goroutine 去处理该请求。在该请求内部又有三个分支 goroutine 分别处理订单详情、推荐商品、物流信息;每个分支可能又需要单独调用DB、Redis等存储组件。那么面对这个场景我们需要哪些额外的事情呢?

  1. 三个分支 goroutine 可能是对应的三个不同服务,我们想要携带一些基础信息过去,比如:LogID、UserID、IP等;
  2. 每个分支我们需要设置过期时间,如果某个超时不影响整个流程;
  3. 如果主 goroutine 发生错误,取消了请求,对应的三个分支应该也都取消,避免资源浪费;

简单归纳就是传值、同步信号(取消、超时)。

看到这里可能有人要叫了,完全可以用 channel 来搞啊!那么我们看看 channel 是否可以满足。想一个问题,如果是微服务架构,channel 怎么实现跨进程的边界呢?另外一个问题,就算不跨进程,如果嵌套很多个分支,想一想这个消息传递的复杂度。

如果是你,要实现上面的这个需求,你会怎么做?

Context 出场

幸好,我们不用自己每次写代码都要去实现这个很基础的能力。Golang 为我们准备好了一切,就是 context.Context 这个包,这个包的源代码非常简单,源码部分本文会略过,下期单独一篇文章来讲,本篇我们重点谈正确的使用。

Context 的结构非常简单,它是一个接口。


// Context 提供跨越API的截止时间获取,取消信号,以及请求范围值的功能。
// 它的这些方案在多个 goroutine 中使用是安全的
type Context interface {
    // 如果设置了截止时间,这个方法ok会是true,并返回设置的截止时间
 Deadline() (deadline time.Time, ok bool)

    // 如果 Context 超时或者主动取消返回一个关闭的channel,如果返回的是nil,表示这个
    // context 永远不会关闭,比如:Background()
 Done() <-chan struct{}

    // 返回发生的错误
 Err() error

    // 它的作用就是传值
 Value(key interface{}) interface{}
}

写到这里,我们打住想一想,如果你来实现这样一个能力的 package,你抽象的接口是否也是具备这样四个能力?

  • 获取截止时间
  • 获取信号
  • 获取信号产生的对应错误信息
  • 传值专用

net/http 中是怎么用 context的?

在我们开始自己鼓捣前,我们先看看 net/http 这个包是怎么使用的。

func main() {
 req, _ := http.NewRequest("GET""https://api.github.com/users/helei112g"nil)

 // 这里设置了超时时间
 ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*1)
 defer cancel()
 req = req.WithContext(ctx)

 resp, err := http.DefaultClient.Do(req)
 if err != nil {
  log.Fatalln("request Err", err.Error())
 }
 defer resp.Body.Close()

 body, _ := ioutil.ReadAll(resp.Body)
 fmt.Println(string(body))
}

上面这段程序就是请求 github 获取用户信息的接口,通过 context 包设置了请求超时时间是 1ms (肯定无法访问到)。执行时我们看到控制台做如下输出:

2020/xx/xx xx:xx:xx request Err Get https://api.github.com/users/helei112g: context deadline exceeded
exit status 1

我们继续做实验,将上面的代码稍作修改。

func main() {
    req, _ := http.NewRequest("GET""https://api.github.com/users/helei112g"nil)

    // 这里超时改成了 10s,怎么都够了吧
    ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
    // 但是这里移出了 defer 关键字
  cancel()
  req = req.WithContext(ctx)

    // 没有改动的部分,省略
    ... ...
}

大家猜猜看能否获取到请求结果?肯定是不能的,因为 context 取消的信号,在 net/http 包内部通过 ctx.Done() 是能够拿到的,一旦获取到就会进行取消。上面的代码,控制台会输出:

2020/xx/xx xx:xx:xx request Err Get https://api.github.com/users/helei112g: context canceled
exit status 1

注意两次控制台输出的错误信息是不一样的。

  • context deadline exceeded 表示执行超时被取消了
  • context canceled 表示主动取消

net/http 中 context 获取取消信号

接下来,我们去看看 net/http 包内部是怎么捕捉信号的,我们只关注 context 的部分,其它的直接忽略,源码路径如下;

net/http/transport.go (go 1.13.7)

// req 就是我们上面传进来的 req,它有个 context 字段
func (t *Transport) roundTrip(req *Request) (*Response, error) {
 t.nextProtoOnce.Do(t.onceSetNextProtoDefaults)
 ctx := req.Context() // 获取了 context
 trace := httptrace.ContextClientTrace(ctx) // 这里内部实际用到了 context.Value() 方法

 // 各种处理,无关代码删除了

 // 处理请求
 for {
  // 检查是否关闭了,如果关闭了就直接返回
  select {
  case <-ctx.Done():
   req.closeBody()
   return nil, ctx.Err()
  default:
  }

  // 发送请求出去
 }
}

来总结下上面这段代码,实际上关于 context 的精髓就在 for 循环中的 select,它通过 ctx.Done() 来获取信号,因为不管是自动超时,还是主动取消,ctx.Done() 都会收到一个关闭的 channel 的信号。

这里隐藏了一个细节,那就是如果按照上面的逻辑只能处理到发起请求前的超时,但是如果请求已经被发出去了,等待这段时间的超时该如何控制呢?感兴趣的小伙伴可以去看源码的这里:

net/http/transport.go:1234 (go 1.13.7)

其实就是在内部等待返回的时候不断的检查 ctx.Done() 信号,如果发现了就立即返回。

好了,官方的技巧我们已经学完了,现在轮到我们把开头的例子写个代码来实现下。

多个 goroutine 控制超时及传值

由于服务内部不方便模拟,我们简化成函数调用,假设图中所有的逻辑都可以并发调用。现在我们的要求是:

  1. 整个函数的超时时间为1s;
  2. 需要从最外层传递 LogID/UserID/IP 信息到其它函数;
  3. 获取订单接口超时为 500ms,由于 DB/Redis 是其内部支持的,这里不进行模拟;
  4. 获取推荐超时是 400ms;
  5. 获取物流超时是 700ms。

为了清晰,我这里所有接口都返回一个字符串,实际中会根据需要返回不同的结果;请求参数也都只使用了 context。代码如下:

type key int

const (
 userIP = iota
 userID
 logID
)

type Result struct {
 order     string
 logistics string
 recommend string
}

// timeout: 1s
// 入口函数
func api() (result *Result, err error) {
 ctx, cancel := context.WithTimeout(context.Background(), time.Second*1)
 defer cancel()

 // 设置值
 ctx = context.WithValue(ctx, userIP, "127.0.0.1")
 ctx = context.WithValue(ctx, userID, 666888)
 ctx = context.WithValue(ctx, logID, "123456")

 result = &Result{}
 // 业务逻辑处理放到协程中
 go func() {
  result.order, err = getOrderDetail(ctx)
 }()
 go func() {
  result.logistics, err = getLogisticsDetail(ctx)
 }()
 go func() {
  result.recommend, err = getRecommend(ctx)
 }()

 for {
  select {
  case <-ctx.Done():
   return result, ctx.Err() // 取消或者超时,把现有已经拿到的结果返回
  default:

  }

  // 有错误直接返回
  if err != nil {
   return result, err
  }

  // 全部处理完成,直接返回
  if result.order != "" && result.logistics != "" && result.recommend != "" {
   return result, nil
  }
 }
}

// timeout: 500ms
func getOrderDetail(ctx context.Context) (string, error) {
 ctx, cancel := context.WithTimeout(ctx, time.Millisecond*500)
 defer cancel()

 // 模拟超时
 time.Sleep(time.Millisecond * 700)

 // 获取 user id
 uip := ctx.Value(userIP).(string)
 fmt.Println("userIP", uip)

 return handleTimeout(ctx, func() string {
  return "order"
 })
}

// timeout: 700ms
func getLogisticsDetail(ctx context.Context) (string, error) {
 ctx, cancel := context.WithTimeout(ctx, time.Millisecond*700)
 defer cancel()

 // 获取 user id
 uid := ctx.Value(userID).(int)
 fmt.Println("userID", uid)

 return handleTimeout(ctx, func() string {
  return "logistics"
 })
}

// timeout: 400ms
func getRecommend(ctx context.Context) (string, error) {
 ctx, cancel := context.WithTimeout(ctx, time.Millisecond*400)
 defer cancel()

 // 获取 log id
 lid := ctx.Value(logID).(string)
 fmt.Println("logID", lid)

 return handleTimeout(ctx, func() string {
  return "recommend"
 })
}

// 超时的统一处理代码
func handleTimeout(ctx context.Context, f func() string(string, error) {
 // 请求之前先去检查下是否超时
 select {
 case <-ctx.Done():
  return "", ctx.Err()
 default:
 }

 str := make(chan string)
 go func() {
  // 业务逻辑
  str <- f()
 }()

 select {
 case <-ctx.Done():
  return "", ctx.Err()
 case ret := <-str:
  return ret, nil
 }
}

不知道你是否看明白了整个使用,我们这个例子看起来很复杂,实际上与我给你介绍的 net/http 包控制超时是一样的,只不过 net/http 的控制超时代码不需要我们写,而且我们这里一次性把三个调用的整合到了一起。

还有一点说明一下,对于 select,如果没有写 defalut 分支,是不需要放在 for 循环中的,因为它本身就会阻塞(网络上有很多例子放在for循环中)。

参考资料

  • [1] Package context
  • [2] Go Concurrency Patterns: Context




推荐阅读



学习交流 Go 语言,扫码回复「进群」即可


站长 polarisxu

自己的原创文章

不限于 Go 技术

职场和创业经验


Go语言中文网

每天为你

分享 Go 知识

Go爱好者值得关注


浏览 41
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报