深入解读Golang标准库中context包的源码

本篇解读参考 Go1.16 版本。

context 包可谓是 Golang 语言的一个重要杀器,它可以很轻松地做到多个 goroutine(网络调用)之间的级联控制,也就是当一个主 goroutine 退出,其内部相关 goroutine 随即全部及时退出。光这一点,就足以让 golang 傲视群雄。

对于源码中那些加锁、解锁的部分就是为了保证一个 context 在多个 goroutine 中进行读写时能够保证并发安全,这里不会对它们进行解释。我会尽最大努力帮助你来弄清:一个 context 对象从创建到被取消,某个时间点上该对象的各个成员都处于什么样的状态。

cancelCtx

定义在 344 行:

// A cancelCtx can be canceled. When canceled, it also cancels any children
// that implement canceler.
type cancelCtx struct {
	Context

	mu       sync.Mutex            // protects following fields
	done     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
}

初始化 cancelCtx 对象的方法在 242 行:

// newCancelCtx returns an initialized cancelCtx.
func newCancelCtx(parent Context) cancelCtx {
	return cancelCtx{Context: parent}
}
  • 要想初始化一个 cancelCtx 的对象,必须传入一个父 context,并将父 context 的信息保存在新节点内部。
  • 这个初始化方法 newCancelCtx 不会对外暴露,必须通过 WithCancel 或者 WithDeadline 方法来初始化一个 cancelCtx 的对象。我想这样做就是为了保证 cancelCtx 对象中各个成员变量随时都能够处于预期范围内的状态。

创建第一个 cancelCtx 节点 ele01OfCancelCtx

创建 cancelCtx 对象的方法只有两个,WithCancel 和 WithDeadline,先来看 WithCancel 方法。

func TestCancelCtx(t *testing.T)  {
	ele01OfCancelCtx, cancelOfEle01 := context.WithCancel(context.Background())
}
  • 这一步操作其实创建了两个 context 节点,第一个节点是 context.Background(),它可以被看作是一个根节点,另一个节点就是 ele01OfCancelCtx
  • ele01OfCancelCtx 的成员 done、children、err 此时都为 nil
  • WithCancel 方法内部调用了 propagateCancel(parent, &c) ,propagateCancel 方法会首先判断 parent.Done()的结果是否为 nil,而根节点调用 Done()的结果将永远是 nil,所以执行 context.WithCancel(context.Background()时,相当于 WithCancel 方法内部直接跳过了 propagateCancel 方法

ele01OfCancelCtx.done 何时会变成 open 状态?

还记得 cancelCtx 这个类的定义吗?其成员 done 是一个 chan 类型。官方给这个done的注释是“created lazily”,大概意为“懒惰模式创建”。

// A cancelCtx can be canceled. When canceled, it also cancels any children
// that implement canceler.
type cancelCtx struct {
	Context

	mu       sync.Mutex            // protects following fields
	done     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
}

“created”如何解释?

ele01OfCancelCtx.done 此时是 nil 状态,对一个 nil 状态的 channel 进行读写都会阻塞,对其进行 close 则会触发 panic,所以始终为 nil 状态的 channel 肯定无法正常工作。意味着必须使用 make(chan struct{})来对其进行初始化,channel 被初始化后就会变成 open 状态。所以官网注释中的“created”其实就是等价于“ele01OfCancelCtx.done = make(chan struct{})”

而纵观 context 整个包内,只有一处对 done 成员进行了初始化。就是在 context 包内的 363 行。

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
}

所以 ele01OfCancelCtx.done 变为 open 状态的途径只有一个,那就是调用 ele01OfCancelCtx.Done()方法。这个方法会返回一个只读 channel,当 ele01OfCancelCtx.done 被 close时,这个只读 channel 的读端会读出数据。

很显然,当你得到了 ele01OfCancelCtx 对象后,你可以直接执行 ele01OfCancelCtx.Done()以将 ele01OfCancelCtx.done 变成 open 状态。但是这还不够严谨,别忘了还有一个 propagateCancel 函数,它被定义在 context 包的第 250 行.

func propagateCancel(parent Context, child canceler) {}

这个函数内部上来就直接执行了一次 parent.Done(),而 propagateCancel 方法在整个 context 包内只被两个方法调用过,那就是 WithCancel 和 WithDeadline。

那么再回到这个子标题提出的问题——ele01OfCancelCtx.done 何时会变成 open 状态?

答案就是:

对于 context 包外的使用者而言,一共有三种方式。除此之外别无它法。

  1. ele01OfCancelCtx.Done()
  2. context.WithCancel(ele01OfCancelCtx)
  3. context.WithDeadline(ele01OfCancelCtx)

那么我们再进一步想想,对于 context 链表中的任意一个 cancelCtx 节点 elexxOfCancelCtx 来说,elexxOfCancelCtx.done 这个 channel 何时会变成 open 状态?

答案也是三种:

  1. elexxOfCancelCtx.Done()
  2. context.WithCancel(elexxOfCancelCtx)
  3. context.WithDeadline(elexxOfCancelCtx)

现在还记得官方对 done 这个 channel 的注释吗?

created lazily。

created 已经解释过了,lazily 就是意味着必须要执行上面三种方式的任意一种才能让一个 cancelCtx 节点上的 done 变为 open 状态。

ele01OfCancelCtx.done 何时会变成 closed状态?

context 包内只有 cancel 方法对 cancelCtx 对象的 done 成员进行了 close 操作。

cancel 方法定义在第 394 行。

所以 ele01OfCancelCtx.done 要想变成 closed 状态就必须通过调用 cancel 方法来完成。而全篇调用 cancel 方法的方式只有当前节点 c.cancel()和孩子节点 child.cancel()

ele01OfCancelCtx 是第一个 cancelCtx 节点,这意味着任何一个节点的 child 成员都不可能指向 ele01OfCancelCtx,所以也就无法通过 child.cancel()的方式来将 ele01OfCancelCtx 的 done 给关闭。

所以对 context 包外的使用者来说,想让 ele01OfCancelCtx.done 变成 closed 状态就只有一种方式,那就是执行 ele01OfCancelCtx 这个节点对应的 cancelOfEle01()方法。

所以当你的代码中出现 <-ele01OfCancelCtx.Done()这个操作时,就意味着这行代码要想能读出数据,就必须等到 cancelOfEle01()执行过后。别无它法。

一个 cancelCtx 节点的成员 err 何时变为非 nil 状态?

当且仅当这个 cancelCtx 节点被取消掉时,这个 cancelCtx 节点的成员 err 才会变成非 nil。

因为只有在 context 包的第 403 行对 err 成员进行了赋值。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TOhwXlCQ-1639403769560)(assets/image-20211211184954-xe4qrm3.png)]

一个 cancelCtx 节点的成员 children 何时变为非 nil 状态?

children 成员的初始化、赋值仅在函数 propagateCancel 中发生过。propagateCancel 在源码的第 250 行。

我们仔细看一下这个函数。

// propagateCancel arranges for child to be canceled when parent is.
func propagateCancel(parent Context, child canceler) {
        // 调用父节点的Done()方法。如果父节点是根节点,done就是nil;
        // 如果父节点不是根节点,done一定不是nil,因为非根节点调用Done()
        // 方法时如果发现当前节点的done是nil,则会对其进行初始化
	done := parent.Done()
	if done == nil {
                // 如果done为nil,说明父节点是个根节点,不需要执行广播取消,
                // 实际上根节点也无法执行,因为它根本没有children这个成员,
                // 所以针对父节点是根节点的情况,不做后续处理。
		return // parent is never canceled
	}

        // go1.16版本在这里对父节点的done的状态做了进一步的判断
	select {
	case <-done:
		// parent is already canceled
                // 如果done是closed状态,则程序会走到这个分支,
                // 这里将新的child节点取消,实际上就是给child的err成员赋了值,
                // 这样child就被标记成了已取消的状态并返回。
                // 所以当你通过WithCancelCtx(context.Background())方法得到
                // 第一个cancelCtx节点ele01OfContextCtx之后,若将ele01OfContextCtx取消并
                // 再次调用WithCancelCtx(ele01OfContextCtx),得到的将是一个被标记为取消状态的cancelCtx节点
		child.cancel(false, parent.Err())
		return
	default:
                // 如果done是open状态,则程序会走default分支并跳出select语句
	}

        // parentCancelCtx方法其实是判断这个父节点的done是否为open状态
        // 且类型为cancelCtx节点,当这两个条件都满足,就将其转换为cancelCtx类型并返回,bool类型将返回true
	if p, ok := parentCancelCtx(parent); ok {
		p.mu.Lock()
                // 再次判断父节点是否已被取消,因为父节点可能是在parentCancelCtx执行期间被取消的
		if p.err != nil {
			// parent has already been canceled
			child.cancel(false, p.err)
		} else {
                        // 父节点的children成员若为nil,则将其初始化
			if p.children == nil {
				p.children = make(map[canceler]struct{})
			}
                        // 将child节点挂载到父节点的成员children中,这意味着父子可以相互指向。
                        // 自第一个非根节点开始的节点将组成一个双向链表。
			p.children[child] = struct{}{}
		}
		p.mu.Unlock()
	} else {
		atomic.AddInt32(&goroutines, +1)
                // 程序执行到这里意味着父节点的done成员不是open状态,或者父节点是自定义的context类型。
                // 此时child节点无法被挂载到父节点的成员children中,所以在父节点调用cancel方法进行取消时,
                // 就不能通过遍历其成员children将这里的child节点取消。那么当这个特殊的父节点被取消时,
                // 该如何通知这些孩子节点全部取消呢?
                // 答案就是启动一个goroutine来监听父节点的done成员和child节点的done成员。
                // 若父节点被取消了,就将child节点取消。若child节点被取消了,也就没有必要维系这个goroutine了,直接将goroutine退出
		go func() {
			select {
			case <-parent.Done():
				child.cancel(false, parent.Err())
			case <-child.Done():
			}
		}()
	}
}

总结

有了上面的知识作为基础后,源码剩下的部分就很容易理解了,WithDeadline 内部其实就是尝试创建一个 timerCtx 节点,它与 cancelCtx 相比,无非就是多了几个成员变量,特性就是:一旦到达截止时间,就调用这个 timerCtx 节点的 cancel 方法。

至于 WithValue 方法,这里就不做过多介绍了,不明白这个方法说明对递归理解得不好。

最后总结一下:WithContext 就是根据一个父节点,返回一个子节点和这个子节点的 cancel 方法,一旦调用这个 cancel 方法,这个子节点的成员 done 就会被关闭。这 done 就好比一颗炸弹,而 cancel 就好比能够引爆这颗炸弹的遥控器。而引爆范围则是当前节点以及当前节点的所有子节点,以及所有子节点的子节点,子子节点,子子子节点…

踩过的坑

直接说结论:不要尝试利用 context 来对一个计算密集型操作进行超时控制。

下面代码里的 searchv1 就相当于一个计算密集型操作。虽然 process02 在到达期限时是可以立即退出的,但是由于 main 函数一直阻塞,所以这个 searchv1 是不会在 process02 退出后就立即退出的,也就是说当 context 到达期限时,这个 sarchv1 还是会一直跑下去,直到它把这个计算密集型操作跑完。这个密集型的代码段只要得到执行机会就会一直进行下去,除非主协程退出。根本没有任何办法可以从外部把这个计算密集型操作给终止掉。不信的话,你可以自己挑战一把。

而如果这个 searchv1 是 go 网络库的一个网络调用,那么情况就不一样了。引用一下毛老师的一段话:

对于计算密集型的 goroutine,的确是不好打断它使其中途退出的。对于网络密集型的 context 处理是超级简单的。对于一个正常的业务代码而言,计算密集型的代码绝对不会占用很长时间。我们基本可以默认:计算密集型操作是可以在很短的时间内返回的,比如 1ms 或者 2ms,所以计算密集型的超时控制我们通常是不处理的。而 go 的网络标准库基本全部能够被托管,这就是为什么 go 写业务代码、写中间件能够吊打其他语言的重要原因。

// 模拟一个十分耗时的操作
func searchv1(term string) (string, error) {
	for i := 0; i < 20; i++ {
		fmt.Println("searching")
		time.Sleep(time.Millisecond * 200)
	}
	return "some value", nil
}

func process02(term string) error {
	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()
	ch := make(chan *result)

	// 如果main不退出,而process02协程退出了,那么这个协程是不会退出的
	// 这个serchv1是一定会执行完的,没有任何办法能使得它中途退出
	go func() {
		record, err := searchv1(term)
		ch <- &result{record, err}
	}()

	select {
	// ctx的超时时间过后,ctx.Done()返回的channel中会读到数据
	case <-ctx.Done():
		return errors.New("search error:"+ctx.Err().Error())
	case res := <-ch:
		if res.err != nil {
			return res.err
		}
		fmt.Println("Received:", res.record)
		return nil
	}
}

func TestProcess02(t *testing.T) {
	err := process02("hello")
	if err != nil {
		fmt.Println(err)
	}
	time.Sleep(time.Second*10)
}

关于goroutine 无法从外部关闭,可以认真读读下方的内容。
https://stackoverflow.com/questions/21842963/can-i-force-termination-a-goroutine-without-waiting-for-it-to-return

你可能感兴趣的:(Golang项目学习,golang,开发语言,后端,源码,并发编程)