golang中你应该知道的常见优化手段

Golang性能优化:改进代码的技巧

golang golang-advanced-02(from github.com/MariaLetta/free-gophers-pack)|300

什么是性能优化?

性能优化是一种通过改进代码和系统的性能,使其运行得更快、更高效的过程。性能优化不仅仅是让程序更快地运行,还包括在不同的资源(如 CPU、内存)之间找到平衡,确保程序在各种情况下都能有效地运行。

在性能优化的过程中,通常会涉及到以下几个方面:

  1. CPU 的利用:确保程序能够有效地利用 CPU 资源。
  2. 内存的使用:优化内存的分配和使用,减少不必要的内存消耗。
  3. 输入输出的效率:提升 I/O 操作的效率,如文件读写、网络通信等。
  4. 并发处理:通过有效地并发处理机制,提升程序的吞吐量和响应速度。

当多个协程协作时,难免会遇到要读写同一块内存的情况,此时我们通常会要引入锁操作。而在并发编程时,锁往往会成为一个系统的性能瓶颈所在,这需要我们对所有用到锁的地方精打细算,最小化减少引入锁产生的开销。

面向 CPU 的优化

在编写高性能的 Go 语言程序时,理解并优化 CPU 的使用是至关重要的。面向 CPU 的优化主要包括以下几个方面:

  1. 缓存友好性

缓存友好性是指如何使代码访问内存的方式与 CPU 缓存的行为相匹配,以便提升性能。以下是一些实现缓存友好性的方法:

  • 连续内存分配: 尽量使用连续的内存块,这样可以有效利用 CPU 的缓存行。
  • 减少缓存不命中: 避免对大数组进行随机访问,因为这会导致频繁的缓存不命中。

例子: 如果你有一个数组,需要对其进行大量的遍历操作,最好将其设计为连续内存块,而不是分散的节点。

  1. 减少锁竞争

在并发编程中,锁是不可避免的,但频繁的竞争会导致性能下降。以下是减少锁竞争的方法:

  • 细粒度锁: 将大锁拆分为几个小的细粒度锁,以减少锁的争用。
  • 无锁数据结构: 使用无锁的数据结构,例如 sync/atomic 包提供的原子操作来实现无锁编程。
  1. 避免频繁的内存分配

频繁的内存分配和释放会导致大量的垃圾回收。以下是减少内存分配的方法:

  • 对象重用: 使用对象池来重复利用已经分配的对象而不是频繁地创建新对象。
  • 预分配内存: 在需要存储大量数据时,提前分配足够的内存,这样可以减少运行时的内存分配开销。

减少锁竞争

降低锁粒度

锁之所以容易成为性能瓶颈的根源在于同一时刻出现了竞争情况,所以我们需要尽可能让竞争情况出现的概率最小。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Website struct {
mu sync.Mutex
uv int // unique visitors, rarely change
pv int // page views, frequently change
}
func (w *Website) Add(uv, pv bool) {
w.mu.Lock()
if uv {
w.uv++
}
if pv {
w.pv++
}
w.mu.Unlock()
}

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

1
2
3
4
5
6
7
8
9
10
func (w *WebsiteSplit) AddUV() {
w.muv.Lock()
w.uv++
w.muv.Unlock()
}
func (w *WebsiteSplit) AddPV() {
w.mpv.Lock()
w.pv++
w.mpv.Unlock()
}

读写锁

如果某一个/一组变量同时要被读写,且读的概率要远大写,那么最适合使用读写锁。读写锁的读操作可并发重入,而写操作是互斥的,可以进一步降低锁冲突。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type WebsiteRW struct {
mu sync.RWMutex
uv int
}
func (w *WebsiteRW) AddUV() {
w.mu.Lock()
w.uv++
w.mu.Unlock()
}
func (w *WebsiteRW) UV() (uv int) {
w.mu.RLock()
uv = w.uv
w.mu.RUnlock()
return uv
}

当然,这种针对整数的读写最适合的还是直接用 atomic 原子操作,以上代码仅作为表达场景示例使用。

减少锁持有时间

锁冲突的概率与 Lock 与 Unlock 之间的时间有直接的关系,所以除了降低锁粒度外,我们还可以尽可能把没有必要加锁的操作移到释放锁之后进行:

1
2
3
4
5
6
7
8
9
10
11
type Buffer struct {
mu sync.Mutex
queue []string
}
func (w *Buffer) Flush() {
w.mu.Lock()
tmp := w.queue
w.queue = nil
w.mu.Unlock() // unlock before handler
batchHandler(tmp)
}

分片锁

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func NewMap() *Map {
return &Map{
locks: make([]sync.Mutex, runtime.GOMAXPROCS(0)),
mm: make([]map[string]string, runtime.GOMAXPROCS(0)),
}
}
type Map struct {
locks []sync.Mutex
mm []map[string]string
}
func (o *Map) Set(key, val string) {
shard := hash(key) % len(o.locks)
o.locks[shard].Lock()
o.mm[shard][key] = val
o.locks[shard].Unlock()
}

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

无锁化

在其他语言中,我们常常会有 ThreadLocal 的概念,Go 在语言上对用户屏蔽了线程的概念,但我们依然可以通过 go:linkname 将内部的线程相关操作暴露出来:

1
2
3
4
//go:linkname procPin runtime.procPin
func procPin() int
//go:linkname procUnpin runtime.procUnpin
func procUnpin() int

procPin 能够返回当前 P 的唯一 ID 并且禁止对 P 的抢占,当调用了该函数后,进程中就没有任何其他地方能够影响到该 P 所持有的变量。于是我们便能够实现为每一个 P 都创建一个本地的对象池:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
type Cache struct {
pool [][]*Object
}

func (c *Cache) Get() (o *Object) {
pid := procPin()
p := c.pool[pid]
if len(p) == 0 {
procUnpin()
return &Object{}
}
o = p[len(p)-1]
c.pool[pid] = p[:len(p)-1]
procUnpin()
return o
}

func (c *Cache) Put(o *Object) {
pid := procPin()
c.pool[pid] = append(c.pool[pid], o)
procUnpin()
}

这类用法常用于缓存类场景,即每一个 P 获取的对象都可以是一样的,且不要求从哪个 P 获取要从哪个 P 归还。
当然,直接使用 //go:linkname 指令将运行时内部函数暴露出来是一种不安全且不被官方支持的做法。 这种方法可能导致代码在不同的 Go 版本中出现不可预期的行为或兼容性问题。

对于需要对象池功能的场景,Go 语言标准库提供了 sync.Pool,它是一个高效的对象复用池。sync.Pool 为每个 P 维护一个本地池,减少不同 P 之间的锁竞争,从而提高性能。

避免频繁的内存分配

Go 内置的垃圾回收器为开发者节省了大量原本需要用于手动管理对象生命周期的心智负担,但也牺牲了一定程度的性能。对于日常的业务开发来说,这种取舍或许是值得的,但如果我们需要使用 Go 去编写一些有极致性能要求的底层代码时,这种语言层面的取舍就变成了一种阻碍。

预分配对象

在 Go 语言中,切片(slice)和映射(map)等数据结构在动态扩容时会产生较高的性能开销。每次扩容操作通常涉及以下步骤:

  1. 内存重新分配:当现有容量不足以容纳新元素时,系统需要分配一块更大的内存空间。
  2. 数据复制:将原有数据从旧内存区域复制到新分配的内存区域。
  3. 垃圾回收压力增加:旧的内存区域在不再使用后需要被垃圾回收器回收,频繁的内存分配和释放会增加垃圾回收的负担。

这些操作会导致 CPU 和内存资源的额外消耗,影响程序的性能。因此,在可以预估数据量的情况下,建议在初始化时预先分配合适的容量,以避免频繁的扩容操作。

复用对象

一个运行中的程序会源源不断创建出许多新的对象,每创建一个对象,不仅需要从内存中申请一块空间,还需要在之后的运行时去不断扫描该对象是否可被清理。

我们假设某个程序每 1 ms 会创建一个新对象,每个对象会被使用 10 ms 的时间,那么该程序每秒会创建 1000 个对象,而同一时刻,系统内仅仅只有 10 个对象正在被使用。

在这个例子中我们很容易发现,我们完全可以仅创建 10 个对象,使它们反复被程序使用,这样就大大降低了创建的对象数,从而大大降低了 GC 扫描对象的成本。

但是在实际编程时,我们往往还要考虑到多个协程间并发申请对象的问题,这时候就应该使用 sync.Pool。在大多数情况下,sync.Pool 已经能够满足对象复用的需求,且使用起来更加安全和简洁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var objectPool sync.Pool
func NewObject() *Object {
obj := objectPool.Get()
if obj == nil { return &Object{} }
return obj.(*Object)
}
type Object struct {
Name string
}
func (o *Object) Recycle() {
o.Name = "" // reset object
objectPool.Put(o)
}
func main() {
obj := NewObject()
obj.Recycle()
}

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

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

总结

性能优化只是一种额外的手段,简单的代码才是最好的代码。

只有当有特定场景需要时,我们才需要使用到其中进阶的优化手段。

更多内容

最近文章:

随机文章:


更多该系列文章,参考medium链接:

https://wesley-wei.medium.com/list/you-should-know-in-golang-e9491363cd9a

English post: https://programmerscareer.com/golang-advanced-02/
作者:微信公众号,Medium,LinkedIn,Twitter
发表日期:原文在 2025-01-05 16:12 时创作于 https://programmerscareer.com/zh-cn/golang-advanced-02/
版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证

golang中你应该知道的 go.sum 知识 golang中你应该知道的协程协作知识

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×