深度思维者

永远年轻,永远热泪盈眶

九. Go并发编程--context.Context

一. 序言

1.1 场景一

现在有一个 Server 服务在执行,当请求来的时候我们启动一个 goroutine 去处理,然后在这个 goroutine 当中有对下游服务的 rpc 调用,也会去请求数据库获取一些数据,这时候如果下游依赖的服务比较慢,但是又没挂,只是很慢,可能一次调用要 1min 才能返回结果,这个时候我们该如何处理?

如下图所示, 首先假设我们使用WaitGroup进行控制, 等待所有的goroutine处理完成之后返回,可以看到我们实际的好事远远大于了用户可以容忍的时间

如下图所示,再考虑一个常见的场景,万一上面的 rpc goroutine 很早就报错了,但是 下面的 db goroutine 又执行了很久,我们最后要返回错误信息,很明显后面 db goroutine 执行的这段时间都是在白白的浪费用户的时间。

这时候就应该请出context包了, context主要就是用来在多个 goroutine中设置截至日期, 同步信号, 传递请求相关值。

每一次 context 都会从顶层一层一层的传递到下面一层的 goroutine 当上面的 context 取消的时候,下面所有的 context 也会随之取消

上面的例子当中,如果引入 context 后就会是这样,如下图所示,context 会类似一个树状结构一样依附在每个 goroutine 上,当上层的 req goroutine 的 context 超时之后就会将取消信号同步到下面的所有 goroutine 上一起返回,从而达到超时控制的作用

如下图所示,当 rpc 调用失败之后,会出发 context 取消,然后这个取消信号就会同步到其他的 goroutine 当中

1.2 还有一种场景

Golang context是Golang应用开发常用的并发控制技术,它与WaitGroup最大的不同点是context对于派生goroutine有更强的控制力,它可以控制多级的goroutine。

context翻译成中文是 上下文,即它可以控制一组呈树状结构的goroutine,每个goroutine拥有相同的上下文。

典型的使用场景如图所示

上图中由于goroutine派生出子goroutine,而子goroutine又继续派生新的goroutine,这种情况下使用WaitGroup就不太容易,因为子goroutine个数不容易确定。

二. context

2.1 使用说明

2.1.1 使用准则

下面几条准则

  • 对 server 应用而言,传入的请求应该创建一个 context
  • 通过 WithCancel , WithDeadline , WithTimeout 创建的 Context 会同时返回一个 cancel 方法,这个方法必须要被执行,不然会导致 context 泄漏,这个可以通过执行 go vet 命令进行检查
  • 应该将 context.Context 作为函数的第一个参数进行传递,参数命名一般为 ctx 不应该将 Context 作为字段放在结构体中。
  • 不要给 context 传递 nil,如果你不知道应该传什么的时候就传递 context.TODO()
  • 不要将函数的可选参数放在 context 当中,context 中一般只放一些全局通用的 metadata 数据,例如 tracing id 等等
  • context 是并发安全的可以在多个 goroutine 中并发调用

2.1.2 函数签名

context 包暴露的方法不多,看下方说明即可

// 创建一个带有新的 Done channel 的 context,并且返回一个取消的方法
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
// 创建一个具有截止时间的 context
// 截止时间是 d 和 parent(如果有截止时间的话) 的截止时间中更早的那一个
// 当 parent 执行完毕,或 cancel 被调用 或者 截止时间到了的时候,这个 context done 掉
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
// 其实就是调用的 WithDeadline
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
type CancelFunc
type Context interface 
	// 一般用于创建 root context,这个 context 永远也不会被取消,或者是 done
    func Background() Context
	// 底层和 Background 一致,但是含义不同,当不清楚用什么的时候或者是还没准备好的时候可以用它
    func TODO() Context
	// 为 context 附加值
	// key 应该具有可比性,一般不应该是 string int 这种默认类型,应该自己创建一个类型
	// 避免出现冲突,一般 key 不应该导出,如果要导出的话应该是一个接口或者是指针
    func WithValue(parent Context, key, val interface{}) Context
 

2.2. 源码

2.2.1 context.Context接口

type Context interface {
    // 返回当前 context 的结束时间,如果 ok = false 说明当前 context 没有设置结束时间
	Deadline() (deadline time.Time, ok bool)
    // 返回一个 channel,用于判断 context 是否结束,多次调用同一个 context done 方法会返回相同的 channel
	Done() <-chan struct{}
    // 当 context 结束时才会返回错误,有两种情况
    // context 被主动调用 cancel 方法取消:Canceled
    // context 超时取消: DeadlineExceeded
	Err() error
    // 用于返回 context 中保存的值, 如何查找,这个后面会讲到
	Value(key interface{}) interface{}
}

2.2.2 context.Backgroud

一般用于创建 root context,这个 context 永远也不会被取消,或超时
TODO(), 底层和 Background 一致,但是含义不同,当不清楚用什么的时候或者是还没准备好的时候可以用它

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

func Background() Context {
	return background
}

func TODO() Context {
	return todo
}

查看源码我们可以发现,background 和 todo 都是实例化了一个 emptyCtx. emptyCtx又是一个 int类型的别名

type emptyCtx int

// emptyCtx分别绑定了四个方法,而这四个方法正是 context接口定义的方法,所以emptyCtx实现了 Context接口
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
}

emptyCtx 就如同他的名字一样,全都返回空值

2.2.3 WithCancel

WithCancel(), 方法会创建一个可以取消的 context

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
	if parent == nil {
		panic("cannot create context from nil parent")
	}
    // 包装出新的 cancelContext
	c := newCancelCtx(parent)
    // 构建父子上下文的联系,确保当父 Context 取消的时候,子 Context 也会被取消
	propagateCancel(parent, &c)
	return &c, func() { c.cancel(true, Canceled) }
}

传入的参数是一个实现了Context接口的类型(root context),且不能为 nil, 所以我们经常使用方式如下:

// context.Background 返回的是一个 root context
context.WithCancel(context.Background())

不止 WithCancel 方法,其他的 WithXXX 方法也不允许传入一个 nil 值的父 context
newCancelCtx 只是一个简单的包装就不展开了, propagateCancel 比较有意思,我们一起来看看

func propagateCancel(parent Context, child canceler) {
	// 首先判断 parent 能不能被取消
    done := parent.Done()
	if done == nil {
		return // parent is never canceled
	}

    // 如果可以,看一下 parent 是不是已经被取消了,已经被取消的情况下直接取消 子 context
	select {
	case <-done:
		// parent is already canceled
		child.cancel(false, parent.Err())
		return
	default:
	}

    // 这里是向上查找可以被取消的 parent context
	if p, ok := parentCancelCtx(parent); ok {
        // 如果找到了并且没有被取消的话就把这个子 context 挂载到这个 parent context 上
        // 这样只要 parent context 取消了子 context 也会跟着被取消
		p.mu.Lock()
		if p.err != nil {
			// parent has already been canceled
			child.cancel(false, p.err)
		} else {
			if p.children == nil {
				p.children = make(map[canceler]struct{})
			}
			p.children[child] = struct{}{}
		}
		p.mu.Unlock()
	} else {
        // 如果没有找到的话就会启动一个 goroutine 去监听 parent context 的取消 channel
        // 收到取消信号之后再去调用 子 context 的 cancel 方法
		go func() {
			select {
			case <-parent.Done():
				child.cancel(false, parent.Err())
			case <-child.Done():
			}
		}()
	}
}

接下来我们就看看 cancelCtx 长啥样

type cancelCtx struct {
	Context // 这里保存的是父 Context

	mu       sync.Mutex            // 互斥锁
	done     chan struct{}         // 关闭信号
	children map[canceler]struct{} // 保存所有的子 context,当取消的时候会被设置为 nil
	err      error
}

Done()

在 Done 方法这里采用了 懒汉式加载的方式,第一次调用的时候才会去创建这个 channel

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

Value()

value方法很有意思,这里相当于是内部 cancelCtxKey 这个变量的地址作为了一个特殊的 key,当查询这个 key 的时候就会返回当前 context 如果不是这个 key 就会向上递归的去调用 parent context 的 Value 方法查找有没有对应的值

func (c *cancelCtx) Value(key interface{}) interface{} {
	if key == &cancelCtxKey {
		return c
	}
	return c.Context.Value(key)
}

在前面讲到构建父子上下文之间的关系的时候,有一个去查找可以被取消的父 context 的方法 parentCancelCtx 就用到了这个特殊 value

func parentCancelCtx(parent Context) (*cancelCtx, bool) {
    // 这里先判断传入的 parent 是否是一个有效的 chan,如果不是是就直接返回了
	done := parent.Done()
	if done == closedchan || done == nil {
		return nil, false
	}

    // 这里利用了 context.Value 不断向上查询值的特点,只要出现第一个可以取消的 context 的时候就会返回
    // 如果没有的话,这时候 ok 就会等于 false
	p, ok := parent.Value(&cancelCtxKey).(*cancelCtx)
	if !ok {
		return nil, false
	}
    // 这里去判断返回的 parent 的 channel 和传入的 parent 是不是同一个,是的话就返回这个 parent
	p.mu.Lock()
	ok = p.done == done
	p.mu.Unlock()
	if !ok {
		return nil, false
	}
	return p, true
}

接下来我们来看最重要的这个 cancel 方法,cancel 接收两个参数,removeFromParent 用于确认是不是把自己从 parent context 中移除,err 是 ctx.Err() 最后返回的错误信息

func (c *cancelCtx) cancel(removeFromParent bool, err error) {
	if err == nil {
		panic("context: internal error: missing cancel error")
	}
	
	// 互斥锁用来保证并发安全
	c.mu.Lock()
	if c.err != nil {
		c.mu.Unlock()
		return // already canceled
	}
	c.err = err
   
    // 由于 cancel context 的 done 是懒加载的,所以有可能存在还没有初始化的情况
	if c.done == nil {
		c.done = closedchan
	} else {
		close(c.done)
	}
	
    // 循环的将所有的子 context 取消掉
	for child := range c.children {
		// NOTE: acquiring the child's lock while holding parent's lock.
		child.cancel(false, err)
	}
    // 将所有的子 context 和当前 context 关系解除
	c.children = nil
	c.mu.Unlock()

    // 如果需要将当前 context 从 parent context 移除,就移除掉
	if removeFromParent {
		removeChild(c.Context, c)
	}
}

2.2.4 WithTimeout

WithTimeout 其实就是调用了 WithDeadline 然后再传入的参数上用当前时间加上了 timeout 的时间

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
   return WithDeadline(parent, time.Now().Add(timeout))
}

再来看一下实现超时的 timerCtx,WithDeadline 我们放到后面一点点

type timerCtx struct {
	cancelCtx // 这里复用了 cancelCtx
	timer *time.Timer // Under cancelCtx.mu.

	deadline time.Time // 这里保存了快到期的时间
}

Deadline() 就是返回了结构体中保存的过期时间

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

cancel 其实就是复用了 cancelCtx 中的取消方法,唯一区别的地方就是在后面加上了对 timer 的判断,如果 timer 没有结束主动结束 timer

func (c *timerCtx) cancel(removeFromParent bool, err error) {
	c.cancelCtx.cancel(false, err)
	if removeFromParent {
		// Remove this timerCtx from its parent cancelCtx's children.
		removeChild(c.cancelCtx.Context, c)
	}
	c.mu.Lock()
	if c.timer != nil {
		c.timer.Stop()
		c.timer = nil
	}
	c.mu.Unlock()
}

timerCtx 并没有重新实现 Done() 和 Value 方法,直接复用了 cancelCtx 的相关方法

2.2.5 WithDeadline

源码如下

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
	if parent == nil {
		panic("cannot create context from nil parent")
	}

   	// 会先判断 parent context 的过期时间,如果过期时间比当前传入的时间要早的话,就没有必要再设置过期时间了
    // 只需要返回 WithCancel 就可以了,因为在 parent 过期的时候,子 context 也会被取消掉
	if cur, ok := parent.Deadline(); ok && cur.Before(d) {
		// The current deadline is already sooner than the new one.
		return WithCancel(parent)
	}

    // 构造相关结构体
	c := &timerCtx{
		cancelCtx: newCancelCtx(parent),
		deadline:  d,
	}

    // 和 WithCancel 中的逻辑相同,构建上下文关系
	propagateCancel(parent, c)

    // 判断传入的时间是不是已经过期,如果已经过期了就 cancel 掉然后再返回
	dur := time.Until(d)
	if dur <= 0 {
		c.cancel(true, DeadlineExceeded) // deadline has already passed
		return c, func() { c.cancel(false, Canceled) }
	}
	c.mu.Lock()
	defer c.mu.Unlock()

    // 这里是超时取消的逻辑,启动 timer 时间到了之后就会调用取消方法
	if c.err == nil {
		c.timer = time.AfterFunc(dur, func() {
			c.cancel(true, DeadlineExceeded)
		})
	}
	return c, func() { c.cancel(true, Canceled) }
}

可以发现超时控制其实就是在复用 cancelCtx 的基础上加上了一个 timer 来做定时取消

2.2.6 WithValue

主要就是校验了一下 Key 是不是可比较的,然后构造出一个 valueCtx 的结构

func WithValue(parent Context, key, val interface{}) 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 主要就是嵌入了 parent context 然后附加了一个 key val

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

Value 的查找和之前 cancelCtx 类似,都是先判断当前有没有,没有就向上递归,只是在 cancelCtx 当中 key 是一个固定的 key 而已

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

Value 就没有实现 Context 接口的其他方法了,其他的方法全都是复用的 parent context 的方法

三. 使用案例

3.1 使用cancel context

package main

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

// 模拟请求
func HandelRequest(ctx context.Context) {
	// 模拟讲数据写入redis
	go WriteRedis(ctx)

	// 模拟讲数据写入数据库
	go WriteDatabase(ctx)

	for {
		select {
		case <-ctx.Done():
			fmt.Println("HandelRequest Done.")
			return
		default:
			fmt.Println("HandelRequest running")
			time.Sleep(2 * time.Second)
		}
	}
}

func WriteRedis(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("WriteRedis Done.")
			return
		default:
			fmt.Println("WriteRedis running")
			time.Sleep(2 * time.Second)
		}
	}
}

func WriteDatabase(ctx context.Context) {
    // for 循环模拟间歇性尝试
	for {
		select {
		case <-ctx.Done():
			fmt.Println("WriteDatabase Done.")
			return
		default:
			fmt.Println("WriteDatabase running")
			time.Sleep(2 * time.Second)
		}
	}
}

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

	// 模拟耗时
	time.Sleep(5 * time.Second)
	fmt.Println("It's time to stop all sub goroutines!")
	cancel()

	// Just for test whether sub goroutines exit or not
	time.Sleep(5 * time.Second)
}

上面代码中协程HandelRequest()用于处理某个请求,其又会创建两个协程:WriteRedis()、WriteDatabase(),main协程创建context,并把context在各子协程间传递,main协程在适当的时机可以cancel掉所有子协程。

程序输出如下所示:

HandelRequest running
WriteRedis running
WriteDatabase running
HandelRequest running
WriteRedis running
WriteDatabase running
WriteRedis running
WriteDatabase running
HandelRequest running
It's time to stop all sub goroutines!
WriteDatabase Done.
HandelRequest Done.
WriteRedis Done.

3.2 WithTimeout 使用

用WithTimeout()获得一个context并在其子协程中传递:

package main

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

func HandelRequest2(ctx context.Context) {
	go WriteRedis2(ctx)
	go WriteDatabase2(ctx)
	for {
		select {
		case <-ctx.Done():
			fmt.Println("HandelRequest Done.")
			return
		default:
			fmt.Println("HandelRequest running")
			time.Sleep(2 * time.Second)
		}
	}
}

func WriteRedis2(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("WriteRedis Done.")
			return
		default:
			fmt.Println("WriteRedis running")
			time.Sleep(2 * time.Second)
		}
	}
}

func WriteDatabase2(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("WriteDatabase Done.")
			return
		default:
			fmt.Println("WriteDatabase running")
			time.Sleep(2 * time.Second)
		}
	}
}

func main() {
	// 定义超时时间,当超过定义的时间后会自动执行 context cancel, 从而终止字写成
	ctx, _ := context.WithTimeout(context.Background(), 5*time.Second)
	go HandelRequest2(ctx)

	// 模拟阻塞等待防止主线程退出
	time.Sleep(10 * time.Second)
}

主协程中创建一个5s超时的context,并将其传递给子协程,10s自动关闭context。程序输出如下:

HandelRequest running
WriteDatabase running
WriteRedis running                        # 循环第一次 耗时1s         
WriteRedis running                        # 循环第二次 耗时3s
WriteDatabase running
HandelRequest running
HandelRequest running
WriteRedis running                        # 循环第三次 耗时5s
WriteDatabase running
WriteRedis Done.
HandelRequest Done.
WriteDatabase Done.

3.3 Value值传递

下面示例程序展示valueCtx的用法:

package main

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

func HandelRequest3(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("HandelRequest Done.")
			return
		default:
			fmt.Println("HandelRequest running, parameter: ", ctx.Value("parameter"))
			time.Sleep(2 * time.Second)
		}
	}
}

func main() {
	// 取消时,将父 context value值传递给子context
	ctx := context.WithValue(context.Background(), "parameter", "1")
	go HandelRequest3(ctx)

	time.Sleep(10 * time.Second)
}

输出:

HandelRequest running, parameter:  1
HandelRequest running, parameter:  1
HandelRequest running, parameter:  1
HandelRequest running, parameter:  1
HandelRequest running, parameter:  1

上例main()中通过WithValue()方法获得一个context,需要指定一个父context、key和value。然后通将该context传递给子协程HandelRequest,子协程可以读取到context的key-value。

注意:本例中子协程无法自动结束,因为context是不支持cancle的,也就是说<-ctx.Done()永远无法返回。

如果需要返回,需要在创建context时指定一个可以cancelcontext作为父节点,使用父节点的cancel()在适当的时机结束整个context。

3.4 错误取消

假设我们在 main 中并发调用了 f1 f2 两个函数,但是 f1 很快就返回了,但是 f2 还在阻塞

package main

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

func f1(ctx context.Context) error {
	select {
	case <-ctx.Done():
		return fmt.Errorf("f1: %w", ctx.Err())
	case <-time.After(time.Millisecond): // 模拟短时间报错
		return fmt.Errorf("f1 err in 1ms")
	}
}

func f2(ctx context.Context) error {
	select {
	case <-ctx.Done():
		return fmt.Errorf("f2: %w", ctx.Err())
	case <-time.After(time.Hour): // 模拟一个耗时操作
		return nil
	}
}

func main() {
	// 超时 context
	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()

	var wg sync.WaitGroup
	wg.Add(2)
	go func() {
		defer wg.Done()
		// f1 由于时间很短,会返回error,从而调用 cancel
		if err := f1(ctx); err != nil {
			fmt.Println(err)
			// ctx 调用cancel后,会传递到f1,f2
			cancel()
		}
	}()

	go func() {
		defer wg.Done()
		if err := f2(ctx); err != nil {
			fmt.Println(err)
			cancel()
		}
	}()

	wg.Wait()
	fmt.Println("exit...")
}

执行结果,可以看到 f1 返回之后 f2 立即就返回了,并且报错 context 被取消

root@failymao:/mnt/d/gopath/src/Go_base/daily_test# go run context/err_cancel.go
f1 err in 1ms
f2: context canceled
exit...

这个例子其实就是 errgroup 的逻辑,是的它就是类似 errgroup 的简单逻辑

3.5 传递共享数据

一般会用来传递 tracing id, request id 这种数据,不要用来传递可选参数,这里借用一下饶大的一个例子,在实际的生产案例中我们代码也是这样大同小异

const requestIDKey int = 0

func WithRequestID(next http.Handler) http.Handler {
	return http.HandlerFunc(
		func(rw http.ResponseWriter, req *http.Request) {
			// 从 header 中提取 request-id
			reqID := req.Header.Get("X-Request-ID")
			// 创建 valueCtx。使用自定义的类型,不容易冲突
			ctx := context.WithValue(
				req.Context(), requestIDKey, reqID)

			// 创建新的请求
			req = req.WithContext(ctx)

			// 调用 HTTP 处理函数
			next.ServeHTTP(rw, req)
		}
	)
}

// 获取 request-id
func GetRequestID(ctx context.Context) string {
	ctx.Value(requestIDKey).(string)
}

func Handle(rw http.ResponseWriter, req *http.Request) {
	// 拿到 reqId,后面可以记录日志等等
	reqID := GetRequestID(req.Context())
	...
}

func main() {
	handler := WithRequestID(http.HandlerFunc(Handle))
	http.ListenAndServe("/", handler)
}

3.6 防止 goroutine 泄漏

看一下官方文档的这个例子, 这里面 gen 这个函数中如果不使用 context done 来控制的话就会导致 goroutine 泄漏,因为这里面的 for 是一个死循环,没有 ctx 就没有相关的退出机制

func main() {
	// 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
		}
	}
}

四. 总结

  • 对 server 应用而言,传入的请求应该创建一个 context,接受
    通过 WithCancel , WithDeadline , WithTimeout 创建的 Context 会同时返回一个 cancel 方法,这个方法必须要被执行,不然会导致 context 泄漏,这个可以通过执行 go vet 命令进行检查
  • 应该将 context.Context 作为函数的第一个参数进行传递,参数命名一般为 ctx 不应该将 Context 作为字段放在结构体中。
  • 不要给 context 传递 nil,如果你不知道应该传什么的时候就传递 context.TODO()
  • 不要将函数的可选参数放在 context 当中,context 中一般只放一些全局通用的 metadata 数据,例如 tracing id 等等
  • context 是并发安全的可以在多个 goroutine 中并发调用

4.1 使用场景

  • 超时控制
  • 错误取消
  • 跨 goroutine 数据同步
  • 防止 goroutine 泄漏

4.2 缺点

  1. 最显著的一个就是 context 引入需要修改函数签名,并且会病毒的式的扩散到每个函数上面,不过这个见仁见智,我看着其实还好
  2. 某些情况下虽然是可以做到超时返回提高用户体验,但是实际上是不会退出相关 goroutine 的,这时候可能会导致 goroutine 的泄漏,针对这个我们来看一个例子

我们使用标准库的 timeout handler 来实现超时控制,底层是通过 context 来实现的。我们设置了超时时间为 1ms 并且在 handler 中模拟阻塞 1000s 不断的请求,然后看 pprof 的 goroutine 数据

package main

import (
	"net/http"
	_ "net/http/pprof"
	"time"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
		// 这里阻塞住,goroutine 不会释放的
		time.Sleep(1000 * time.Second)
		rw.Write([]byte("hello"))
	})
	handler := http.TimeoutHandler(mux, time.Millisecond, "xxx")
	go func() {
		if err := http.ListenAndServe("0.0.0.0:8066", nil); err != nil {
			panic(err)
		}
	}()
	http.ListenAndServe(":8080", handler)
}

查看数据我们可以发现请求返回后, goroutine 其实并未回收,但是如果不阻塞的话是会立即回收的

goroutine profile: total 29
24 @ 0x103b125 0x106cc9f 0x1374110 0x12b9584 0x12bb4ad 0x12c7fbf 0x106fd01

看它的源码,超时控制主要在 ServeHTTP 中实现,我删掉了部分不关键的数据, 我们可以看到函数内部启动了一个 goroutine 去处理请求逻辑,然后再外面等待,但是这里的问题是,当 context 超时之后 ServeHTTP 这个函数就直接返回了,在这里面启动的这个 goroutine 就没人管了

func (h *timeoutHandler) ServeHTTP(w ResponseWriter, r *Request) {
	ctx := h.testContext
	if ctx == nil {
		var cancelCtx context.CancelFunc
		ctx, cancelCtx = context.WithTimeout(r.Context(), h.dt)
		defer cancelCtx()
	}
	r = r.WithContext(ctx)
	done := make(chan struct{})
	tw := &timeoutWriter{
		w:   w,
		h:   make(Header),
		req: r,
	}
	panicChan := make(chan interface{}, 1)
	go func() {
		defer func() {
			if p := recover(); p != nil {
				panicChan <- p
			}
		}()
		h.handler.ServeHTTP(tw, r)
		close(done)
	}()
	select {
	case p := <-panicChan:
		panic(p)
	case <-done:
		// ...
	case <-ctx.Done():
		// ...
	}
}

4.3 总结

context 是一个优缺点都十分明显的包,这个包目前基本上已经成为了在 go 中做超时控制错误取消的标准做法,但是为了添加超时取消我们需要去修改所有的函数签名,对代码的侵入性比较大,如果之前一直都没有使用后续再添加的话还是会有一些改造成本

这篇真的很长

五.参考文献

  1. context · pkg.go.dev
  2. Go 语言实战笔记(二十)| Go Context
  3. https://lailin.xyz/post/go-training-week3-context.htm
  4. https://www.topgoer.cn/docs/gozhuanjia/chapter055.3-context
posted @ 2021-11-17 01:03  failymao  阅读(390)  评论(0编辑  收藏  举报
Fork me on GitHub