1 内存优化

1.1 小对象合并成结构体一次分配,减少内存分配次数

做过 C/C++ 的同学可能知道,小对象在堆上频繁地申请释放,会造成内存碎片(有的叫空洞),导致分配大的对象时无法申请到连续的内存空间,一般建议是采用内存池。Go runtime 底层也采用内存池,但每个 span 大小为 4k,同时维护一个 cache。cache 有一个 0 到 n 的 list 数组,list 数组的每个单元挂载的是一个链表,链表的每个节点就是一块可用的内存,同一链表中的所有节点内存块都是大小相等的;但是不同链表的内存大小是不等的,也就是说 list 数组的一个单元存储的是一类固定大小的内存块,不同单元里存储的内存块大小是不等的。这就说明 cache 缓存的是不同类大小的内存对象,当然想申请的内存大小最接近于哪类缓存内存块时,就分配哪类内存块。当 cache 不够再向 spanalloc 中分配。

建议:小对象合并成结构体一次分配,示意如下:

  1. for k, v := range m {
  2. k, v := k, v
  3. go func() {
  4. }()
  5. }

替换为:

  1. for k, v := range m {
  2. x := struct {k , v string} {k, v}
  3. go func() {
  4. }()
  5. }

1.2 缓存区内容一次分配足够大小空间,并适当复用

在协议编解码时,需要频繁地操作[]byte,可以使用 bytes.Buffer 或其它 byte 缓存区对象。

建议:bytes.Buffert 等通过预先分配足够大的内存,避免当 Grow 时动态申请内存,这样可以减少内存分配次数。同时对于 byte 缓存区对象考虑适当地复用。

1.3 slice 和 map 采 make 创建时,预估大小指定容量

slice 和 map 与数组不一样,不存在固定空间大小,可以根据增加元素来动态扩容。

slice 初始会指定一个数组,当对 slice 进行 append 等操作时,当容量不够时,会自动扩容:

  • 如果新的大小是当前大小 2 倍以上,则容量增涨为新的大小;
  • 否而循环以下操作:如果当前容量小于 1024,按 2 倍增加;否则每次按当前容量 1/4 增涨,直到增涨的容量超过或等新大小。

map 的扩容比较复杂,每次扩容会增加到上次容量的 2 倍。它的结构体中有一个 buckets 和 oldbuckets,用于实现增量扩容:

  • 正常情况下,直接使用 buckets,oldbuckets 为空;
  • 如果正在扩容,则 oldbuckets 不为空,buckets 是 oldbuckets 的 2 倍,

建议:初始化时预估大小指定容量

  1. m := make(map[string]string, 100)
  2. s := make([]string, 0, 100)

1.4 长调用栈避免申请较多的临时对象

goroutine 的调用栈默认大小是 4K(1.7 修改为 2K),它采用连续栈机制,当栈空间不够时,Go runtime 会不动扩容:

  • 当栈空间不够时,按 2 倍增加,原有栈的变量崆直接 copy 到新的栈空间,变量指针指向新的空间地址;
  • 退栈会释放栈空间的占用,GC 时发现栈空间占用不到 1/4 时,则栈空间减少一半。

比如栈的最终大小 2M,则极端情况下,就会有 10 次的扩栈操作,这会带来性能下降。

建议:

  • 控制调用栈和函数的复杂度,不要在一个 goroutine 做完所有逻辑;
  • 如查的确需要长调用栈,而考虑 goroutine 池化,避免频繁创建 goroutine 带来栈空间的变化。

1.5 避免频繁创建临时对象

Go 在 GC 时会引发 stop the world,即整个情况暂停。虽 1.7 版本已大幅优化 GC 性能,1.8 甚至量坏情况下 GC 为 100us。但暂停时间还是取决于临时对象的个数,临时对象数量越多,暂停时间可能越长,并消耗 CPU。

建议:GC 优化方式是尽可能地减少临时对象的个数:

  • 尽量使用局部变量
  • 所多个局部变量合并一个大的结构体或数组,减少扫描对象的次数,一次回尽可能多的内存。

2 并发优化

2.1 高并发的任务处理使用 goroutine 池

goroutine 虽轻量,但对于高并发的轻量任务处理,频繁来创建 goroutine 来执行,执行效率并不会太高效:

  • 过多的 goroutine 创建,会影响 go runtime 对 goroutine调度,以及 GC 消耗;
  • 高并时若出现调用异常阻塞积压,大量的 goroutine 短时间积压可能导致程序崩溃。

2.2 避免高并发调用同步系统接口

goroutine 的实现,是通过同步来模拟异步操作。在如下操作操作不会阻塞 go runtime 的线程调度:

  • 网络 IO
  • channel
  • time.sleep
  • 基于底层系统异步调用的 Syscall

下面阻塞会创建新的调度线程:

  • 本地 IO 调用
  • 基于底层系统同步调用的 Syscall
  • CGo 方式调用 C 语言动态库中的调用 IO 或其它阻塞

网络 IO 可以基于 epoll 的异步机制(或 kqueue 等异步机制),但对于一些系统函数并没有提供异步机制。例如常见的 posix api 中,对文件的操作就是同步操作。虽有开源的 fileepoll 来模拟异步文件操作。但 Go 的 Syscall 还是依赖底层的操作系统的 API。系统 API 没有异步,Go 也做不了异步化处理。

建议:把涉及到同步调用的 goroutine,隔离到可控的 goroutine 中,而不是直接高并的 goroutine 调用。

2.3 高并发时避免共享对象互斥

传统多线程编程时,当并发冲突在 4~8 线程时,性能可能会出现拐点。Go 中的推荐是不要通过共享内存来通讯,Go 创建 goroutine 非常容易,当大量 goroutine 共享同一互斥对象时,也会在某一数量的 goroutine 出在拐点。

建议:goroutine 尽量独立,无冲突地执行;若 goroutine 间存在冲突,则可以采分区来控制 goroutine 的并发个数,减少同一互斥对象冲突并发数。

3 其它优化

3.1 避免使用 CGO 或者减少 CGO 调用次数

GO 可以调用 C 库函数,但 Go 带有垃圾收集器且 Go 的栈动态增涨,但这些无法与 C 无缝地对接。Go 的环境转入 C 代码执行前,必须为 C 创建一个新的调用栈,把栈变量赋值给 C 调用栈,调用结束现拷贝回来。而这个调用开销也非常大,需要维护 Go 与 C 的调用上下文,两者调用栈的映射。相比直接的 GO 调用栈,单纯的调用栈可能有 2 个甚至 3 个数量级以上。

建议:尽量避免使用 CGO,无法避免时,要减少跨 CGO 的调用次数。

3.2 减少[]byte 与 string 之间转换,尽量采用[]byte 来字符串处理

GO 里面的 string 类型是一个不可变类型,不像 c++ 中 std:string,可以直接 char*取值转化,指向同一地址内容;而 GO 中[]byte 与 string 底层两个不同的结构,他们之间的转换存在实实在在的值对象拷贝,所以尽量减少这种不必要的转化

建议:存在字符串拼接等处理,尽量采用[]byte,例如:

  1. func Prefix(b []byte) []byte {
  2. return append([]byte("hello", b...))
  3. }

3.3 字符串的拼接优先考虑 bytes.Buffer

由于 string 类型是一个不可变类型,但拼接会创建新的 string。GO 中字符串拼接常见有如下几种方式:

  • string + 操作 :导致多次对象的分配与值拷贝
  • fmt.Sprintf :会动态解析参数,效率好不哪去
  • strings.Join :内部是[]byte 的 append
  • bytes.Buffer :可以预先分配大小,减少对象分配与拷贝

建议:对于高性能要求,优先考虑 bytes.Buffer,预先分配大小。非关键路径,视简洁使用。fmt.Sprintf 可以简化不同类型转换与拼接。


参考:

  1. Go 语言内存分配器 - FixAlloc
  2. https://blog.golang.org/strings
    https://www.cnblogs.com/zhangboyu/p/7456609.html