[Go] 我在工作中总结的 Golang 高性能编程(下)
退出协程避免协程泄漏
协程泄漏原因一:超时控制
超时控制在网络编程中是非常常见的,利用 context.WithTimeout
和 time.After
都能够很轻易地实现。下面代码是一个典型的实现超时的例子。
下面代码的问题是,done
是一个无缓冲 channel
, 当走到超时逻辑的时候,timeout
函数退出,done
没有了接收方,导致 doBadthing
函数在发送的时候会一直阻塞。无法退出 goroutine
。
func doBadthing(done chan bool) {
time.Sleep(time.Second)
done <- true
}
func timeout(f func(chan bool)) error {
done := make(chan bool)
go f(done)
select {
case <-done:
fmt.Println("done")
return nil
case <-time.After(time.Millisecond):
return fmt.Errorf("timeout")
}
}
怎么解决呢?
第一种方法,创建有缓冲区的 channel, 缓冲区设置为 1,即使没有接收方,发送方也不会发生阻塞。
第二种方法,使用 select + defalt 尝试发送,如果发送失败,则说明缺少接收者(receiver),即超时了,那么走 default 逻辑直接退出即可。
func doGoodthing(done chan bool) {
time.Sleep(time.Second)
select {
case done <- true:
default:
return
}
}
实际的业务中更为复杂,我们会将任务拆分为好几段,这种情况下,就只能够使用 select,而不能够设置缓冲区的方式了。
协程泄漏原因二:channel 未关闭
func do(taskCh chan int) {
for {
select {
case t := <-taskCh:
time.Sleep(time.Millisecond)
fmt.Printf("task %d is done\n", t)
}
}
}
func sendTasks() {
taskCh := make(chan int, 10)
go do(taskCh)
for i := 0; i < 1000; i++ {
taskCh <- i
}
}
sendTasks 中启动了一个子协程 go do(taskCh),因为这个协程一直处于阻塞状态,等待接收任务,因此直到程序结束, 协程也没有释放
解决办法:
- 任务发送结束之后,使用 close(taskCh) 将 channel taskCh 关闭
- t, beforeClosed := <-taskCh 判断 channel 是否已经关闭, 如果关闭,退出协程
func doCheckClose(taskCh chan int) {
for {
select {
case t, beforeClosed := <-taskCh:
if !beforeClosed {
fmt.Println("taskCh has been closed")
return
}
time.Sleep(time.Millisecond)
fmt.Printf("task %d is done\n", t)
}
}
}
func sendTasksCheckClose() {
taskCh := make(chan int, 10)
go doCheckClose(taskCh)
for i := 0; i < 1000; i++ {
taskCh <- i
}
close(taskCh)
}
这里提一嘴,关闭 chanel 的时候,可以使用 sync.Once
或互斥锁(sync.Mutex
)确保 channel
只被关闭一次
type MyChannel struct {
C chan T
once sync.Once
}
func NewMyChannel() *MyChannel {
return &MyChannel{C: make(chan T)}
}
func (mc *MyChannel) SafeClose() {
mc.once.Do(func() {
close(mc.C)
})
}
还有一种粗暴,但不推荐的关闭方式,可以无限关闭,但是有通过 recover 使程序恢复正常。
func SafeClose(ch chan T) (justClosed bool) {
defer func() {
if recover() != nil {
// 一个函数的返回结果可以在defer调用中修改。
justClosed = false
}
}()
// 假设ch != nil。
close(ch) // 如果 ch 已关闭,将 panic
return true // <=> justClosed = true; return
}
控制协程的并发数量
为什么要控制协程的并发数量,简单来说就是系统资源有限,每个协程至少需要消耗 2KB 的空间,那么假设计算机的内存是 2GB,那么至多允许 2GB/2KB = 1M 个协程同时存在。当然不止是内存有限,文件句柄,栈资源也是有限的,系统资源奔溃的时候,会出现以下但不限于的下面的错误信息。
// too many open files
// out of memory
// panic: too many concurrent operations on a single file or socket (max 1048575)
怎么控制:
方式一:利用缓冲区 channel
实现
// main_chan.go
func main() {
var wg sync.WaitGroup
ch := make(chan struct{}, 3)
for i := 0; i < 10; i++ {
ch <- struct{}{}
wg.Add(1)
go func(i int) {
defer wg.Done()
log.Println(i)
time.Sleep(time.Second)
<-ch
}(i)
}
wg.Wait()
}
方式二: 利用第三方库
- ants
- tunny
方式三:如果控制了,但还是资源不足,可以使用 ulimit 来调整系统参数,比如: ulimit -n 999999
,将同时打开的文件句柄数量调整为 999999 来解决这个问题,其他的参数也可以按需调整
sync.Pool
为什需要 Pool
, 考虑下面的场景,json
的反序列化在文本解析和网络通信过程中非常常见,当程序并发度非常高的情况下,短时间内需要创建大量的临时对象。而这些对象是都是分配在堆上的,会给 GC
造成很大压力,严重影响程序的性能
Pool
可以保存和复用临时对象,减少内存分配,降低 GC 压力
Put 归还操作的时候,记得将值进行重置,不然下一次的 Get 操作,还会得到未进行重置的值
var studentPool = sync.Pool{
New: func() interface{} {
return new(Student)
},
}
stu := studentPool.Get().(*Student)
json.Unmarshal(buf, stu)
studentPool.Put(stu)
sync.Once
sync.Once
是 Go
标准库提供的使函数只执行一次的实现,常应用于单例模式,和 init
有什么区别呢?
sync.Once
可以在代码的任意位置初始化和调用,因此可以延迟到使用时再执行,并发场景下是线程安全的, 而 init 函数是当所在的 package 首次被加载时执行,若迟迟未被使用,则既浪费了内存,又延长了程序加载时间。
sync.Cond
sync.Cond
经常用在多个 goroutine
等待,一个 goroutine
通知(事件发生)的场景。如果是一个通知,一个等待,使用互斥锁或 channel
就能搞定了。
下面是一个简单的例子,三个协程调用 Wait()
等待,另一个协程调用 Broadcast()
唤醒所有等待的协程
var done = false
func read(name string, c *sync.Cond) {
c.L.Lock()
for !done {
c.Wait()
}
log.Println(name, "starts reading")
c.L.Unlock()
}
func write(name string, c *sync.Cond) {
log.Println(name, "starts writing")
time.Sleep(time.Second)
c.L.Lock()
done = true
c.L.Unlock()
log.Println(name, "wakes all")
c.Broadcast()
}
func main() {
cond := sync.NewCond(&sync.Mutex{})
go read("reader1", cond)
go read("reader2", cond)
go read("reader3", cond)
write("writer", cond)
time.Sleep(time.Second * 3)
}
编译优化
如何减少 go
程序编译后的体积, 观察下面的 build 代码。
go build -ldflags="-s -w" -o server main.go
-s
忽略符号表和调试信息
-w
忽略DWARFv3调试信息,使用该选项后将无法使用gdb进行调试
逃逸分析
在 C 语言中,可以使用 malloc 和 free 手动在堆上分配和回收内存。Go 语言中,堆内存是通过垃圾回收机制自动管理的,无需开发者指定。那么,Go 编译器怎么知道某个变量需要分配在栈上,还是堆上呢?编译器决定内存分配位置的方式,就称之为逃逸分析(escape analysis)。逃逸分析由编译器完成,作用于编译阶段。
什么情况会逃逸?
情况一:指针逃逸
指针逃逸应该是最容易理解的一种情况了,即在函数中创建了一个对象,返回了这个对象的指针。这种情况下,函数虽然退出了,但是因为指针的存在,对象的内存不能随着函数结束而回收,因此只能分配在堆上。
// main_pointer.go
package main
import "fmt"
type Demo struct {
name string
}
func createDemo(name string) *Demo {
d := new(Demo) // 局部变量 d 逃逸到堆
d.name = name
return d
}
func main() {
demo := createDemo("demo")
fmt.Println(demo)
}
情况二:interface{} 动态类型逃逸
在 Go 语言中,空接口即 interface{} 可以表示任意的类型,如果函数参数为 interface{},编译期间很难确定其参数的具体类型,也会发生逃逸。
情况二:栈空间不足
操作系统对内核线程使用的栈空间是有大小限制的,64 位系统上通常是 8 MB。可以使用 ulimit -a 命令查看机器上栈允许占用的内存的大小。
对 Go 编译器而言,超过一定大小的局部变量将逃逸到堆上, 比如切片占用内存超过一定大小,或无法确定当前切片长度时,对象占用内存将在堆上分配
情况三:闭包
Increase() 返回值是一个闭包函数,该闭包函数访问了外部变量 n,那变量 n 将会一直存在,直到 in 被销毁。很显然,变量 n 占用的内存不能随着函数 Increase() 的退出而回收,因此将会逃逸到堆上。
func Increase() func() int {
n := 0
return func() int {
n++
return n
}
}
func main() {
in := Increase()
fmt.Println(in()) // 1
fmt.Println(in()) // 2
}
那么如何利用逃逸分析提升性能呢?
一般情况下,对于需要修改原对象值,或占用内存比较大的结构体,选择传指针。对于只读的占用内存较小的结构体,直接传值能够获得更好的性能。虽然传指针可以减少值的拷贝,但是会导致内存分配逃逸到堆中,增加垃圾回收(GC)的负担。在对象频繁创建和删除的场景下,传递指针导致的 GC 开销可能会严重影响性能。
总结源自:极客兔兔, 以及结合了自己在工作当中的实战经验。