golang中goroutine泄漏的问题以及解决方案

参考文章

Goroutine leak 

Golang中的goroutine泄漏问题

如何退出协程 goroutine (超时场景)

如何退出协程 goroutine (其他场景)

问题纠正

之前视频讲过一个知识点,如何设置子协裎超时机制,其实像下面这段代码,主协裎关闭后子协裎是不会停止的:

func TestZ92(t *testing.T) {

    // 超时时间为1秒的ctx
    ctx, cancel := context.WithTimeout(context.Background(), time.Duration(time.Second*1))
    defer cancel()

    // 超时的子任务
    go func(ctx context.Context) {
        // 子任务执行3秒
        fmt.Println("子协裎任务开始执行!")
        // Notice 已经在执行的这个任务没有办法立刻退出!
        time.Sleep(time.Second * 3)
        fmt.Println("子协裎任务执行完成!")
    }(ctx)

    select {
    case <-ctx.Done():
        fmt.Println("主协裎的ctx已经完成!")
        // case <-time.After(time.Duration(time.Second * 5)):
        //     fmt.Println("timeout!!!")
        //     return
    }

    time.Sleep(time.Second * 6)

    fmt.Println("主协裎关闭!!!")
}
View Code

打印的结果如下(可以看到,虽然把ctx传进去但是没有用,主协裎由于超时关闭了,子协裎还在执行~):

子协裎任务开始执行!
主协裎的ctx已经完成!
子协裎任务执行完成!
主协裎关闭!!!

可以做如下修改:

func TestZ92(t *testing.T) {

    // 超时时间为1秒的ctx
    ctx, cancel := context.WithTimeout(context.Background(), time.Duration(time.Second*1))
    defer cancel()

    // 超时的子任务
    go func(ctx context.Context) {
        // 子任务执行3秒
        fmt.Println("子协裎任务开始执行!")
        // Notice 已经在执行的这个任务没有办法立刻退出!
        time.Sleep(time.Second * 3)
        // Notice 在耗时操作后面判断一下,如果ctx已经Done了,就不往下执行了!!!
        select {
        case <-ctx.Done():
            return
        }
        fmt.Println("子协裎任务执行完成!")
    }(ctx)

    select {
    case <-ctx.Done():
        fmt.Println("主协裎的ctx已经完成!")
        // case <-time.After(time.Duration(time.Second * 5)):
        //     fmt.Println("timeout!!!")
        //     return
    }

    time.Sleep(time.Second * 6)

    fmt.Println("主协裎关闭!!!")
} 

本机测试主协裎数量为2

func TestGnum(t *testing.T) {
    fmt.Println("最后协裎剩余数: ", runtime.NumGoroutine()) // 2
}

goroutine泄漏的概念

goroutine leak 的意思是go协程泄漏,那么什么又是协程泄漏呢?我们知道每次使用go关键字开启一个gorountine任务,经过一段时间的运行,最终是会结束,从而进行系统资源的释放回收。而如果由于操作不当导致一些goroutine一直处于阻塞状态或者永远运行中,永远也不会结束,这就必定会一直占用系统资源。最球的情况下是随着系统运行,一直在创建此类goroutine,那么最终结果就是程序崩溃或者系统崩溃。这种情况我们一般称为goroutine leak。

超时场景1及修改方案

func doSth1(done chan bool) {
    // 子协裎执行1秒
    time.Sleep(time.Second)

    // Notice 由于done是不带缓存的channel 如果done没有接收方,子协裎会一直夯在这里
    done <- true
}

func timeOut1(f func(chan bool)) error {
    // 不带缓存的channel ———— 发送/接收 得配对,否则都会夯住
    done := make(chan bool)

    go f(done)

    select {
    case <-done: // 接收done
        fmt.Println("done!")
        return nil
    case <-time.After(time.Millisecond): // 主协裎 1微秒 就超时
        //fmt.Println("timeOut!!!")
        return fmt.Errorf("timeout!")
    }
}

func test1(t *testing.T, f func(chan bool)) {
    t.Helper()
    for i := 0; i < 1000; i++ {
        timeOut1(f)
    }
    // 主协裎执行
    time.Sleep(time.Second * 2)
    t.Log("最终协裎的数量: ", runtime.NumGoroutine())

}

func TestZ93(t *testing.T) {
    test1(t, doSth1) // // 最终协裎的数量:  1002
}

done 是一个无缓冲区的 channel,如果没有超时,doBadthing 中会向 done 发送信号,select 中会接收 done 的信号,因此 doBadthing 能够正常退出,子协程也能够正常退出。

但是,当超时发生时,select 接收到 time.After 的超时信号就返回了,done 没有了接收方(receiver),而 doBadthing 在执行 1s 后向 done 发送信号,由于没有接收者且无缓存区,发送者(sender)会一直阻塞,导致协程不能退出。

方案一:使用带缓存的channel

func doSth1(done chan bool) {
    // 子协裎执行1秒
    time.Sleep(time.Second)

    done <- true
}

func timeOut1(f func(chan bool)) error {
    // 带缓存的channel 缓冲区设置为 1,即使没有接收方,发送方也不会发生阻塞
    done := make(chan bool, 1)

    go f(done)

    select {
    case <-done: // 接收done
        fmt.Println("done!")
        return nil
    case <-time.After(time.Millisecond): // 主协裎 1微秒 就超时
        //fmt.Println("timeOut!!!")
        return fmt.Errorf("timeout!")
    }
}

func test1(t *testing.T, f func(chan bool)) {
    t.Helper()
    for i := 0; i < 1000; i++ {
        timeOut1(f)
    }
    // 主协裎执行
    time.Sleep(time.Second * 2)
    t.Log("最终协裎的数量: ", runtime.NumGoroutine())

}

func TestZ93(t *testing.T) {
    test1(t, doSth1) // // 最终协裎的数量:  2

方案二:使用select尝试发送

func doSth1(done chan bool) {
    // 子协裎执行1秒
    time.Sleep(time.Second)

    // 使用select尝试发送
    // 使用 select 尝试向信道 done 发送信号,如果发送失败,则说明缺少接收者(receiver),即超时了,那么直接退出即可。
    select {
    case done <- true:
    default:
        return
    }
}

func timeOut1(f func(chan bool)) error {
    // 带缓存的channel 缓冲区设置为 1,即使没有接收方,发送方也不会发生阻塞
    done := make(chan bool)

    go f(done)

    select {
    case <-done: // 接收done
        fmt.Println("done!")
        return nil
    case <-time.After(time.Millisecond): // 主协裎 1微秒 就超时
        //fmt.Println("timeOut!!!")
        return fmt.Errorf("timeout!")
    }
}

func test1(t *testing.T, f func(chan bool)) {
    t.Helper()
    for i := 0; i < 1000; i++ {
        timeOut1(f)
    }
    // 主协裎执行
    time.Sleep(time.Second * 2)
    t.Log("最终协裎的数量: ", runtime.NumGoroutine())

}

func TestZ93(t *testing.T) {
    test1(t, doSth1) // // 最终协裎的数量:  2
}

使用goleak工具检测

go get go.uber.org/goleak

在上面有问题的那段代码中加上goleak检测的代码,运行一下就会提示报错了!:

func TestZ93(t *testing.T) {
    defer goleak.VerifyNone(t)
    test1(t, doSth1) // // 最终协裎的数量:  1002
}  

超时场景2(复杂/常见)及修改方案

还有一些更复杂的场景,例如将任务拆分为多段,只检测第一段是否超时,若没有超时,后续任务继续执行,超时则终止。

// 假设处理业务时需要分为2个步骤
func do2thing(p1, done chan bool) {
    // 第一个步骤 耗时1秒
    time.Sleep(time.Second * 1)
    // 结束后使用select尝试给p1这个channel中写入数据
    select {
    case p1 <- true:
    default:
        return
    }

    // 第二个步骤
    time.Sleep(time.Second * 1)
    // 如果主协程超时退出,没有 phase1 的接收方,子协程中 case phase1 <- true: 仍然是阻塞的消息发送失败就返回了(走default分支)
    // 所以这里可以使用无缓存的channel并且也不需要用select尝试发送
    done <- true
}

func timeOut2() error {
    // 都是不带缓存的channel
    p1 := make(chan bool)
    done := make(chan bool)

    go do2thing(p1, done)

    select {
    case <-p1:
        <-done
        fmt.Println("done!")
        return nil
    case <-time.After(time.Millisecond):
        return fmt.Errorf("timeOut!")
    }
}

func TestZ99(t *testing.T) {
    for i := 0; i < 1000; i++ {
        timeOut2()
    }

    time.Sleep(time.Second * 3)

    t.Log("最后协裎的数量: ", runtime.NumGoroutine()) // 最后协裎的数量:  2

这种场景在实际的业务中更为常见,例如我们将服务端接收请求后的任务拆分为 2 段,一段是执行任务,一段是发送结果。那么就会有两种情况:

  • 任务正常执行,向客户端返回执行结果。
  • 任务超时执行,向客户端返回超时。

这种情况下,就只能够使用 select,而不能能够设置缓冲区的方式了。因为如果给信道 phase1 设置了缓冲区,phase1 <- true 总能执行成功,那么无论是否超时,都会执行到第二阶段,而没有即时返回,这是我们不愿意看到的。对应到上面的业务,就可能发生一种异常情况,向客户端发送了 2 次响应:

  • 任务超时执行,向客户端返回超时,一段时间后,向客户端返回执行结果。

缓冲区不能够区分是否超时了,但是 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() {
    // 缓冲区为10
    taskCh := make(chan int, 10)
    
    go do(taskCh)
    
    for i := 0; i < 1000; i++ {
        taskCh <- i
    }
}

func TestDo(t *testing.T) {
    t.Log(runtime.NumGoroutine())
    sendTasks()
    time.Sleep(time.Second)
    
    // 最后多了一个协裎~因为taskCh一直没有关闭
    t.Log("最后剩余的协裎数: ",runtime.NumGoroutine()) // 3
}

解决

解决的核心是得让子协裎知道channel已经关闭了:

func do(taskCh chan int) {
    for {
        select {
        case t, beforeClosed := <-taskCh: // channel在接收前是否已经被关闭了,false表示已经关闭了
            if !beforeClosed {
                fmt.Println("taskCh has been closed")
                return
            }
            time.Sleep(time.Millisecond)
            fmt.Printf("task %d is done\n", t)
        }
    }
}

func sendTasks() {
    // 缓冲区为10
    taskCh := make(chan int, 10)

    go do(taskCh)

    for i := 0; i < 100; i++ {
        taskCh <- i
    }

    // close
    close(taskCh)
}

func TestDo(t *testing.T) {
    t.Log(runtime.NumGoroutine())

    sendTasks()

    time.Sleep(time.Second)

    // 最后多了一个协裎~因为taskCh一直没有关闭
    t.Log("最后剩余的协裎数: ", runtime.NumGoroutine()) // 2
}

~~~

posted on 2022-12-25 10:51  江湖乄夜雨  阅读(663)  评论(0编辑  收藏  举报