[Go] 我在工作中总结的 Golang 高性能编程(下)

10 天前(已编辑)
24

[Go] 我在工作中总结的 Golang 高性能编程(下)

退出协程避免协程泄漏

协程泄漏原因一:超时控制

超时控制在网络编程中是非常常见的,利用 context.WithTimeouttime.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.OnceGo 标准库提供的使函数只执行一次的实现,常应用于单例模式,和 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 开销可能会严重影响性能。

总结源自:极客兔兔, 以及结合了自己在工作当中的实战经验。

使用社交账号登录

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...