Golang context.Context 原理,实战用法,问题

4,593 阅读11分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第3天,点击查看活动详情

开篇

在日常开发 Golang 项目的过程中,几乎每个程序员都会接触官方提供的的 context 包,无论是打日志,RPC调用,还是访问数据库,context 几乎无处不在。

近日笔者在日常使用的过程中,发现仍然有一些话题和 context 绕不开,比如:框架一般是怎样控制超时的?我们是否能够放心的把 context 放入自定义的结构体之中?官方不建议的原因是什么?Gorm 开启事务后忘记 rollback 会是什么表现?

这篇文章希望能够帮助大家了解使用context的姿势和遇到的问题。

context 是什么

官方文档:golang.org/pkg/context…

源码:golang.org/src/context…

Package context defines the Context type, which carries deadlines, cancellation signals, and other request-scoped values across API boundaries and between processes.

context 是 Golang 从 1.7 版本引入的一个标准库。它使得一个request范围内所有goroutine运行时的取消可以得到有效的控制。当最上层的 goroutine 因为某些原因执行失败时,下层的 Goroutine 由于没有接收到这个信号所以会继续工作;但是当我们正确地使用 context.Context 时,就可以在下层及时停掉无用的工作以减少额外资源的消耗。

理解 context 提供的能力,关键记住三个词:deadline, cancellation, metadata

// 一个 Context 对象包含了截止时间, 取消信号,以及请求级别的键值等属性

type Context interface {

   // 如果context可被cancel,返回一个chan,当context被cancel后,chan会被close

   // 如果context不能被cancel,则返nil

    Done() <-chan struct{}



    // 当 Done() 返回的channel被关闭后,Err 可以读取到这个 Context 被取消的原因

    Err() error



    // 返回context被cancel的deadline,ok==false代表没有deadline

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



    // Value 返回和 key 关联的值,若无此key则返回nil

    Value(key interface{}) interface{}

}

可以看到,Context 本质就是一个 interface,实现这四个方法的对象都可以理解为是一个 Context。

  • Done 方法返回的 channel 可以理解为是对运行在此 Context 之上的函数的取消信号,一旦 channel 被关闭,意味着基于当前上下文的操作不再有效,函数需要停止当前工作并返回。在你不需要控制并发的工作时,不需要 select ctx.Done()。这个方法本质上是为了控制协程的工作状态。如 parent 进行了取消,child 的 select 需要接收这个消息。(设计:blog.golang.org/pipelines)
  • Err 方法在 channel 关闭之前读取没有意义,会直接返回 nil,只有在关闭之后解释goroutine被取消的原因。
  • Deadline方法指示一段时间后当前goroutine是否会被取消

原理解析

虽然只要实现了上面四个方法即可被认为是一个 Context 对象,实际很少有开发者会完全自己实现一整套控制逻辑,绝大部分场景下基于 context 包提供的实现即可满足需求。这一部分我们结合源码来看一下 context 包底层实现逻辑。

四种 Context 实现

context 包提供了四种实现了 Context 接口的 struct。分别为最基础的 emptyCtx,包含键值对的 valueCtx,具备取消能力的 cancelCtx 以及在此之上补充了计时器功能的 timerCtx。 我们实际使用的绝大部分 context.Context 对象底层都是这四种 struct 的指针类型。下面我们先了解下这四种 Context 的实现代码:

emptyCtx

官方库提供的最基础的 Context 实现,emptyCtx是一个int类型的变量,但实现了context的接口。emptyCtx没有超时时间,不能取消,也不能存储任何额外信息,所以emptyCtx用来作为context树的根节点。我们常用的 context.Background() 与 context.TODO() 底层即为 emptyCtx,没有 Deadline,也获取不到 Value。日常使用的绝大部分 Context 都是在 emptyCtx 的基础上派生出来的。

type emptyCtx int

func (*emptyCtx) Deadline() (deadline time.Time, ok bool) {
   return
}

func (*emptyCtx) Done() <-chan struct{} {
   return nil
}

func (*emptyCtx) Err() error {
   return nil
}

func (*emptyCtx) Value(key interface{}) interface{} {
   return nil
}

func (e *emptyCtx) String() string {
   switch e {
   case background:
      return "context.Background"
   case todo:
      return "context.TODO"
   }
   return "unknown empty Context"
}


var (
   background = new(emptyCtx)
   todo       = new(emptyCtx)
)

func Background() Context {
   return background
}

func TODO() Context {
   return todo
}

valueCtx

valueCtx 在内嵌 Context 的同时,包含了一组键值对,皆为 interface{},我们经常使用的 context.WithValue() 函数底层就是一个嵌入了参数 context 的 valueCtx。

type valueCtx struct {
   Context
   key, val interface{}
}

Value() 获取值时为递归获取,自下而上形成查询链条

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

因为递归获取,所以若 child context 的 key 与 parent context相同,会直接覆盖掉。 parent context是感知不到 child context 的行为的。

cancelCtx

type cancelCtx struct {
  Context

  mu       sync.Mutex            // 并发保护其他成员字段
  done     chan struct{}         // 懒创建,第一次调用 cancel() 时关闭
  children map[canceler]struct{} // 第一次调用 cancel() 置 nil
  err      error                 // 第一次调用 cancel() 置非 nil
}

Value() 在传入 cancelCtxKey 时返回自身,可用于找到第一个祖先 cancelCtx,否则向上递归

func (c *cancelCtx) Value(key interface{}) interface{} {
   if key == &cancelCtxKey {
      return c
   }
   return c.Context.Value(key)
}

Done() 和 Err() 涉及到结构体成员,用锁进行并发保护

func (c *cancelCtx) Done() <-chan struct{} {
   c.mu.Lock()
   if c.done == nil {
      c.done = make(chan struct{})
   }
   d := c.done
   c.mu.Unlock()
   return d
}

func (c *cancelCtx) Err() error {
   c.mu.Lock()
   err := c.err
   c.mu.Unlock()
   return err
}

cancel() ,先取消自己,然后根据 children 递归遍历并取消所有可取消子节点。可根据传入参数决定是否"removeFromParent",这里将 ctx 对应的 cancel() 函数设计为与 ctx 本身独立,是为了保证信息的单向流通:只能由父 ctx 流向子 ctx。

 // cancel closes c.done, cancels each of c's children, and, if
 // removeFromParent is true, removes c from its parent's children.
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
    if err == nil {
        panic("context: internal error: missing cancel error")
    }
    c.mu.Lock()
    if c.err != nil {
        c.mu.Unlock()
        return // already canceled
    }
    c.err = err
    if c.done == nil {
        c.done = closedchan
    } else {
        close(c.done)
    }
    for child := range c.children {
         // NOTE: acquiring the child's lock while holding parent's lock. 
        child.cancel(false, err)
    }
    c.children = nil
    c.mu.Unlock()
    if removeFromParent {
        removeChild(c.Context, c)
    }
}

// removeChild 将child从parent的children中移除
func removeChild(parent Context, child canceler) {
    p, ok := parentCancelCtx(parent)
    if !ok {
        return
    }
    p.mu.Lock()
    if p.children != nil {
        delete(p.children, child)
    }
    p.mu.Unlock()

}

timerCtx

实现一个超时自动取消的Context,内部仍然使用cancelCtx实现取消,额外增一个定时器定时调用 cancel 函数实现该功能(WithTimeOut将当前时间+超时时间计算得到绝对时间后使用WithDeadLine实现)。只能嵌入 cancelCtx ,其他类型的 Context 不行。

type timerCtx struct {

  cancelCtx

  timer *time.Timer 

  deadline time.Time

}

timerCtx 实现的方法有 Deadline() 与 cancel()

cancel() 方法对将其嵌入的 cancelCtx 取消

func (c *timerCtx) Deadline() (deadline time.Time, ok bool) {
	return c.deadline, true
}

func (c *timerCtx) cancel(removeFromParent bool, err error) {
	c.cancelCtx.cancel(false, err)

	if removeFromParent {
	removeChild(c.cancelCtx.Context, c)
	}

	c.mu.Lock()
	if c.timer != nil {
	c.timer.Stop()
	c.timer = nil
	}
	c.mu.Unlock()
}

四个函数

先看一下对外暴露的四个函数签名,这里先直观感受一下 context 设计的用意。

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

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

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

func WithValue(parent Context, key, val interface{}) Context

context 一个重要的理念在于“嵌套”,所有的 WithXXX()返回的都是基于 parent ctx 构造出来的一个 child ctx 的指针,而非具体的结构体类型。通过嵌入的方式,Go 对树形组织的 Context 体系中的每个 Context 节点都构造了一个指向父亲实例“指针”。从另一个角度来说,这是一种经典代码组织模式 —— 组合模式,每一层只增量 or 覆盖实现自己所关注的功能,然后通过路由调用来复用已有的实现。

这就是为什么我们会写出如下代码:

func fn(ctx context.Context, value interface{}) {

    // 基于原有的 Context,加入 key, value,扩展出来一个新的 Context 赋值回ctx

    // 注意:在此函数内想直接修改 parent Context 是无法成功的

    // 入参 ctx 仅仅代表一个指向底层 Context 实现的指针

    // 此处操作只是让 ctx 指向了此处构造的 child ctx


    ctx = context.WithValue(ctx, CUSTOM_KEY, value)

    

    // 后续业务逻辑

    ...

}

下面依次详细看一下各个函数实现

WithValue

可以说是我们使用的最经常的函数,将一些常见的上下文信息通过这个函数写入 ctx。本质是用 valueCtx 基于 parent Context 派生出来一个 child Context,形成了一条链。获取 value 的时候是逆序的。

 // WithValue returns a copy of parent in which the value associated with key is

 // val.

 //

 // Use context Values only for request-scoped data that transits processes and

 // APIs, not for passing optional parameters to functions.

 //

 // The provided key must be comparable and should not be of type

 // string or any other built-in type to avoid collisions between

 // packages using context. Users of WithValue should define their own

 // types for keys. To avoid allocating when assigning to an

 // interface{}, context keys often have concrete type

 // struct{}. Alternatively, exported context key variables' static

 // type should be a pointer or interface.

func WithValue(parent Context, key, val interface{}) Context {

    if key == nil {

        panic("nil key")

    }

    if !reflectlite.TypeOf(key).Comparable() {

        panic("key is not comparable")

    }

    return &valueCtx{parent, key, val}

}

valueCtx 通常的使用场景:

type keyType struct{} //空struct{}作为key,不占用内存空间

var xxxKey keyType    //同时避免了不同包使用ctx传值时出现冲突



func main(){

   ctx := context.WithValue(context.Background(), xxxKey, "A's value")

   fmt.Println(ctx.Value(xxxKey))

}

Go 最细节篇 - 空结构体是什么?

WithCancel

生成 cancelCtx 及其对应的 cancelFunc()

父 ctx 被取消或子 ctx 的 cancelFunc() 被调用,是子 ctx 被取消的两种方式

type CancelFunc func()

func newCancelCtx(parent Context) cancelCtx {

   return cancelCtx{Context: parent}

}

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
   if parent == nil {
      panic("cannot create context from nil parent")
   }
   c := newCancelCtx(parent)
   propagateCancel(parent, &c)
   return &c, func() { c.cancel(/* removeFromParent */true, Canceled) }
}

在 WithCancel() 中调用了 propageteCancel() ,其作用在于建立子 ctx 与父 ctx 的关联,使得父 ctx 被取消时,连带子 ctx 一起取消。

其中调用了 parentCancelCtx() 来确定,其自身与向上链条中,第一个实现了 Done() 的ctx是否为 cancelCtx(源码中只有 cancelCtx 直接实现了返回值非nil的 Done())

新版本的代码实现了对第三方Context的兼容

func propagateCancel(parent Context, child canceler) {

   if parent.Done() == nil {

        return // parent is never canceled

    }



   if p, ok := parentCancelCtx(parent); ok {

      p.mu.Lock()

 if p.err != nil {

         //在withXXX过程中父Context被cancel

 child.cancel(false, p.err)

      } else {

         if p.children == nil {

            p.children = make(map[canceler]struct{})

         }

         //children 保存的是子树中所有路径向下走的第一个实现了 canceler 接口的 Context 

         p.children[child] = struct{}{} //建立关联

 }

      p.mu.Unlock()

   } else {

   //如果为非 canceler 的第三方 Context 实例,则我们不知其内部实现

   //因此只能为每个新加的子 Context 启动一个守护 goroutine 将父子 ctx 关联起来

 atomic.AddInt32(&goroutines, +1)

      //新起一个goroutine监控父节点和子节点的done信号

 go func() {

         select {

         case <-parent.Done():

            child.cancel(false, parent.Err())

         case <-child.Done():

            //作用:防止goroutine泄漏

 }

      }()

   }

}



func parentCancelCtx(parent Context) (*cancelCtx, bool) {

  done := parent.Done() // 

  if done == closedchan || done == nil {

    // done==nil不可以cancel,肯定不是cancelCtx

    // done==closedchan,parent已经被cancel的状态,removeChild函数中无需再次remove

    return nil, false

  }

  p, ok := parent.Value(&cancelCtxKey).(*cancelCtx) //回溯链中第一个 cancelCtx 实例

  if !ok {

    return nil, false

  }

  p.mu.Lock()

  ok = p.done == done //检查第一个实现 Done() 的和第一个 cancelCtx的 Done() 是否一致

  p.mu.Unlock()

  if !ok { 

    // 说明回溯链中第一个实现 Done() 的实例不是 cancelCtx 的实例(存在第三方Ctx)

    return nil, false

  }

  return p, true

}

通过对 WithCancel() 的多次调用,我们可以得到一棵 cancelCtx 树:

image.png

这里将 ctx 对应的 cancel() 函数设计为与 ctx 本身独立,是为了保证信息的单向流通:只能由父 ctx 流向子 ctx.

调用 cancelFunc() 时的过程

image.png

WithDeadline

创建 timerCtx 并返回。相比 cancelCtx,timerCtx 的取消方式多了一种:超时取消。内部使用time.AfterFunc 实现,从而会导致context在计时器超时前都不会被垃圾回收,养成及时 cancel 的习惯,避免资源浪费。

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {

  if cur, ok := parent.Deadline(); ok && cur.Before(d) {

    // 祖先节点的时限更早

    return WithCancel(parent)

  }

  

  c := &timerCtx{             

    cancelCtx: newCancelCtx(parent), // 使用一个新 cancelCtx 实现部分 cancel 功能

    deadline:  d,

  }

  propagateCancel(parent, c) // 构建 Context 取消树,注意传入的是 c 而非 c.cancelCtx

  dur := time.Until(d)       // 测试时限是否设的太近以至于已经结束了

  if dur <= 0 {

    c.cancel(true, DeadlineExceeded)

    return c, func() { c.cancel(false, Canceled) }

  }

  

  // 设置超时取消

  c.mu.Lock()

  defer c.mu.Unlock()

  if c.err == nil {

    c.timer = time.AfterFunc(dur, func() {

      c.cancel(true, DeadlineExceeded)

    })

  }

  return c, func() { c.cancel(true, Canceled) }

}

WithTimeout

与 WithDeadline() 本质上相同

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

   return WithDeadline(parent, time.Now().Add(timeout))

}

image.png

官方说明

Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it. The Context should be the first parameter, typically named ctx.

Do not pass a nil Context, even if a function permits it. Pass context.TODO if you are unsure about which Context to use.

Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.

The same Context may be passed to functions running in different goroutines; Contexts are safe for simultaneous use by multiple goroutines.

  1. 不要将 Context 放入 struct 类型定义中,而是显式地作为首个参数传递给函数

为防止业务越用越乱,采用了一种比较严格的方式,直接明文标识不让大家使用。但这一点一直是存在争议的,甚至Golang 自身官方提供的 net/http 库中 Request 对象就将 context.Context 内置。业务可以根据自身的场景和用法进行判断。

  1. 不要传递 nil 到 Context 参数,如果不确定用什么,使用 context.TODO() ; context.Background() 和 context.TODO() 虽然函数名不同,但底层实现是完全一致的,本质都是一个 emptyCtx。Golang 官方区分两者的用意是什么?在这种不确定用什么 Context 的时候,我可以用 context.Background() 么?

从功能上而言,二者没有任何区别,它们的区别体现在语义上。

context.Background() 的语义是:我故意传入一个emptyCtx,这就是当前场景下的选择。

context.TODO() 的语义是:此处的context可能是其他实现,可能是empty的,也可能是一个timerCtx,或是 cancelCtx,但此时我不确定,所以我需要暂时放一个 placeholder 在这里。比如,真正的Context可能是调用方直接传过来的,不需要自己创建(比如来自http请求,需要控制最长时间等),但此时这部分实现都没有ready,所以我暂时放一个 context.TODO() 在这里,随后可能被替换。

  1. 使用context的Value相关方法只应该用于在程序和接口中传递的和请求相关的元数据,不要用它来传递一些可选的参数;

一个 context 应该跟单独一个 request 的生命周期相同,而不是在多个 request之间共享。

  1. 同一个Context对象可以被多个正在运行的 goroutine 使用,并发安全。

Context 本身的实现是不可变的(immutable),想要存入键值对,只能通过 WithValue 的方式构造一个新的 context,而并不是在原有 context 中写入。通过 Context.Done() 返回的通道可以协调 goroutine 的行为。

实战:父子任务协程调度

Context 提供了一种优雅的方案来实现如下机制:

  • 上层任务取消后,所有的下层任务都会被取消;
  • 中间某一层的任务取消后,只会将当前任务的下层任务取消,而不会影响上层的任务以及同级任务。

这里我们介绍一个官方 blog 中提到的案例:

现在有一个 http server 能处理 /search?q=golang&timeout=1s 的请求,底层会把这个 query 提取出来,通过请求 google 官方搜索的API 来获取结果,timeout 参数可以控制超时时间。

相关代码一共三个 package:

  1. server 提供了 /search 相关handler
  2. userip 提供了解析请求 ip 的函数
  3. google 封装了 Search 函数,底层会发请求到搜索api,获取 query 对应结果。
  • server
func handleSearch(w http.ResponseWriter, req *http.Request) {

    // 调用 cancel 关闭 ctx.Done channel,可以理解为一个对req的取消信号

    var (

        ctx    context.Context

        cancel context.CancelFunc

    )

    timeout, err := time.ParseDuration(req.FormValue("timeout"))

    if err == nil {

        // 有timeout 参数,构建一个新的 timerCtx 指定时间到了之后 cancel

        ctx, cancel = context.WithTimeout(context.Background(), timeout)

    } else {

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

    }

    defer cancel() // 尽早取消ctx



    query := req.FormValue("q")

    if query == "" {

        http.Error(w, "no query", http.StatusBadRequest)

        return

    }



    // 解析ip

    userIP, err := userip.FromRequest(req)

    if err != nil {

        http.Error(w, err.Error(), http.StatusBadRequest)

        return

    }

    // 将用户请求 ip 存储到ctx中

    ctx = userip.NewContext(ctx, userIP)

    // 调用搜索api获取查询结果

    start := time.Now()

    results, err := google.Search(ctx, query)

    elapsed := time.Since(start)



    // 返回结果

    ...

}
  • userip
// 非导出类型,避免和其他包的 key 冲突

type key int



const userIPKey key = 0



// 将ip写入context,返回生成的 child context

func NewContext(ctx context.Context, userIP net.IP) context.Context {

    return context.WithValue(ctx, userIPKey, userIP)

}



func FromContext(ctx context.Context) (net.IP, bool) {

    userIP, ok := ctx.Value(userIPKey).(net.IP)

    return userIP, ok

}
  • google
func Search(ctx context.Context, query string) (Results, error) {

    // Prepare the Google Search API request.

    req, err := http.NewRequest("GET", "https://ajax.googleapis.com/ajax/services/search/web?v=1.0", nil)

    if err != nil {

        return nil, err

    }

    q := req.URL.Query()

    q.Set("q", query)





    // 从 ctx 解出 user ip

    if userIP, ok := userip.FromContext(ctx); ok {

        q.Set("userip", userIP.String())

    }

    req.URL.RawQuery = q.Encode()

    var results Results

    // 请求搜索 api

    err = httpDo(ctx, req, func(resp *http.Response, err error) error {

        if err != nil {

            return err

        }

        defer resp.Body.Close()



        // 解析 response

        // https://developers.google.com/web-search/docs/#fonje

        var data struct {

            ResponseData struct {

                Results []struct {

                    TitleNoFormatting string

                    URL               string

                }

            }

        }

        if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {

            return err

        }

        for _, res := range data.ResponseData.Results {

            results = append(results, Result{Title: res.TitleNoFormatting, URL: res.URL})

        }

        return nil

    })

    // httpDo 会等待提供的 closure 返回

    return results, err

}



func httpDo(ctx context.Context, req *http.Request, f func(*http.Response, error) error) error {

    // 单起goroutine发送请求

    c := make(chan error, 1)

    req = req.WithContext(ctx)

    go func() { c <- f(http.DefaultClient.Do(req)) }()

    select {

    case <-ctx.Done():

        <-c // 等待 f 返回

        return ctx.Err()

    case err := <-c:

        return err

    }

}

这部分还有一个很好的例子是 net/http 中 http server 相关处理流程,有兴趣的同学可以了解一下。

能否将 context 放入在结构体内

首先明确一点,这种做法是不符合官方规范的:Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it 。但从代码实现角度上来看,Golang并没有从机制上阻止我们这样做,编译器,lint 都没有硬性限制,甚至连官方库 net/http 内部也在使用这一做法(golang.org/src/net/htt…

Golang 团队对于这一条规范发布了篇blog 说明,强调了“显式传递”对于代码清晰度的帮助。建议大家看一下官方给出的示例深入体会一下:blog.golang.org/context-and…

Context makes it easy to propagate important cross-library and cross-API information down a calling stack. But, it must be used consistently and clearly in order to remain comprehensible, easy to debug, and effective.

When passed as the first argument in a method rather than stored in a struct type, users can take full advantage of its extensibility in order to build a powerful tree of cancelation, deadline, and metadata information through the call stack. And, best of all, its scope is clearly understood when it's passed in as an argument, leading to clear comprehension and debuggability up and down the stack.

可以看出,这条规范的核心论点还是在于代码的清晰度和可维护性。我们需要结合业务场景,对两个核心问题进行思考:

  1. Scope 差异

我们知道 Context 是一个 request-scope 的对象,但struct未必如此,同一个结构体可能会在不同的 request 中被使用,由此导致 Context 出现在多个请求中,代码运行可能出现意料之外的效果。http 的 Request 结构也是基于 request-scope 存在的,二者生命周期匹配(即便如此,从 blog 中了解到,Golang net/http 包中内置 ctx 也是在维护 Go 1 API 兼容性的承诺,不得已而为之,若重新设计,官方会将 ctx 提取出来作为参数)。

  1. 自定义的 Struct 是否对外导出(甚至用在 API 接口参数中)

一旦我们开始用 wrapper 的形式基于 ctx 扩展出 XContext 作为入参,就有可能存在 YContext,ZContext 等等,不仅仅调用方增加了构造 XContext 的负担,当你需要从一个 XContext 构造出 YContext 的时候也会存在丢失上下文的情况,

func F(fctx foo.Context) {

  bctx := bar.NewContext(fctx.Context(), nil /*the *Bar value*/)

  G(bctx)

}

//Furthermore, if G later calls a function that expects a foo.Context, the

//*Foo that should have been plumbed through has been lost:

这部分建议大家看一下 issue 中 Sajmani 给出的反例:context: relax recommendation against putting Contexts in structs · Issue #22602 · golang/go

目前经常看到的用法中,常见的是在接口层直接解析 ctx,获取到一些通用鉴权的字段(如用户id,租户id等),这些数据在单个请求中是不会变化的,而且是在服务内部高频使用的公参,这种场景下如果将 ctx 和业务数据拆分参数,函数签名可能是这样的:

// 单独的 ctx

func doSomethingSeparate(ctx context.Context, octx openContext, a ...interface{}) string

// 内置到 openContext

func doSomethingInclude(octx openContext, a ...interface{}) string

内置到 Context 中的方案可以省去开发者处理大量重复参数,简化函数入参为一个 openContext 即可,其作用仅仅为传参,而不涉及存储,同样是一个 request-scope 的内部对象。这种方式是可以考虑的。

(这里如果有别的 concern ,欢迎comment补充)

常见问题和注意事项

  • context 库返回的 Context 底层都是什么类型

函数返回类型
BackgroundemptyCtx 指针
TODOemptyCtx 指针
WithCancelcancelCtx 指针
WithDeadlinetimerCtx 指针 或 cancelCtx 指针
WithTimeouttimerCtx 指针 或 cancelCtx 指针

当遇到函数签名中包含 context.Context 时,被拷贝到变量中的值本质上是个指针地址,且无法修改。

func fn(ctx context.Context) {

    fmt.Println(*ctx) // 编译错误,无法对 interface 取址

    

    isPtr := reflect.TypeOf(ctx).Kind() == reflect.Ptr

    fmt.Println(isPtr) // true

}
  • cancel 是无侵入的

上游任务仅仅使用context通知下游任务不再需要,但不会直接干涉和中断下游任务的执行,由下游任务自行决定后续的处理操作。

  • 通过context.Value() 获取值再 type assertion 后记得校验 bool,避免panic

type User struct{}



func main() {

    var user *User

    userKey := "user"

    ctx := context.Background()

    ctx = context.WithValue(ctx, userKey, 1)

    

    

    // bad,此处直接 panic: interface conversion: interface {} is nil, not *main.User

    user = ctx.Value(ctx).(*User)

    fmt.Println("user", user)



    // recommend,进入 !ok 分支,进行兜底处理

    user, ok := ctx.Value(userKey).(*User)

    if !ok {

        // 处理异常

        fmt.Println("value for userKey is not type *User", user)

        return

    }

}
  • 为什么建议 gorm 显式开启事务后加个 defer 的 rollback

Gorm 开启事务时底层调用了 database/sql 的 BeginTx 方法:

 // BeginTx starts a transaction.

 // The provided context is used until the transaction is committed or rolled back. 

 // If the context is canceled, the sql package will roll back

 // the transaction. Tx.Commit will return an error if the context provided to

 // BeginTx is canceled.



func (db *DB) BeginTx(ctx context.Context, opts *TxOptions) (*Tx, error) {

    var tx *Tx

    var err error

    for i := 0; i < maxBadConnRetries; i++ {

        tx, err = db.begin(ctx, opts, cachedOrNewConn)

        if err != driver.ErrBadConn {

            break

        }

    }

    if err == driver.ErrBadConn {

        return db.begin(ctx, opts, alwaysNewConn)

    }

    return tx, err

}



func (db *DB) begin(ctx context.Context, opts *TxOptions, strategy connReuseStrategy) (tx *Tx, err error) {

    // 拿到数据库链接,可能是新建的/缓存的

    dc, err := db.conn(ctx, strategy)

    if err != nil {

        return nil, err

    }

    // 用拿到的链接开启事务

    return db.beginDC(ctx, dc, dc.releaseConn, opts)

}

方法内部会创建子 Context,并开启 goroutine 等待 Done 信号。

 // beginDC starts a transaction. The provided dc must be valid and ready to use.

func (db *DB) beginDC(ctx context.Context, dc *driverConn, release func(error), opts *TxOptions) (tx *Tx, err error) {

    // ...... 其他逻辑

    

    // Schedule the transaction to rollback when the context is cancelled.

    // The cancel function in Tx will be called after done is set to true.

    ctx, cancel := context.WithCancel(ctx)

    tx = &Tx{

        db:                 db,

        dc:                 dc,

        releaseConn:        release,

        txi:                txi,

        cancel:             cancel,

        keepConnOnRollback: keepConnOnRollback,

        ctx:                ctx,

   }

    go tx.awaitDone()

    return tx, nil

}

这里开启的 goroutine 只有在事务被 rollback/commit,或 Context 被关闭时,才会被释放:

 // awaitDone blocks until the context in Tx is canceled and rolls back

 // the transaction if it's not already done.

func (tx *Tx) awaitDone() {

     // Wait for either the transaction to be committed or rolled

     // back, or for the associated context to be closed. 

    <-tx.ctx.Done()

    

    // Discard and close the connection used to ensure the

    // transaction is closed and the resources are released.  This

    // rollback does nothing if the transaction has already been

    // committed or rolled back.

    // Do not discard the connection if the connection knows

    // how to reset the session.

    discardConnection := !tx.keepConnOnRollback

    tx.rollback(discardConnection)

}

经常遇到的case:由于开发者忘记对于某些 gorm 出错分支进行 rollback 就直接 return,进而导致上面这个协程一直被卡住,goroutine 数量逐步上升,而由于已获取链接不会释放,进而导致链接打满。

看一下 gorm v2 中的事务模板:

github.com/go-gorm/gor…

// Transaction start a transaction as a block, return error will rollback, otherwise to commit.
func (db *DB) Transaction(fc func(tx *DB) error, opts ...*sql.TxOptions) (err error) {
        panicked := true
        if committer, ok := db.Statement.ConnPool.(TxCommitter); ok && committer != nil {
                // nested transaction
                if !db.DisableNestedTransaction {
                        err = db.SavePoint(fmt.Sprintf("sp%p", fc)).Error
                        defer func() {
                                // Make sure to rollback when panic, Block error or Commit error
                                if panicked || err != nil {
                                        db.RollbackTo(fmt.Sprintf("sp%p", fc))
                                }

                        }()
                }
                if err == nil {
                        err = fc(db.Session(&Session{}))
                }
        } else {
                tx := db.Begin(opts...)
                defer func() {
                        // Make sure to rollback when panic, Block error or Commit error
                        if panicked || err != nil {
                                tx.Rollback()
                        }

                }()
                if err = tx.Error; err == nil {
                        err = fc(tx)
                }
                if err == nil {
                        err = tx.Commit().Error
                }
        }
        panicked = false
        return
}
  • 既然 context.Context 是并发安全的,我可以在里面用 map 么?

机制上是支持的,但多个goroutine 中对同一个 valueCtx 中存储的 map 进行读写依然会导致 fatal error: concurrent map iteration and map write. 需要业务根据自身使用场景谨慎判断。曾经有人提出过使用 slice / map 对于 valueCtx 查找的效率进行改进,但同样存在性能上的取舍,暂未被官方接受:github.com/golang/go/i…