要做Goroutine级别的存储,首先是要获取到Goroutine的标识,之前提到过获取routine id的两个库,效率也比较低下,用在性能要求比较苛刻的场景下并不适合。
最近看到有个通过go汇编获取goid的方法,https://chai2010.cn/advanced-go-programming-book/ch3-asm/ch3-08-goroutine-id.html。原理其实很简单,golang的内部其实是存储了routineid的,放在了runtime2.go文件的g struct中,只不过这个struct是私有变量,不可以通过外部访问。通过go汇编,我们可以绕过go语言层级带来的障碍,直接访问到对象所在的内存。
获取到goid以后,我们就可以通过map来存储上下文了。
新的问题是,golang的map并不是线程安全,并发的读写会产生问题。实现过程中需要考虑多线程的安全问题。原文提供的方案比较简单,就是在读写过程中对map加互斥锁。进一步的优化方案是使用RWMutex,读map的时候加RLock,写操作加互斥锁,这个也是golang官方推荐的方案,https://blog.golang.org/go-maps-in-action。
golang的sync包下面,也有一套map的同步方案,sync/map.go,那么这个方法又有什么特点,我们该怎么选择呢?看了源码以后,发现这一套方案主要是通过空间换时间的方法来减少锁的使用,内部通过两个map存储数据,老的数据存放在read map,新数据存放dirty map,如果数据特征是一次写入,多次读出,那么多数的读请求都会落入read map,不需要加锁,从而提升并发性能。
那么,实际过程中,这三种方案的性能表现到底如何呢?我对不同的读写比例和不同的并发程度做了benchmark,详情见下图。直接上结论,在读写比例100:1以下时RWMutex方案有绝对优势,更高的读写比例下,sync.Map的方案具有更高的性能;RWMutex和sync.Map的性能在大部分情况下都比单锁的方案高,并发程度越高,优势越明显。
回到我们的场景,上下文存储经常用来处理http server请求相关的数据,减少层层传参。这种场景下,读写比例不会非常高,所以一般来说RWMutex方案是最优的选择。
实现代码和bechmark代码见github,https://github.com/JasonYuan/gls.git。
另,在看sync.Map的过程中,顺便看了下sync.atomic,顾名思义,这是保证各种数据操作原子性的的库。看到一个有意思的事情,在amd64下,大部分的内置类型实际上都可以保证写入的原子性,但是interface类型是不能保证的,原因是interface的内部存储是个struct,同时保存了类型和地址。这时候可以通过atomic.Value来实现,实现过程无锁,可以放心服用。