此文介绍从各个方向优化golang代码,让golang运行更流畅,内存消耗更少。
此文章为学习 极客兔兔 《Go 语言高性能编程》后的总结性文章。
字符串拼接
在Go语言中,字符串只是可读的,它无法被修改,拼接字符串实际上是创建了一个新的字符串对象。因此如果频繁的拼接字符串,就意味着在频繁的分配内存,会对性能造成严重的影响。
当使用 + 拼接 2 个字符串时,生成一个新的字符串,那么就需要开辟一段新的空间,新空间的大小是原来两个字符串的大小之和。拼接第三个字符串时,再开辟一段新空间,新空间大小是三个字符串大小之和,以此类推。
而 strings.Builder
,bytes.Buffer
,包括切片 []byte 的内存是以倍数申请的。例如,初始大小为 0,当第一次写入大小为 10 byte 的字符串时,则会申请大小为 16 byte 的内存(恰好大于 10 byte 的 2 的指数),第二次写入 10 byte 时,内存不够,则申请 32 byte 的内存,第三次写入内存足够,则不申请新的,以此类推。在实际过程中,超过一定大小,申请策略上会有些许调整。
官方推荐的字符串拼接方法是strings.Builder
,它的性能比bytes.Buffer
略快10%,一个比较重要的区别在于,bytes.Buffer
转化为字符串时重新申请了一块空间,存放生成的字符串变量,而 strings.Builder
直接将底层的 []byte 转换成了字符串类型返回了回来。
速度最快的是用[]byte来拼接,因为它一次性分配完了所需要的内存,通过append来拼接字符串,但是这个有一个很大的缺陷就是,在日常开发中,很多时候并不知道给这个字符串分配有多大的内存。而且strings.Builder
也有 Grow()
方法可以进行预分配内存。
- bytes.Buffer
|
|
- strings.Builder
|
|
bytes.Buffer 的注释中还特意提到了:
To build strings more efficiently, see the strings.Builder type.
切片使用陷阱
在已有切片的基础上进行切片,不会创建新的底层数组,它会与原切片共用一个底层数组。因为原来的底层数组没有发生变化,内存会一直占用 (内存逃逸),直到没有变量引用该数组才会进行垃圾回收。
因此很可能出现这么一种情况,原切片由大量的元素构成,但是我们在原切片的基础上切片,虽然只使用了很小一段,但底层数组在内存中仍然占据了大量空间,得不到释放。比较推荐的做法,使用 copy 替代 re-slice。copy会创建一个新的底层数据,原数据可以正确的得到回收。
|
|
for 和 range 的性能比较
array/slice
|
|
- 变量 words 在循环开始前,仅会计算一次,如果在循环中修改切片的长度不会改变本次循环的次数。
- 迭代过程中,每次迭代的下标和值被赋值给变量 i 和 s,第二个参数 s 是可选的。
- 针对 nil 切片,迭代次数为 0。
map
|
|
- 和切片不同的是,迭代过程中,删除还未迭代到的键值对,则该键值对不会被迭代。尽量不要在遍历map时进行删除操作,因为map是无序的,你根本不知道它的迭代顺序,因此无法确定它会在迭代前删除还是删除前迭代
- 在迭代过程中,如果创建新的键值对,那么新增键值对,可能被迭代,也可能不会被迭代。
- 针对 nil 字典,迭代次数为 0
channel
|
|
- 发送给信道(channel) 的值可以使用 for 循环迭代,直到信道被关闭。
- 如果是 nil 信道,循环将永远阻塞。
- 如果去除close就会发生死锁panic。
range优化
range在迭代过程中返回的值是对元素的拷贝,每遍历一个都会拷贝一次当前索引元素,修改拷贝的值不会影响到切片中元素值,因此如果每次迭代的元素的内存占用很低,那么 for 和 range 的性能几乎是一样,例如 []int。但是如果元素是一个很大的结构体时range就会严重的影响到性能(因为拷贝需要时间)。
对应这种情况,建议使用for来进行遍历,通过索引下标去寻找对应的值,不进行拷贝。也可以用for i:=range slice
的方法来忽略拷贝。如果必须迭代值,则需要将切片或者数组的元素改为指针, 这样它虽然会发生拷贝,但是拷贝的是指针,指针相比于具体的值会小很多,而且还可以通过寻址直接修改元素的值。
反射(reflect)性能
标准库 reflect 为 Go 语言提供了运行时动态获取对象的类型和值以及动态创建对象的能力。反射可以帮助抽象和简化代码,提高开发效率。
但是相比于硬编码的方式,反射的性能就会慢很多。
- 创建对象时,reflect.New的耗时约为new()的1.5倍
- 修改反射字段值时,FieldByName的性能相比Field劣化10倍以上,而Field的性能相比不使用反射的普通赋值操作劣化100倍左右,因此如果要使用反射修改值,尽量使用Field()
- FieldByName()和Field()有这么大的性能差距就在于两者的底层逻辑实现不同,在反射的内部,字段是按照定义时的顺序存储的。
- FieldByName()底层是使用for循环逐个查找字段名匹配的字段,其查找效率为O(N)
- Field()是根据下标直接访问对应字段,查找效率为O(1)。
- 结构体所包含的字段(包括方法)越多,两者之间的效率差距则越大。
提高性能
- 避免使用反射
使用反射赋值,效率非常低下,如果有替代方案的话,尽量避免使用反射,特别是会被频繁使用的热点代码尤为注意。例如在RPC协议中,需要对结构体进行序列化和反序列化,这个时候避免使用 Go 语言自带的 json
的 Marshal
和 Unmarshal
方法,因为标准库中的 json 序列化和反序列化是利用反射实现的。可选的替代方案有 easyjson
,在大部分场景下,相比标准库,有 5 倍左右的性能提升。
- 缓存Map
因为FieldByName()相比于Field()有一个数量级的性能劣化。那么在实际应用中就要避免之间调用FieldByName。因此就可以通过map来实现,先使用for循环遍历typ.NumField(),将所有的字段通过Field()的方法存到一个map中,然后后续使用时再从map中取。
map的value尽量存储对应字段的索引,直接存储对应结构体的话,它为Type的StructField结构体,无法获取到对应具体实例该字段的值。
使用map可以让FieldByName消耗的时间从原本的10倍缩小到2倍左右。
使用空结构体节省内存
空结构体 struct{} 实例不占据任何的内存空间,可以通过unsafe.Sizeof 计算出一个数据类型实例需要占用的字节数。
因为空结构体不占据内存空间,因此被广泛作为各种场景下的占位符使用。一是节省资源,二是空结构体本身就具备很强的语义,即这里不需要任何值,仅作为占位符
- 将空结构体作为map的值使用,实现set类型。
- 作为不发送数据的信道,只用来通知子协程(goroutine)执行任务,或只用来控制协程并发度。
- 作为仅包含方法的结构体,主要用来给一系列函数进行分组。
内存对齐
CPU访问内存时并不是逐个字节的访问,而是以字长为单位进行访问。而32位的cpu字长为(32/8) 4字节,即cpu每次访问内存的单位也是4字节。
这么设计的目的主要是减少CPU访问内存的次数,加大CPU访问内存的吞吐量。
如此图中,如果不进行内存对齐,访问b变量时就需要进行2次内存访问。第一次访问得到b变量的第1个字节,第二次访问得到b变量的后两个字节。
并且内存对齐对实现变量的原子性操作也是有好处的,如果变量的大小不超过字长,那么内存对齐后,对该变量的访问就是原子的。
总结:合理的内存对齐可以提高内存读写的性能,并且便于实现变量操作的原子性。
unsafe.Alignof()
可以根据此方法计算内存对齐遵守的规律。它会返回一个类型的对齐值,也可以叫做对齐系数或者对齐倍数。
其实例占据的空间必须是对齐值的整数倍,例如一个结构体有一个int16和int32成员,那么它的内存就是2+4=6,但是通过Alignof()得到它的对齐系数为4,因此最终的内存占用值就为8。
它的对齐系数取决于其结构体中占用内存最大的那个成员的对齐系数。
- 对于任意类型的变量 x ,unsafe.Alignof(x) 至少为 1
- 对于 struct 结构体类型的变量 x,计算 x 每一个字段 f 的 unsafe.Alignof(x.f),unsafe.Alignof(x) 等于其中的最大值
- 对于 array 数组类型的变量 x,unsafe.Alignof(x) 等于构成数组的元素类型的对齐倍数(例如切片组成为 cap、len和指向底层数组的指针ptr),对齐倍数取三者中占用内存最大的那个成员的对齐系数即可
合理布局减少内存占用
对齐系数只是用来推算结构体内存占用必须为系数的整数倍,它无法直接得出该结构体占用内存的值,还需要根据具体排列情况来获取。
|
|
unsafe.Alignof(x)只会返回最终的对齐系数,。每个字段会按照自身的对齐系数来确定在内存中的偏移量,字段排列顺序不同,上一个字段因为偏移而浪费的大小也不同。
- demo1:
- a是第一个字段,它的对齐系数为8/8=1,自身就是8bit,即占满第0个字节。
- b是第二个字段,它的对齐系数为16/8=2,那么偏移量必须为2的倍数,即占据第2和第3个字节,第1个字节被留空。
- c是第三个字段,它的对齐系数为32/8=4,那么偏移量必须为4的倍数,即占据第4-7字节。
因此demo1内存占用为8字节。
- demo2:
- a是第一个字段,和demo1一样占据第0字节。
- b是第二个字段,对齐系数为4,4的倍数即必须从第4字节开始,占据4-7字节。
- c是第三个字段,对齐系数为2,即占用8-9字节。
- demo2的最终对齐倍数为占据内存最大的成员对齐系数值,即它的对齐系数为4,而abc加起来占据了10字节,并不是4的整数倍,因此demo2最终内存占用为12字节。
因此,在对内存特别敏感的结构体的设计上,可以通过调整字段的顺序来减少内存的占用。只需要将结构体的成员按照各自内存占用大小升序排列即可,这样成员的对齐系数是逐渐增大的,排列的会更加紧凑,内存占用也就越小。
空结构体struct{} 的对齐
空 struct{} 大小为 0,当作为其他 struct 的字段时,一般不需要内存对齐(某个结构体中存在空结构体,只要不是放置在最后,都不会占用空间)。
但是有一种情况除外:即当 struct{} 作为结构体最后一个字段时,会被填充对齐到前一个字段的大小,地址偏移对齐规则不变。
因为如果有指针指向该字段, 返回的地址将在结构体之外,如果此指针一直存活不释放对应的内存,就会有内存泄露的问题(该内存不因结构体释放而释放,因为空结构不占用内存,导致这个指针无法指向相对应的值)。
因此,当 struct{} 作为其他 struct 最后一个字段时,需要填充额外的内存保证安全。我们做个试验,验证下这种情况。
|
|
互斥锁和读写锁的性能比较
- 互斥锁: 互斥即不可同时运行。即使用了互斥锁的两个代码片段互相排斥,只有其中一个代码片段执行完成后,另一个才能执行。在一个 Go 协程调用 Lock 方法获得锁后,其他请求锁的协程都会阻塞在 Lock 方法,直到锁被释放。
- 读写锁: 保证读操作的安全,那只要保证并发读时没有写操作在进行就行。在这种场景下我们需要一种特殊类型的锁,其允许多个只读操作并行执行,但写操作会完全互斥。这种锁称之为
多读单写锁
,简称读写锁,读写锁分为读锁和写锁,读锁是允许同时执行的,但写锁是互斥的。一般来说,有如下几种情况:- 读锁之间不互斥,没有写锁的情况下,读锁是无阻塞的,多个协程可以同时获得读锁。
- 写锁之间是互斥的,存在写锁,其他写锁阻塞。
- 写锁与读锁是互斥的,如果存在读锁,写锁阻塞,如果存在写锁,读锁阻塞。
读写锁的存在是为了解决读多写少时的性能问题,读场景较多时,读写锁可有效地减少锁阻塞的时间。读写锁一般作用在读远远大于写的情况,最好在读某个共享变量时加锁,读完立马解锁,不要在锁里面处理业务逻辑,这会造成锁住的时间增长。
互斥锁如何实现公平
互斥锁有两种状态: 正常状态和饥饿状态。 在正常情况下,所有等待锁的goroutine会按照FIFO(先进先出) 顺序等待。唤醒的goroutine不会直接拥有锁,而是会和新请求获取锁的goroutine竞争 锁的拥有。新请求锁的goroutine具有优势,因为它正在CPU上执行,所以刚刚唤醒的goroutine有很大可能在锁的竞争中失败。在这种情况下,这个被唤醒的goroutine会加入到等待队列的前面,但是如果一个等待的goroutine超过1ms 没有获取锁,那么它会将锁转变为饥饿模式。 在饥饿模式下,锁的分配模式会变成 根据等待队列的顺序依次给予,新来的goroutine不会再去尝试获取锁,即使锁可能是unlock解锁状态,也不会去尝试自旋操作,它会直接放在等待队列尾部等待获取锁。 如果一个等待的goroutine获取了锁,并且满足以下其中任何一个条件:
- 它是队列中的最后一个,不再有新的goroutine排在后面,这说明这个锁即将变成空闲状态。
- 它等待的时间小于1ms。
那么锁就会从饥饿状态转换为正常状态。
正常状态有很好的性能表现,饥饿模式也是非常重要的,因为它能阻止尾部延迟的现象。
协程的退出
超时返回时的陷阱
超时控制在网络编程中是非常常见的,利用 context.WithTimeout 和 time.After 都能够很轻易地实现。
超时退出后,子协程依然存在,导致内存泄漏
|
|
在这个代码中出现的问题就是,假如执行了1000次timeout(doBadthing),那么就会多1000个子协程永久存在与内存中,直到程序结束。原因就是超时时间为1ms,而doBadthing的执行为1s,那么每次都会走超时逻辑,这就导致1s后向done中发送信息时却找不到接收者(timeout已经结束),那么它会永久阻塞。
解决办法:
- 将done从无缓冲channel改为缓存为1的有缓冲channel,这样就不会在done处阻塞,而这个有缓冲的channel因为没人使用也会被垃圾回收掉。
- 在doBadthing中增加select{} 机制,如果向done发送数据失败,则说明缺少接收者,即超时了,那么这个子协程直接退出。
强制kill goroutine 是不能实现的
即时超时返回了,但是子协程仍在继续运行,直到自己退出。那么有可能在超时的时候,就强制关闭子协程吗?
答案是不能,goroutine 只能自己退出,而不能被其他 goroutine 强制关闭或杀死。
goroutine 被设计为不可以从外部无条件地结束掉,只能通过 channel 来与它通信。也就是说,每一个 goroutine 都需要承担自己退出的责任。
因为 goroutine 不能被强制 kill,在超时或其他类似的场景下,为了 goroutine 尽可能正常退出,建议如下:
- 尽量使用非阻塞 I/O(非阻塞 I/O 常用来实现高性能的网络库),阻塞 I/O 很可能导致 goroutine 在某个调用一直等待,而无法正确结束。
- 业务逻辑总是考虑退出机制,避免死循环。
- 任务是分段执行时,超时后立马退出,避免 goroutine 无用的执行过多,浪费资源(在复杂的业务逻辑中,超时可能和部分业务是耦合在一起的,在这种情况下就很难使用非阻塞 I/O 来设计超时,因为如果是非阻塞I/O,它不会进行阻塞,就无法判断有没有超时,会继续向后执行很多不应该执行的业务代码,因此在设计超时机制时,尝试考虑使用select{}是否可行)
channel 忘记关闭的陷阱
|
|
通过测试结果发现,子协程多了一个,也就是说,有一个协程一直没有得到释放。原因就是子协程go do(taskCh)
的select一直处于阻塞状态,等待接收任务,因此直到程序结束协程都没有释放。
解决办法就是发送完成后通过close(chan)来关闭通道,此时接收者获取的值就是对应类型的零值,然后通过t, beforeClosed := <-taskCh
的beforeClosed来判断通道是否关闭,当它为false时表示channel已经被关闭,并且channel里面的数据为空,直接返回。也可以将select{}
改为for range
的方式,它也会一直读取channel的数据,当close执行后,for循环会退出。
关于通道和协程的垃圾回收
注意,一个通道被其发送数据协程队列和接收数据协程队列中的所有协程引用着。因此,如果一个通道的这两个队列只要有一个不为空,则此通道肯定不会被垃圾回收。另一方面,如果一个协程处于一个通道的某个协程队列之中,则此协程也肯定不会被垃圾回收,即使此通道仅被此协程所引用。事实上,一个协程只有在退出后才能被垃圾回收。
通道关闭原则
一个常用的使用Go通道的原则是不要在数据接收方或者在有多个发送者的情况下关闭通道
。换句话说,我们只应该让一个通道唯一的发送者关闭此通道 (因为对一个已经关闭的channel再次关闭会panic)。
可以使用sync.One() 或者互斥锁来确保channel 只被关闭一次。
|
|
|
|
优雅的关闭通道
- M 个接受者,1 个发送者。这是最简单的情况,只需让发送者在不想再发送数据的时候关闭数据通道,直接在发送者方close(ch)即可。
- 1 个接收者,N 个发送者。这个情况比上面的要复杂一点。我们不能让接收者关闭数据通道,不然就会违反了
通道关闭原则
。但是可以在接收者处创建一个额外的channel,通过关闭额外的信号通道来通知发送者不要再发送数据了
|
|
- M个接收者和N个发送者。它们中的任何一个协程都可以去通知让一个中间调解协程帮忙发出停止数据传送的信号。这是最复杂的一种情形。我们不能让接收者和发送者中的任何一个关闭用来传输数据的通道,我们也不能让多个接收者之一关闭一个额外的信号通道。 这两种做法都违反了
通道关闭原则
。 然而,我们可以引入一个中间调解者角色并让其关闭额外的信号通道来通知所有的接收者和发送者结束工作
|
|
并没有什么情况非得逼得我们违反通道关闭原则。 如果你遇到了此情形,请考虑修改你的代码流程和结构设计
并发过高导致程序崩溃
如果无限制的开启协程会导致内存不足崩溃,或者对单个 file/socket 的并发操作个数超过系统上限 (比如在协程中打印内容,fmt.Printf也是操作文件描述符,过多的协程会导致系统资源耗尽)
不同的应用程序所消耗的资源是不一样的。比较推荐的方式是:应用程序来主动限制并发的协程数量。
利用channel的缓冲区来限制goroutine的数量
每次开启协程前先向一个有缓冲的channel中发送一条消息,当channel满时就会阻塞,不会再创建新的协程。而每个协程结束时都会从channel中接收一条消息,只有一个协程结束,才能新建一个协程,从而控制了程序创建的协程数量。
runtime.GOMAXPROCS(逻辑CPU数量)
控制的是可以并发执行的最大 P 数量(即逻辑 CPU 数量),,但是它不能控制总协程数量 (GMP模型中,协程可以在P队列中等待),GOMAXPROCS默认值就是CPU逻辑核心数量,如8核16线程GOMAXPROCS设置的值就是16,可以通过NumCPU()
查看,可以设置比核心数量大,但是没意义,因为正在运行的协程依然最大只能有逻辑CPU数,多余的 P 只会浪费资源,不会带来更好的性能。
使用第三方库
目前有很多第三方库实现了协程池,可以很方便地用来控制协程的并发数量。
以tunny
举例:
|
|
调整系统资源的上限
ulimit
有些时候,即使我们有效地限制了协程的并发数量,仍然会出现某一类资源不足的问题。
比如分布式编译加速工具需要解析gcc命令以及依赖的源文件和头文件,有些编译命令依赖的头文件可能有上百个,那这个时候即使我们将协程的并发数限制到 1000,也可能会超过进程运行时并发打开的文件句柄数量 (程序打开的文件数量超过了系统设置的程序打开句柄数量,资源耗尽)
,但是分布式编译工具,仅将依赖的源文件和头文件分发到远端机器执行,并不会消耗本机的内存和 CPU 资源,因此 1000 个并发并不高,这种情况下,降低并发数会影响到编译加速的效率,这种时候我们就可以通过设置系统的打开句柄数量来解决。
操作系统通常会限制同时打开文件数量、栈空间大小等,ulimit -a 可以看到系统当前的设置:
|
|
我们可以使用 ulimit -n 999999
,将同时打开的文件句柄数量调整为 999999 来解决这个问题,其他的参数也可以按需调整
虚拟内存/交换分区 (virtual memory)
虚拟内存是一项非常常见的技术了,即在内存不足时,将磁盘映射为内存使用,比如 linux 下的交换分区(swap space)。设置完交换分区后,内存不足时系统会自动使用交换分区作内存用。 在 linux 上创建并使用交换分区是一件非常简单的事情:
|
|
关闭交换分区也非常简单:
|
|
磁盘的 I/O 读写性能和内存条相差是非常大的,
例如 DDR3 的内存条读写速率很容易达到 20GB/s,但是 SSD 固态硬盘的读写性能通常只能达到 0.5GB/s,相差 40倍之多。因此,使用虚拟内存技术将硬盘映射为内存使用,显然会对性能产生一定的影响。如果应用程序只是在较短的时间内需要较大的内存,那么虚拟内存能够有效避免 out of memory (内存不足)
的问题。如果应用程序长期高频率读写大量内存,那么虚拟内存对性能的影响就比较明显了。
sync.Pool 复用对象
通过保存和复用临时对象,减少内存分配,降低GC垃圾回收压力,sync.Pool
主要就是复用一个临时变量,避免频繁的创建临时结构体来承载数据,造成极大的GC压力和不必要的内存,它是并发安全的,所以可以多协程共用。
常用于网络包收取发送的时候,因为收取发送时需要频繁的反序列化,如果每次反序列化时都是一个新的临时变量,在高并发时,会造成极大的GC压力,采用sync.Pool的话,从池中取曾经存在的对象(不存在才new一个)就可以极大的减少GC压力。
例如json 的反序列化在文本解析和网络通信过程中非常常见,当程序并发度非常高的情况下,短时间内需要创建大量的临时对象来承载反序列化的数据。而这些对象是都是分配在堆上的,会给 GC 造成很大压力,严重影响程序的性能。
sync.Pool 是可伸缩的,同时也是并发安全的,其大小仅受限于内存的大小。sync.Pool 用于存储那些被分配了但是没有被使用,而未来可能会使用的值。这样就可以不用再次经过内存分配,可直接复用已有对象,减轻 GC 的压力,从而提升系统的性能。
sync.Pool 的大小在高负载时会动态扩容,存放在池中的对象如果不活跃了会被自动清理。
使用方法
声明对象池
|
|
只需要实现 New 函数即可。对象池中没有对象时,将会调用 New 函数创建。
|
|
- Get() 用于从对象池中获取对象,因为返回值是 interface{},因此需要类型转换。
- Put() 则是在对象使用完毕后,返回对象池。
sync.Pool
作为使用方不能对 Pool 里面的对象个数做假定,同时也无法获取 Pool 池中对象个数。可以往池中Put发送多个对象,但是Get()时是随机取出对象,无法保证以固定的顺序获取Pool池中的存储对象。
没有配置 New 方法时,如果 Get 操作多于 Put 操作,继续 Get 会得到一个 nil interface{} 对象,所以需要配置New()代码进行兼容。
配置 New 方法后,Get 获取不到对象时(Pool 池中已经没有对象了),会调用自定义的 New 方法创建一个对象并返回。需要注意的是,sync.Pool 本身数据结构是并发安全的,但是 Pool.New 函数(用户自定义的)不一定是线程安全的。
Pool.New 函数可能会被并发调用,如果 New 函数里面的实现逻辑是 非并发安全的,那就会有问题。
sync.Pool
不适合存储带状态的对象,因为获取对象是随机的 (Get 到的对象可能是刚创建的,也可能是之前创建并 cache 住的)
,并且缓存对象的释放策略完全由 runtime 内部管理,你无法确定此次获取的数据是否是自己需要的,也许是之前未被取出,还未释放的数据。
sync.Once 如何提升性能
sync.Once 是 Go 标准库提供的使函数只执行一次的实现,常应用于单例模式,例如初始化配置、保持数据库连接等。作用与 init 函数类似,但有区别。
- init 函数是当所在的 package 首次被加载时执行,若执行后init()中的全局变量迟迟未被使用,则既浪费了内存,又延长了程序加载时间。
- sync.Once 可以在代码的任意位置初始化和调用,因此可以延迟到需要使用时再执行,并发场景下是线程安全的。
在多数情况下,sync.Once 被用于控制变量的初始化,这个变量的读写满足如下三个条件:
- 当且仅当第一次访问某个变量时,进行初始化(写);
- 变量初始化过程中,其他执行该sync.Once.Do()的协程会发生阻塞,直到初始化完成,保证所有协程都能拿到初始化后的值;
- 变量仅初始化一次,初始化完成后驻留在内存里。
sync.Once 仅提供了一个方法 Do,参数 f 是对象初始化函数。
|
|
如果不使用sync.One,并发运行初始化时,每次都构造出一个新的对象,既浪费内存,又浪费初始化时间。如果初始化时不加锁,初始化全局变量就可能出现并发冲突。这种情况下,使用 sync.Once 既能够保证全局变量初始化时是线程安全的,又能节省内存和初始化时间。
sync.Once 的原理
首先要保证变量仅被初始化一次,那么就需要有一个标志来判断变量是否已经初始化过,若没有才需要初始化。
其次就是保证线程安全,支持并发,这无疑需要互斥锁来实现。
源码 (代码位于 $(dirname $(which go))/../src/sync/once.g)
:
|
|
done 是在热路径中的,done 放在第一个字段,能够减少 CPU 指令,也就是说,这样做能够提升性能
- 热路径
(hot path)
是程序非常频繁执行的一系列指令,sync.Once 绝大部分场景都会访问 o.done (判断是否执行过),在热路径上是比较好理解的,如果hot path
编译后的机器码指令更少,更直接,必然是能够提升性能的 - 为什么放在第一个字段就能够减少指令呢? 因为结构体第一个字段的地址和结构体的指针是相同的,如果是第一个字段,直接对结构体的指针解引用即可。 如果是后面的字段,除了结构体指针外,还需要计算与第一个值的偏移(calculate offset)。在机器码中,偏移量是随指令传递的附加值,CPU 需要做一次偏移值与指针的加法运算,才能获取要访问的值的地址。因此,访问第一个字段的机器代码更紧凑,速度更快。
sync.Cond 条件变量
sync.Cond
条件变量 用来协调想要访问共享资源的那些 goroutine,当共享资源的状态发生变化的时候,它可以用来通知被互斥锁阻塞的 goroutine。
sync.Cond 基于互斥锁/读写锁,但是它和锁是有区别的。
锁通常用来保护临界区和共享资源,而sync.Cond是用来协调想要访问共享资源的goroutine。
sync.Cond 经常用在多个goroutine等待,一个goroutine通知(事件发生)的场景。如果是一个通知一个等待,使用互斥锁或channel就能搞定的。
比如,有一个协程在异步地接收数据,剩下的多个协程必须等待这个协程接收完数据,才能读取到正确的数据。在这种情况下,如果单纯使用 chan 或互斥锁,那么只能有一个协程可以等待,并读取到数据,没办法通知其他的协程也读取数据。
这个时候,就需要有个全局的变量来标志第一个协程数据是否接受完毕,剩下的协程,反复检查该全局变量的值,直到满足要求。或者创建多个 channel,每个协程阻塞在一个 channel 上,由接收数据的协程在数据接收完毕后,逐个通知。总之,需要额外的复杂度来完成这件事。
sync.Cond 的四个方法
sync.Cond 的定义如下:
|
|
每个 Cond 实例都会关联一个锁 L(互斥锁 *Mutex,或读写锁 *RWMutex),当修改条件变量或者调用 Wait 方法时,必须加锁。
NewCond 创建实例
|
|
NewCond 创建 Cond 实例时,需要通过参数来关联一个锁。
Broadcast 广播唤醒所有
|
|
Broadcast 唤醒所有等待条件变量 c 的 goroutine,无需锁保护。
Signal 唤醒一个协程
|
|
Signal 只唤醒任意 1 个等待条件变量 c 的 goroutine,无需锁保护。BroadCast和Signal都只是唤醒正在Wait的协程,如果没有,那么就相当于不起作用
Wait 等待
|
|
调用 Wait 会自动释放锁 c.L,并挂起调用者所在的 goroutine,因此当前协程会阻塞在 Wait 方法调用的地方。如果其他协程调用了 Signal 或 Broadcast 唤醒了该协程,那么 Wait 方法在结束阻塞时,会重新给 c.L 加锁,并且继续执行 Wait 后面的代码。
对条件变量的检查,要使用 for !condition() 而非 if
,是因为当前协程被唤醒时,条件不一定符合要求,需要再次 Wait 等待下次被唤醒。为了保险起见,使用 for 能够确保条件符合要求后,再执行后续的代码。
有点类似于sync.WaitGroup
,只不过WaitGroup是某一个协程等待其他协程全部结束后再继续往下走,sync.Cond
是多个协程等待某一个协程执行完成后才能继续执行。
示例:
|
|
channel也能作为广播使用,close(ch)关闭时,其他协程都可以获取它的零值,但是close有一个问题是它不能复用,无法对close的channel再次close,而sync.Cond是可以复用的
减小编译体积
减小编译后的二进制文件体积,能够加快程序的发布和安装过程。
编译选项
Go 编译器默认编译出来的程序会带有符号表和调试信息,一般来说 release 版本可以去除调试信息以减小二进制体积。
|
|
- -s:忽略符号表和调试信息。
- -w:忽略DWARFv3调试信息,使用该选项后将无法使用gdb进行调试。
使用 upx 减小体积
upx 是一个常用的压缩动态库和可执行文件的工具,通常可减少 50-70% 的体积。
upx 的安装方式非常简单,我们可以直接从 github 下载最新的 release 版本,支持 Windows 和 Linux,在 Ubuntu 或 Mac 可以直接使用包管理工具安装。
upx 有很多参数,最重要的则是压缩率,1-9,1 代表最低压缩率,9 代表最高压缩率。
|
|
可以看到,使用 upx 后,可执行文件的体积从 9.8M 缩小到了 5M,缩小了 50%。
然后再此基础上可以再加上编译选项进一步压缩可执行文件的大小。
|
|
upx 的原理
upx 压缩后的程序和压缩前的程序一样,无需解压仍然能够正常地运行,这种压缩方法称之为带壳压缩,压缩包含两个部分:
- 在程序开头或其他合适的地方插入解压代码;
- 将程序的其他部分压缩。
执行时,也包含两个部分:
- 首先执行的是程序开头插入的解压代码,将原来的程序在内存中解压出来;
- 再执行解压后的程序。
也就是说,upx 在程序执行时,会有额外的解压动作,不过这个耗时几乎可以忽略不计。
如果对编译后的体积没什么要求的情况下,可以不使用 upx 来压缩。一般在服务器端独立运行的后台服务,无需压缩体积。
分析内存逃逸对性能的影响
Go程序会在2个地方为变量分配内存,一个是 全局
的堆(heap)空间用来动态分配内存,另一个是 每一个goroutine
的栈(stack)空间。
Go语言实现了垃圾回收(Garbage Collector)机制,因此Go语言的内存管理是自动的,通常开发者并不需要关心内存是分配在栈上还是堆上。但是从性能的角度出发,在栈上分配内存和在堆上分配内存,两者的性能差异是非常大的。
在函数中申请一个对象,如果分配在栈中,函数执行结束时自动回收。但是如果分配在堆中,则在函数结束后某个时间点进行垃圾回收。
在栈上分配和回收内存的开销很低,只需要2个 CPU指令: PUSH和POP,一个是将数据PUSH到栈空间以完成分配,POP则是释放空间,也就是说在栈上分配内存,消耗的仅仅是将数据拷贝到内存的时间,而内存的I/O通常能够达到 30GB/s,因此在栈上分配内存的效率是非常高的。
在堆上分配内存,一个很大的额外开销则是垃圾回收。Go语言使用的是标记清除算法,并在此基础上使用了三色标记法和写屏障技术来提高效率。
|
|
因此,堆内存分配由于垃圾回收的原因导致其开销远远大于栈空间分配与释放的开销。
逃逸分析
在Go语言中,堆内存是通过垃圾回收机制自动管理的。那么Go编译器怎么知道某个变量要分配在堆上还是栈上呢?编译器决定内存分配位置的方式就称之为逃逸分析(Escape Analysis)。逃逸分析由编译器完成,作用于编译阶段。
指针逃逸
指针逃逸即在函数中创建了一个对象,并将这个对象的指针通过return返回了出去。这种情况下,函数虽然退出了,但是因为指针还存在在函数外部,这个对象的内存不能随着函数的结束而回收(避免外部有使用这个指针指向的对象时,却因为回收出现空指针异常),因此只能分配在堆上,由垃圾回收机制管理。
|
|
这个例子中,函数 createDemo 的局部变量 d 发生了逃逸。d 作为返回值,在 main 函数中继续使用,因此 d 指向的内存不能够分配在栈上,随着函数结束而回收,只能分配在堆上。
编译时可以借助选项 -gcflags=-m,查看变量逃逸的情况
|
|
new(Demo) escapes to heap 即表示 new(Demo) 逃逸到堆上了。
interface{} 动态类型逃逸
在Go语言中,空接口 interface{}
可以表示任意的类型,如果函数的参数或返回值为 interface{},编译期间很难确定其参数的具体类型,也会发生逃逸。
|
|
因为 fmt.Println() 接收的参数是空接口类型,Go 编译器无法确定入参变量的具体类型,所以此类情况变量也会逃逸到堆上
栈空间不足逃逸
操作系统对内核线程使用的栈空间是有大小限制的,64位系统上通常是8MB。可以使用 ulimit -a
命令查看机器上栈允许占用的内存的大小。
|
|
由于栈空间通常比较小,因此当递归函数实现不当时,很容易导致栈溢出。
对应Go语言来说,运行时(runtime) 会尝试在goroutine需要的时候动态地分配栈空间,goroutine的初始栈大小为2KB。当goroutine被调度时,会绑定内核线程执行,栈空间大小也不会超过操作系统的限制。
对Go编译器而言,超过一定大小的局部变量将逃逸到堆上,不同Go版本的大小限制可能不一样。
|
|
- generate8191() 创建了大小为 8191 的 int 型切片,恰好小于 64 KB(64位机器上,int 占 8 字节),不包含切片内部字段占用的内存大小。
- generate8192() 创建了大小为 8192 的 int 型切片,恰好占用 64 KB。
- generate(n),切片大小不确定,调用时传入。
编译结果如下:
|
|
make([]int, 8191)
没有发生逃逸,make([]int, 8192)
和make([]int, n)
逃逸到堆上,也就是说,当切片占用内存超过一定大小,或编译时无法确定当前切片长度时,对象占用内存将在堆上分配。
闭包
|
|
例如:
|
|
Increase()
返回值就是一个闭包函数,该闭包函数访问了外部变量n,那变量n将一直存在,直到in
被销毁。很显然,变量n 占用的内存不能随着函数Increase() 的退出而回收,因此将会逃逸到堆上。
|
|
如何利用逃逸分析提升性能
函数参数和返回值 传值时会拷贝整个对象,而传指针只会拷贝指针地址,指向的对象是同一个对象。但是传指针虽然可以减少值的拷贝,却会导致对象逃逸到堆中,增加垃圾回收(GC)的负担。在对象频繁创建和删除的场景下,传递指针导致的GC开销可能会严重影响性能。
一般情况下,对于需要修改原对象值或者占用内存比较大的结构体会选择指针传递(避免对象拷贝,并且大结构体也可能会因为栈空间不足发生逃逸,既拷贝也要逃逸,那直接使用指针性能会更好)。对于只读的占用内存较小的结构体,直接传值能够获得更好的性能(避免频繁的指针逃逸影响GC性能)。
死码消除与调试模式
死码消除
死码消除(dead code elimination, DCE) 是一种编译器优化技术,用处是在编译阶段去掉对程序运行结果没有任何影响的代码。
死码消除有很多好处:减小程序体积,程序运行过程中避免执行无用的指令,缩短运行时间。
Go 语言中的应用
使用常量提升性能
在某些场景下,将变量替换为常量的话,性能会有很大的提升。
例如:
|
|
将var改为const后分别编译会发现两者大小有差距:
|
|
可以看到 maxconst 比 maxvar 体积小了约 10% = 0.22 MB。
为什么会出现 11% 的差异呢?
我们使用 -gcflags=-m 参数看一下编译器做了哪些优化:
|
|
max 函数被内联了
,即被展开了,手动展开后如下:
|
|
如果a和b均为常量的话,那在编译阶段就可以直接进行计算:
|
|
计算之后,10 > 20
永远为假,那么分支消除后:
|
|
进一步,20 == 10
也永远为假,再次分支消除:
|
|
如果全局变量a和b不为常量,编译器并不知道运行过程中ab会不会发生改变,因此不能进行死码消除
,这部分代码会被编译到最终的二进制程序中。因此maxvar
比 maxconst
二进制体积大了约 10%。
如果在if语句中调用了更多的库,死码消除后,体积差距会更大。
因此在声明全局变量时,如果能够确定这个变量不会发生改变,那么尽量使用const而非var,这样很多运行代码在编译时期就可以执行。死码消除后,既减小了二进制的体积,又可以提高运行时效率,如果这部分代码是 热路径 (hot path)
,那么对性能的提升会更加明显。
可推断的去局部变量
如果ab作为局部变量呢
|
|
编译结果如下,大小与 varconst 一致,即 a、b 作为局部变量时,编译器死码消除是生效的。因为ab局部变量只作用在该函数内部,是可以推断出它的值大小的,就可以直接进行死码消除。
|
|
那如果再修改一下,函数中增加修改 a、b 变量的并发操作。
|
|
编译结果如下,大小增加了 10%,此时,a、b 的值不能有效推断,死码消除失效。
|
|
其实这个结果很好理解,包(package)级别的变量和函数内部的局部变量的推断难度是不一样的。函数内部的局部变量的修改只会发生在该函数中(注意内部这个词,有协程修改它就不属于内部了)。但是如果是包级别的变量,对该变量的修改可能出现在:
- 包初始化函数 init() 中,init() 函数可能有多个,且可能位于不同的 .go 源文件。
- 包内的其他函数。
- 如果是 public 变量(首字母大写),其他包引用时可修改。
推断 package 级别的变量是否被修改难度是非常大的,从上述的例子看,Go 编译器只对局部变量作了优化,如果这个局部变量被协程引用,即被外部引用,那么它就会像全局变量一样无法进行死码消除。
由此可知:只有在其为常量(局部常量也是常量,它无法被外部函数修改)和只作用于当前函数内部的局部变量才会进行死码消除,其他情况由于无法在编译时期有效推断出具体的值,因此无法进行死码消除。
调试(debug)模式
我们可以在源代码中定义全局常量debug,值设置为false,在需要增加调试代码的地方,使用条件语句 if debug
包裹:
|
|
如果是正常编译,常量debug始终等于false,调试语句在编译过程中就会被当做死码消除,不会影响最终的二进制大小,也不会对运行效率产生任何影响。
如果想编译出debug版本的二进制程序就只需要将debug 修改为true编译即可。这对于开发者日常调试是非常有帮助的,日常开发过程中,在进行单元测试或者是简单的集成测试时,总会执行一些额外的操作,例如打印日志,或者是修改变量的值。提交代码时再将debug修改为false即可,这样在开发过程中增加的额外的调试代码在编译时期就会被消除,不会对正式版本产生任何影响。
条件编译
可以结合 build tags
来实现条件编译,可以不修改源代码也能编译出debug版本。
新建 release.go
和 debug.go
:
- debug.go
|
|
- release.go
|
|
- // +build debug 表示 build tags 中包含 debug 时,该源文件参与编译。
- // +build !debug 表示 build tags 中不包含 debug 时,该源文件参与编译。
编译一个 debug 版本并运行:
|
|
编译 release 版本并运行:
|
|
除了全局布尔值常量 debug
以外,debug.go
和release.go
还可以根据需要添加其他代码。比如相同的函数定义,debug 和release 模式下有不同的函数实现。
一个源文件中可以有多个build tags,同一行的空格隔开的tag之间是逻辑或的关系。不同行之间的tag是逻辑与的关系。
例如:
|
|
这种写法表示此源文件只能在linux_386 或者 darwin_386 平台下编译。
数组切片陷阱
数组
|
|
上面代码的输出为 [1,2]
,数组a没有发生改变。
- 在Go语言中,
数组是一种值类型
,而且不同长度的数组属于不同的类型。例如[2]int
和[20]int
属于不同的类型。 当值类型作为参数传递时,参数是该值的一个拷贝,因此更改拷贝的值并不会影响原值。
为了避免数组的拷贝,提高性能,建议传递数组的指针作为参数,或者直接使用切片代替数组。
切片
|
|
输出仍然是 [1,2]
,切片a没有发生改变。
传参时拷贝了切片,包括底层指针,此时两者指向的是同一个底层数组,但是在foo()中切片新增了8个元素,原切片的cap只有2,不够放置这些元素,因此申请了新的空间来放置扩充后的底层数组,导致函数体中的切片底层指针指向新的空间,此时foo中的a切片和外部的a切片就不是同一个了(引用类型拷贝的是指针,两者指针不同,只是最初都指向相同的底层数组罢了)。因此对新切片的修改不会影响到原切片。
如果希望foo函数的操作能够影响到原切片的话,可以给foo() 设置返回值,将新切片返回并赋值给 main 函数中的变量 a;也可以将参数改为指针传参 foo(a *[]int)
。从可读性上来说,更推荐返回值的方式。