Go Book / 2 Go Advances / 10 Go Context 上下文

10 Go Context 上下文

一、Context概述

1.缘起

在开发web服务应用时,我们知道http启动的服务每接收到一个请求是便启动一个goroutine处理该request。而每个协程处理该请求是一般都会启动多个协程去处理不同任务,如调用RPC、访问数据库资源、缓存资源等等,这些协程都是为处理同一个request工作的,同时当request被取消或者超时的时候,从这个request处理协程创建的所有子协程也应该被结束。此时一个handler就必须对其启动的子协程有控制权,在context出现前,上述那些处理还是很丑陋的,有些甚至引起全局资源的滥用或者回调噩梦。context出现后,一切都得到解脱,context解决了处理同一生命周期协程树的资源管理问题。

2.官方解释:

Context,翻译为“上下文”,context包定义了Context接口类型,其接口签名方法定义了跨API边界和进程之间的执行最后期限、取消信号和其他请求范围的值。

对服务器的传入请求应创建Context类型,对服务器的传出调用应接受Context。它们之间的函数调用链必须传播Context,可以选择将其替换为使用WithCancel()、WithDeadline()、WithTimeout()或WithValue()创建的派生Context。当一个context被取消时,从它派生的所有context也被取消。WithCancel()、WithDeadline()和WithTimeout()函数接受上下文(父级)并返回派生上下文(子级)和Cancelfunc。调用Cancelfunc将取消子级及其子孙级,删除父级对子级的引用,并停止任何关联的计时器。如果不调用Cancelfunc,则会泄漏子级及其子孙级,直到父级被取消或计时器触发。Go-Vet工具检查取消功能是否用于所有控制流路径。

使用Context的程序应该遵循这些规则,以保持包之间的接口一致,并允许静态分析工具检查上下文传播:

//context传递的写法
func DoSomething(ctx context.Context, arg Arg) error {
	// ... use ctx ...
}
  • 不要将Context存储在结构类型中;而是将Context显式传递给每个需要它的函数。文应该是第一个参数,通常名为ctx:

  • 即使函数允许,也不要传递nil上下文。如果不确定要使用哪个上下文,请传递context.TODO(),该函数返回一个可被跟踪的顶级Context。

  • 只对传输进程和API的请求范围数据使用Context值,而不用于向函数传递可选参数。

  • 同一Context可以传递给在不同goroutine中运行的函数;上下文对于多个goroutine同时使用是安全的。

3.context包解析

我们来看一下Context接口的签名方法:

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}
3.1 Context接口签名方法解析:
  • Deadline() (deadline time.Time, ok bool)

Deadline方法返回应取消代表此上下文完成的工作的时间。

  • 未设置截止时间时,Deadline方法返回ok==false。
  • 对Deadline方法的连续调用将返回相同的结果。
  • Done() <-chan struct{}

done返回一个通道,该通道在应取消代表此上下文完成的工作时关闭。如果无法取消此上下文,则done可能返回nil。对done的连续调用返回相同的值。不同的派生Context对done通道关闭有不同的处理方式:

  • WithCancel()在调用cancel时安排关闭done;
  • WithDeadline()在截止时间过期时安排关闭done;
  • WithTimeout()在超时结束时安排关闭done。
  • Err() error

Err方法在done关闭后返回非零错误值。 返回值:

  • 如果上下文被取消,则返回Canceled;如果上下文的截止时间已过,则返回DeadLineExceeded;
  • 没有为err定义其他值。
  • 完成后关闭,对err的连续调用将返回相同的值。
  • Value(key interface{}) interface{}
  • 该方法可以让协程共享一些数据,获得数据是协程安全的。
  • 该方法返回与键的上下文关联的值,如果没有值与键关联,则返回nil。
  • 对具有相同键的值的连续调用返回相同的结果。 仅对传输进程和API边界的请求范围数据使用上下文值,而不用于向函数传递可选参数。 键标识上下文中的特定值。希望在上下文中存储值的函数通常在全局变量中分配一个键,然后使用该键作为context.WithValue() 和 Context.Value的参数。键可以是支持相等的任何类型;包应将键定义为未排序的类型以避免冲突。
3.2 顶级Context

context包提供两种顶级的上下文类型,由工厂方法创建:

(1).func Background() Context

context.Background()返回非零的空上下文。它从不被取消,没有值,也没有最后期限。它通常由主函数、初始化和测试使用,并且作为传入请求的顶级上下文。

(2).func TODO() Context

context.TODO()返回非零的空上下文。当不清楚要使用哪个上下文或者它还不可用时(因为周围的函数还没有被扩展以接受上下文参数),应该使用context.TODO()。静态分析工具可以识别TODO,它确定上下文是否在程序中正确传播。

两者区别:

==本质来讲两者区别不大,其源码实现是一样的,只不过使用场景不同,context.Background()通常由主函数、初始化和测试使用,是顶级Context;context.TODO()通常用于主协程外的其他协程向下传递,分析工具可识别它在调用栈中传播。==

3.3 派生Context

除以上两种顶级Context类型,context包提供四种创建可派生Context类型的函数:

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

WithCancel函数返回具有新done通道的父级副本。当调用返回的cancel函数或关闭父上下文的done通道时(以先发生者为准),将关闭返回的上下文的done通道。 取消此上下文将释放与其关联的资源,因此代码应在此上下文中运行的操作完成后立即调用Cancel。

官方使用示例:

// gen generates integers in a separate goroutine and
// sends them to the returned channel.
// The callers of gen need to cancel the context once
// they are done consuming generated integers not to leak
// the internal goroutine started by gen.
gen := func(ctx context.Context) <-chan int {
    dst := make(chan int)
    n := 1
    go func() {
        for {
            select {
            case <-ctx.Done():
                return // returning not to leak the goroutine
            case dst <- n:
                n++
            }
        }
    }()
    return dst
}

ctx, cancel := context.WithCancel(context.Background())
defer cancel() // cancel when we are finished consuming integers

for n := range gen(ctx) {
    fmt.Println(n)
    if n == 5 {
        break
    }
}

//OUTPUT:
1
2
3
4
5

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

WithDeadline函数返回父上下文的副本,其截止时间调整为不迟于d。如果父上下文的截止时间早于d,则WithDeadline(Parent,d)在语义上等同于父上下文。当截止时间到期、调用返回的cancel函数或关闭父上下文的done通道(以先发生者为准)时,返回的上下文的done通道将关闭。 取消此上下文将释放与其关联的资源,因此代码应在此上下文中运行的操作完成后立即调用Cancel。

官方使用示例: 这个例子传递一个具有任意截止时间的上下文,告诉一个阻塞函数一旦到达它就应该放弃它的工作。

d := time.Now().Add(50 * time.Millisecond)
ctx, cancel := context.WithDeadline(context.Background(), d)

// Even though ctx will be expired, it is good practice to call its
// cancelation function in any case. Failure to do so may keep the
// context and its parent alive longer than necessary.
defer cancel()

select {
case <-time.After(1 * time.Second):
    fmt.Println("overslept")
case <-ctx.Done():
    fmt.Println(ctx.Err())
}

//OUTPUT:
context deadline exceeded
(3). func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

WithTimeout 返回 WithDeadline(parent, time.Now().Add(timeout))。取消此上下文将释放与其关联的资源,因此代码应在此上下文中运行的操作完成后立即调用取消:

官方使用示例:这个例子传递一个带有超时的上下文,告诉一个阻塞函数它应该在超时结束后放弃它的工作。

// Pass a context with a timeout to tell a blocking function that it
// should abandon its work after the timeout elapses.
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()

select {
case <-time.After(1 * time.Second):
    fmt.Println("overslept")
case <-ctx.Done():
    fmt.Println(ctx.Err()) // prints "context deadline exceeded"
}


//OUTPUT:
context deadline exceeded

*以上函数的特殊返回类型值:type CancelFunc func()

CancelFunc告诉操作放弃其工作。CancelFunc不等待工作停止。在第一次调用之后,对CancelFunc的后续调用不做任何操作。

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

WithValue返回父级的副本,可为上下文设置一个键值对。 只对传输进程和API的请求范围数据使用上下文值,而不用于向函数传递可选参数。 提供的键必须是可比较的,并且不应是字符串或任何其他内置类型,以避免使用上下文的包之间发生冲突。WithValue的用户应该为键定义自己的类型。为了避免在分配给接口时进行分配,上下文键通常具有具体的类型结构。或者,导出的上下文键变量的静态类型应该是指针或接口。

官方使用示例:

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


//OUTPUT:
found value: Go
key not found: color

三、Context使用示例

使用context包来实现线程安全退出或超时的控制:


//定义一个并发worker
func worker(ctx context.Context, wg *sync.WaitGroup) error {
	defer wg.Done()

	for {
		select {
		//当父协程调用cancel()时,会从ctx.Done()得到struct{},此时返回ctx.Err()退出子线程
		case <-ctx.Done():
			return ctx.Err()
		default:
		//默认输出hello
		fmt.Println("hello")
		}
	}
}

func main() {
    //生成一个有超时控制的衍生Context,超时10s退出所有子协程
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)

	var wg sync.WaitGroup
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go worker(ctx, &wg)
	}
    
    //主协程1s后就cancel所有子协程了,每个worker都可以安全退出
	time.Sleep(time.Second)
	cancel()
	wg.Wait()
}

当并发体超时或main主动停止工作者Goroutine时,每个工作者都可以安全退出。

Go语言是带内存自动回收特性的,因此内存一般不会泄漏。当main函数不再使用管道时后台Goroutine有泄漏的风险。我们可以通过context包来避免这个问题,下面是防止内存泄露的素数筛实现:


// 返回生成自然数序列的管道: 2, 3, 4, ...
func GenerateNatural(ctx context.Context) chan int {
	ch := make(chan int)
	go func() {
		for i := 2; ; i++ {
			select {
			//父协程cancel()时安全退出该子协程
			case <- ctx.Done():
				return
			//生成的素数发送到管道
			case ch <- i:
			}
		}
	}()
	return ch
}

// 管道过滤器: 删除能被素数整除的数
func PrimeFilter(ctx context.Context, in <-chan int, prime int) chan int {
	out := make(chan int)
	go func() {
		for {
			if i := <-in; i%prime != 0 {
				select {
				//父协程cancel()时安全退出该子协程
				case <- ctx.Done():
					return
				case out <- i:
				}
			}
		}
	}()
	return out
}

func main() {
	// 使用一个可由父协程控制子协程安全退出的Context。
	ctx, cancel := context.WithCancel(context.Background())

	ch := GenerateNatural(ctx) // 自然数序列: 2, 3, 4, ...
	
	for i := 0; i < 100; i++ {
        // 新出现的素数打印出来
		prime := <-ch 
		fmt.Printf("%v: %v\n", i+1, prime)
		// 基于新素数构造的过滤器
		ch = PrimeFilter(ctx, ch, prime) 
	}
    
    //输出100以内符合要求的素数后安全退出所有子协程
	cancel()
}

当main函数完成工作前,通过调用cancel()来通知后台Goroutine退出,这样就避免了Goroutine的泄漏。