“ Golang让开发变得高效有趣已经成为一个不争的事实。事实上,极低的开发门槛带来的最大的问题在于,开发者甚至不需要对一个概念(包)进行过多的了解即可写出满足需要的程序。长久以往,写出的程序在健壮性,可维护性上都大打折扣。”
本篇作为深入Golang系列的一个开篇,将从实际开发入手,对于平日常见的,却又用不好的包:context,进行一个全面的割析,希望能够对今后的开发工作有所帮助。
01
—
啥是Context
首先,context包是在golang1.7版本中加入进来的特性,它的加入是为了简化处理请求之间调用所传递的上下文信息,这个信息包括了截止时间,超时时间,取消信号,和额外的请求范围的值。
理论上讲,对server发起一个请求时,就应该创建一个Context对象,并随着调用请求将这个对象传递给server;同样,server需要接受这个Context,并按需求做出相应的处理。如果是在一个函数调用链中,servers必须传递Context,甚至可选的,使用包装函数WithCancel,WithDeadline,WithTimeout或WithValue来给原始的Context赋予更多的信息和能力。WithCancel,WithDeadline,WithTimeout或WithValue这些包装函数都接受一个父Context对象,并针对不同的需求包装成一个子Context返回,同时返回的还有一个CancelFunc函数,调用CancelFunc会取消子项及其子项,删除父项对子项的引用,并停止任何关联的定时器。未能调用CancelFunc会泄漏子项及其子项,直到取消父项或计时器触发。
WithCancel的实现举例:
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) { c := newCancelCtx(parent) propagateCancel(parent, &c) return &c, func() { c.cancel(true, Canceled) }}
官方提出的一些需要遵循的规范:
不要将Context存储在结构类型中;相反地,将Context明确传递给需要它的每个函数。Context应该是第一个参数,通常命名为ctx:
func DoSomething(ctx context.Context, arg Arg) error { // ... use ctx ...}
即使函数允许,也不要传递nil Context。如果不确定要使用哪个上下文,传递context.TODO。
仅将Context中的Value用于转换进程和API的请求范围内的数据,而不是将可选参数传递给函数。
可以将相同的Context传递给在不同goroutine中运行的函数;Context对于多个goroutine同时使用是安全的。
02
—
走进源码
Context包的位置:GO_SDK/src/context/,这个包中除了context.go剩下的都是测试文件。
在context.go中可以发现我们今天的主角Context,其是一个拥有四个方法的接口:
type Context interface { // Deadline返回一个时间和一个状态布尔变量ok,这个时间表示工作 // 完成的最后期限,到期后当前context实例被取消掉。 // 如果ok==false代表当前context并没有设置deadline。 // 无论多少次调用Deadline()都将返回相同的结果。 Deadline() (deadline time.Time, ok bool) // Done 返回一个channel,这个channel在context被取消的时候被 // 关闭。 // 当这个context无法被取消的时候,Done 可能返回nil。 // 多次调用Done()返回的都是同一个值. // // WithCancel 让Done通道在cancel函数被调用时关闭 // WithDeadline 让Done通道在deadline到期时关闭 // WithTimeout 让Done通道在超时时间到时关闭 // // Done的一个推荐用法: // // // Stream 是一个负责通过DoSomething函数生成值的一个函数, // // 被调用之后,它会一直给out channel发送生成的值, // // 直到DoSomething 返回一个错误或者ctx.Done的通道被关闭。 // func Stream(ctx context.Context, out chan // for { // v, err := DoSomething(ctx) // if err != nil { // return err // } // select { // case // return ctx.Err() // case out // } // } // } // Done() struct{} // 如果Done还没有被closed, Err返回值为nil. // 如果Done已经被closed, Err返回一个非空error,它会解释退出 // 原因: // 被取消了如果context被取消或者DeadlineExceeded了如果 // deadline到了。 // 一旦Err返回了一个非空error,之后无论调用多少次Err都会得到 // 同样的错误 Err() error // Value返回与key的context关联的值,或者为nil,如果没有值与 // 键相关联。 // 连续调用Value,传入相同的键返回始终相同的结果。 // // 在一个context中key唯一指向一个特定的value。 // key可以是任意支持比较相等性的值;开发者的包应该负责将key定义 // 为不可导出类型来避免碰撞。 Value(key interface{}) interface{}}
因此,任何实现了上述四个方法的实体都是一个Context(Interface)。
为了方便用户使用,context包中已经预实现了两个Context:
var ( background = new(emptyCtx) todo = new(emptyCtx))
emptyCtx其实底层类型就是int:
// 一个emptyCtx永远不会被取消,没有任何的value,并且没有一个// deadline。// 它不是定义为一个空struct{},的原因在于这个类型的变量必须拥有// 彼此独立不相等的地址。type emptyCtx int
由以上源码可以看出,每一次调用context.Background()都会返回一个新的emptyCtx实例,且拥有自己的地址空间。
emptyCtx所实现的Context接口的方法非常简单:
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}
可以看出,emptyCtx的确仅仅是实现了Context接口,但它永远不会被取消,没有任何的value,并且没有一个deadline。
要使用一个能够传递控制信息(cancel、deadline)及数据信息(value)的context,那就一定是在原始的context实例基础上包装所需要的能力,这个过程对应到的就是四个with函数:WithCancel,WithDeadline,WithTimeout及WithValue。
前文已经举出了WithCancel的源码实例:
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) { c := newCancelCtx(parent) propagateCancel(parent, &c) return &c, func() { c.cancel(true, Canceled) }}
关键看看newCancelCtx这个函数:
func newCancelCtx(parent Context) cancelCtx { return cancelCtx{Context: parent}}
原来WithCancel调用之后,返回的已经不是原来传入的那个context了,取而代之的组合了原本的context并且根据With的类型派生出的新的子context:在这里是cancelCtx,相应的还有timerCtx,vakueCtx。对应的源码如下:
type cancelCtx struct { Context mu sync.Mutex done chan struct{} children map[canceler]struct{} err error }type timerCtx struct { cancelCtx timer *time.Timer deadline time.Time}type valueCtx struct { Context key, val interface{}}
值得注意的是timerCtx其实内嵌了cancelCtx,因此timerCtx天然也具有cancelCtx的所有性质。
ok,以上基本上就是context源码的主要细节,更详细的内容可以直接浏览go doc官网。
了解了这么多,接下来看看context在实际的开发中如何使用。
03
—
Context应用举例
由于原始的context实现(emptyCtx)没有任何的额外特性,因此下面的例子由官方提供的包装函数(WithCancel,WithDeadline,WithTimeout及WithValue)展开。
WithCancel
这个例子演示了使用可取消的context来防止goroutine泄漏。在示例函数结束时,gen启动的goroutine将返回而不会泄漏。
package mainimport ( "context" "fmt")func main() { // gen函数在一个独立的goroutine不断生产int数,并且将产生出来 // 的int数传递到一个无缓冲的channel中。 // 调用gen函数的函数需要在完成所需的处理之后取消产生int数的 // goroutine,来防止gen启动的内部goroutine泄露。 gen := func(ctx context.Context) chan int { dst := make(chan int) n := 1 go func() { for { select { case return // 返回能够避免goroutine泄露 case dst n++ } } }() return dst } ctx, cancel := context.WithCancel(context.Background()) defer cancel() // 延迟到main函数结束时调用 for n := range gen(ctx) { fmt.Println(n) if n == 5 { break } }}
WithDeadline
这个例子传递一个带有任意截止日期的context来告诉阻塞函数它应该在deadline到来时放弃它的工作。
package mainimport ( "context" "fmt" "time")func main() { d := time.Now().Add(50 * time.Millisecond) ctx, cancel := context.WithDeadline(context.Background(), d) // 虽然ctx会按需超期并触发cancel函数, 但是在任何时候调用 // cancel()函数主动取消context仍然是一个最佳实践。 // 如果不这样做可能会保留context及其父级的活动时间超过必要时间。 defer cancel() select { case After(1 * time.Second): fmt.Println("overslept") case Done(): fmt.Println(ctx.Err()) }}
WithTimeout
这个例子传递具有超时的context,以告知阻塞函数在超时过后它应该放弃其工作。
package mainimport ( "context" "fmt" "time")func main() { // 传递一个带有timeout的context能够告知一个blocking函数当 // timeout超时时退出工作。 // 这个例子和deadline的很像,区别在于一个是传未来的一个时间点, // 一个是传一段时间. ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() select { case 1 * time.Second): fmt.Println("overslept") case fmt.Println(ctx.Err()) // prints "context deadline exceeded" }}
WithValue
这个例子演示如何将值传递给上下文以及如何检索它(如果存在)。
package mainimport ( "context" "fmt")func main() { type favContextKey string f := func(ctx context.Context, k favContextKey) { if v := ctx.Value(k); v != nil { fmt.Println("found value:", v) return } fmt.Println("key not found:", k) } k := favContextKey("language") ctx := context.WithValue(context.Background(), k, "Go") f(ctx, k) f(ctx, favContextKey("color"))}