用golang开发系统软件的一些细节( 四 )


尽量不加锁以生产者-消费者模型为例:如果多个消费者之间可以做到互不关联的处理业务逻辑,那么应该尽量避免他们之间产生关联 。其业务处理过程中需要的各个对象,宜各自一份 。
对数据加锁,而不是对过程加锁拥有JAVA经验的同学要特别小心这一点:JAVA中,在方法上加上个关键字就能实现互斥,但这时非常不好的设计方式 。只需要对并发环境下产生冲突的变量加锁即可,代码及其不冲突的变量都是不必要加锁的 。
更进一步,如果存在多个冲突的变量,且在程序中不同的位置发生冲突,那么可以对特定的一组变量定义一个特定的锁,而不是使用一把统一的大锁来进行互斥——尽量使用多个锁,让冲突进一步减小 。
读多写少的场景考虑读写锁某些读写的场景下,读是可以并发的,而写是互斥的 。这种场景下,读写锁是比互斥锁更好的选择 。
原子操作基础的原子操作技巧 var value int64 = 0 atomic.AddInt64(&value, 1)           // 原子加 atomic.AddInt64(&value, -1)          // 原子减  var n uint64 = 1  atomic.AddUint64(&n, 1)  atomic.AddUint64(&n, ^uint64(0))   // 原子减1,无符号类型,使用反码来减 newValue := atomic.LoadInt64(&value) // 内存屏障,避免乱序执行,并且同步CPU cache和内存 atomic.StoreInt64(&value, newValue) oldValue := atomic.SwapInt64(&value, 0) // 获取当前值,并清零原子操作就能搞定的并发场景,就不要再使用锁 。
自旋锁golang里面哪来的自旋锁?
其实我们可以自己写一个:
var globalValue int64 = 0func xxx(newValue int64){ oldValue := atomic.LoadInt64(&globalValue)  // 相当于使用 memory barrier 指令,避免指令乱序 for !atomic.CompareAndSwapInt64(&globalValue, oldValue, newValue) {  // 自旋等待,直到成功oldValue = atomic.LoadInt64(&globalValue)  // 失败后,说明那一瞬间值被修改了 。需要重新获取最新的值// 其他数值操作的准备 }}以上是无锁数据结构的经典套路 。
atomic.Value: 用于并发场景下需要切换的对象有的对象很基础,可能需要频繁访问,且有时又会发生引用的切换 。比如程序中的全局配置,很多地方都会引用,有时配置更新后,又会切换为最新的配置 。
这种情况下,加锁的成本太高,不加锁又会带来风险 。因此,使用sync.Value来保存全局配置的数据是个不错的选择 。
type Configs map[string]stringvar globalConfig atomic.Valuefunc GetConfig() Configs { v, ok := globalConfig.Load().(Configs) if ok{return v } return map[string]string{}}func SetConfig(cfg Configs){ globalConfig.Store(cfg)}并发容器sync.Map并发map设计得很精巧,用起来也很简单 。不过很可惜,sync.Map没有那么快,要避免将sync.Map用在程序的关键路径上 。
当然,我上述的观点的区分点是:这是业务程序还是系统程序,如果是系统程序,尽量不要用 。我实际使用中发现,sync.Map会导致CPU消耗高,且GC压力增大 。
RoaringBitmap(或类似实现)对某些特定的场景,可以做到很少的锁,很小的内存,比如存储大量UINT64类型的集合这一点,RoaringBitmap是个非常好的选型 。
VictoriaMetrics中有一个RoaringBitmap实现的组件,叫做uint64set 。具体介绍请见:《vm中仿照RoaringBitmap的实现:uint64set》(本人) 。

经验总结扩展阅读