golang 并发(1) --- goroutine 泄漏

 

最近在工作中使用 golang 编程,因为语言内置对并发的支持(go 关键字),所以 golang 越来越受到服务端开发的青睐。今天的文章给大家分享一下 go 并发编程中一个比较隐晦的 bug --- goroutine 泄漏。

注意本文不是 golang 语法的科普贴,如果对 golang channel、go、error 等基础语法不了解的话,推荐大家先去看一下 《go 语言圣经》。

在日常的开发场景中,我们经常可以将一个大任务分解成多个互相独立、可以相互并行(无前后依赖)的小任务。一旦完成了对任务的分解,我们就可以使用并发/并行技术,加速我们的程序。假设现有:

func Task() error {
    // 调度各个 sub-task 等待其结束
    return nil    
}

func SubTaskA() error {
    // 略
    return nil
}

func SubTaskB() error {
    // 略
    return nil
}

func SubTaskC() error {
    // 略
    return nil
}

这些 SubTask 可以独立于其他 SubTask 自己运行。那在 go 中通过下面的代码实现自任务的并发处理:

func Task() error {
    go SubTaskA()
    go SubTaskB()
    go SubTaskC()
    return nil
}

简单的一个关键字 go,就实现了多个 subroutine 的并发调度,这里真的不得不感慨 golang 对并发的友好。

上面的代码固然实现了多个子任务并发执行的需求,但是使用 go 关键字后,我们对子任务各自的返回值变得一无所知,这显然是不科学的。这个问题的本质其实是线程(注意 go 中的是 goroutine,比线程要更加轻量,但是因为本质特征是在同一内存地址内的通信)之间的通信问题。这个主题就不是三言两语可以覆盖的了,共享内存、有名/无名管道、fifo 等等不一而足。这里我们使用 golang 中内置提供的 channel 来实现通信:

func Task() error {
    chA := make(chan error)
    chB := make(chan error)
    chC := make(chan error)
    go SubTaskA(chA)
    go SubTaskB(chB)
    go SubTaskC(chC)

    for i := 0; i < 3; i ++ {
        select {
        case err := <-chA:
            // 处理 SubTaskA 的返回值
        case err := <-ChB:
            // 处理 SubTaskB 的返回值
        case err := <-ChC:
            // 处理 SubTaskC 的返回值
        }
    }        
    return nil
}

func SubTaskA(ch <-chan error) {
    err := doSomethingA()
    ch <- err
}

func SubTaskB(ch <-chan error) {
    err := doSomethingB()
    ch <- err
}

func SubTaskC(ch <-chan error) {
    err := doSomethingC()
    ch <- err
}

为了实现 goroutine 之间的通信,我们使用了 channel 来传递信息(这里是各个子任务的 error)。 Task 主 routine 在启动了各个 subroutine 以后,就在 for 循环中 select 3 个 channel,等待 SubTask 的信息。

这里需要注意一点,在处理 SubTask 的 error 的时候,千万不要这样做:

func Task() error {
    chA := make(chan error)
    chB := make(chan error)
    chC := make(chan error)
    go SubTaskA(chA)
    go SubTaskB(chB)
    go SubTaskC(chC)

    for i := 0; i < 3; i ++ {
        select {
        case err := <-chB:
            if err != nil {
                // 千万不要直接返回,这样可能导致 goroutine 泄漏
                return err
            }
            // 正常逻辑
        // ... 类似处理 SubTaskA
    }        
    return nil
}

func SubTaskA(ch <-chan error) {
    err := doSomethingA()
    // 写入 ch 的代码,必须要在 channel 的接收端接收了信息才可以返回
    ch <- err
}

这样的代码可能会导致 goroutine 泄漏的问题,因为我们创建的都是无 buffer 的 channel,如果写入 channel 的时候,没有接收端(在这里是我们收到了某个 SubTask* 的 error 以后直接退出了 Task 函数),那么写入端(这里的 SubTaskA,见注释),会一直阻塞,最终导致这个本该被回收的 goroutine 无法被 runtime 回收。一个简单的代码可以验证:

package main

import "fmt"

func main() {

	ch := make(chan int)

	go func() {
		fmt.Println("I'm recver")
		<-ch
	}()
	ch <- 1
	fmt.Println("All right")

	// 会一直阻塞
	ch <- 1
	fmt.Println("Will never reach")
}

/*
输出:
I'm recver
All right
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()
	/Users/nealzhu/Downloads/keyspider/x.go:17 +0xda
exit status 2
*/

可以看到,这样的代码可以导致死锁、资源无法回收等问题。那么怎么解决呢?一般来说可以通过两种方法:

  1. 如果预先知道这个 channel 会(最多)接收多少信息(比如这里的 1),可以使用 buffered channel(个人比较推荐)
  2. 如果 buffered channel 不可用(比如你不知道可能会接收多少信息),退出函数前启动一个 channel drainer

对应到例子的代码:

buffered channel:

func Task() error {
    chA := make(chan error, 1)
    chB := make(chan error, 1)
    chC := make(chan error, 1)
    go SubTaskA(chA)
    go SubTaskB(chB)
    go SubTaskC(chC)

    for i := 0; i < 3; i ++ {
        select {
        case err := <-chB:
            if err != nil {
                return err
            }
            // 正常逻辑
        // ... 类似处理 SubTaskA
    }        
    return nil
}

func SubTaskA(ch <-chan error) {
    err := doSomethingA()
    // 写入 ch 的代码,因为 ch 的 cap 是1,所以第一次的写入不可能被阻塞
    ch <- err
}

channel drainer


func Task() error {
    chA := make(chan error)
    chB := make(chan error)
    chC := make(chan error)
    go SubTaskA(chA)
    go SubTaskB(chB)
    go SubTaskC(chC)
    defer func() {
        // drainer, 注意需要写入端配合加入 close(chan),否则这些 for range 会一直阻塞
        for _ := range chA {
        }
        for _ := range chB {
        }
        for _ := range chC {
        }
    }()

    for i := 0; i < 3; i ++ {
        select {
        case err := <-chB:
            if err != nil {
                return
            }
            // 正常逻辑
        // ... 类似处理 SubTaskA
    }        
    return nil
}

func SubTaskA(ch <-chan error) {
    err := doSomethingA()
    // 写入 ch 的代码,因为 ch 的 cap 是1,所以第一次的写入不可能被阻塞
    ch <- err
    // close,防止接收端陷入无限的等待
    close(ch)
}

可以看到,buffered channel 的处理方式要优雅不少,channel drainer 的方法的话,不同业务代码中,drainer 的逻辑、写法相差比较大,而且往往需要写入方(有时候可能还需要引入额外的通知机制)配合。

看到这里,相信大家对 goroutine 泄漏为什么会发生,和如何避免已经有所了解了,这篇博客也就到此为止。希望有帮到大家,也希望大家可以帮忙点赞、评论~

你可能感兴趣的:(golang)