Context与协程局部存储

最近,公司devops团队搭建了全链路追踪平台,并且提供了相应的SDK供各个业务团队接入。看着团队的基础设施逐步建设完善,技术同学们的心里也是十分欣喜,于是各团队都积极展开了全链路的接入工作。

本文就是简单的聊一下我们Team在接入全链路的过程中用到的一些技术手段。

浅谈全链路追踪

互联网公司的后端服务架构通常都非常复杂,尤其是在分布式集群、微服务化盛行的今天。一个请求的处理通常涉及多个业务模块,数据要在多个服务间流转,这就使得问题定位、性能优化等变得更加困难。我们常常需要收集一个请求的完整调用链路,于是全链路追踪系统应运而生。

公司的链路追踪系统基于Uber开源的Jaeger搭建,因为之前已经有了比较完善的ELK日志平台,所以链路追踪并没有使用基于agent的部署方式,而是自己实现了符合opentracing规范的SDK。tracing SDK会打印trace日志,进而被已有的日志收集系统自动收集,再经过聚合处理后展示于Jaeger UI。

opentracing定义了Trace、Span等核心概念。Span可以认为是一个逻辑操作单元,Trace则是一个完整的链路,是由一系列Span构成的一个有向无环图。Span与Span之间有两种关系,一是ChildOf,二是FollowsFrom。

链路追踪SDK的主要功能包括:从请求头中提取Trace相关信息、将Trace信息加入到请求头中、基于Trace信息组装Span、基于已有Span创建Child或Follow关系的Span,以及Finish一个Span。

我们团队的后端服务并存新老两套架构,老架构已经维护了很长一段时间,新架构是最近刚刚完成开发上线的,灰度了一小部分流量。现在这两套系统都需要接入全链路追踪,目前来看对业务代码的侵入性不可避免。

我们遇到的问题就是,如何在最小化代码改动的前提下,将Trace信息在单个服务内传递。新架构是后来设计的,采用了golang建议的传递Context的方式,所以我们可以把Trace信息放到Context中,很方便的就实现了向后传递。而老架构因为时间较早,并没有这方面的考虑,各个模块间只传递了必要的业务数据,可扩展性稍差一些,为了最小化代码改动,我们最终决定通过goroutine local storage机制来传递Trace信息。

context.Context

golang的context包定义了Context类型,根据官方文档的说法,该类型被设计用来在API边界之间以及过程之间传递截止时间、取消信号及其他与请求相关的数据。Context实际上是一个接口,提供了4个方法:

1
2
3
4
5
6
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
  1. Deadline用来返回ctx的截止时间,ok为false表示没有设置。达到截止时间的ctx会被自动Cancel掉;
  2. 如果当前ctx是cancelable的,Done返回一个chan用来监听,否则返回nil。当ctx被Cancel时,返回的chan会同时被close掉,也就变成“有信号”状态;
  3. 如果ctx已经被canceled,Err会返回执行Cancel时指定的error,否则返回nil;
  4. Value用来从ctx中根据指定的key提取对应的value;

为了方便我们使用Context,context包还实现了一组函数:

1
2
3
4
5
6
7
func Background() Context
func TODO() Context

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithValue(parent Context, key, val interface{}) Context
  1. Background函数会返回一个没有Deadline、没有Value,也不能被Cancel的Context,它通常在一个请求的初始化阶段被用作最顶层的根Context;
  2. TODO函数会返回一个和Background类型相同的Context,官方文档建议在本来应该使用外层传递的ctx而外层却没有传递的地方使用,就像函数名的含义一样,留下一个TODO;
  3. WithCancel函数会基于传入的parent Context创建一个可以Cancel的ctx,与cancel函数一起返回。调用cancel函数就会将这个新的ctx Cancel掉,所有基于此ctx创建的子孙Context也会一并被Cancel掉;
  4. WithDeadline内部会创建一个可以Cancel的ctx,并且为该ctx设置一个超时时间,然后与一个cancel函数一并返回。如果用户代码没有主动调用cancel函数,则ctx会在超时时间到达后自动Cancel;
  5. WithTimeout只是WithDeadline的一个wrapper,接受一个相对时间的参数,使用当前时间加上相对时间来设置Deadline;
  6. WithValue将传入的parent Context和key、val打包成一个新的ctx,可以通过后者的Value方法和key来提取对应的val;

golang对Context接口的具体实现基于如下几种类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type emptyCtx int

type cancelCtx struct {
Context
mu sync.Mutex // 保护其他字段
done chan struct{} // 延迟创建,被第一个cancel close
children map[canceler]struct{} // 被第一个cancel设为nil
err error // 被第一个cancel赋值(非nil)
}

type timerCtx struct {
cancelCtx
timer *time.Timer // 被cancelCtx.mu保护(并发保护)
deadline time.Time
}

type valueCtx struct {
Context
key, val interface{}
}
  1. emptyCtx类型只是实现了Context接口,几个方法都只是简单的返回nil、false等,实际上什么也不做。Background和TODO返回的Context,实际上都是emptyCtx;
  2. cancelCtx类型可以说是最核心的一个类型,它实现了Cancel操作和信号机制,以及Context父子关系关联,从而支持在父Context Cancel时同步Cancel所有子Context;
  3. timerCtx类型用来支持Deadline、Timeout,Cancel操作依赖于内置的cancelCtx;
  4. valueCtx类型用来支持key、val打包,结合内置的Context字段,实际上构造了一个单链表节点;

上述的WithCancel、WithDeadline和WithValue函数,内部就是分别构造了cancelCtx、timerCtx和valueCtx这3种结构,从而将层层Context像链表一样串起来。因为一个父ctx可以有多个子ctx,最终所有的ctx将构成类似一棵树的结构,在根节点执行cancel就可以Cancel整个棵树,所以非常适合用来控制请求处理。

因为WithValue会在ctx链式结构中新增一个valueCtx节点,里面只存一个键值对,所以频繁的使用WithValue来向ctx中添加大量kv是很低效的,必要的话可以考虑向ctx中添加一个map。

goroutine local storage

在使用C/C++编写程序的时候,经常会用到线程局部存储tls,即thread local storage。tls给我们带来了极大的方便,再也不用将一些参数从头传到尾,直接存放到tls中,需要的时候再从中读取即可。像Win32 API、pthread等,都提供了tls相关的API,使用起来非常方便。

golang提供了非常方便易用的协程特性,通过go关键字即可直接创建一个goroutine。但是却没有提供任何用来获取当前goroutine的ID、句柄的函数或方法。看起来语言的设计者并不想暴露goroutine的太多底层细节,虽然你会说golang是开源的,我所指的“暴露底层细节”指的是像公布API一样的承诺。API一旦公布,就不能再随意变更,所以会对后续的升级更新造成很大影响。golang处于快速发展中,很多特性的底层实现可能会在未来版本中发生巨大变化,所以golang的开发团队不会提供“太底层”的API。

然而要实现goroutine local storage,后面简称gls,就必须能够得到每个goroutine的唯一ID。如何得到呢?不妨回想一下,在go服务进程发生panic的时候,runtime能够打印出每个goroutine的调用栈,其中每个goroutine都有一个唯一的数字编号,如果能够拿到这个编号,或许就能用来实现我们想要的gls。

接下来就需要稍微分析一下源代码。golang的G、P、M模型大家都应该比较熟悉了,在g结构中就有一个名为goid的int64字段,存储的就是我们想要的goroutine唯一ID。

1
2
3
4
5
type g struct {
...
goid int64
...
}

为了获取到这个字段的值,需要用到反射技术,还要用上一点汇编语言。汇编代码中,从当前线程的tls拿到m指针,再通过m拿到g指针,然后再通过反射拿到goid字段,具体实现参见goid。拿到这个goid之后,我们就可以基于一个如下类型的map实现gls:

1
var gls map[int64]map[string]interface{}

map的key为int64类型,即goroutine的ID,value的类型是一个map[string]interface{},用来为单个goroutine实现kv存储。除了外层这个map存在并发访问外,内层map只会有单个goroutine访问,所以只需对外层map加锁。

使用gls时有一点需要格外注意,那就是在goroutine结束前一定要清除自身ID对应的存储空间,否则就会造成内存泄漏。感兴趣的同学可以尝试一下笔者实现的gls,在一些场景下确实非常方便。对于新的项目,还是建议使用Context,那才是正途。

本文就先到这里,如果有错误,望不吝指正。最后,祝大家五一节日快乐!