Go context.Context 浅析

context 是 Golang 标准库所提供的上下文相关的库,它所定义的 context.Context 类型在 Golang 程序中被广泛应用于跨 Goroutine 的信号同步和数据传递,是 Golang 语言中的特殊设计,在其他语言中也很少见到类似的设计。本文我们将浅析 Golang 的 context 的使用和原理。

context 的使用#

创建上下文#

context.Context 是 Go 上下文的接口类型,它的定义如下。

1
2
3
4
5
6
type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}

Golang 标准库提供了两个创建上下文的函数。

1
2
func Background() Context
func TODO() Context

其实这两个函数返回的上下文类型都一样,都是由 emptyCtx 封装而来,也就是空的上下文,它们的实现也一模一样。前者是上下文的默认值,通常作为最顶层逻辑需要的上下文传入,一般是其他上下文的根节点。后者顾名思义,是代办的上下文,也就是不知道该传什么上下文的时候,用 TODO 作为占位符临时传入。

一般情况,我们使用过 context.Background 即可。

比如,当前我们有一个程序存在一部分复杂的数据处理逻辑,需要并行去分别处理不同的逻辑,我们可以编写这样的代码。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func ProcessA(ctx context.Context) {
	time.Sleep(time.Second) // 模拟 1 秒的处理逻辑
	fmt.Println("A finish")
}

func ProcessB(ctx context.Context) {
	time.Sleep(2 * time.Second) // 模拟 2 秒的处理逻辑
	fmt.Println("B finish")
}

func main() {
	ctx := context.Background()
	var wg sync.WaitGroup
	wg.Add(2)
	go func() {
        defer wg.Done()
		ProcessA(ctx)
	}()
	go func() {
        defer wg.Done()
		ProcessB(ctx)
	}()
	wg.Wait()
	fmt.Println("Main finish")
}

Golang 官方推荐我们通过函数第一个参数去传递上下文,所以在上面的代码中,ProcessA 和 ProcessB 的第一个参数都是 context.Context。可以观察到,上面的代码我们其实只是简单传递上下文,并没有使用上它。当然,这种方式有它的优缺点,优点是逻辑直观,但是缺点是过于冗杂,每个函数调用都需要传递 context.Context。

数据传递#

上下文的一个重要功能是数据传递。

在程序中可能有一些数据需要在逻辑中的各个地方都用上,但是每新增一个数据,都需要一层层显式添加到函数参数中也不合理。于是有一些人就封装了一个结构体,代码逻辑每一层函数调用都传递这个结构体,如果需要新增数据,则修改这个结构体即可。这里,其实 context.Context 就充当这个结构体的角色,我们只需要每一层函数调用都传递上下文。在顶层创建上下文的时候携带上需要的数据,底层代码即可获取相应的数据。

比如在 Web 服务中,我们希望每一次请求都有一个唯一的 TraceID,通过 TraceID 将所有相关的日志、数据库请求记录、网络请求记录都关联起来。这种场景即可通过上下文携带 TraceID。

Golang 标准库提供了 context.WithValue 方法使得上下文携带数据,之后可以通过上下文的 Value 方法获取数据。context.WithValue 需要传入一个上下文和对应数据的键值,并返回一个新的上下文,传入的上下文即为新的上下文的父上下文。由此可见,其实 Go 的 context 是存在树形关系的,而 context.Background 通常充当树的根节点。

1
2
3
4
5
func WithValue(parent Context, key, val any) Context

type Context interface {
	Value(key any) any
}

下面是一个使用上下文携带数据 TraceID 的例子,在最开始的逻辑使用 context.WithValue 创建子上下文,然后在更深的逻辑中获取相应的数据。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
type CtxKey string

const CtxKeyTraceID CtxKey = "TraceID"

func ProcessA(ctx context.Context) {
	time.Sleep(time.Second) // 模拟 1 秒的处理逻辑
	fmt.Printf("A finish, traceID:%d\n", ctx.Value(CtxKeyTraceID))
}

func main() {
	ctx := context.Background()
	ctx = context.WithValue(ctx, CtxKeyTraceID, 123)
	ProcessA(ctx)
}

context 是并发安全的。每次需要携带或修改数据时,其实都是在原来的 context 基础上创建一个子 context,因此 context 创建后相当于只读的,在多个 Goroutine 并发的情况下可以直接使用上下文。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func ProcessA(ctx context.Context) {
	time.Sleep(time.Second) // 模拟 1 秒的处理逻辑
	fmt.Printf("A finish, traceID:%d\n", ctx.Value(CtxKeyTraceID))
}

func ProcessB(ctx context.Context) {
	time.Sleep(2 * time.Second) // 模拟 2 秒的处理逻辑
	fmt.Printf("B finish, traceID:%d\n", ctx.Value(CtxKeyTraceID))
}

func main() {
	ctx := context.Background()
	ctx = context.WithValue(ctx, CtxKeyTraceID, 123)
	var wg sync.WaitGroup
	wg.Add(2)
	go func() {
		defer wg.Done()
		ProcessA(ctx)
	}()
	go func() {
		defer wg.Done()
		ProcessB(ctx)
	}()
	wg.Wait()
	fmt.Println("Main finish")
}

取消信号#

Golang 提供了 context.WithCancel 方法创建一个可取消的子上下文。

1
2
3
4
5
type CancelFunc func()
type CancelCauseFunc func(cause error)

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

在一些业务场景,我们可能需要创建多个 Goroutine 并发执行某些耗时的逻辑,这些 Goroutine 处理的逻辑可能比较复杂,逻辑执行到一半后如果出错或命中某些逻辑,可能剩下的逻辑和其他 Goroutine 继续执行下去也没有意义了。这时候如果能终止一些逻辑的处理,可以使得这些 Goroutine 能快速结束,避免执行更多无意义的耗时逻辑。

可取消的上下文被应用于这一场景,通过 context.WithCancel 可获得可取消的上下文以及取消函数。当调用取消函数时,上下文 Done 方法将通过 chan 返回结束信号,并且通过 Err 方法返回结束的错误。下面是一个传递取消信号的例子。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func ProcessA(ctx context.Context) {
	finish := make(chan struct{})
	go func() {
		time.Sleep(time.Second) // 模拟 1 秒的处理逻辑
		finish <- struct{}{}
	}()
	select {
	case <-ctx.Done(): // 上下文信号
		fmt.Printf("A cancel, err:%s\n", ctx.Err()) // A cancel, err:context canceled
	case <-finish: // 处理逻辑结束
		fmt.Printf("A finish\n")
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	var wg sync.WaitGroup
	wg.Add(1)
	go func() {
		defer wg.Done()
		ProcessA(ctx)
	}()
	if true { // 模拟一些逻辑判断, 发送取消信号
		cancel()
	}
	wg.Wait()
}

前面提及上下文其实是树形的关系,在树结果中上下文的信号也是可以被传递的,如果我们取消某个上下文时,该上下文的子节点上下文均被取消。而反过来,该上下文的父节点上下文并不会被取消。如果我们给 ProcessA 传入子上下文,其内部逻辑依然可以被取消。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	var wg sync.WaitGroup
	wg.Add(1)
	go func() {
		defer wg.Done()
		childCtx := context.WithValue(ctx, CtxKeyTraceID, 123)
		ProcessA(childCtx) // 传入子上下文
	}()
	if true { // 模拟一些逻辑判断, 发送取消信号
		cancel()
	}
	wg.Wait()
}

在多 Goroutine 的使用场景下,我们也可以使用上下文的信号传递方式,实现取消部分 Goroutine 中部分逻辑的功能。

超时信号#

取消信号使得 Golang 跨 Goroutine 可以依赖上下文实现信号同步,在实际应用中,应用更多的还是在超时场景。如果某些耗时较大的逻辑执行超时后,我们期望取消其之后的逻辑,超时后这些逻辑已经无意义了。

Golang 对 cancelCtx 进行了封装,提供了 context.WithTimeout 和 context.WithDeadline 两类方法创建超时后取消的上下文。前者需设置超时耗时,后者需设置超时时间点。

1
2
3
4
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithTimeoutCause(parent Context, timeout time.Duration, cause error) (Context, CancelFunc)
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
func WithDeadlineCause(parent Context, d time.Time, cause error) (Context, CancelFunc)

跟取消信号一样的使用方式,可以主动调用 CancelFunc 触发取消,也可以等到超时后被动接收超时信号。在下面的例子中,我们配置了 1.5s 超时,ProcessA 可以正常执行结束,而 ProcessB 则处理异常并返回超时错误

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
func ProcessA(ctx context.Context) {
	finish := make(chan struct{})
	go func() {
		time.Sleep(time.Second) // 模拟 1 秒的处理逻辑
		finish <- struct{}{}
	}()
	select {
	case <-ctx.Done():
		fmt.Printf("A cancel, err:%s\n", ctx.Err())
	case <-finish:
		fmt.Printf("A finish\n")
	}
}

func ProcessB(ctx context.Context) {
	finish := make(chan struct{})
	go func() {
		time.Sleep(2 * time.Second) // 模拟 2 秒的处理逻辑
		finish <- struct{}{}
	}()
	select {
	case <-ctx.Done():
		fmt.Printf("B cancel, err:%s\n", ctx.Err()) // B cancel, err:context deadline exceeded
	case <-finish:
		fmt.Printf("B finish\n")
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
	defer cancel()
	var wg sync.WaitGroup
	wg.Add(2)
	go func() {
		defer wg.Done()
		ProcessA(ctx)
	}()
	go func() {
		defer wg.Done()
		ProcessB(ctx)
	}()
	wg.Wait()
}

context 的约定#

这里我们列举一些关于 Golang 上下文的约定,以便于我们更好地使用 context。

上下文显式传递#

通常我们有两种方案去传递上下文变量,一种是显式地在每个需要它地函数中传递,另一种是将上下文封装到合适的结构体种。上面示例我们均采用显式传递的方案,而标准库 http.Request 则采用后者方案。

推荐使用显式传递的方式传播上下文变量,而非存储到结构体种。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 显式传递 ctx
func ProcessA(ctx context.Context)

// 通过结构体传递
type Request struct {
	// 内部使用直接使用 ctx 字段
	ctx context.Context

	// 初始化的时候通过 WithContext 携带 ctx
	WithContext(ctx context.Context) *Request
	// 外部使用可通过 Context 方法获取内部上下文变量
	Context() context.Context
}

同时,在传递上下文时应该禁止传递 nil 值,以保证只要拿到 context 变量即可使用。显式传递时如果没有可传递的上下文时应通过 context.Background 构造新的上下文;如果是通过结构体传递,可封装好上下文获取方法。

1
2
3
4
5
6
func (r *Request) Context() context.Context {
	if r.ctx != nil {
		return r.ctx
	}
	return context.Background()
}

避免滥用传值功能#

context 通过树形派生方式创建子上下文,并通过子上下文携带需要传递的数据。如果我们在实际使用中需要传递大量的数据时,需要使用 context.WithValue 的次数会比较多,这样会导致 context 的嵌套层级较深,对数据查询性能也有影响。

应避免滥用传值功能,仅携带有需要的数据。如果需要携带较多数据时,可考虑将数据整合到一起再携带。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 每个上下文节点单独携带一个数据
ctx := context.Background()
ctx = context.WithValue(ctx, CtxKeyTraceID, 123)
ctx = context.WithValue(ctx, CtxKeyUserID, 1)
ctx = context.WithValue(ctx, CtxKeyUserRole, "admin")

// 整合携带
ctx := context.Background()
ctx = context.WithValue(ctx, CtxReqParams, ReqParams{
	TraceID:  123,
	UserID:   1,
	UserRole: "admin",
})

使用自定义类型 Key#

在使用 context.WithValue 的时候,需要指定键值对数据。在查询数据的时候是通过 Key 去查询数据的,context 将根据 Key 一层层往上查到第一个满足条件的键值对,然后将值数据返回。那么在使用过程中,就会遇到子上下文携带的 Key 与它的父节点或者祖先节点携带的 Key 相同的情况,这种情况只会返回前者的值,而后者的值相当于被覆盖了。

在一些场景下这不是一个符合预期的行为,比如创建了 context,携带了组件 A 的 UserID,然后将该 context 传递给 组件 B,组件 B 由重新添加上自身系统的 UserID。如果该 context 再回到下一个组件时就丢失了原组件 A 的 UserID。

为了避免这种情况,我们应该使用自定义类型的 Key。context.WithValue 传入的键和值都是 any 类型,在获取数据时,需要判断键的类型和数据都同时相等。

1
2
3
4
ctx := context.Background()
ctx = context.WithValue(ctx, CtxKeyUserID, 1)
fmt.Println(ctx.Value(CtxKeyUserID))  // => 1
fmt.Println(ctx.Value("UserID"))      // => <nil>

context 的实现#

接下来我们通过分析 Golang 的源码来学习下 context 是如何实现的,下面源码均来源于 Golang 1.21.0。

context.Background#

context.Background 和 context.TODO 都是对 emptyCtx 的封装,本身没有任何逻辑,仅用于创建一个可使用的上下文变量。

empty-ctx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func Background() Context {
	return backgroundCtx{}
}

type backgroundCtx struct{ emptyCtx }
func (backgroundCtx) String() string {
	return "context.Background"
}

// 空上下文实现
type emptyCtx struct{}
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 any) any {
	return nil
}

context.WithValue#

context.WithValue 在父上下文变量的基础上,生成一个携带键值对的 valueCtx 类型的子上下文。阅读源代码可以发现,valueCtx 并没有直接实现 context.Context 接口,而是通过内嵌 context.Context 接口的方式,直接通过父上下文变量实现 context.Context 接口的所有方法。

value-ctx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 携带键值对的上下文实现
type valueCtx struct {
	Context
	key, val any
}

func WithValue(parent Context, key, val any) Context {
	if parent == nil {
		panic("cannot create context from nil parent")
	}
	if key == nil {
		panic("nil key")
	}
	if !reflectlite.TypeOf(key).Comparable() {
		panic("key is not comparable")
	}
	return &valueCtx{parent, key, val}
}

valueCtx 仅实现 Value 方法,其他方法直接来源于内嵌 context.Context 变量。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
func (c *valueCtx) Value(key any) any {
	// 判断 key 是否相等, 这里会同时判断类型和值都相等
	// 如果相等则直接返回当前上下文携带的数据
	if c.key == key {
		return c.val
	}
	// 如果不相等, 则往上一层层找到需要的值
	return value(c.Context, key)
}

func value(c Context, key any) any {
	// 循环找到需要的值
	for {
		switch ctx := c.(type) {
		case *valueCtx:
			// 如果当前上下文类型为 valueCtx 且 key 相同则直接返回对应值
			// 否则向上一层遍历父节点
			if key == ctx.key {
				return ctx.val
			}
			c = ctx.Context
		case *cancelCtx:
			// 如果当前上下文类型为 cancelCtx 且 key 为特殊 key则返回上下文自身
			// 否则向上一层遍历父节点
			// 该特殊 key 可用于查找最近的 cancelCtx
			if key == &cancelCtxKey {
				return c
			}
			c = ctx.Context
		case withoutCancelCtx:
			// 如果当前上下文类型为 withoutCancelCtx 且 key 为特殊 key 则直接返回 nil
			// 该上下文类型可以使得查找 cancelCtx 失效, 从而实现 cancel 失效
			if key == &cancelCtxKey {
				return nil
			}
			c = ctx.c
		case *timerCtx:
			// 同 cancelCtx
			if key == &cancelCtxKey {
				return &ctx.cancelCtx
			}
			c = ctx.Context
		case backgroundCtx, todoCtx:
			return nil
		default:
			return c.Value(key)
		}
	}
}

context.WithCancel#

context.WithCancel 在父上下文的基础上,创建了可取消的上下文。相比 valueCtx,cancelCtx 的结构要复杂得多。

cancel-ctx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
type cancelCtx struct {
	Context                        // 父上下文
	mu       sync.Mutex            // 确保下面的字段并发安全
	done     atomic.Value          // chan struct{} 类型,判断上下文是否已结束
	children map[canceler]struct{} // 可取消的子孙上下文
	err      error                 // 首次取消时的错误
	cause    error                 // 首次取消时的根因错误, 用于自定义错误
}

type canceler interface {
	cancel(removeFromParent bool, err, cause error)
	Done() <-chan struct{}
}

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
	if parent == nil {
		panic("cannot create context from nil parent")
	}
	c := &cancelCtx{}
	c.propagateCancel(parent, c) // 向下传播取消
	return c, func() { c.cancel(true, Canceled, nil) } // 返回上下文和取消函数
}

func (c *cancelCtx) Done() <-chan struct{} {
	d := c.done.Load()
	if d != nil {
		return d.(chan struct{})
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	d = c.done.Load()
	if d == nil {
		d = make(chan struct{})
		c.done.Store(d)
	}
	return d.(chan struct{})
}

上面代码中,context 在构造可取消的上下文过程中,使用了 propagateCancel 和 cancel 函数。前者实现了上下文向下传播取消的逻辑,即父上下文取消时,子上下文也跟着一起取消;后者实现了当前上下文的取消逻辑。接下来我们看看 propagateCancel 的代码实现。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
func (c *cancelCtx) propagateCancel(parent Context, child canceler) {
	c.Context = parent
	done := parent.Done()
	if done == nil {
		return // 如果父节点永远不会结束则直接返回
	}

	select {
	case <-done:
		// 如果父节点已经结束了, 直接取消当前上下文
		child.cancel(false, parent.Err(), Cause(parent))
		return
	default:
	}

	if p, ok := parentCancelCtx(parent); ok {
		// 找到最近的类型为 *cancelCtx 的祖先节点, 并将当前可取消的上下文记录在祖先节点的 children 中
		p.mu.Lock()
		if p.err != nil { // 祖先节点已经结束了, 直接取消当前上下文
			child.cancel(false, p.err, p.cause)
		} else {
			if p.children == nil {
				p.children = make(map[canceler]struct{})
			}
			p.children[child] = struct{}{}
		}
		p.mu.Unlock()
		return
	}

	/* ...其他操作... */

	// 创建一个 Goroutine, 异步等待当前节点或父节点的取消信号, 并执行取消动作
	go func() {
		select {
		case <-parent.Done():
			child.cancel(false, parent.Err(), Cause(parent))
		case <-child.Done():
		}
	}()
}

当前上下文或者父上下文被取消时,将直接调用上下文的 cancel 函数。cancel 函数将关闭 done 管道, 然后逐个遍历子孙节点进行取消操作。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func (c *cancelCtx) cancel(removeFromParent bool, err, cause error) {
	/* ...前置判断和处理... */

	// 取出并关闭 chan, 执行取消动作
	d, _ := c.done.Load().(chan struct{})
	if d == nil {
		c.done.Store(closedchan)
	} else {
		close(d)
	}

	// 遍历当前上下文的可取消的子孙节点, 并逐一取消
	for child := range c.children {
		child.cancel(false, err, cause)
	}
	c.children = nil

	/* ...后置处理... */
}

context.WithDeadline#

context.WithDeadline 和 context.WithTimeout 都是对 timerCtx 的封装,而 timerCtx 是对 cancelCtx 的封装,本质上是从一个可取消的上下文,变成了一个计时取消的上下文。接下来我们看看 timerCtx 的实现。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
type timerCtx struct {
	cancelCtx          // 内嵌可取消的上下文
	timer *time.Timer  // 计时器
	deadline time.Time // 截止时间
}

func (c *timerCtx) Deadline() (deadline time.Time, ok bool) {
	return c.deadline, true
}

func (c *timerCtx) cancel(removeFromParent bool, err, cause error) {
	c.cancelCtx.cancel(false, err, cause) // 取消动作也是对 cancelCtx 的封装
	if removeFromParent {
		removeChild(c.cancelCtx.Context, c)
	}
	c.mu.Lock()
	if c.timer != nil { // 取消之后关闭计时器
		c.timer.Stop()
		c.timer = nil
	}
	c.mu.Unlock()
}

当通过 context.WithDeadline 和 context.WithTimeout 等函数构造计时取消的上下文时,这些函数将封装 cancelCtx,并设置一个相应的计时器,等到截止时间后自动取消该 cancelCtx。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
	// 如果父节点存在截止时间且小于当前节点的截至时间, 则当前节点可直接使用 cancelCtx
	// 因为父节点会先到时间取消
	if cur, ok := parent.Deadline(); ok && cur.Before(d) {
		return WithCancel(parent)
	}
	// 构造 timerCtx
	c := &timerCtx{
		deadline: d,
	}
	c.cancelCtx.propagateCancel(parent, c)
	dur := time.Until(d)
	if dur <= 0 {
		c.cancel(true, DeadlineExceeded, cause) // 已经超时, 直接取消
		return c, func() { c.cancel(false, Canceled, nil) }
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.err == nil {
		// 设置计时器, 到时间后取消该节点
		c.timer = time.AfterFunc(dur, func() {
			c.cancel(true, DeadlineExceeded, cause)
		})
	}
	return c, func() { c.cancel(true, Canceled, nil) }
}

context.WithTimeout 类似以上的逻辑,只不过对 context.WithDeadline 进行了一些封装。

context 的实战和业界实践#

http.Request 信号同步#

接下来我们了解下一些 Go 中使用上下文的实践,http.Request 是一个典型的案例。Golang 的 net/http 标准库简易上手,大部分 Web 程序或第三方库都是基于标准库进行封装的。在 Web 场景或多或少会涉及到超时、连接断开等的问题,而 net/http 标准库原生支持通过上下文的方式管理信号同步。

http.Request 并没有采用显式传递上下文的约定,这是因为 http.Request 涉及的函数和方法较多,当时为了保证兼容性和实现简单,将上下文封装到结构体中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
type Request struct {
	ctx context.Context
}

// Context 获取请求的上下文, 如果不存在则创建一个新的上下文
func (r *Request) Context() context.Context {
	if r.ctx != nil {
		return r.ctx
	}
	return context.Background()
}

// WithContext 拷贝一个带新的上下文的请求
func (r *Request) WithContext(ctx context.Context) *Request {
	r2 := new(Request)
	*r2 = *r
	r2.ctx = ctx
	return r2
}

标准库还额外提供了创建带上下文的请求的函数。

1
2
func NewRequest(method, url string, body io.Reader) (*Request, error)
func NewRequestWithContext(ctx context.Context, method, url string, body io.Reader) (*Request, error)

假设我们实现一个 Web HTTP 程序,需要执行两个串行的任务,其中一个耗时 3 秒,一个耗时 1 秒,当超时或连接断开时不再处理剩下逻辑时,可以通过上下文进行结束信号监听。在逻辑处理函数中,我们首先创建一个 Goroutine 去执行耗时 3 秒的任务,然后根据上下文是否结束或者上一个任务是否结束,再决定是否需要执行耗时 1 秒的任务。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func Process(w http.ResponseWriter, r *http.Request) {
	finish := make(chan struct{})
	go func() {
		time.Sleep(3 * time.Second) // 模拟长耗时任务, 执行3秒
		finish <- struct{}{}
	}()

	select {
	case <-r.Context().Done():
		fmt.Printf("Cancel: %v\n", r.Context().Err())
		return
	case <-finish:
	}

	time.Sleep(1 * time.Second) // 模拟之后的任务, 执行1秒
	w.WriteHeader(http.StatusOK)
	w.Write([]byte("Success"))
	fmt.Println("Success")
}

func main() {
	// 创建 Server
	mux := http.NewServeMux()
	mux.HandleFunc("/", Process)
	server := &http.Server{Addr: ":8080", Handler: mux}
	server.ListenAndServe()
}

编写客户端逻辑,通过上下文控制 2 秒超时断开连接。最终客户端返回错误 context deadline exceeded,而服务端走到取消逻辑,返回错误 context canceled。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
ctx := context.Background()
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost:8080/", nil)
if err != nil {
	panic(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
	fmt.Printf("Error: %v\n", err)
	return
}
defer resp.Body.Close()
fmt.Println(io.ReadAll(resp.Body))

gin.Context 实现#

gin 是一个著名的 Golang Web 第三方库,其中的 gin.Context 是 gin 处理 HTTP 请求和响应的结构体类型。在 gin 中 gin.Context 并不是 Golang 概念中上下文的实现,但是它本身结合了 http.Request 实现了 context.Context 接口,因此我们也可以将 gin.Context 作为上下文传递。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// gin.Context 实现
type Context struct {
	Request *http.Request
	Writer  ResponseWriter
	/* ...more fields... */
}

func (c *Context) Done() <-chan struct{} {
	if !c.hasRequestContext() {
		return nil
	}
	return c.Request.Context().Done()
}

func (c *Context) Value(key any) any {
	// 先判断 gin.Context 内部的数据
	if keyAsString, ok := key.(string); ok {
		if val, exists := c.Get(keyAsString); exists {
			return val
		}
	}
	// 后从 http.Request 中获取
	if !c.hasRequestContext() {
		return nil
	}
	return c.Request.Context().Value(key)
}

由于 gin.Context 本质上对 http.Request 进行了封装,所以使用方法跟 http.Request 的使用类似。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func Process(c *gin.Context) {
	finish := make(chan struct{})
	go func() {
		time.Sleep(3 * time.Second) // 模拟长耗时任务, 执行3秒
		finish <- struct{}{}
	}()

	select {
	case <-c.Done(): // 可直接判断 c.Done() 是否结束, 也可以使用 c.Request.Context().Done()
		fmt.Printf("Cancel: %v\n", c.Err()) // 获取 c.Err()
		return
	case <-finish:
	}

	time.Sleep(1 * time.Second) // 模拟之后的任务, 执行1秒
	c.String(http.StatusOK, "Success")
	fmt.Println("Success")
}

不过需要注意的是,gin.Context 的 Done、Value 默认并不会使用 http.Request.Context() 上下文。为了确保 gin 的向后兼容,gin 提供了一个配置开关,只有打开开关,才能通过 Done 和 Value 方法获得 http.Request 中的上下文数据。

1
2
3
4
router := gin.Default()
router.ContextWithFallback = true // 启用 context.Context 实现
router.GET("/", Process)
router.Run(":8080")

结语#

context.Context 的介绍就到这里结束了,Golang 的这些标准库设计确实比较新颖,上下文相关的能力也给程序的数据传递和信号同步的支持提供了一个有利的工具。不过在我观察来,很多场景的代码实现大家并不关心信号同步和逻辑取消,在编写代码时也基本上只是将 context.Context 作为一个传递数据的变量。当然,希望你阅读本文后,能运用起上下文这些特性,在上下文的使用过程中更得心应手。