乐趣区

Golang Context是好的设计吗?

最近实现系统的分布式日志与事务管理时,在寻求所谓的全局唯一 Goroutine ID 无果之后,决定还是简单利用 Context 机制实现了基本的想法,不够高明,但是好用。于是对它当初的设计比较好奇,便有了此文。
1、What Context
Context 是 Golang 官方定义的一个 package,它定义了 Context 类型,里面包含了 Deadline/Done/Err 方法以及绑定到 Context 上的成员变量值 Value,具体定义如下:
type Context interface {
// 返回 Context 的超时时间(超时返回场景)
Deadline() (deadline time.Time, ok bool)
// 在 Context 超时或取消时(即结束了)返回一个关闭的 channel
// 即如果当前 Context 超时或取消时,Done 方法会返回一个 channel,然后其他地方就可以通过判断 Done 方法是否有返回(channel),如果有则说明 Context 已结束
// 故其可以作为广播通知其他相关方本 Context 已结束,请做相关处理。
Done() <-chan struct{}

// 返回 Context 取消的原因
Err() error

// 返回 Context 相关数据
Value(key interface{}) interface{}
}
那么到底什么 Context?
可以字面意思可以理解为上下文,比较熟悉的有进程 / 线程上线文,关于 Golang 中的上下文,一句话概括就是:goroutine 的相关环境快照,其中包含函数调用以及涉及的相关的变量值。通过 Context 可以区分不同的 goroutine 请求,因为在 Golang Severs 中,每个请求都是在单个 goroutine 中完成的。
注:关于 goroutine 的理解可以移步这里。
2、Why Context
由于在 Golang severs 中,每个 request 都是在单个 goroutine 中完成,并且在单个 goroutine(不妨称之为 A)中也会有请求其他服务(启动另一个 goroutine(称之为 B)去完成)的场景,这就会涉及多个 Goroutine 之间的调用。如果某一时刻请求其他服务被取消或者超时,则作为深陷其中的当前 goroutine B 需要立即退出,然后系统才可回收 B 所占用的资源。即一个 request 中通常包含多个 goroutine,这些 goroutine 之间通常会有交互。
那么,如何有效管理这些 goroutine 成为一个问题(主要是退出通知和元数据传递问题),Google 的解决方法是 Context 机制,相互调用的 goroutine 之间通过传递 context 变量保持关联,这样在不用暴露各 goroutine 内部实现细节的前提下,有效地控制各 goroutine 的运行。
如此一来,通过传递 Context 就可以追踪 goroutine 调用树,并在这些调用树之间传递通知和元数据。虽然 goroutine 之间是平行的,没有继承关系,但是 Context 设计成是包含父子关系的,这样可以更好的描述 goroutine 调用之间的树型关系。
3、How to use
生成一个 Context 主要有两类方法:
3.1)顶层 Context:Background
要创建 Context 树,首先就是要创建根节点
// 返回一个空的 Context,它作为所有由此继承 Context 的根节点
func Background() Context
该 Context 通常由接收 request 的第一个 goroutine 创建,它不能被取消、没有值、也没有过期时间,常作为处理 request 的顶层 context 存在。
3.2)下层 Context:WithCancel/WithDeadline/WithTimeout
有了根节点之后,接下来就是创建子孙节点。为了可以很好的控制子孙节点,Context 包提供的创建方法均是带有第二返回值(CancelFunc 类型),它相当于一个 Hook,在子 goroutine 执行过程中,可以通过触发 Hook 来达到控制子 goroutine 的目的(通常是取消,即让其停下来)。再配合 Context 提供的 Done 方法,子 goroutine 可以检查自身是否被父级节点 Cancel:
select {
case <-ctx.Done():
// do some clean…
}
注:父节点 Context 可以主动通过调用 cancel 方法取消子节点 Context,而子节点 Context 只能被动等待。同时父节点 Context 自身一旦被取消(如其上级节点 Cancel),其下的所有子节点 Context 均会自动被取消。
有三种创建方法:
// 带 cancel 返回值的 Context,一旦 cancel 被调用,即取消该创建的 context
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

// 带有效期 cancel 返回值的 Context,即必须到达指定时间点调用的 cacel 方法才会被执行
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)

// 带超时时间 cancel 返回值的 Context,类似 Deadline,前者是时间点,后者为时间间隔
// 相当于 WithDeadline(parent, time.Now().Add(timeout)).
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
下面来看改编自 Advanced Go Concurrency Patterns 视频提供的一个简单例子:
package main

import (
“context”
“fmt”
“time”
)

func someHandler() {
// 创建继承 Background 的子节点 Context
ctx, cancel := context.WithCancel(context.Background())
go doSth(ctx)

// 模拟程序运行 – Sleep 5 秒
time.Sleep(5 * time.Second)
cancel()
}

// 每 1 秒 work 一下,同时会判断 ctx 是否被取消,如果是就退出
func doSth(ctx context.Context) {
var i = 1
for {
time.Sleep(1 * time.Second)
select {
case <-ctx.Done():
fmt.Println(“done”)
return
default:
fmt.Printf(“work %d seconds: \n”, i)
}
i++
}
}

func main() {
fmt.Println(“start…”)
someHandler()
fmt.Println(“end.”)
}

输出结果:

注意,此时 doSth 方法中 case 之 done 的 fmt.Println(“done”) 并没有被打印出来。
超时场景:
package main

import (
“context”
“fmt”
“time”
)

func timeoutHandler() {
// 创建继承 Background 的子节点 Context
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
go doSth(ctx)

// 模拟程序运行 – Sleep 10 秒
time.Sleep(10 * time.Second)
cancel() // 3 秒后将提前取消 doSth goroutine
}

// 每 1 秒 work 一下,同时会判断 ctx 是否被取消,如果是就退出
func doSth(ctx context.Context) {
var i = 1
for {
time.Sleep(1 * time.Second)
select {
case <-ctx.Done():
fmt.Println(“done”)
return
default:
fmt.Printf(“work %d seconds: \n”, i)
}
i++
}
}

func main() {
fmt.Println(“start…”)
timeoutHandler()
fmt.Println(“end.”)
}

输出结果:

4、Really elegant solution?
前面铺地了这么多。
确实,通过引入 Context 包,一个 request 范围内所有 goroutine 运行时的取消可以得到有效的控制。但是这种解决方式却不够优雅。
4.1 Like a virus
一旦代码中某处用到了 Context,传递 Context 变量(通常作为函数的第一个参数)会像病毒一样蔓延在各处调用它的地方。比如在一个 request 中实现数据库事务或者分布式日志记录,创建的 context,会作为参数传递到任何有数据库操作或日志记录需求的函数代码处。即每一个相关函数都必须增加一个 context.Context 类型的参数,且作为第一个参数,这对无关代码完全是侵入式的。
更多详细内容可参见:Michal Strba 的 context-should-go-away-go2 文章
Google Group 上的讨论可移步这里。
4.2 Context isn’t for cancellation
Context 机制最核心的功能是在 goroutine 之间传递 cancel 信号,但是它的实现是不完全的。
Cancel 可以细分为主动与被动两种,通过传递 context 参数,让调用 goroutine 可以主动 cancel 被调用 goroutine。但是如何得知被调用 goroutine 什么时候执行完毕,这部分 Context 机制是没有实现的。而现实中的确又有一些这样的场景,比如一个组装数据的 goroutine 必须等待其他 goroutine 完成才可开始执行,这是 context 明显不够用了,必须借助 sync.WaitGroup。
func serve(l net.Listener) error {
var wg sync.WaitGroup
var conn net.Conn
var err error
for {
conn, err = l.Accept()
if err != nil {
break
}
wg.Add(1)
go func(c net.Conn) {
defer wg.Done()
handle(c)
}(conn)
}
wg.Wait()
return err
}
4.3 context.value
context.Value 相当于 goroutine 的 TLS(Thread Local Storage),但它不是静态类型安全的,任何结构体变量都必须作为字符串形式存储。同时,所有 context 都会在其中定义变量,很容易造成命名冲突。
5、Summary
https://golang.org/pkg/context/https://faiface.github.io/pos…https://juejin.im/entry/58088…https://dave.cheney.net/2017/…https://dave.cheney.net/2017/…https://sites.google.com/site…

退出移动版