golang中context的Q&A


标准库 context Q&A

参考文档

context如何被取消

1. context.Context讲解
type Context interface {
	// 返回context是否会被取消,以及自动取消时间
	Deadline() (deadline time.Time, ok bool)

	// 当context被取消了,或者到了最后的deadline, 返回一个被关闭的channel
	Done() <-chan struct{}

	// 在channel Done()关闭后,返回context取消原因
	Err() error

	// 获取key对应的value
	Value(key interface{}) interface{}
}
2. Context是一个接口,定义了4个方法,它们都是幂等的,也就是说连续多次调用同一个方法,得到的结果是相同的
3. Done()返回一个channel, 可以表示context被取消的信号,当这个channel被关闭了,说明context被取消了
    注意这里是一个只读的channel, 我们知道读取一个已关闭的channel,会读出响应类型的零值,并且源码里没有地方
    会向这个channel塞入值,换句话说,这个一个read-only的channel,因此在子协程里读取这个channel,
    除非被关闭,否则读取不出来任何东西, 也正是利用了这一点,子协程从channel中读出值(零值)后,就可以收尾退出了
4. Err() 返回一个错误,表示channel被关闭的原因,例如是被取消,还是超时
5. Deadline() 返回context的截止时间
6. Value() 获取之前设置的key对应的value
7. context.Background()通常用在main函数中,作为所有context的根节点
    context.TODO()通常用在字并不知道传递什么context的情形,例如调用一个需要传递context的函数,
    你手头并没有其它context可以传递,这时就可以传递TODO(),这常常发生在重构中,给一些函数添加了一个Context参数
    但不知道要传递什么,就用TODO占个位子,最终要换成其他context

context是什么

1. go1.7标准库引入context,中文名就是"上下文", 准确说它是goroutine的上下文,包含goroutine的运行状态、环境、现场等信息
main goroutine 通知 child goroutine退出任务的案例
func main() {
	messages := make(chan int, 10)
	done := make(chan bool)  // 创建一个无缓冲只读channel

	go func() {
		var ticker = time.NewTicker(time.Second)
		for _ = range ticker.C{  // 遍历只读channel
			select {
			case <-done:
				fmt.Println("main goroutine 通知 child goroutine结束了...")
				return
			default:
				fmt.Println("messages: ", <-messages)
			}
		}
	}()

	for i := 0; i < 10; i++{
		messages <- i
	}

	time.Sleep(time.Second * 5)  // 主协程对上面的子协程有5秒的超时控制
	close(done)  // 关闭 channel
	time.Sleep(time.Second)
	fmt.Println("main goroutine 结束了...")

}
2. 上述例子中定义了一个buffer为0的channel done, 子协程运行这定时任务,如果主协程需要在某个时刻发送消息通知
    子协程中断任务并退出,那么就可以让子协程监听这个done channel, 一旦主协程关闭done channel,那么子协程就可以
    退出了,这样实现了主协程通知子协程的需求,但是还是有限的
3. 假如我们可以在简单的通知上附加额外的信息来控制取消,为什么取消,或者有一个它必须要完成的最终期限
    更或者有多个取消选项,我们需要额外的信息来判断选择执行哪个取消选项
4. 考虑下面这种情况,假如主协程有多个任务,1,2,m,主协程对这些任务有超时控制,
    而其中任务1又有多个子任务1,2,n, 任务1对这些子任务也有超时控制,
    那么这些子任务即要感知主协程的取消信号,也要感知任务1的取消信号,
5. 如果还是使用done channel的方法,我们需要定义两个done channel, 子任务需要监听这两个done channel
    这样好像也还行,但是如果层级更深的话,这些子任务还有子任务的话,那么使用done channel的方法将变得非常繁琐且混乱
6. 我们需要优雅的方案来实现这一种机制:
    * 上层任务取消后,所有的下层任务都会被取消
    * 中间某一层任务取消后,只会将当前任务的下层任务全部取消,而不会影响上层任务及同级任务
    这个时候,context就派上用场了  
7. Context接口包含四个方法:
    Deadline返回绑定当前context的任务被取消的截止时间;如果没有设定期限,将返回ok == false。
    Done 当绑定当前context的任务被取消时,将返回一个关闭的channel;如果当前context不会被取消,将返回nil。
    Err 如果Done返回的channel没有关闭,将返回nil;如果Done返回的channel已经关闭,将返回非空的值表示任务结束的原因。如果是context被取消,Err将返回Canceled;如果是context超时,Err将返回DeadlineExceeded。
    Value 返回context存储的键值对中当前key对应的值,如果没有对应的key,则返回nil。
8. 可以看到Done()方法返回的channel用来传递结束信号以中断当前任务,
    Deadline()方法指示一段时间后当前goroutine是否会被取消,
    以及一个Err()方法用来解释goroutine被取消的原因,而Value用于获取当前任务树的额外信息,
9. Background()方法和TODO()方法生成的context其实是一致的,那么我们何时调用哪个呢
    background通常用于主函数、初始化、以及测试中使用,作为一个顶层的context,
    也就是说一般我们创建的context都是基于bakground
    TODO()是在不确定使用什么context的时候使用

两种不同功能的基础context类型,valueCtx、cancelCtx

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)
}
1. valueCtx嵌入了Context变量来表示父节点context,表示当前context继承了父context的所有信息
    valueCtx还携带了一组键值对,也就是说这种context可以携带额外的信息,valueCtx实现了Value方法
    用以在context链路上获取key对应的值,如果当前context不存在需要的key,会沿着context链向上
    寻找key对应的值,直到根节点
WithValue
1. WithValue用以向context添加键值对,
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}
}
2. 这里添加键值对不是在原结构体上直接添加,而是以此context作为父节点,重新黄建一个valueCtx子节点
    将键值对添加到子节点上,由此形成一条context链,获取value的过程就是在此context链上由尾部向前搜索
cancelCtx
type cancelCtx struct {
	Context

	mu       sync.Mutex            // protects following fields
	done     atomic.Value          // of chan struct{}, created lazily, closed by first cancel call
	children map[canceler]struct{} // set to nil by the first cancel call
	err      error                 // set to non-nil by the first cancel call
}
1. cancelCtx跟valueCtx类似,cancelCtx结构体中也有一个变量context作为父节点,
    变量done表示一个channel, 用来表示传递关闭信号,children表示一个map,用来存储当前context节点下的子节点
    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
	d, _ := c.done.Load().(chan struct{})
	// 设置一个关闭的channel或者将done channel关闭,用以发送关闭的信号
	if d == nil {
		c.done.Store(closedchan)
	} else {
		close(d)
	}
	// 将子节点context依次取消
	for child := range c.children {
		// NOTE: acquiring the child's lock while holding parent's lock.
		child.cancel(false, err)
	}
	c.children = nil
	c.mu.Unlock()

	// 将当前context节点从父节点上移除
	if removeFromParent {
		removeChild(c.Context, c)
	}
}
2. 可以发现cancelCtx类型的变量其实也是canceler类型,因为cancelCtx实现了canceler接口,
    Done()方法返回的是通知goroutine取消的信号通道,Err()方法返回的是被取消的原因
    cancelCtx类型的context在调用cancel方法时,会设置取消原因,将done channel设置为一个关闭的channel
    或者关闭channel, 然后将子节点context依次取消,如果有需要还会将当前节点从父节点直接移除
WithCancel
1. WithCancel函数用来创建一个可取消的context, 即cancelCtx类型的context, 
    WithCancel()方法返回一个cancelCtx和CancelFunc, 调用CancelFunc即可触发cancel操作,看源码
type CancelFunc func()

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
	if parent == nil {
		panic("cannot create context from nil parent")
	}
	c := newCancelCtx(parent)
	propagateCancel(parent, &c)
	return &c, func() { c.cancel(true, Canceled) }
}
TimerCtx
1. timerCtx是一种基于cancelCtx的context类型,从字面上就可以看出,这是一种可以定时取消的context类型
    timerCtx增加了两个字段,timer和deadline, timer:计时器,deadline:截止日期
2. timerCtx内部使用cancelCtx实现取消,另外使用定时器timer和过期时间deadline实现定时取消的功能,
    timerCtx在调用cancel方法时,会先将内部的cancelCtx取消,如果需要则将自己从cancelCtx祖先节点上移除
    最后取消计时器
WithDeadline
1. 如果父节点parent有过期时间,并且过期时间<设置的时间d,那么新建的子节点context无须设置过期时间
    使用WithCancel创建一个可取消的context即可
2. 否则就利用parent和过期时间d创建一个定时取消的timerCtx, 并建立context与可取消context祖先节点的
    取消关联关系,接下来判断当前时间具体过期时间d的时长dur
3. 如果dur<0,表明当前已经过了过期,则直接取消新的timerCtx
4. 为新建的timerCtx设置定时器,一旦达到过期时间就取消当前的timerCtx
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
	if parent == nil {
		panic("cannot create context from nil parent")
	}
	// 如果父节点parent有过期时间,并且过期时间<设置的时间d,那么新建的子节点context无须设置过期时间
	// 使用WithCancel创建一个可取消的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,
	}
	propagateCancel(parent, c)
	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()
	// 为新建的timerCtx设置定时器,一旦达到过期时间就取消当前的timerCtx
	if c.err == nil {
		c.timer = time.AfterFunc(dur, func() {
			c.cancel(true, DeadlineExceeded)
		})
	}
	return c, func() { c.cancel(true, Canceled) }
}
WithTimeout
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
	return WithDeadline(parent, time.Now().Add(timeout))
}
1. 与WithDeadline类似,WithTimeout也是创建一个定时取消的context,只不过WithDeadline是接收一个过期时间点
    而WithTimeout是接收一个过期时长,看源码也知道,WithTimeout也是调用的WithDeadline

Context的使用

1. 使用context实现文章开头done channel的例子示范一下如何更优雅的实现协程间取消信号的同步
func main() {
	messages := make(chan int, 10)

	for i := 0; i < 10; i++{
		messages <- i
	}

	// 创建带过期时间的context
	ctx, cancel := context.WithTimeout(context.Background(), time.Second * 5)

	go func(ctx context.Context) {
		ticker := time.NewTicker(time.Second)
		for _ = range ticker.C{  // 遍历通道,一秒执行一次
			select{
			case <-ctx.Done():
				fmt.Println("child goroutine 结束了...")
				return
			default:
				fmt.Println("receive message: ", <-messages)
			}
		}
	}(ctx)

	defer close(messages)
	defer cancel()  // 此语句可以省略,因为context是带过期时间的,不需要手动取消

	select{
	case <-ctx.Done():
		time.Sleep(time.Second)
		fmt.Println("main goroutine 结束了")
	}

}
2. 这个例子中,只要让子协程监听主协程传入的ctx,一旦ctx.Done()方法返回空channel, 子线程即可取消执行任务
    但是这个例子还无法展现context的传递取消信息的强大优势
3. net/http包的源码里在实现http server就用到了context, 简单分析下
4. 重点:
这样处理的目的主要有以下几点:
1. 一旦请求超时,即可中断当前请求;
2. 在处理构建response过程中如果发生错误,可直接调用response对象的cancelCtx方法结束当前请求;
3. 在处理构建response完成之后,调用response对象的cancelCtx方法结束当前请求。
* 在整个server处理流程中,使用了一条context链贯穿Server、Connection、Request,不仅将上游的信息共享给下游任务,
* 同时实现了上游可发送取消信号取消所有下游任务,而下游任务自行取消不会影响上游任务。

context是什么?

1. context主要用来在goroutine之间传递上下文信息,包括取消信号,超时时间,截止时间,k-v等
2. 随着context包的引入,标准库中很多接口也增加了context参数,例如database/sql,
    context几乎成为了并发控制和超时控制的标准做法

context有什么用?

1. go通常用来写后台服务,只需要几行代码就可以写一个http server,在go的server里
    通常每来一个请求都会启动若干个goroutine同时工作,有些去数据库拿数据,有些调用下游接口获取相关数据
    这些goroutine需要共享这个请求的基本数据,例如登录的token,处理请求的最大超时时间
    当请求被取消或者超时,所有为这个请求工作的goroutine需要快速退出,系统就可以回收相关资源
2. go语言中的server实际上是一个"协程模型", 也就是说一个协程处理一个请求,例如业务高峰期,某个下游服务器响应变慢
    而当前系统的请求又没有超时控制,或者说超时时间设置的过大,那么等待下游服务器返回数据的协程会越来越多,
    协程也是需要消耗资源的,如果携程数量激增,内存暴涨,甚至导致服务不可用,更严重会导致雪崩效应,整个服务对外不可用,P0级别的事故
3. 上面说的P0级别事故,可以通过设置下游服务器最大处理时间就可以避免,给下游服务器设置timeout=5ms,
    如果这个时间没有接收到数据,就直接返回给客户端一个默认值或错误,
4. context包就是为了解决上面这些问题而开发的,在一组goroutine之间传递共享的值,取消信号,超时控制,截止日期
5. 用简练的话来说,在go里面,我们不能直接杀死协程,需要通过channel+select的方法来关闭协程,
    但是在某些场景下,例如一个请求衍生了很多个协程,这些协程间是相互关联的,需要共享一些全局变量,有共同的deadline
    而且可以同时被关闭,再用channel+select就会比较麻烦,这是就可以通过context来实现
* 一句话解决:context用来解决goroutine之间 退出通知、元数据传递 的功能
6. 【引申1】举例说明context在实际项目中如何使用
    context会在函数传递间传递,只需要在适当的时间调用cancel函数就可以向goroutine发出取消信号
    或者调用Value函数取出context中的值
7. context使用注意4个事项
    1. 不要将context塞入结构体里,直接将context作为函数的第一参数,而且一般命名为ctx
    2. 不要向函数传入一个nil context,如果你是在不知道传递什么,标准库给提供好了一个context:todo
    3. 不要把本应该作为函数参数的类型放入到context中,context应该存储一些共同的数据,例如登录的session、cookie等
    4. 同一个context有可能会被传入到多个goroutine,别担心,context是并发安全的
context可以传递共享的数据
1. 对于web服务端开发,往往希望将一个请求处理的整个过程串起来,这非常依赖于Thread Local(对于go可以理解为单个协程所独有的)
    变量,go语言中没有Thread Local这个概念,所以在调用函数是需要传递context
func main() {
	bgCtx := context.Background()
	process(bgCtx)

	vCtx := context.WithValue(bgCtx, "traceId", "123456")
	process(vCtx)
}

func process(ctx context.Context){
	if traceId, ok := ctx.Value("traceId").(string); ok{
		fmt.Printf("process over, traceId = %s\n", traceId)
	} else {
		fmt.Println("process over, no traceId")
	}
}
/*
	process over, no traceId
	process over, traceId = 123456
*/
context可以信号通知取消goroutine
1. 设想一个场景,打开外卖订单,上面显示外卖小哥的位置,而且每秒更新一次,app向后端发起websocket链接后,
    后台启动一个协程,每隔一秒计算一次小哥的位置并发送给前端,如果用户退出订单页面,后台需要取消此过程,
    退出goroutine,系统回收资源
func Perform(ctx context.Context) {
    for {
        calculatePos()
        sendResult()
        select {
        case <-ctx.Done():
            // 被取消,直接返回
            return
        case <-time.After(time.Second):
            // block 1 秒钟 
        }
    }
}

ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
go Perform(ctx)
// ……
// app 端返回页面,调用cancel 函数
cancel()
2. 注意一个细节,WithTimeout函数返回的context和cancel是分开的,context本身并没有取消函数,
    这样做的原因是取消函数只能由外层函数调用,防止子节点contxt调用取消函数,从而严格控制信息的流向
    由父节点context流向子节点context
防止goroutine泄漏
1. 举一个例子,如果不用context取消,goroutine就会泄漏的例子
func gen() <-chan int {
	ch := make(chan int)
	var n int
	go func() {
		for {
			ch <- n
			n++
			time.Sleep(time.Second)
		}
	}()
	return ch
}

func main() {
	for v := range gen(){
		fmt.Println(v)
		if v == 5{
			break
		}
	}
	time.Sleep(time.Second * 5)
}
2. 这是一个可以无限生成整数的协程,但如果我们只要产生的前5个数,那么就会发生goroutine泄漏
    当n == 5, 直接break掉,那么gen函数的协程就会执行无限循环,发生了goroutine泄漏
3. 用context改进这个例子
func gen(ctx context.Context) <-chan int {
	ch := make(chan int)
	go func() {
		var n int
		for {
			select{
			case <-ctx.Done():  // 监听主协程发出的退出信号
				return
			case ch <- n:
				n++
				time.Sleep(time.Millisecond * 200)
			}
		}
	}()
	return ch
}

func main() {
	cancelCtx, cancelFunc := context.WithCancel(context.Background())
	defer cancelFunc()  // 避免其它地方忘记cancel,并且重复调用不影响

	for v := range gen(cancelCtx){
		fmt.Println(v)
		if v == 5{
			cancelFunc()  // 取消所有子协程, 然后退出循环
			break
		}
	}
}
4. 增加一个context, 在break前调用cancel函数,取消子goroutine, gen()函数在接收到取消信号后,
    直接退出,系统回收资源

context.Value的查找过程是怎样的

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

func (c *valueCtx) String() string {
	return contextName(c.Context) + ".WithValue(type " +
		reflectlite.TypeOf(c.key).String() +
		", val " + stringify(c.val) + ")"
}

func (c *valueCtx) Value(key interface{}) interface{} {
	if c.key == key {
		return c.val
	}
	return c.Context.Value(key)
}
1. 由于它直接将Context作为匿名字段,因此尽管它只实现了两个方法String()和Value(),其它方法继承自Context
    但它仍然是一个context,这是go语言的一个特点
2. 创建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}
}
3. 对key的要求是可比较,因为之后要通过key取出context中的值,可比较是必须的,通过层层传递context,
    最终形成一棵树

img.png

4. 和链表有点像,只是它的方向相反,Context指向它的父节点,而链表指向下一个节点,
    通过WithValue函数可以创建层层的valueCtx,存储goroutine间可以共享的变量,
    取值的过程,实际上是一个递归查找的过程
func (c *valueCtx) Value(key interface{}) interface{} {
	// 取值的过程,实际上是一个递归查找的过程,先从当前valueCtx中的key查找
	// 如果不存在就从它的父节点的Context中去查找,层层递归,直到找到或nil为止
	if c.key == key {
		return c.val
	}
	return c.Context.Value(key)
}
5. 它会顺着链路一直往上找,比较当前节点的key是否是要查找的key,如果是,则直接返回value,
    否则一直顺着context往前,最终找到根节点(一般是emtpyCtx),直接返回一个nil, 
    所以用Value方法的时候要判断,结果是否为nil
6. 因为查找方向是往上走的,所以父节点没法获取子节点的值,子节点却可以获取父节点的值,
7. WithValue: 创建context节点的过程实际上就是创建链表节点的过程,两个节点的key值是可以相等的,
    但他们是两个不同的context节点,查找的时候会先从当前节点查找,如果找不到一直找到最后根节点,
    整体而言,用WithValue构造的其实是一个低效率的链表
8. 注意:如果能用函数参数传递的尽量不要使用context传参,context尽量传递一些共享的变量如session,cookie等

相关