Go 高手技法

多分支映射的声明

开发中有种常见的 多分支路由 需求,即定义一组条件和对应逻辑,判断传入参数值,并路由到对应分支逻辑上执行。通常我们会用 switch case 语句来处理,比如下面是个格式化 Level 的例子:

type Level int

const (
    LevelA Level = 1
    ...
    LevelC Level = 3
)

func (l Level) Format() string {
    switch l {
    case LevelA:
        return "A"
    ...
    case LevelC:
        return "C"
    }
    return "" 
} 

由于 switch case 是顺序判读条件的,当分支数量变多时(大约八个以上),switch case 效率会下降。考虑到分支数量的可扩展性,多数有经验的工程师会使用 map 来代替 switch case 做条件映射。比如上例可改为:

如果 const 定义为数字并且数值不大,还可以使用 slice 来代替 map,比上述两种做法更好。Go 源码里也有类似做法,如 syscall.Errno 的实现等。对于上例,可以修改为:

刀法灵活的切片

容量声明

  1. 使用 var 声明切片不会开辟内存空间;直接赋空值会分配内存空间,后续的扩容会额外重新分配内存。

  1. 声明切片时,可以预留冗余容量,这样当调整切片大小时,仍然是基于当前物理地址操作,而不会开辟新的内存空间。

下标操作

  1. 切片不能越界访问超过 len 的下标,但是可以调整切片大小,灵活访问

  1. 结合 append 可以实现在数组指定位置插入或者删除元素,而不占用新的存储空间

  1. 共享同一块内存地址的多个切片,内容相互可见;但是如果 append 导致了扩容,新创建的切片将无法共享内存

unsafe.Pointer 最佳实践

根据定义知道以下几点:

  • []byte 与 string 在定义上的区别仅在于多一个 cap 属性

  • string 的 str 指向的是内容是不可修改的,每更改一次 string 的值,就要重分配一次内存

那么思考一下,如果我们可以 保证 []byte 是只读的,比如做反序列化、打日志等,符合不修改的定义,是不是可以不用复制,直接零成本和 string 互转呢 ?答案是可以,通过 unsafe.Pointer 可以实现,展示如下:

通过这种方式进行类型转换,可以规避大量的 copy、内存分配和 GC 开销,在处理大量数据流、序列化等场景下收益十分可观。但是一定要注意:必须保证 []byte 不会被修改。

字符串拼接速度对比

格式化优化技巧

以打印日志举例,常常会见到使用 %v%+v%#v 来打印 struct 类型的参数,但是这种方式会带来大量反射,计算开销是惊人的,其效率甚至低于 json.Marshal

对于这种情况,最佳实践是在 struct 上实现 .String() 方法,并使用 %s 占位,来实现 format;当然即使是偷懒,也建议使用 json.Marshal 处理一下,效果要比 %v 好。

Bad Case:

Good Case:

Lazy Case:

编译后执行效率优化

goto 减少 cache miss

在 Go 开发中,频繁出现的 if 条件分支会增大编译产物体积,编译后分支逻辑太多,CPU 执行容易导致 cache miss,而因此更新 cache line 会短暂的中断 CPU 计算,影响效率。

针对这一点,如果代码中有大量的 if return 逻辑,我们推荐使用 goto 语法来减少编译产物的体积,从而减少 cache miss。以下举例展示

Bad Case:

Good Case:

传值与传指针对比

在 Go 程序中,管理内存同时使用 栈 和 堆 两种数据结构,简单来说,栈一般用来存放临时变量,内存分配和释放较快;堆一般用来生命周期不确定的变量,内存分配慢,还有 GC 代价,因此 堆变量 比 栈变量 开销大很多。

当变量具有指针对象时,内存会逃逸到堆上,从这个角度考虑,传指针传值 开销更大;另一方面,传值传指针 复制了更多的数据。两方面综合看,传值 or 传指针 比较的其实是 复制开销堆开销 哪个更小。因此我们可以给出最佳实践:

  1. 如果参数本身数据量不大,复制速度更快,那么传值更优;

  2. 如果参数本身数据量大,复制慢,那么传指针效率更高。

以下展示一些对比 case:

Case 1: struct 较小时,应直接传递值

Case 2: slice 较小时,可以定义为数组

相辅相成的协程

协程复用

每个 Goroutine 初始 stack size 为 2KB ,在协程内调用函数时,会通过 morestack 判断是否需要栈扩张,如需要,则需要调用 copystack 拷贝完整的栈。

而在现实中,大部分线上程序的运行模式都有一定的固定规律,例如一个 Server 所有请求都会经过一系列固定的函数调用链,而如果这些函数最终会导致 stack 被固定扩张到 8KB,那么这个程序每一次请求都会重复调用 copystack 。基于此,我们希望每一个请求都能尽可能复用前一个请求已经扩张好栈的 Goroutine:

以上是一个最简单的 Goroutine 池实现,我们并未考虑超时销毁等逻辑。如果该系统的并发度是 100 ,那么我们仅需要常驻 100 个 Goroutine 便可以完成所有任务。

协程协作

Go 社区有一句金玉良言是「不要通过共享内存来通信,而应该通过通信来共享内存」。由于协程可以轻量切换上下文的,我们可以轻易地通过通信的方式将内存对象“传输”给其他需要使用的协程,而无需通过加互斥锁的方式来保证并发安全性。

在并发编程中,生产者消费者模型是最常见的编程场景,我们就以此举例,来看看在 Go 中如何达成多协程并发协作的目的。利用 Go 语言内置的 channel 数据结构,我们仅需几行代码就能够实现一个最简单的消息队列:

简单的生产者消费者模型

批量消费

基于 Channel 实现的消息队列有一个缺陷是获取一个元素的代价过大,且无法一次操作就获得全部的元素。在许多场景中我们希望能够批量消费当前所有元素。此时我们可以利用加锁的链表实现消息队列,同时利于 Channel 作为协程间的信号通知器:

提升消费效率

在上面的实现里,我们的消费者 Goroutine 每当队列中有 1 条消息,就有可能会唤醒执行。而在有些场景下,我们希望消费者能够积蓄一定数量的消息再进行批量处理,典型的场景如异步的日志写入,数据的合并发送等。最简单的做法是每次 Sleep 一个窗口时间去积累消息发送,但这样做的代价是,即便后续没有任何数据需要写入,我们整体消费速度都会被拖长到至少一个窗口时间。

在这种情况下,我们可以巧用 runtime.Gosched() 实现有限度的延迟等待:

在程序处于繁忙状态时,会有大量(假设为 N 个) Goroutine 不停调用 Produce 函数,而消费者的 Goroutine 仅有 1 个,假设每个 Goroutine 的调度机会相同,那么消费者 Goroutine 每获得一次调度机会,意味着生产者 Goroutine 至少已经生产了 N 条消息,这样便批量消费至少 N 条消息(理想情况下)。

而如果该模型处于空闲状态下,生产者的 Goroutine 数量非常少,而此时即便消费者调用 runtime.Gosched(),也能够很快由于没有其他 Goroutine 需要被调度而获得执行机会,从而尽快地消费。

精打细算的锁

降低锁粒度

假设我们需要实现一个网站浏览量计数器,其中 UV 是不太容易变更的变量,PV 是高频变更的变量。我们可以实现如下的代码:

上述代码将两个不同访问频率的变量都用同一个锁控制,加大了锁冲突的概率,尤其是对于不频繁改变的 UV 变量来说增加了没必要的锁冲突。所以我们可以将这两个变量用不同的锁来保护:

分片锁

既然并发时使用同一个变量容易产生冲突,那么我们还有另一个思路是在不同情况下去使用不同的变量以减少冲突。分片锁就是这样的实现。

分片锁顾名思义要求该场景首先是要能够被分片的,典型场景如 Map:

这里我们将分片数量设置为 Go runtime 中 Process 的数量,每次 Set 时,都根据 key 计算属于哪一个分片,然后只去修改该分片的对象。理论上我们能够将锁冲突的概率降低到分片数分之一。

追求极致的 No GC

复用对象

我们假设某个程序每 1 ms 会创建一个新对象,每个对象会被使用 10 ms 的时间,那么该程序每秒会创建 1000 个对象,而同一时刻,系统内仅仅只有 10 个对象正在被使用。在这个例子中我们很容易发现,我们完全可以仅创建 10 个对象,使它们反复被程序使用,这样就大大降低了创建的对象数,从而大大降低了 GC 扫描对象的成本。

不过在实际编程时,我们往往还要考虑到多个协程间并发申请对象的问题。好在 Go 标准库为我们贴心地提供了 sync.Pool 来实现该需求:

对于我们想要复用的对象,我们只需要为其实现 Recycle() 方法,在程序确定对象不再需要被使用时,调用 .Recycle() ,便可将其重置并释放,留给程序下一次需要对象时使用。

需要注意的是,对象能够被复用的前提是我们能够精确控制对象的使用生命周期,如果我们提前释放了一个正在其他地方被使用的对象,有可能引发不可预料的错误。

优雅的错误处理

if err != nil 判断逻辑封装到 Write 方法中,使代码清晰易读。

最后更新于