Contents

Context与链路,如何用Context对抗协程泄漏

关于如何将Context与链路结合,如何使用Context 去对抗可能会阻塞的协程(因io阻塞/因不小心写出了死循环)。 如何在不同的框架模型中建立链路(单物理节点单协程,多物理节点,单物理节点但是多协程)

引言

  1. 为何会引发链路与Context结合的讨论: 在我工作的公司,有一个大拿提出了在项目中引入链路id的概念。虽然以前在使用goframe的时候有使用过这个东西,但是goframe是web框架的。在游戏框架中引入这一概念对我来说非常新颖,加之大拿引入的链路偏web向,所以我觉得是有使他更适合我们当前游戏框架的优化空间的。
  2. 为何会引发使用Context去对协程泄漏的思考: 使用golang有差不多2年了,一直有个疑惑——golang不提供从协程外部强制退出协程的能力,那么正在线上狂奔的程序就会有概率遇到无法退出协程的情况。对于一个完备的语言,没有相关的措施是不太合理的。一个月前偶然一次询问ai+国庆前两天的处理链路的思考让我突然想明白了这个问题。

Context

Context是Go语言中的一个标准库,用于在程序中传递请求范围的值、取消信号和截止时间。它可以用于控制goroutine的生命周期,避免资源泄漏和提高程序的可读性和可维护性。在并发编程中,Context是一个非常有用的工具,可以帮助我们优雅地处理并发请求。

在golang中, Context 是一个树形结构,每个context都可以有一个父Context和多个子Context。当一个Context被取消的时候,他所有的子Context都会被取消。基于这种树形结构设计,我们有了管理并发请求,避免goroutine泄漏的能力。

当下使用golang的作为游戏框架语言的公司并不多,广州应该还是一水的lua。而正在用golang作为框架语言的,很可能也是当初那帮写lua的大拿。这让我发现了一个问题,就是对于context的应用其实并不多(具体原因会比较复杂,这里就不讨论了)。但是context可以说在控制goroutine的生命周期,和一些特殊场景下无法绕开的话题。

Context能做到的事情有以下这几个方面

  1. 控制goroutine的生命周期,可以在需要的时候取消goroutine的执行。
  2. 传值,可以使用context在整个请求的链路中传递context(context可以被塞入键值对),避免函数之间传递大量的参数。
  3. 控制请求处理超时,给context设置截止时间,超过指定时间以后,自动取消goroutine的执行()

链路

链路是一个请求发起之后->请求处理完毕之后,所经历的所有函数/远程消息调用形成的路径。但是单纯的链路只是一个概念,具有讨论意义的是可观测的链路,下文中如果不特殊说明,我们说的链路就指代可观测链路。

链路的可观测性在性能,和日志分析上有非常大的意义。

  1. 性能: 我们可以在每个链路节点产生和消亡/去往下一个节点的时候计算节点与节点之间传递所消耗的时间。通过观察所有节点间传递消耗的时间,我们可以找到性能瓶颈存在于链路的那个环节
  2. 日志: 处理过日志的伙伴应该都知道,日志没有根据用户id/链路id进行标志的话,日志是难以进行分析的 (因为日志过于杂乱)。有了链路id后,我们就可以很方便的通过过滤收集到整个链路的日志。

观测链路是有一些方式的

  1. 直接使用日志进行纪录
  2. 搭建专门的链路收集服务。

第一种方式会相对简单,易于实现,坏处是如果有多个物理节点,就需要挨个去差日志。第二种方式会相对重量级一些,好处是,有专门的服务收集链路,链路的查看会更加集中,有更好的拓展性。

需要注意的是如果存在不止一个物理节点,还需要在各种中间件上想办法嵌入链路id和物理节点id,以防止不知道链路的某一环究竟跑在哪一台物理节点上了。

Context+链路如何与日志结合

单物理节点单协程处理方式:

前面有提到过Context可以用于传值, 我们可以在收到请求后,生成链路的id,塞入Context进而一层一层的在整个链路中传递。

一种做法是我们可以将Log封在Context中。这里只是给出思想,并配上简单的代码说明。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
type fooContext struct {
    traceId string // 为了叙述方便这里并没有用到context提供的接口
}

func (c *fooContext) TraceId() string {
    return c.traceId
}

func (c *fooContext) LogError(format string, args ...interface{}) {
    fmt.Println("[%d:] "+format, c.TraceId(), args...) //自动在log中拼接上链路的id
}

单物理节点但是多协程处理方式

如果逻辑需要分配到多个协程处理,可以基于当前的Context 派生多个Context到各个协程中去。

context.WithValue这个接口会自动拷贝父Context的所有Value到生成的子Context中, 如果在Context中没有指针或者引用类型,可以粗暴共享,反之就不可以。在有指针/引用类型的情况下可能会需要借用不可变原则去设计协程间数据流转的方法。

另外是,如果是多协程,有必要的情况下,还可以附带协程的id/name, 方便更好的分析。

多物理节点处理方式

多物理节点需要在跨物理节点的时候,想办法把链路信息跨到另一个物理节点去。可以考虑改造rpc,或者协议封装附带的方式。

在物理节点内部可以参考单节点的处理方式和注意点。

Context 要如何与协程泄漏对抗

首先需要讨论为何goroutine会泄漏。可能有些小伙伴会说,因为go不提供强制关闭goroutine的能力,所以才会泄漏。但是在我了解到的是,这么设计是基于一些原因的(我不说,你别问,因为我也是不甚了解)。先看下这两种情况

  1. io阻塞(这里用chan模拟以下这种场景)
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    
    func doSomething(foo chan struct{}) {
        for {
            _ := <- foo // 没人往foo中写数据的时候,这里会阻塞
            if err != nil {
                fmt.Println(err)
                return
            }
            time.Sleep(time.Second)
        }
    }
    
    func main() {
        fooChan := make(chan struct{})
        go doSomething(fooChan)
    
        // 等待一段时间,以便doSomething协程可以执行一段时间
        time.Sleep(5 * time.Second)
    }
    
  2. 有个叼毛写了死循环
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    
    func doSomething() {
        for {
            // 死循环
        }
    }
    
    func main() {
        go doSomething()
    
        // 等待一段时间,以便doSomething协程可以执行一段时间
        time.Sleep(5 * time.Second)
    }
    

上面两种情况,都反应了一个问题。在编写这段代码的时候,没有好好的处理阻塞这个异常情况,所以才会导致泄漏。在第二中情况下,有些老鸟甚至会告诉你你可以写一个会循环很多次的循环,但是不要写一个有可能真的无法退出的循环。我猜测,go在设计的时候,也认为这种问题应该由编码的人自行妥善处理,而不是由语言提供粗暴的强制关闭。

前面有提到过, 在golang中, Context 是一个树形结构,每个context都可以有一个父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
func doSomething(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            // 收到取消信号,退出goroutine的执行
            return
        default:
            // 执行任务
        }
    }
}

func main() {
    // 创建一个带有取消信号的context
    ctx, cancel := context.WithCancel(context.Background())

    // 启动goroutine
    go doSomething(ctx)

    // 休眠10秒
    time.Sleep(10*time.Second)

    // 在需要的时候取消goroutine的执行
    cancel()
}

需要解释的是context.Done() 这个接口会返回一个chan, 这个chan 在context.WithCancel(context.Background) 所返回的cancel()函数被调用的时候会被隐式的写入一个信号。

利用信号监听,在每一个循环开始的时候,都进行ctx.Done()的监听, 如果有收到取消信号,就代表外面有人通知该协程需要退出了。

如果想要避免goroutine泄漏,我们就必须遵守context的这种约定。否则,golang中并没有提供强制关闭的方法(强制关闭并非一个好方法)。一切都需要在编写程序的时候自觉遵守处理ctx.Done()的约定。目前我了解到的需要处理的场景是

  1. 在处理阻塞的io的时候
  2. 当在书写可能会死循环的循环的时候。

协程阻塞检测

在国庆之前在和我的老大讨论防止协程泄漏的问题, 当时我们讨论到了利用一个协程监测器来查看协程是否泄漏。但是只是检测并不能解决协程泄漏的问题。基于本篇文章思考后,我想到可以将对应协程的context生成的cancel上交给检测器,当检测器判定协程泄漏后,直接利用cancel() 通知协程退出,从而最大可能的避免死循环/阻塞io站着茅坑不拉屎。

总结

  1. 就像避免内存泄漏那样, go想要避免goroutine泄漏,并没有非常保险的方法。go将这种健壮性的保证交给的编码的人来解决。
  2. 链路的问题我们可以利用context来处理。
  3. 链路的设计要合理,最好一个请求一个链路id, 链路的信息要完善,否则会有丢失环节的风险。
  4. 涉及多个物理节点,还要考虑改造rpc接口,以附带链路信息。
  5. 警惕多个协程共享context的Value会导致panic的情况。
  6. 不只是context,chan的传值最好也遵循不可变原则,否则会有panic的风险。