乐趣区

关于go:轻松上手手把手带你掌握从Context到go设计理念

导语 | 本文推选自腾讯云开发者社区 -【技思广益 · 腾讯技术人原创集】专栏。该专栏是腾讯云开发者社区为腾讯技术人与宽泛开发者打造的分享交换窗口。栏目邀约腾讯技术人分享原创的技术积淀,与宽泛开发者互启迪共成长。本文作者是腾讯后端开发工程师陈雪锋。
context 包比拟小,是浏览源码比拟现实的一个动手,并且外面也涵盖了许多 go 设计理念能够学习。
go 的 Context 作为 go 并发形式的一种,无论是在源码 net/http 中,开源框架例如 gin 中,还是外部框架 trpc-go 中都是一个比拟重要的存在,而整个 context 的实现也就不到 600 行,所以也想借着这次机会来学习学习,本文基于 go 1.18.4。话不多说,例:
为了使可能对 context 不太熟悉的同学有个相熟,先来个 example,摘自源码:

咱们利用 WithCancel 创立一个可勾销的 Context,并且遍历频道输入,当 n== 5 时,被动调用 cancel 来勾销。
而在 gen func 中有个协程来监听 ctx 当监听到 ctx.Done()即被勾销后就退出协程。

func main(){gen := func(ctx context.Context) <-chan int {dst := make(chan int)

n := 1

go func() {

for {

select {case <-ctx.Done():

                                        close(dst)

return // returning not to leak the goroutine

case dst <- n:

n++

}

}

}()

return dst

}

 

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

// defer cancel() // 理论应用中应该在这里调用 cancel

 

for n := range gen(ctx) {fmt.Println(n)

if n == 5 {cancel() // 这里为了使不相熟 go 的更能明确在这里调用了 cancel()

break

}

}

// Output:

// 1

// 2

// 3

// 4

// 5

}

这是最根本的应用办法。

概览

对于 context 包先上一张图,便于大家有个初步理解(外部函数并未全列举,后续会逐个解说):

最重要的就是左边的接口局部,能够看到有几个比拟重要的接口,上面逐个来说下:

type Context interface{Deadline() (deadline time.Time, ok bool)
        Done() <-chan struct{}        Err() error
        Value(key any) any
}

首先就是 Context 接口,这是整个 context 包的外围接口,就蕴含了四个 method,别离是:

Deadline() (deadline time.Time, ok bool) // 获取 deadline 工夫,如果没有的话 ok 会返回 false
Done() <-chan struct{} // 返回的是一个 channel,用来利用监听工作是否曾经实现 
Err() error // 返回勾销起因 例如:Canceled\DeadlineExceeded
Value(key any) any // 依据指定的 key 获取是否存在其 value 有则返回 

能够看到这个接口十分清晰简单明了,并且没有过多的 Method,这也是 go 设计理念,接口尽量简略、玲珑,通过组合来实现丰盛的性能,前面会看到如何组合的。

再来看另一个接口 canceler,这是一个勾销接口,其中一个非导出 method cancel,接管一个 bool 和一个 error,bool 用来决定是否将其从父 Context 中移除,err 用来表明被勾销的起因。还有个 Done()和 Context 接口一样,这个接口为何这么设计,前面再揭晓。

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

接下来看这两个接口的实现者都有谁,首先 Context 间接实现者有 valueCtx(比较简单放最初讲)和 emptyCtx

而 canceler 间接实现者有 cancelCtx 和 timerCtx,并且这两个同时也实现了 Context 接口(记住我后面说得另外两个是间接实现,这俩是嵌套接口实现松耦合,前面再说具体益处),上面逐个解说每个实现。

空的

见名知义,这是一个空实现,事实也的确如此,能够看到啥啥都没有,就是个空实现,为何要写呢?

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 any) any {return nil}
func (e *emptyCtx) String() string {  switch e {  case background:    return "context.Background"  case todo:    
return "context.TODO"  }  return "unknown empty Context"}

再往下读源码会发现两个有意思的变量,底层截然不同,一个取名叫 background,一个取名叫 todo,为何呢?急躁的能够看看解释,其实是为了不便大家辨别应用,背景 是在入口处来传递最初始的 context,而 todo 则是当你不晓得用啥,或者你的函数尽管接管 ctontext 参数,然而并没有做任何实现时,那么就应用 todo 即可。后续如果有具体实现再传入具体的上下文。所以下面才定义了一个空实现,就为了给这俩应用呢,这俩也是咱们最常在入口处应用的。

var (background = new(emptyCtx)

  todo       = new(emptyCtx)

)

 

// Background returns a non-nil, empty Context. It is never canceled, has no

// values, and has no deadline. It is typically used by the main function,

// initialization, and tests, and as the top-level Context for incoming

// requests.

func Background() Context {return background}

 

// TODO returns a non-nil, empty Context. Code should use context.TODO when

// it's unclear which Context to use or it is not yet available (because the

// surrounding function has not yet been extended to accept a Context

// parameter).

func TODO() Context {return todo}

上面再看看具体的定义吧。

cancelCtx 与 timerCtx、valueCtx

type cancelCtx struct{

  Context

  mu       sync.Mutex  // 锁住上面字段的操作

  // 寄存的是 chan struct{}, 懒创立,

  //  只有第一次被 cancel 时才会敞开

  done     atomic.Value  

  // children 寄存的是子 Context canceler,并且当第一次被 cancel 时会被

  // 设为 nil

  children map[canceler]struct{}

  //  第一次被调用 cancel 时,会被设置

  err      error                 

}

 

type timerCtx struct{

 cancelCtx

 timer *time.Timer // 定时器,用来监听是否超时该勾销

 deadline time.Time // 终止工夫

}

 

type valueCtx struct {

 Context

 key, val any

}

这里就看进去为何 cancelCtx 为非导出了,因为它通过内嵌 Context 接口也也是实现了 Context 的。并且通过这种形式实现了松耦合,能够通过 WithCancel(父 Context)(ctx Context,cancel CancelFunc)来传递任何自定义的 Context 实现。

而 timerCtx 是嵌套的 cancelCtx,同样他也能够同时调用 Context 接口所有 method 与 cancelCtx 所有 method,并且还能够重写局部办法。而 valueCtx 和下面两个比拟独立,所以间接嵌套的 Context。

这里应该也看明确了为何 canceler 为何一个可导出 Done 一个不可导出 cancel,Done 是重写 Context 的 method 会由下层调用,所以要可导出,cancel 则是由 return func(){c.cancel(false,DeadlineExeceed)相似的封装导出,所以不应该导出。

这是 go 中推崇的通过组合而非继承来编写代码。其中字段解释我已在前面注明,前面也会讲到。看懂了大的一个设计理念,上面咱们就逐个击破,通过下面能够看到 timerCtx 其实是复用了 cancelCtx 能力,所以 cancelCtx 最为重要,上面咱们就先将 cancelCtx 实现。

勾销

它非导出,是通过一个办法来间接返回 Context 类型的,这也是 go 理念之一,不裸露实现者,只裸露接口(前提是实现者中的可导出 method 不蕴含接口之外的 method,否则导出的 method 里面也无奈调用)。

先看看内部构造函数 WithCancel,

  • 先判断 parent 是否为 nil,如果为 nil 就 panic,这是为了防止到处判断是否为 nil。所以永远不要应用 nil 来作为一个 Context 传递。
  • 接着将父 Context 封装到 cancelCtx 并返回,这没啥说得,尽管只有一行代码,然而多处应用,所以做了封装,并且后续如果要更改行为调用者也无需更改。很不便。
  • 调用 propagateCancel,这个函数作用就是当 parent 是能够被勾销的时候就会对子 Context 也进行勾销的勾销或者筹备勾销动作。
  • 返回 Context 与 CancelFunc type >CancelFunc func()就是一个 type func 别名,底层封装的是 c.cancel 办法,为何这么做呢?这是为了给下层利用一个对立的调用,cancelCtx 与 timerCtx 以及其余能够实现不同的 cancel 然而对下层是通明并且统一的行为就可。这个 func 应该是协程平安并且屡次调用只有第一次调用才有成果。
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(true, Canceled) }

}

 

func newCancelCtx(parent Context) cancelCtx {return cancelCtx{Context: parent}

}

接下来就来到比拟重要的 func  propagateCancel,咱们看看它做了啥,

首先是判断父 context 的 Done()办法返回的 channel 是否为 nil,如果是则间接返回啥也不做了。这是因为父 Context 从来不会被勾销的话,那就没必要进行上面动作。这也表名咱们应用. 与猫(上下文。Background())这个函数是不会做任何动作的。

 done := parent.Done()
  if done == nil {return // parent is never canceled}

接下里就是一个 select,如果父 Context 曾经被勾销了的话,那就间接勾销子 Context 就好了,这个也理所应当,父亲都被勾销了,儿子当然也应该勾销,没有存在必要了。

select {
  case <-done:
    // parent is already canceled
    child.cancel(false, parent.Err())
    return
  default:
  }

如果父 Context 没有被勾销,这里就会做个判断,

  • 看看 parent 是否是一个 *cancelCtx,如果是的话就返回其 p,再次查看 p.err 是否为 nil,如果不为 nil 就阐明 parent 被勾销,接着勾销 子 Context,如果没被勾销的话,就将其退出到 p.children 中,看到这里的 map 是个 canceler,能够接管任何实现勾销器 的类型。这里为何要加锁呢?因为要对 p.err 以及 p.children 进行读取与写入操作,要确保协程平安所以才加的锁。
  • 如果不是 *cancelCtx 类型就阐明 parent 是个被封装的其余实现 Context 接口的类型,则会将 goroutines 是个 int 加 1 这是为了测试应用的,能够不论它。并且会启动个协程,监听父 Context,如果父 Context 被勾销,则勾销子 Context,如果监听到子 Context 曾经完结(可能是下层被动调用 CancelFunc)则就啥也不必做了。
if p, ok := parentCancelCtx(parent); ok {p.mu.Lock()
    if p.err != nil {
      // parent has already been canceled
      child.cancel(false, p.err)
    } else {
      if p.children == nil {p.children = make(map[canceler]struct{})
      }
      p.children[child] = struct{}{}
    }
    p.mu.Unlock()} else {atomic.AddInt32(&goroutines, +1)
    go func() {
      select {case <-parent.Done():
        child.cancel(false, parent.Err())
      case <-child.Done():}
    }()}

接下来看看 parentCancelCtx 的实现:它是为了找寻 parent 底下的 *cancelCtx,

它首先查看 parent.Done()如果是一个 closedchan 这个频道 在初始化时曾经是个一个被敞开的通道或者未 nil 的话(emptyCtx)那就间接返回 nil,false。

func parentCancelCtx(parent Context) (*cancelCtx, bool) {done := parent.Done()
  if done == closedchan || done == nil {return nil, false}
var closedchan = make(chan struct{})
func init() {close(closedchan)
p, ok := parent.Value(&cancelCtxKey).(*cancelCtx)
if !ok {return nil, false}

接着判断是否 parent 是 *cancelCtx 类型,如果不是则返回 nil,false,这里调用了 parent.Value 办法,并最终可能会落到 value 办法:

func value(c Context, key any) any {
  for {switch ctx := c.(type) {
    case *valueCtx:
      if key == ctx.key {return ctx.val}
      c = ctx.Context
    case *cancelCtx:
      if key == &cancelCtxKey {return c}
      c = ctx.Context
    case *timerCtx:
      if key == &cancelCtxKey {return &ctx.cancelCtx}
      c = ctx.Context
    case *emptyCtx:
      return nil
    default:
      return c.Value(key)
    }
  }
}
  • 如果是 *valueCtx,并且 key==ctx.key 则返回,否则会将 c 赋值为 ctx.Context,持续下一个循环
  • 如果是 *cancelCtx 并且 key==&cancelCtxKey 则阐明找到了,间接返回,否则 c = ctx. 上下文持续
  • 如果是 timerCtx,并且 key== &cancelCtxKey 则会返回外部的 cancelCtx
  • 如果是 *emptyCtx 则间接返回 nil,
  • 默认即如果是用户自定义实现则调用对应的 Value 找寻

能够发现如果嵌套实现过多的话这个办法其实是一个递归调用。

如果是则要持续判断 p.done 与 parent.Done()是否相等,如果没有则阐明:*cancelCtx 曾经被包装在一个自定义实现中,提供了一个不同的包装,在这种状况下就返回 nil,false:

pdone, _ := p.done.Load().(chan struct{})
if pdone != done {return nil, false}
return p, true

结构算是完结了,接下来看看如何勾销的:

  • 查看 err 是否为 nil
 if err == nil {panic("context: internal error: missing cancel error")
  } 
  • 因为要对 err、cancelCtx.done 以及 children 进行操作,所以要加锁
  • 如果 c.err 不为 nil 则阐明曾经勾销过了,间接返回。否则将 c.err=err 赋值,这里看到只有第一次调用才会赋值,屡次调用因为曾经有!= nil+ 锁的查看,所以会间接返回,不会反复赋值
c.mu.Lock()
  if c.err != nil {c.mu.Unlock()
    return // already canceled
  }
       c.err = err
  • 会尝试从 c.done 获取,如果为 nil,则保留一个 closedchan,否则就敞开 d,这样当你 context.Done()办法返回的 channel 才会返回。
d, _ := c.done.Load().(chan struct{})

  if d == nil {c.done.Store(closedchan)

  } else {close(d)

  }
  • 循环遍历 c.children 去敞开子 Context,能够看到开释子 context 时会获取 子 Context 的锁,同时也会获取父 Context 的锁。所以才是线程平安的。完结后开释锁
     d, _ := c.done.Load().(chan struct{})

  if d == nil {c.done.Store(closedchan)

  } else {close(d)

  }
  • 如果要将其从父 Context 删除为 true,则将其从父上下文删除
if removeFromParent {removeChild(c.Context, c)

  }

removeChild 也比较简单,当为 *cancelCtx 就将其从 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()}

Done 就是返回一个 channel 用于告知应用程序工作曾经终止:这一步是只读没有加锁,如果没有读取到则尝试加锁,再读一次,还没读到则创立一个 chan,能够看到这是一个懒创立的过程。所以当用户被动调用 CancelFunc 时,其实根本就是将 c.done 内存储的 chan close 掉,这其中可能牵扯到父敞开,也要循环敞开子 Context 过程。

func (c *cancelCtx) Done() <-chan struct{} {d := c.done.Load()

  if d != nil {return d.(chan struct{})

  }

  c.mu.Lock()

  defer c.mu.Unlock()

  d = c.done.Load()

  if d == nil {d = make(chan struct{})

    c.done.Store(d)

  }

  return d.(chan struct{})

}

cancelCtx 次要内容就这么多,接下里就是 timerCtx 了

计时器

回顾下 timerCtx 定义,就是内嵌了一个 cancelCtx 另外多了两个字段 timer 和 deadline,这也是组合的体现。

type timerCtx struct {

  cancelCtx

  timer *time.Timer // Under cancelCtx.mu.

 

  deadline time.Time

}

上面就看看两个构造函数,WithDeadline 与 WithTimeout,WithTimeout 就是对 WithDealine 的一层简略封装。

查看不多说了,第二个查看如果父 context 的截止工夫比传递进来的早的话,这个工夫就无用了,那么就进化成 cancelCtx 了。

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

  if parent == nil {panic("cannot create context from nil parent")

  }

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

  }
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {return WithDeadline(parent, time.Now().Add(timeout))

}

结构 timerCtx 并调用 propagateCancel,这个曾经在下面介绍过了。

c := &timerCtx{cancelCtx: newCancelCtx(parent),

    deadline:  d,

  }

  propagateCancel(parent, c)

接着会看,会先利用 time. 直到(d. 分时。Now())来判断传入的 deadlineTime 与以后工夫差值,如果在以后工夫之前的话阐明曾经该勾销了,所以会间接调用 cancel 函数进行勾销,并且将其从父 Context 中删除。否则就创立一个定时器,当工夫达到会调用勾销函数,这里是定时调用,也可能用户被动调用。

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) }

上面看看 cancel 实现吧,相比拟 cancelCtx 就比较简单了,先勾销 cancelCtx,也要加锁,将 c.timer 进行并赋值 nil,这里也是第一次调用才会赋值 nil,因为外层还有个 c.timer!=nil 的判断,所以屡次调用只有一次赋值。

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

  if removeFromParent {

    // Remove this timerCtx from its parent cancelCtx's children.

    removeChild(c.cancelCtx.Context, c)

  }

  c.mu.Lock()

  if c.timer != nil {c.timer.Stop()

    c.timer = nil

  }

  c.mu.Unlock()}

相比拟于 cancelCtx 还笼罩实现了一个 Deadline(),就是返回以后 Context 的终止工夫。

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

上面就到了最初一个内置的 valueCtx 了。

构造器就更加加单,就多了 key,val

type valueCtx struct {

 Context

 key, val any

}

也就有个 Value method 不同,能够看到底层应用的就是咱们下面介绍的 value 函数,反复复用

func (c *valueCtx) Value(key any) any {

  if c.key == key {return c.val}

  return value(c.Context, key)

}

几个次要的解说完了,能够看到不到 600 行代码,就实现了这么多功能,其中蕴含了组合、封装、构造体嵌套接口等许多理念,值得好好推敲。上面咱们再看看其中有些有意思的中央。咱们个别打印字符串都是应用 fmt 包,那么不应用 fmt 包该如何打印呢?context 包里就有相应实现,也很简略,就是 switch case 来判断 v 类型并返回,它这么做的起因也有说:

“因为咱们不心愿上下文依赖于 unicode 表”,这句话我还没了解,有晓得的小伙伴能够在底下评论,或者等我有工夫看看 fmt 包实现。

func stringify(v any) string {switch s := v.(type) {

  case stringer:

    return s.String()

  case string:

    return s

  }

  return "<not Stringer>"

}

 

func (c *valueCtx) String() string {return contextName(c.Context) + ".WithValue(type" +

    reflectlite.TypeOf(c.key).String() +

    ", val" + stringify(c.val) + ")"

}

应用 Context 的几个准则

间接在函数参数传递,不要在 struct 传递,要明确传递,并且作为第一个参数,因为这样能够由调用方来传递不同的上下文在不同的办法上,如果你在 struct 内应用 context 则一个实例是专用一个 context 也就导致了协程不平安,这也是为何 net 包 Request 要拷贝一个新的 Request WithRequest(context go 1.7 才被引入),net 包牵扯过多,要做到兼容才嵌入到 struct 内。

不要应用 nil 而当你不晓得应用什么时则应用 TODO,如果你用了 nil 则会 panic。防止到处判断是否为 nil。

WithValue 不应该传递业务信息,只应该传递相似 request-id 之类的申请信息。

无论用哪个类型的 Context,在构建后,肯定要加上:defer cancel(),因为这个函数是能够屡次调用的,然而如果没有调用则可能导致 Context 没有被勾销继而其关联的上下文资源也得不到开释。

在应用 WithValue 时,包应该将键定义为未导出的类型以防止产生碰撞,这里贴个官网的例子:

// package user 这里为了演示间接在 main 包定义

// User 是存储在 Context 值

type User struct {

  Name string

  Age  int

}

 

// key 是非导出的,能够避免碰撞

type key int

 

// userKey 是存储 User 类型的键值,也是非导出的。var userKey key

 

// NewContext 创立一个新的 Context,携带 *User

func NewContext(ctx context.Context, u *User) context.Context {return context.WithValue(ctx, userKey, u)

}

 

// FromContext 返回存储在 ctx 中的 *User

func FromContext(ctx context.Context) (*User, bool) {u, ok := ctx.Value(userKey).(*User)

  return u, ok

}

那怎么可能避免碰撞呢?能够做个示例:看最初输入,咱们在第一行就用 userKey 的值 0,存储了一个值“a”。

而后再利用 NewContext 存储了 &User,底层理论用的是 context.WithValue(ctx,userKey,u)

读取时用的是 FromContext,两次存储即便底层的 key 值都为 0,然而互不影响,这是为什么呢?

还记得 WithValue 怎么实现的么?你每调用一次都会包一层,并且一层一层解析,而且它会比拟 c.key==key,这里记住 go 的 == 比拟是比拟值和类型的,二者都相等才为 true,而咱们应用 type key int 所以 userKey 与 0 底层值尽管一样,然而类型曾经不一样了(这里就是 main.userKey 与 0),所以内部无论定义何种类型都无奈影响包内的类型。这也是容易令人蛊惑的中央

package main
import (
  "context"
  "fmt"
)

func main() {ctx := context.WithValue(context.Background(), , "a")
  ctx = NewContext(ctx, &User{})
  v, _ := FromContext(ctx)
  fmt.Println(ctx.Value(0), v) // Output: a, &{0}
}

如果你是腾讯技术内容创作者,腾讯云开发者社区诚邀您退出【腾讯云原创分享打算】,支付礼品,助力职级降职。

退出移动版