前言

Go 1.7 版本之前,context 还是非编制的,它存在于 golang.org/x/net/context 包中。
后来,Golang 团队发现 context 还挺好用的,就把 context 收编了,在 Go 1.7 版本正式纳入了标准库。

为什么需要Context

当一个协程(goroutine)开启后,我们是无法强制关闭它的。
常见的关闭协程的原因有如下几种:

  1. goroutine 自己跑完结束退出(正常关闭,本文不讨论)。
  2. 主进程 crash 退出,goroutine 被迫退出(属异常关闭,应优化代码)。
  3. 通过通道发送信号,引导协程的关闭(属于开发者手动控制协程的方法)。
func main() {
	// 定义通知 goroutine 停止的 chanel
	stopSingal := make(chan bool)

	// 创建 5 个 goroutine
	for i := 1; i <= 5; i++ {
		go monitor(stopSingal, i)
	}

	// 等待时间
	time.Sleep(1 * time.Second)

	// 关闭所有 goroutine
	close(stopSingal)

	// 等待5s,若此时屏幕没有输出 <正在监控中> 就说明所有的goroutine都已经关闭
	time.Sleep(5 * time.Second)

	fmt.Println("主程序退出!!")

}

func monitor(ch chan bool, number int) {
	for {
		select {
			case v := <-ch:
				// 仅当 ch 通道被 close,或者有数据发过来(无论是true还是false)才会走到这个分支
				fmt.Printf("监控器%v,接收到通道值为:%v,监控结束。\n", number, v)
				return
			default:
				fmt.Printf("监控器%v,正在监控中...\n", number)
				time.Sleep(2 * time.Second)
		}
	}
}
监控器1,正在监控中...
监控器5,正在监控中...
监控器4,正在监控中...
监控器2,正在监控中...
监控器3,正在监控中...
监控器2,接收到通道值为:false,监控结束。
监控器1,接收到通道值为:false,监控结束。
监控器3,接收到通道值为:false,监控结束。
监控器5,接收到通道值为:false,监控结束。
监控器4,接收到通道值为:false,监控结束。
主程序退出!!

上面的例子,在我们定义一个无缓冲通道时,要实现对所有的 goroutine 进行关闭,可以使用 close 关闭通道,然后在所有的 goroutine 里不断检查通道是否关闭*(前提你得约定好,该通道你只会进行 close 而不会发送其他数据,否则发送一次数据就会关闭一个goroutine,这样会不符合咱们的预期,所以最好你对这个通道再做一层封装做个限制)*来决定是否结束 goroutine。

所以你看到这里,我做为初学者还是没有找到使用 Context 的必然理由,我只能说 Context 是个很好用的东西,使用它方便了我们在处理并发时候的一些问题,但是它并不是不可或缺的。

换句话说,它解决的并不是 能不能 的问题,而是解决 更好用 的问题。

简单使用Context

此处的代码,我们先实现一个简单的 Context Demo(我使用 Context 对上面的例子进行了一番改造),然后详细分析其中的代码意义。

func main() {
    ctx, cancel := context.WithCancel(context.Background())

    for i :=1 ; i <= 5; i++ {
        go monitor(ctx, i)
    }

    time.Sleep( 1 * time.Second)
    // 关闭所有 goroutine
    cancel()

    // 等待5s,若此时屏幕没有输出 <正在监控中> 就说明所有的goroutine都已经关闭
    time.Sleep( 5 * time.Second)

    fmt.Println("主程序退出!!")

}

func monitor(ctx context.Context, number int)  {
    for {
        select {
        // 其实可以写成 case <- ctx.Done()
        // 这里仅是为了让你看到 Done 返回的内容
        case v :=<- ctx.Done():
            fmt.Printf("监控器%v,接收到通道值为:%v,监控结束。\n", number,v)
            return
        default:
            fmt.Printf("监控器%v,正在监控中...\n", number)
            time.Sleep(2 * time.Second)
        }
    }
}

代码分析

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

以 context.Background() 为 parent context 定义一个可取消的 context

case <- ctx.Done():

然后你可以在所有的goroutine 里利用 for + select 搭配来不断检查 ctx.Done() 是否可读,可读就说明该 context 已经取消,你可以清理 goroutine 并退出了。

cancel()

当你想到取消 context 的时候,只要调用一下 cancel 方法即可。这个 cancel 就是我们在创建 ctx 的时候返回的第二个值。

该程序的运行结果如下

监控器3,正在监控中...
监控器4,正在监控中...
监控器1,正在监控中...
监控器2,正在监控中...
监控器2,接收到通道值为:{},监控结束。
监控器5,接收到通道值为:{},监控结束。
监控器4,接收到通道值为:{},监控结束。
监控器1,接收到通道值为:{},监控结束。
监控器3,接收到通道值为:{},监控结束。
主程序退出!!

根Context 是什么?

创建 Context 必须要指定一个 父 Context,当我们要创建第一个Context时该怎么办呢?
不用担心,Go 已经帮我们实现了2个,我们代码中最开始都是以这两个内置context作为最顶层的parent context,衍生出更多的子Context。

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

func Background() Context {
    return background
}

func TODO() Context {
    return todo
}

Background:主要用于main函数、初始化以及测试代码中,作为Context这个树结构的最顶层的Context,也就是根Context,它不能被取消。
TODO:如果我们不知道该使用什么Context的时候,可以使用这个,但是实际应用中,暂时还没有使用过这个TODO。
他们两个本质上都是emptyCtx结构体类型,是一个不可取消,没有设置截止时间,没有携带任何值的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 interface{}) interface{} {
    return nil
}

Context 的继承衍生

上面在定义我们自己的 Context 时,我们使用的是 WithCancel 这个方法。除它之外,context 包还有其他几个 With 系列的函数.

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。

WithCancel

WithCancel 返回带有新Done通道的父节点的副本。当调用返回的cancel函数或当关闭父上下文的Done通道时,将关闭返回上下文的Done通道,无论先发生什么情况。

取消此上下文将释放与其关联的资源,因此代码应该在此上下文中运行的操作完成后立即调用cancel。

func main() {
	ctx, cancel := context.WithCancel(context.Background())

	for i := 1; i <= 5; i++ {
		go job1(ctx, i)
	}

	time.Sleep(1 * time.Second)

	// 关闭所有 goroutine
	cancel()

	// 等待5s,若此时屏幕没有输出 <正在监控中> 就说明所有的goroutine都已经关闭
	time.Sleep(5 * time.Second)

	fmt.Println("主程序退出!!")
}

func job1(ctx context.Context, number int) {
	for {
		select {
			// 其实可以写成 case <- ctx.Done()
			// 这里仅是为了让你看到 Done 返回的内容
			case v := <-ctx.Done():
				fmt.Printf("监控器%v,接收到通道值为:%v,监控结束。\n", number, v)
				return
			default:
				fmt.Printf("监控器%v,正在监控中...\n", number)
				time.Sleep(2 * time.Second)
		}
	}
}
监控器5,正在监控中...
监控器2,正在监控中...
监控器3,正在监控中...
监控器4,正在监控中...
监控器1,正在监控中...
监控器2,接收到通道值为:{},监控结束。
监控器4,接收到通道值为:{},监控结束。
监控器5,接收到通道值为:{},监控结束。
监控器1,接收到通道值为:{},监控结束。
监控器3,接收到通道值为:{},监控结束。
主程序退出!!

WithDeadline

WithDeadline 返回父上下文的副本,并将deadline调整为不迟于d。如果父上下文的deadline已经早于d,则WithDeadline(parent, d)在语义上等同于父上下文。当截止日过期时,当调用返回的cancel函数时,或者当父上下文的Done通道关闭时,返回上下文的Done通道将被关闭,以最先发生的情况为准。

取消此上下文将释放与其关联的资源,因此代码应该在此上下文中运行的操作完成后立即调用cancel。

func main() {
	ctx01, cancel := context.WithCancel(context.Background())
	ctx02, cancel := context.WithDeadline(ctx01, time.Now().Add(1 * time.Second))

	defer cancel()

	for i :=1 ; i <= 5; i++ {
		go job2(ctx02, i)
	}

	time.Sleep(5  * time.Second)
	if ctx02.Err() != nil {
		fmt.Println("监控器取消的原因: ", ctx02.Err())
	}

	fmt.Println("主程序退出!!")
}

func job2(ctx context.Context, number int)  {
	for {
		select {
			case <- ctx.Done():
				fmt.Printf("监控器%v,监控结束。\n", number)
				return
			default:
				fmt.Printf("监控器%v,正在监控中...\n", number)
				time.Sleep(2 * time.Second)
		}
	}
}
监控器5,正在监控中...
监控器3,正在监控中...
监控器4,正在监控中...
监控器2,正在监控中...
监控器1,正在监控中...
监控器1,监控结束。
监控器3,监控结束。
监控器2,监控结束。
监控器4,监控结束。
监控器5,监控结束。
监控器取消的原因:  context deadline exceeded
主程序退出!!

WithTimeout

WithTimeout 返回WithDeadline(parent, time.Now().Add(timeout))

取消此上下文将释放与其相关的资源,因此代码应该在此上下文中运行的操作完成后立即调用cancel,通常用于数据库或者网络连接的超时控制。

func main() {
	ctx01, cancel := context.WithCancel(context.Background())

	// 此处定义1秒
	ctx02, cancel := context.WithTimeout(ctx01, 1 * time.Second)

	defer cancel()

	for i :=1 ; i <= 5; i++ {
		go job3(ctx02, i)
	}

	time.Sleep(5  * time.Second)
	if ctx02.Err() != nil {
		fmt.Println("监控器取消的原因: ", ctx02.Err())
	}

	fmt.Println("主程序退出!!")
}

func job3(ctx context.Context, number int)  {
	for {
		select {
			case <- ctx.Done():
				fmt.Printf("监控器%v,监控结束。\n", number)
				return
			default:
				fmt.Printf("监控器%v,正在监控中...\n", number)
				time.Sleep(2 * time.Second)
		}
	}
}
监控器5,正在监控中...
监控器4,正在监控中...
监控器2,正在监控中...
监控器3,正在监控中...
监控器1,正在监控中...
监控器4,监控结束。
监控器1,监控结束。
监控器2,监控结束。
监控器5,监控结束。
监控器3,监控结束。
监控器取消的原因:  context deadline exceeded
主程序退出!!

WithValue

WithValue 返回父节点的副本,其中与key关联的值为val。

仅对API和进程间传递请求域的数据使用上下文值,而不是使用它来传递可选参数给函数。

所提供的键必须是可比较的,并且不应该是string类型或任何其他内置类型,以避免使用上下文在包之间发生冲突。WithValue的用户应该为键定义自己的类型。为了避免在分配给interface{}时进行分配,上下文键通常具有具体类型struct{}。或者,导出的上下文关键变量的静态类型应该是指针或接口。

func main() {
	ctx01, cancel := context.WithCancel(context.Background())
	ctx02, cancel := context.WithTimeout(ctx01, 1* time.Second)
	ctx03 := context.WithValue(ctx02, "item", "CPU")

	defer cancel()

	for i :=1 ; i <= 5; i++ {
		go job4(ctx03, i)
	}

	time.Sleep(5  * time.Second)
	if ctx02.Err() != nil {
		fmt.Println("监控器取消的原因: ", ctx02.Err())
	}

	fmt.Println("主程序退出!!")
}

func job4(ctx context.Context, number int)  {
	for {
		select {
			case <- ctx.Done():
				fmt.Printf("监控器%v,监控结束。\n", number)
				return
			default:
				// 获取 item 的值
				value := ctx.Value("item")
				fmt.Printf("监控器%v,正在监控 %v \n", number, value)
				time.Sleep(2 * time.Second)
		}
	}
}
监控器5,正在监控 CPU 
监控器1,正在监控 CPU 
监控器4,正在监控 CPU 
监控器3,正在监控 CPU 
监控器2,正在监控 CPU 
监控器3,监控结束。
监控器1,监控结束。
监控器5,监控结束。
监控器4,监控结束。
监控器2,监控结束。
监控器取消的原因:  context deadline exceeded
主程序退出!!