深入理解Golang中Context的使用

作者:

文章目录
  • cancelCtx 是一个结构体,它继承了父 Context,同时自己维护了一套“家谱”关系。 // 源码简化版 type cancelCtx struct { Context // 1. 嵌入父 Context,保证能调用父类方法 mu sync.Mutex // 2. 互斥锁,保证并发安全(保护下面的字段) done chan struct{} // 3. 核心!用来发信号的 Channel children map[canceler]struct{} // 4. 存自己的“孩子”,以便取消时通知它们 err error // 5. 记录取消原因(Canceled 还是 DeadlineExceeded) } done:这是一个 chan struct{}。这利用了 Go 的一个特性:当一个 channel 被 close(关闭)时,所有监听这个 channel 的 goroutine 都会收到信号(读到零值)。 这就是它的“广播”机制。 children:这是一个 Set(用 Map 实现)。当父节点取消时,它需要遍历这个 Map,把所有的子节点也干掉。
  • 当你调用 context.WithCancel(parent) 时,核心逻辑在于如何把自己“挂”到父节点的 children 列表里。 这主要依赖于内部函数 propagateCancel(传播取消)。 func WithCancel(parent Context) (ctx Context, cancel CancelFunc) { c := newCancelCtx(parent) // 创建自己 propagateCancel(parent, &c) // 【关键】把自己挂到父节点上 return &c, func() { c.cancel(true, Canceled) } } propagateCancel 的逻辑如下: 判断父节点状态:如果父节点 parent.Done() 已经是 nil(永远不会取消,比如 Background()),那就不用挂了,因为父亲永远不死。如果父亲已经死了,直接提前 cancel 并返回; done := parent.Done() if done == nil { return // parent is never canceled } select { case <-done: // parent is already canceled child.cancel(false, parent.Err(), Cause(parent)) return default: } 查找祖先:它会尝试找到最近的、也就是标准的 *cancelCtx 类型的祖先。 if p, ok := parentCancelCtx(parent); ok { // parent is a *cancelCtx, or derives from one. p.mu.Lock() if err := p.err.Load(); err != nil { // parent has already been canceled child.cancel(false, err.(error), p.cause) } else { if p.children == nil { p.children = make(map[canceler]struct{}) } p.children[child] = struct{}{} } p.mu.Unlock() return } 判断是否实现了 afterFuncer 接口: if a, ok := parent.(afterFuncer); ok { // parent implements an AfterFunc method. c.mu.Lock() stop := a.AfterFunc(func() { child.cancel(false, parent.Err(), Cause(parent)) }) c.Context = stopCtx{ Context: parent, stop: stop, } c.mu.Unlock() return } 挂载: 如果找到了亲爹(Go 标准库的 Context):加锁,把自己塞到父亲的 children map 里。 if a, ok := parent.(afterFuncer); ok { // parent implements an AfterFunc method. c.mu.Lock() stop := a.AfterFunc(func() { child.cancel(false, parent.Err(), Cause(parent)) }) c.Context = stopCtx{ Context: parent, stop: stop, } c.mu.Unlock() return } 如果父亲是外人(比如自定义的 Context):没办法直接操作它的 map。于是启动一个 Goroutine,在那傻傻地监听 parent.Done()。一旦父亲 Done 了,这个 Goroutine 就负责把自己干掉。 goroutines.Add(1) go func() { select { case <-parent.Done(): child.cancel(false, parent.Err(), Cause(parent)) case <-child.Done(): } }()
  • 源码中的 cancel 方法(简化版逻辑): // removeFromParent: 是否需要把自己从父亲的 map 中移除 func (c *cancelCtx) cancel(removeFromParent bool, err error) { c.mu.Lock() if c.err != nil { // 如果已经取消过了,直接返回 c.mu.Unlock() return } c.err = err // 记录错误原因 // 1. 【核心动作】关闭 channel! // 此时,所有监听 <-ctx.Done() 的协程都会瞬间收到信号 if c.done == nil { c.done = closedchan } else { close(c.done) } // 2. 【递归通知】遍历所有孩子,挨个调用它们的 cancel for child := range c.children { // 递归调用孩子的 cancel 方法 // 注意:这里传 false,意思是孩子不需要从我这里移除(因为我自己都要没了,大家一起销毁) child.cancel(false, err) } c.children = nil // 释放 map,利用 GC 回收内存 c.mu.Unlock() // 3. 【断绝关系】把自己从父亲的 map 里删掉 // 避免父亲一直持有我的引用,导致内存泄漏 if removeFromParent { removeChild(c.Context, c) } }
  • 假设有这样一个调用链:CtxA (根) -> CtxB -> CtxC。 建立连接: CtxB 创建时,将自己放入 CtxA.children。 CtxC 创建时,将自己放入 CtxB.children。 触发取消 (CtxB.cancel() 被调用): 自身:CtxB 关闭自己的 done channel。所有监听 CtxB 的代码收到停止信号。 向下(递归):CtxB 遍历 children,找到 CtxC,调用 CtxC.cancel()。于是 CtxC 也关闭 done channel。 向上(清理):CtxB 调用 removeChild,让 CtxA 把 CtxB 从 map 中删掉。
  • 我们主动调用 cancel 的时候,removeFromParent 是不是传 true ? 回答:是的 当我们我们在业务代码中调用那个由 context.WithCancel(或 WithTimeout、WithDeadline)返回的 cancel() 函数时,传入 removeFromParent 的参数确实是 true。 这一步非常关键,它是防止 Context 树内存泄漏 的核心手段。 我们来看一下源码验证,顺便对比一下什么时候传 false。
  • 主动调用(外部触发):removeFromParent = true。因为只有这一个节点要走,必须精准地从父节点的名单里删掉,防止内存泄漏。 级联取消(内部递归):removeFromParent = false。因为父节点随后会直接清空整个名单,子节点不需要自己去注销。 到此这篇关于深入理解Golang中Context的使用的文章就介绍到这了,更多相关Golang Context使用内容请搜索风君子博客以前的文章或继续浏览下面的相关文章希望大家以后多多支持风君子博客! 您可能感兴趣的文章: 深入Golang之context的用法详解 GOLANG使用Context实现传值、超时和取消的方法 GoLang之使用Context控制请求超时的实现 golang中context的作用详解 详解Golang中Context的三个常见应用场景 golang通过context控制并发的应用场景实现 提升Golang应用性能:深入理解Context的应用 golang context接口类型方法介绍 golang中context使用小结 详解Golang中Context的原理和使用技巧
  • 目录
    • 1 第一步:数据结构 —— 它是怎么长的?
    • 2 第二步:挂载机制 —— 如何与父节点建立联系?
    • 3 第三步:取消机制 —— 信号是如何传播的?
    • 4 总结:Context 的生命周期图解
    • 5 补充
      • 1. 证据:源码中的入口
      • 2. 对比:什么时候传false?
    • 总结:

      理解 cancelCtx 的源码,其实就是理解 Go 语言如何优雅地处理并发控制信号广播

      Go 的 context 源码在 src/context/context.go 中,代码量不多(不到 600 行),但设计非常精妙。核心逻辑可以归纳为三个关键词:挂载(Mount)、广播(Broadcast)、递归(Recursion)

      我们分三步来拆解 cancelCtx 的底层原理。

      cancelCtx 是一个结构体,它继承了父 Context,同时自己维护了一套“家谱”关系。

      // 源码简化版
      type cancelCtx struct {
          Context             // 1. 嵌入父 Context,保证能调用父类方法
          mu       sync.Mutex // 2. 互斥锁,保证并发安全(保护下面的字段)
          done     chan struct{} // 3. 核心!用来发信号的 Channel
          children map[canceler]struct{} // 4. 存自己的“孩子”,以便取消时通知它们
          err      error      // 5. 记录取消原因(Canceled 还是 DeadlineExceeded)
      }
      
      • done:这是一个 chan struct{}。这利用了 Go 的一个特性:当一个 channel 被 close(关闭)时,所有监听这个 channel 的 goroutine 都会收到信号(读到零值)。 这就是它的“广播”机制。
      • children:这是一个 Set(用 Map 实现)。当父节点取消时,它需要遍历这个 Map,把所有的子节点也干掉。

      当你调用 context.WithCancel(parent) 时,核心逻辑在于如何把自己“挂”到父节点的 children 列表里。

      这主要依赖于内部函数 propagateCancel(传播取消)。

      func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
          c := newCancelCtx(parent) // 创建自己
          propagateCancel(parent, &c) // 【关键】把自己挂到父节点上
          return &c, func() { c.cancel(true, Canceled) }
      }
      

      propagateCancel 的逻辑如下:

      1. 判断父节点状态:如果父节点 parent.Done() 已经是 nil(永远不会取消,比如 Background()),那就不用挂了,因为父亲永远不死。如果父亲已经死了,直接提前 cancel 并返回;
      	done := parent.Done()
      	if done == nil {
      		return // parent is never canceled
      	}
      
      	select {
      	case <-done:
      		// parent is already canceled
      		child.cancel(false, parent.Err(), Cause(parent))
      		return
      	default:
      	}
      
      1. 查找祖先:它会尝试找到最近的、也就是标准的 *cancelCtx 类型的祖先。
      	if p, ok := parentCancelCtx(parent); ok {
      		// parent is a *cancelCtx, or derives from one.
      		p.mu.Lock()
      		if err := p.err.Load(); err != nil {
      			// parent has already been canceled
      			child.cancel(false, err.(error), p.cause)
      		} else {
      			if p.children == nil {
      				p.children = make(map[canceler]struct{})
      			}
      			p.children[child] = struct{}{}
      		}
      		p.mu.Unlock()
      		return
      	}
      
      1. 判断是否实现了 afterFuncer 接口
      	if a, ok := parent.(afterFuncer); ok {
      		// parent implements an AfterFunc method.
      		c.mu.Lock()
      		stop := a.AfterFunc(func() {
      			child.cancel(false, parent.Err(), Cause(parent))
      		})
      		c.Context = stopCtx{
      			Context: parent,
      			stop:    stop,
      		}
      		c.mu.Unlock()
      		return
      	}
      
      1. 挂载
      • 如果找到了亲爹(Go 标准库的 Context):加锁,把自己塞到父亲的 children map 里。
      	if a, ok := parent.(afterFuncer); ok {
      		// parent implements an AfterFunc method.
      		c.mu.Lock()
      		stop := a.AfterFunc(func() {
      			child.cancel(false, parent.Err(), Cause(parent))
      		})
      		c.Context = stopCtx{
      			Context: parent,
      			stop:    stop,
      		}
      		c.mu.Unlock()
      		return
      	}
      
      • 如果父亲是外人(比如自定义的 Context):没办法直接操作它的 map。于是启动一个 Goroutine,在那傻傻地监听 parent.Done()。一旦父亲 Done 了,这个 Goroutine 就负责把自己干掉。
      	goroutines.Add(1)
      	go func() {
      		select {
      		case <-parent.Done():
      			child.cancel(false, parent.Err(), Cause(parent))
      		case <-child.Done():
      		}
      	}()
      

      源码中的 cancel 方法(简化版逻辑):

      // removeFromParent: 是否需要把自己从父亲的 map 中移除
      func (c *cancelCtx) cancel(removeFromParent bool, err error) {
          c.mu.Lock()
          if c.err != nil { // 如果已经取消过了,直接返回
              c.mu.Unlock()
              return
          }
          c.err = err // 记录错误原因
      
          // 1. 【核心动作】关闭 channel!
          // 此时,所有监听 <-ctx.Done() 的协程都会瞬间收到信号
          if c.done == nil {
              c.done = closedchan
          } else {
              close(c.done)
          }
      
          // 2. 【递归通知】遍历所有孩子,挨个调用它们的 cancel
          for child := range c.children {
              // 递归调用孩子的 cancel 方法
              // 注意:这里传 false,意思是孩子不需要从我这里移除(因为我自己都要没了,大家一起销毁)
              child.cancel(false, err) 
          }
          c.children = nil // 释放 map,利用 GC 回收内存
          c.mu.Unlock()
      
          // 3. 【断绝关系】把自己从父亲的 map 里删掉
          // 避免父亲一直持有我的引用,导致内存泄漏
          if removeFromParent {
              removeChild(c.Context, c)
          }
      }
      

      假设有这样一个调用链:CtxA (根) -> CtxB -> CtxC

      1. 建立连接

        • CtxB 创建时,将自己放入 CtxA.children
        • CtxC 创建时,将自己放入 CtxB.children
      2. 触发取消 (CtxB.cancel() 被调用):

        • 自身CtxB 关闭自己的 done channel。所有监听 CtxB 的代码收到停止信号。
        • 向下(递归)CtxB 遍历 children,找到 CtxC,调用 CtxC.cancel()。于是 CtxC 也关闭 done channel。
        • 向上(清理)CtxB 调用 removeChild,让 CtxACtxB 从 map 中删掉。

      我们主动调用 cancel 的时候,removeFromParent 是不是传 true ?

      回答:是的

      当我们我们在业务代码中调用那个由 context.WithCancel(或 WithTimeoutWithDeadline)返回的 cancel() 函数时,传入 removeFromParent 的参数确实是 true

      这一步非常关键,它是防止 Context 树内存泄漏 的核心手段。

      我们来看一下源码验证,顺便对比一下什么时候传 false

      当你调用 context.WithCancel 时,源码是这样写的:

      // src/context/context.go
      
      func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
      	c := newCancelCtx(parent)
      	propagateCancel(parent, &c) // 把自己挂到父亲节点
      	
      	// 【注意看这里】
      	// 返回的闭包函数,硬编码了 true
      	return &c, func() { c.cancel(true, Canceled) }
      }
      

      解析: 这里的 func() { c.cancel(true, Canceled) } 就是你拿到的那个 cancel 函数。 当你调用它时,你告诉 cancelCtx:“我已经干完活了(或者想终止了),请把我从父亲的 children 列表中移除。

      如果这里不传 true,父节点就会一直保留着指向这个子节点的引用,直到父节点自己被取消。如果父节点是一个全局的 Background 或者生命周期很长的 Context,那么这些早已死掉的子节点就会一直堆积在内存里,导致内存泄漏。

      cancel 方法内部,当它递归去取消自己的 子节点 时,传的是 false

      // src/context/context.go -> cancel 方法内部
      
      // ... 前面是关闭 channel 的逻辑
      
      // 遍历所有的孩子
      for child := range c.children {
          // 【注意看这里】
          // 父亲取消孩子时,传的是 false
          child.cancel(false, err) 
      }
      c.children = nil // 父亲直接清空整个 map
      c.mu.Unlock()
      
      // 如果 removeFromParent 为 true,才去调用 removeChild
      if removeFromParent {
          removeChild(c.Context, c)
      }
      

      为什么这里传 false?这是一个性能优化的设计。

      想象一下这个场景:

      1. 父节点取消了。
      2. 父节点需要通知它下面的 1000 个子节点也取消。
      3. 父节点遍历这 1000 个子节点,调用它们的 cancel
      4. 关键点:父节点在通知完所有孩子后,紧接着有一行 c.children = nil,它会直接把整个“孩子名册”撕碎丢进垃圾桶。

      如果此时给子节点传 true,那么这 1000 个子节点每一个都会回头去抢父节点的锁(c.Context),试图从父节点的 map 中删除自己。 但在父节点看来:“你们不用一个个来辞职了,反正整个部门我都要裁掉,名单我已经准备销毁了。

      所以,传 false 避免了成百上千次无意义的锁竞争和 Map 删除操作。

      • 主动调用(外部触发)removeFromParent = true。因为只有这一个节点要走,必须精准地从父节点的名单里删掉,防止内存泄漏。
      • 级联取消(内部递归)removeFromParent = false。因为父节点随后会直接清空整个名单,子节点不需要自己去注销。

      到此这篇关于深入理解Golang中Context的使用的文章就介绍到这了,更多相关Golang Context使用内容请搜索风君子博客以前的文章或继续浏览下面的相关文章希望大家以后多多支持风君子博客!

      您可能感兴趣的文章:

      • 深入Golang之context的用法详解
      • GOLANG使用Context实现传值、超时和取消的方法
      • GoLang之使用Context控制请求超时的实现
      • golang中context的作用详解
      • 详解Golang中Context的三个常见应用场景
      • golang通过context控制并发的应用场景实现
      • 提升Golang应用性能:深入理解Context的应用
      • golang context接口类型方法介绍
      • golang中context使用小结
      • 详解Golang中Context的原理和使用技巧

      站内搜索