一起聊聊 Go Context 的正确使用姿势

开发 后端
在 Go 语言中,Goroutine(协程),也就是关键字 go 是一个家喻户晓的高级用法。这起的非常妙,说到 Go,就会想到这一门语言,想到 goroutine 这一关键字,而与之关联最深的就是 context。

[[410737]]

本文转载自微信公众号「脑子进煎鱼了」,作者陈煎鱼。转载本文请联系脑子进煎鱼了公众号。

大家好,我是煎鱼。

在 Go 语言中,Goroutine(协程),也就是关键字 go 是一个家喻户晓的高级用法。这起的非常妙,说到 Go,就会想到这一门语言,想到 goroutine 这一关键字,而与之关联最深的就是 context。

背景

平时在 Go 工程中开发中,几乎所有服务端(例如:HTTP Server)的默认实现,都在处理请求时新起 goroutine 进行处理。

但一开始存在一个问题,那就是当一个请求被取消或超时时,所有在该请求上工作的 goroutines 应该迅速退出,以便系统可以回收他们正在使用的任何资源。

当年可没有 context 标准库。很折腾。因此 Go 官方在 2014 年正式宣发了 context 标准库,形成一个完整的闭环。

但有了 context 标准库,Go 爱好者们又奇怪了,前段时间我就被问到了:“Go context 的正确使用姿势是怎么样的”?

(一张忘记在哪里被问的隐形截图)

今天这篇文章就由煎鱼带你看看。

Context 用法

在 Go context 用法中,我们常常将其与 select 关键字结合使用,用于监听其是否结束、取消等。

代码如下:

  1. const shortDuration = 1 * time.Millisecond 
  2.  
  3. func main() { 
  4.  ctx, cancel := context.WithTimeout(context.Background(), shortDuration) 
  5.  defer cancel() 
  6.  
  7.  select { 
  8.  case <-time.After(1 * time.Second): 
  9.   fmt.Println("脑子进煎鱼了"
  10.  case <-ctx.Done(): 
  11.   fmt.Println(ctx.Err()) 
  12.  } 

输出结果:

  1. context deadline exceeded 

如果是更进一步结合 goroutine 的话,常见的例子是:

  1. func(ctx context.Context) <-chan int { 
  2.  dst := make(chan int
  3.  n := 1 
  4.  go func() { 
  5.   for { 
  6.    select { 
  7.    case <-ctx.Done(): 
  8.     return 
  9.    case dst <- n: 
  10.     n++ 
  11.    } 
  12.   } 
  13.  }() 
  14.  return dst 

我们平时工程中会起很多的 goroutine,这时候会在 goroutine 内结合 for+select,针对 context 的事件进行处理,达到跨 goroutine 控制的目的。

正确的使用姿势

对第三方调用要传入 context

在 Go 语言中,Context 的默认支持已经是约定俗称的规范了。因此在我们对第三方有调用诉求的时候,要传入 context:

  1. func main() { 
  2.  req, err := http.NewRequest("GET""https://eddycjy.com/", nil) 
  3.  if err != nil { 
  4.   fmt.Printf("http.NewRequest err: %+v", err) 
  5.   return 
  6.  } 
  7.  
  8.  ctx, cancel := context.WithTimeout(req.Context(), 50*time.Millisecond) 
  9.  defer cancel() 
  10.  
  11.  req = req.WithContext(ctx) 
  12.  resp, err := http.DefaultClient.Do(req) 
  13.  if err != nil { 
  14.   fmt.Printf("http.DefaultClient.Do err: %+v", err) 
  15.   return 
  16.  } 
  17.  defer resp.Body.Close() 

这样子由于第三方开源库已经实现了根据 context 的超时控制,那么当你所传入的时间到达时,将会中断调用。

若你发现第三方开源库没支持 context,那建议赶紧跑,换一个。免得在微服务体系下出现级联故障,还没有简单的手段控制,那就很麻烦了。

不要将上下文存储在结构类型中

大家会发现,在 Go 语言中,所有的第三方开源库,业务代码。清一色的都会将 context 放在方法的一个入参参数,作为首位形参。

例如:

标准要求:每个方法的第一个参数都将 context 作为第一个参数,并使用 ctx 变量名惯用语。

当然,我们也不能一杆子打死所有情况。确实存在极少数是把 context 放在结构体中的。基本常见于:

  • 底层基础库。
  • DDD 结构。

每个请求都是独立的,context 自然每个都不一样,想清楚自己的应用使用场景很重要,否则遵循 Go 基本规范就好。

在真实案例来看,有的 Leader 会单纯为了不想频繁传 context 而设计成结构体,结果导致一线 RD 就得天天 NewXXX,甚至有时候忘记了,还得背个小锅。

函数调用链必须传播上下文

我们会把 context 作为方法首位,本质目的是为了传播 context,自行完整调用链路上的各类控制:

  1. func List(ctx context.Context, db *sqlx.DB) ([]User, error) { 
  2.  ctx, span := trace.StartSpan(ctx, "internal.user.List"
  3.  defer span.End() 
  4.  
  5.  users := []User{} 
  6.  const q = `SELECT * FROM users` 
  7.  
  8.  if err := db.SelectContext(ctx, &users, q); err != nil { 
  9.   return nil, errors.Wrap(err, "selecting users"
  10.  } 
  11.  
  12.  return users, nil 

像在上述例子中,我们会把所传入方法的 context 一层层的传进去下一级方法。这里就是将外部的 context 传入 List 方法,再传入 SQL 执行的方法,解决了 SQL 执行语句的时间问题。

context 的继承和派生

在 Go 标准库 context 中具有以下派生 context 的标准方法:

  1. func WithCancel(parent Context) (ctx Context, cancel CancelFunc) 
  2. func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) 
  3. func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) 

代码例子如下:

  1. func handle(w http.ResponseWriter, req *http.Request) { 
  2.   // parent context 
  3.  timeout, _ := time.ParseDuration(req.FormValue("timeout")) 
  4.  ctx, cancel := context.WithTimeout(context.Background(), timeout) 
  5.  
  6.   // chidren context 
  7.  newCtx, cancel := context.WithCancel(ctx) 
  8.  defer cancel() 
  9.  // do something... 

一般会有父级 context 和子级 context 的区别,我们要保证在程序的行为中上下文对于多个 goroutine 同时使用是安全的。并且存在父子级别关系,父级 context 关闭或超时,可以继而影响到子级 context 的程序。

不传递 nil context

很多时候我们在创建 context 时,还不知道其具体的作用和下一步用途是什么。

这种时候大家可能会直接使用 context.Background 方法:

  1. var ( 
  2.    background = new(emptyCtx) 
  3.    todo       = new(emptyCtx) 
  4.  
  5. func Background() Context { 
  6.    return background 
  7.  
  8. func TODO() Context { 
  9.    return todo 

但在实际的 context 建议中,我们会建议使用 context.TODO 方法来创建顶级的 context,直到弄清楚实际 Context 的下一步用途,再进行变更。

context 仅传递必要的值

我们在使用 context 作为上下文时,经常有信息传递的诉求。像是在 gRPC 中就会有 metadata 的概念,而在 gin 中就会自己封装 context 作为参数管理。

Go 标准库 context 也有提供相关的方法:

  1. type Context 
  2.     func WithValue(parent Context, key, val interface{}) Context 

代码例子如下:

  1. func main() { 
  2.  type favContextKey string 
  3.  f := func(ctx context.Context, k favContextKey) { 
  4.   if v := ctx.Value(k); v != nil { 
  5.    fmt.Println("found value:", v) 
  6.    return 
  7.   } 
  8.   fmt.Println("key not found:", k) 
  9.  } 
  10.  
  11.  k := favContextKey("脑子进"
  12.  ctx := context.WithValue(context.Background(), k, "煎鱼"
  13.  
  14.  f(ctx, k) 
  15.  f(ctx, favContextKey("小咸鱼")) 

输出结果:

  1. found value: 煎鱼 
  2. key not found: 小咸鱼 

在规范中,我们建议 context 在传递时,仅携带必要的参数给予其他的方法,或是 goroutine。甚至在 gRPC 中会做严格的出、入上下文参数的控制。

在业务场景上,context 传值适用于传必要的业务核心属性,例如:租户号、小程序ID 等。不要将可选参数放到 context 中,否则可能会一团糟。

总结

 

  • 对第三方调用要传入 context,用于控制远程调用。
  • 不要将上下文存储在结构类型中,尽可能的作为函数第一位形参传入。
  • 函数调用链必须传播上下文,实现完整链路上的控制。
  • context 的继承和派生,保证父、子级 context 的联动。
  • 不传递 nil context,不确定的 context 应当使用 TODO。
  • context 仅传递必要的值,不要让可选参数揉在一起。

 

责任编辑:武晓燕 来源: 脑子进煎鱼了
相关推荐

2023-03-26 23:47:32

Go内存模型

2024-02-26 00:00:00

Go性能工具

2024-06-27 08:54:22

Go模块团队

2024-05-17 08:47:33

数组切片元素

2021-09-11 19:02:34

Hook使用版本

2022-10-08 00:00:05

SQL机制结构

2023-04-26 07:30:00

promptUI非结构化

2024-07-18 08:54:49

2023-08-04 08:20:56

DockerfileDocker工具

2023-08-10 08:28:46

网络编程通信

2023-09-10 21:42:31

2022-05-24 08:21:16

数据安全API

2023-09-21 08:16:56

JDK 21向量计算计算

2023-06-30 08:18:51

敏捷开发模式

2024-02-20 21:34:16

循环GolangGo

2021-08-27 07:06:10

IOJava抽象

2022-12-07 09:01:14

布局容器VStack​

2022-12-06 08:12:11

Java关键字

2023-08-02 08:35:54

文件操作数据源

2024-09-09 08:53:56

点赞
收藏

51CTO技术栈公众号