golang·context

发布时间 2023-05-26 09:47:15作者: 中亿丰数字科技

Context

引入

Q:如何优雅地控制子协程(goroutine)退出?

  • 利用waitgroup+全局变量notify退出
package main

import (
	"fmt"
	"sync"
	"time"
)

// 引入:为什么需要context?
var wg sync.WaitGroup
var notify bool // 默认值为false

func f() {
	defer wg.Done()
	for {
		fmt.Println("Hello,world!")
		time.Sleep(time.Millisecond * 100)
		if notify { // 置为true后退出循环,然后才会执行wg.Done()
			break
		}
	}

}

func main() {
	wg.Add(1)
	go f()
	time.Sleep(time.Second)
	notify = true
	wg.Wait()
	fmt.Println("退出了")
}
  • 用channel的方式通知goroutine退出
package main

import (
	"fmt"
	"time"
)

// 为什么需要context?
var channel = make(chan bool)

func f() {
	for {
		fmt.Println("Hello,world!")
		time.Sleep(time.Millisecond * 100)

		select {
		case <-channel:		// 接收到值就会退出
			// break 只会退出select
			return
		default:
		}
	}

}

func main() {
	go f()
	time.Sleep(time.Second)
	channel <- true
	fmt.Println("退出了")
}

这两个方法的缺点在哪里?

使用全局变量和channel,在独立开发的条件下是可以的。但是,一旦到了团队协同开发,需要统一所有开发者的编码习惯。需要一个共同的解决方案。

  • 利用Context优雅地通知子协程退出,所有go程序员都这么写:
package main

import (
	"context"
	"fmt"
	"time"
)

func f(ctx context.Context) {
	for {
		fmt.Println("Hello,world!")
		time.Sleep(time.Millisecond * 100)

		select {
		case <-ctx.Done(): // 这个Done()返回的是一个只读的channel,接收到那么就可以执行退出循环
			return
		default:
		}
	}

}

func main() {
	// WithCancel可以创建并返回一个取消函数,返回一个派生的ctx,只要执行这个取消函数,所有传入这个ctx的协程都会退出
	// context.Background()是一个空ctx,用来作为所有context的根节点,这个ctx会一级一级传递下去
	ctx, cancel := context.WithCancel(context.Background()) // 返回一个可以取消子协程的ctx和一个用来取消的函数
	go f(ctx)                                               // 将这个ctx传递进去
	time.Sleep(time.Second)
	// context如何通知子协程退出?
	cancel() //	执行这个函数,就是往ctx.Done()这个通道里写入一个空的结构体,ctx.Done()接收成功后退出
}

当子goroutine又开启另外一个goroutine时,只需要将ctx传入就可以同时一起退出:

package main

import (
	"context"
	"fmt"
	"time"
)
// ctx一层一层往下传,传到的子协程可以在cancel()后,一起结束
func f2(ctx context.Context) {
	for {
		fmt.Println("这是f2...")
		time.Sleep(time.Millisecond * 100)

		select {
		case <-ctx.Done(): // 这个Done()返回的是一个只读的channel,接收到那么就可以执行退出循环
			return
		default:
		}
	}
}

func f(ctx context.Context) {
	go f2(ctx)
	for {
		fmt.Println("Hello,world!")
		time.Sleep(time.Millisecond * 100)

		select {
		case <-ctx.Done(): // 这个Done()返回的是一个只读的channel,接收到那么就可以执行退出循环
			return
		default:
		}
	}

}

func main() {
	// WithCancel可以创建并返回一个取消函数,返回一个派生的ctx,只要执行这个取消函数,所有传入这个ctx的协程都会退出
	// context.Background()是一个空ctx,用来作为所有context的根节点,这个ctx会一级一级传递下去
	ctx, cancel := context.WithCancel(context.Background()) // 返回一个可以取消子协程的ctx和一个用来取消的函数
	go f(ctx)                                               // 将这个ctx传递进去
	time.Sleep(time.Second)
	// context如何通知子协程退出?
	cancel() //	执行这个函数,就是往ctx.Done()这个通道里写入一个空的结构体,ctx.Done()接收成功后退出
}

Go标准库Context

在 Go http 包的Server中,每一个请求在都有一个对应的 goroutine 去处理。请求处理函数通常会启动额外的 goroutine 用来访问后端服务,比如数据库和RPC服务。用来处理一个请求的 goroutine 通常需要访问一些与请求特定的数据,比如终端用户的身份认证信息、验证相关的token、请求的截止时间。 当一个请求被取消或超时时,所有用来处理该请求的 goroutine 都应该迅速退出,然后系统才能释放这些 goroutine 占用的资源。控制子协程退出或者设置一个明确的超时时间,这就是context完成的事,当然这不是context的全部功能。

Context在go中的用途?

  1. 可以进行上下文信息传递。比如:处理HTTP请求/在请求处理链路上传递信息。
  2. 控制goroutine的生命周期
  3. 控制超时的方法调用
  4. 可以取消方法调用

context初衷是2、3、4,网络编程会用context传递信息

context是什么?

context的翻译就是"上下文"的意思,准确说它是goroutine的上下文。context会包含goroutine的运行状态、环境、现场等信息。context专门用来在goroutine之间传递上下文信息,包括:取消信号、超时时间、截止时间、Key-Value等。

context 会在函数传递间传递。context.Background()是一个空的context,通常用在main函数中,作为所有context的根节点。

对服务器传入的请求应该创建上下文,而对服务器的传出调用应该接受上下文。它们之间的函数调用链必须传递上下文,或者可以使用WithCancelWithDeadline(设置一个绝对的具体时间,超时就取消退出)、WithTimeout(设置一个相对的时间,比如过了几秒钟,就取消退出)或WithValue(专门传递key-value值的,专门来处理请求)创建的派生上下文。当一个上下文被取消时,它派生的所有上下文也被取消。

本质上:WithCancel和WithDeadline,本质是同一个东西,都会创建一个超时会被取消的ctx(timerCtx结构体的对象)

Context接口

context.Context是一个接口,该接口定义了四个需要实现的方法。具体签名如下:

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

其中:

  • Deadline方法需要返回当前Context被取消的时间,也就是完成工作的截止时间(deadline);
  • Done方法需要返回一个Channel,这个Channel会在当前工作完成或者上下文被取消之后关闭,多次调用Done方法会返回同一个Channel
  • Err方法会返回当前Context结束的原因,它只会在Done返回的Channel被关闭时才会返回非空的值;
    • 如果当前Context被取消就会返回Canceled错误;
    • 如果当前Context超时就会返回DeadlineExceeded错误;
  • Value方法会从Context中返回键对应的值,对于同一个上下文来说,多次调用Value 并传入相同的Key会返回相同的结果,该方法仅用于传递跨API和进程间跟请求域的数据;

Context整体概览

这张表展示了 context 的所有函数、接口、结构体,可以纵览全局,可以在读完文章后,再回头细看。

类型 名称 作用
Context 接口 定义了 Context 接口的四个方法
emptyCtx 结构体 实现了 Context 接口,它其实是个空的 context
CancelFunc 函数 取消函数
canceler 接口 context 取消接口,定义了两个方法
cancelCtx 结构体 可以被取消,生成可以被取消的ctx对象
timerCtx 结构体 超时会被取消,生成超时会被取消的ctx对象
valueCtx 结构体 可以存储 k-v 对
Background 函数 返回一个空的 context,常作为根 context
TODO 函数 返回一个空的 context,常用于重构时期,没有合适的 context 可用
WithCancel 函数 基于父 context,生成一个可以取消的 context
newCancelCtx 函数 创建一个可取消的 context
propagateCancel 函数 向下传递 context 节点间的取消关系
parentCancelCtx 函数 找到第一个可取消的父节点
removeChild 函数 去掉父节点的孩子节点
init 函数 包初始化
WithDeadline 函数 创建一个有 deadline 的 context
WithTimeout 函数 创建一个有 timeout 的 context
WithValue 函数 创建一个存储 k-v 对的 context

Background()和TODO()

Go内置两个函数:Background()TODO(),这两个函数分别返回一个实现了Context接口的backgroundtodo。我们的代码最开始都是以这两个内置的上下文对象作为最顶层的partent context,衍生出更多的子上下文对象。

  • Background()主要用于main函数、初始化以及测试代码中,作为Context这个树结构的最顶层的Context,也就是根Context。
  • TODO(),目前还不知道具体的使用场景,如果我们不知道该使用什么Context的时候,可以使用这个。

backgroundtodo本质上都是emptyCtx结构体类型,是一个不可取消,没有设置截止时间,没有携带任何值的Context。

With系列函数

context包中定义了四个With系列函数。

WithCancel

WithCancel的函数签名如下:

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

返回一个取消用的ctx和一个取消函数。取消此ctx将释放与其关联的资源。

这个代码实例可以看一开始写的例子。下面再加一个例子:

func gen(ctx context.Context) <-chan int {	//	这个函数返回的是一个只读的channel
		dst := make(chan int)
		n := 1
		go func() {
			for {
				select {
				case <-ctx.Done():
					return // return结束该goroutine,防止泄露
				case dst <- n:
					n++
				}
			}
		}()
		return dst
	}
func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel() // 当我们取完需要的整数后调用cancel

	for n := range gen(ctx) {	// 这里遍历gen这个函数,本质是在遍历一个channel,n就是channel中的值
		fmt.Println(n)
		if n == 5 {
			break
		}
	}
}

上面的示例代码中,gen函数在单独的goroutine中生成整数并将它们发送到返回的通道。 gen的调用者在使用生成的整数之后需要取消上下文,以免gen启动的内部goroutine发生泄漏。

WithDeadline

WithDeadline的函数签名如下:

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

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

func main(){
    d := time.Now().Add(50 * time.Millisecond)
    ctx,cancel := context.WithDeadline(context.Background(),d)
    
    // 尽管ctx会过期,但在任何情况下调用它的cancel函数都是很好的实践。
	// 如果不这样做,可能会使上下文及其父类存活的时间超过必要的时间。
    defer cancel()
    
    select {
        case <- time.After(1 * time.Second):
        	fmt.Println("overslept")
        case <- ctx.Done():
        	fmt.Println(ctx.Err())
    }
}

在上述代码中,定义了一个50秒后过期的deadline,然后我们调用context.WithDeadline(context.Background(),d)得到一个上下文(ctx)和一个取消函数(cancel),然后使用一个select让主程序陷入等待:等待1秒后打印overslept退出 或者 在等待ctx过期后退出。

上述代码在运行代码后的50毫秒后就会过期,所以ctx.Done()会首先接收到值,并且打印ctx.Err()

WithTimeout

WithTimeout的函数签名如下:

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

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

package main

import (
	"context"
	"fmt"
	"sync"

	"time"
)

// context.WithTimeout
var wg sync.WaitGroup

func worker(ctx context.Context){
LOOP:
    for{
        fmt.Println("db connecting...")
        time.Sleep(time.Millisecond * 10) // 假设正常连接数据库耗时10毫秒
        select {
            case <- ctx.Done():
            	break LOOP
            default:
        }
    }
    fmt.Println("work done!")
    wg.Done()
}

func main(){
    // 50毫秒后过期
    ctx,cancel := context.Withtimeout(context.Background(),time.Millisecond * 50)
    wg.Add(1)
    go worker(ctx)
    time.Sleep(time.Second * 5)
    cancel() // 通知子协程结束
    wg.Wait()
    fmt.Println("over")
}

即使在main中设置了睡眠5秒钟,在这之后调用了cancel(),但是协程依然会在50毫秒内结束。而主协程会等待5秒后才会打印"over",然后主协程结束。

WithValue

WithValue函数能够将请求作用域的数据与Context对象建立联系。关系如下:

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

需要传入键(key)与值(val)

在API与进程之间传递请求的数据,context成为这个数据的载体,请求API到进程(运行的程序)接收到API请求发送的值,进程还可以把ctx作为参数传入别的函数中,这个函数就接收到了传递进来的值。

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

withvalue源码

// context.go 核心源码部分
// 此函数接收 context 并返回派生 context,其中值 val 与 key 关联,并通过 context 树与 context 一起传递。
// 这意味着一旦获得带有值的 context,从中派生的任何 context 都会获得此值。不建议使用 context 值传递关键参数,
// 而是函数应接收签名中的那些值,使其显式化。
func WithValue(parent Context, key, val interface{}) Context {
	if key == nil {
		panic("nil key")
	}
	if !reflectlite.TypeOf(key).Comparable() {
		panic("key is not comparable")
	}
	return &valueCtx{parent, key, val}
}

type valueCtx struct {
	Context
	key, val interface{}
}


func (c *valueCtx) Value(key interface{}) interface{} {
	if c.key == key {
		return c.val
	}
	return c.Context.Value(key)
}

案例

package main

import (
	"context"
	"fmt"
	"sync"

	"time"
)

// context.WithValue

type TraceCode string

var wg sync.WaitGroup

func worker(ctx context.Context) {
	key := TraceCode("TRACE_CODE")
	traceCode, ok := ctx.Value(key).(string) // 通过键获取值
	if !ok {
		fmt.Println("invalid trace code")
	}
LOOP:
	for {
		fmt.Printf("worker, trace code:%s\n", traceCode)
		time.Sleep(time.Millisecond * 10) // 假设正常连接数据库耗时10毫秒
		select {
		case <-ctx.Done(): // 50毫秒后自动调用
			break LOOP
		default:
		}
	}
	fmt.Println("worker done!")
	wg.Done()
}

func main() {
	// 设置一个50毫秒的超时
	ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*50)
	// 在系统的入口中设置trace code传递给后续启动的goroutine实现日志数据聚合
	ctx = context.WithValue(ctx, TraceCode("TRACE_CODE"), "12512312234")
	wg.Add(1)
	go worker(ctx)
	time.Sleep(time.Second * 5)
	cancel() // 通知子goroutine结束
	wg.Wait()
	fmt.Println("over")
}

作者:朱嘉鎏