Go 内存泄漏那些事

Go 内存泄漏那些事

遵循一个约定:如果goroutine负责创建goroutine,它也负责确保他可以停止 goroutine

channel 泄漏

发送不接收,一般来说发送者,正常发送,接收者正常接收,这样没啥问题。但是一旦接收者异常,发送者会被阻塞,造成泄漏。

select case 导致协程泄漏

go 复制代码
func leakOfMemory() {
	errChan := make(chan error) //a.
	go func() {
		time.Sleep(2 * time.Second)
		errChan <- errors.New("chan error") // b.
		fmt.Println("finish ending ")
	}()

	select {
	case <-time.After(time.Second): 
		fmt.Println("超时") //c
	case err := <-errChan: //d.
		fmt.Println("err:", err)
	}
	fmt.Println("leakOfMemory exit")
}

func TestLeakOfMemory(t *testing.T) {
	leakOfMemory()
	time.Sleep(3 * time.Second)
	fmt.Println("main exit...")
	fmt.Println("NumGoroutine:", runtime.NumGoroutine())
}

上面的代码执行结果:

lua 复制代码
=== RUN   TestLeakOfMemory
超时
leakOfMemory exit
main exit...
NumGoroutine: 3
--- PASS: TestLeakOfMemory (4.00s)
PASS

最开始只有两个 goruntine ,为啥执行后有三个 goruntine ?

由于没有往 errChan 中发送消息,所以 d 处 会一直阻塞,1s 后 ,c 处打印超时,程序退出,此时,有个协程在 b 处往协程中塞值,但是此时外面的 goruntine 已经退出了,此时 errChan 没有接收者,那么就会在 b处阻塞,因此协程一直没有退出,造成了泄漏,如果有很多类似的代码,会造成 OOM

for range 导致的协程泄漏

看如下代码:

go 复制代码
func leakOfMemory_1(nums ...int) {
	out := make(chan int)
	// sender
	go func() {
		defer close(out)
		for _, n := range nums { // c.
			out <- n
			time.Sleep(time.Second)
		}
	}()

	// receiver
	go func() {
		ctx, cancel := context.WithTimeout(context.Background(), time.Second)
		defer cancel()
		for n := range out { //b.
			if ctx.Err() != nil { //a.
				fmt.Println("ctx timeout ")
				return
			}
			fmt.Println(n)
		}
	}()

}

func TestLeakOfMemory(t *testing.T) {
	fmt.Println("NumGoroutine:", runtime.NumGoroutine())
	leakOfMemory_1(1, 2, 3, 4, 5, 6, 7)
	time.Sleep(3 * time.Second)
	fmt.Println("main exit...")
	fmt.Println("NumGoroutine:", runtime.NumGoroutine())
}

上述代码执行结果:

vbnet 复制代码
=== RUN   TestLeakOfMemory
NumGoroutine: 2
1
2
ctx timeout 
main exit...
NumGoroutine: 3
--- PASS: TestLeakOfMemory (3.00s)
PASS

理论上,是不是最开始只有2个goruntine ,实际上执行完出现了3个gorountine, 说明 leakOfMemory_1 里面起码有一个协程没有退出。 因为时间到了,在 a 出,程序就准备退出了,也就是说 b 这个就退出了,没有接收者继续接受 chan 中的数据了,c处往chan 写数据就阻塞了,因此协程一直没有退出,就造成了泄漏。

如何解决上面说的协程泄漏问题?

可以加个管道通知来防止内存泄漏。

go 复制代码
func leakOfMemory_2(done chan struct{}, nums ...int) {
	out := make(chan int)
	// sender
	go func() {
		defer close(out)
		for _, n := range nums {
			select {
			case out <- n:
			case <-done:
				return
			}
			time.Sleep(time.Second)
		}
	}()

	// receiver
	go func() {
		ctx, cancel := context.WithTimeout(context.Background(), time.Second)
		defer cancel()
		for n := range out {
			if ctx.Err() != nil {
				fmt.Println("ctx timeout ")
				return
			}
			fmt.Println(n)
		}
	}()
}
func TestLeakOfMemory(t *testing.T) {
	fmt.Println("NumGoroutine:", runtime.NumGoroutine())
	done := make(chan struct{})
	defer close(done)
	leakOfMemory_2(done, 1, 2, 3, 4, 5, 6, 7)
	time.Sleep(3 * time.Second)
	done <- struct{}{}
	fmt.Println("main exit...")
	fmt.Println("NumGoroutine:", runtime.NumGoroutine())
}

代码执行结果:

vbnet 复制代码
=== RUN   TestLeakOfMemory
NumGoroutine: 2
1
2
ctx timeout 
main exit...
NumGoroutine: 2
--- PASS: TestLeakOfMemory (3.00s)
PASS

最开始是 2个 goruntine 程序结束后还2个 goruntine,没有协程泄漏。

goruntine 中 map 并发

map 是引用类型,函数值传值是调用,参数副本依然指向m,因为值传递的是引用,对于共享变量,资源并发读写会产生竞争,故共享资源遭受到破坏。

go 复制代码
func TestConcurrencyMap(t *testing.T) {
	m := make(map[int]int)
	go func() {
		for {
			m[3] = 3
		}

	}()
	go func() {
		for {
			m[2] = 2
		}
	}()
	//select {}
	time.Sleep(10 * time.Second)
}

上诉代码执行结果:

go 复制代码
=== RUN   TestConcurrencyMap
fatal error: concurrent map writes

goroutine 5 [running]:
runtime.throw({0x1121440?, 0x0?})
	/go/go1.18.8/src/runtime/panic.go:992 +0x71 fp=0xc000049f78 sp=0xc000049f48 pc=0x10333b1
...

用火焰图分析下内存泄漏问题

首先,程序代码运行前,需要加这个代码:

go 复制代码
import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	_ "net/http/pprof"
	"runtime"
	"testing"
	"time"
)

func TestLeakOfMemory(t *testing.T) {

	//leakOfMemory()
	fmt.Println("NumGoroutine:", runtime.NumGoroutine())
	for i := 0; i < 1000; i++ {
		go leakOfMemory_1(1, 2, 3, 4, 5, 6, 7)
	}
	//done := make(chan struct{})
	//defer close(done)
	//leakOfMemory_2(done, 1, 2, 3, 4, 5, 6, 7)
	time.Sleep(3 * time.Second)
	//done <- struct{}{}
	fmt.Println("main exit...")
	fmt.Println("NumGoroutine:", runtime.NumGoroutine())
	log.Println(http.ListenAndServe("localhost:6060", nil))
}

上面的执行后,登陆网址 http://localhost:6060/debug/pprof/goroutine?debug=1,可以看到下面的页面:

但是看不到图形界面,怎么办?

需要安装 graphviz

在控制台执行如下命令

bash 复制代码
brew install graphviz # 安装graphviz,只需要安装一次就行了
go tool pprof -http=":8081" http://localhost:6060/debug/pprof/goroutine?debug=1

然后可以登陆网页:http://localhost:8081/ui/ 看到下图:

发现有一个程序//GoProject/main/concurrency/channel.leakOfMemory_1.func1占用 cpu 特别大. 想看下这个程序是啥?

分析协程泄漏

使用如下结果:

go 复制代码
go tool pprof http://localhost:6060/debug/pprof/goroutine

火焰图分析:

go 复制代码
Total:总共采样次数,100次。
Flat:函数在样本中处于运行状态的次数。简单来说就是函数出现在栈顶的次数,而函数在栈顶则意味着它在使用CPU。
Flat%:Flat / Total。
Sum%:自己以及所有前面的Flat%的累积值。解读方式:表中第3行Sum% 32.4%,意思是前3个函数(运行状态)的计数占了总样本数的32.4%
Cum:函数在样本中出现的次数。只要这个函数出现在栈中那么就算进去,这个和Flat不同(必须是栈顶才能算进去)。也可以解读为这个函数的调用次数。
Cum%:Cum / Total

进入控制台,输入 top

go 复制代码
Type: goroutine
Time: Feb 5, 2024 at 10:02am (CST)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top
Showing nodes accounting for 1003, 99.90% of 1004 total
Dropped 35 nodes (cum <= 5)
      flat  flat%   sum%        cum   cum%
      1003 99.90% 99.90%       1003 99.90%  runtime.gopark
         0     0% 99.90%       1000 99.60%  //GoProject/main/concurrency/channel.leakOfMemory_1.func1
         0     0% 99.90%       1000 99.60%  runtime.chansend
         0     0% 99.90%       1000 99.60%  runtime.chansend1
(pprof)

其中 其中runtime.gopark即可认为是挂起的goroutine数量。发现有大量协程被 runtime.gopark

然后输入 traces runtime.gopark

go 复制代码
(pprof) traces  runtime.gopark
Type: goroutine
Time: Feb 5, 2024 at 10:02am (CST)
-----------+-------------------------------------------------------
      1000   runtime.gopark
             runtime.chansend
             runtime.chansend1
             //GoProject/main/concurrency/channel.leakOfMemory_1.func1
-----------+-------------------------------------------------------
         1   runtime.gopark
             runtime.chanrecv
             runtime.chanrecv1
             testing.(*T).Run
             testing.runTests.func1
             testing.tRunner
             testing.runTests
             testing.(*M).Run
             main.main
             runtime.main
-----------+-------------------------------------------------------
         1   runtime.gopark
             runtime.netpollblock
             internal/poll.runtime_pollWait
             internal/poll.(*pollDesc).wait
             internal/poll.(*pollDesc).waitRead (inline)
             internal/poll.(*FD).Read
             net.(*netFD).Read
             net.(*conn).Read
             net/http.(*connReader).backgroundRead
-----------+-------------------------------------------------------
         1   runtime.gopark
             runtime.netpollblock
             internal/poll.runtime_pollWait
             internal/poll.(*pollDesc).wait
             internal/poll.(*pollDesc).waitRead (inline)
             internal/poll.(*FD).Accept
             net.(*netFD).accept
             net.(*TCPListener).accept
             net.(*TCPListener).Accept
             net/http.(*Server).Serve
             net/http.(*Server).ListenAndServe
             net/http.ListenAndServe (inline)
             //GoProject/main/concurrency/channel.TestLeakOfMemory
             testing.tRunner
-----------+-------------------------------------------------------
(pprof)

可以发现泄漏了 1000 个 goruntine

然后通过调用栈,可以看到调用链路:

rust 复制代码
channel.leakOfMemory_1.func1->runtime.chansend1->runtime.chansend->runtime.gopark

runtime.chansend1 是阻塞的调用,协程最终被 runtime.gopark 挂起,从而导致泄漏。

然后再输入 list GoProject/main/concurrency/channel. leakOfMemory_1.func1 可以看到如下

go 复制代码
(pprof) list //GoProject/main/concurrency/channel.
leakOfMemory_1.func1
Total: 1004
ROUTINE ======================== //GoProject/main/concurrency/channel.leakOfMemory_1.func1 in /Users/bytedance/go/src///GoProject/main/concurrency/channel/channel_test.go
         0       1000 (flat, cum) 99.60% of Total
         .          .     62:	out := make(chan int)
         .          .     63:	// sender
         .          .     64:	go func() {
         .          .     65:		defer close(out)
         .          .     66:		for _, n := range nums {
         .       1000     67:			out <- n
         .          .     68:			time.Sleep(time.Second)
         .          .     69:		}
         .          .     70:	}()
         .          .     71:
         .          .     72:	// receiver

可以看到使用了一个非缓冲的 channel, 上面已经分析了,没有接收者,发送者out 在写入channel 时阻塞, 协程无法退出,因此有协程泄漏。

分析内存增长泄漏

bash 复制代码
go tool pprof http://localhost:6060/debug/pprof/heap

然后输入 top

go 复制代码
(pprof) top
Showing nodes accounting for 6662.08kB, 86.68% of 7686.14kB total
Showing top 10 nodes out of 24
      flat  flat%   sum%        cum   cum%
 5125.63kB 66.69% 66.69%  5125.63kB 66.69%  runtime.allocm
 1024.41kB 13.33% 80.01%  1024.41kB 13.33%  runtime.malg
  512.05kB  6.66% 86.68%   512.05kB  6.66%  internal/poll.runtime_Semacquire
         0     0% 86.68%   512.05kB  6.66%  GoProject/main/concurrency/channel.leakOfMemory_1.func2
         0     0% 86.68%   512.05kB  6.66%  fmt.Fprintln
         0     0% 86.68%   512.05kB  6.66%  fmt.Println (inline)
         0     0% 86.68%   512.05kB  6.66%  internal/poll.(*FD).Write
         0     0% 86.68%   512.05kB  6.66%  internal/poll.(*FD).writeLock (inline)
         0     0% 86.68%   512.05kB  6.66%  internal/poll.(*fdMutex).rwlock
         0     0% 86.68%   512.05kB  6.66%  os.(*File).Write
(pprof)

看着不是很大,达不到内存增长泄漏的级别。

参考资料

相关推荐
一弓虽8 分钟前
SpringBoot 学习
java·spring boot·后端·学习
姑苏洛言17 分钟前
扫码小程序实现仓库进销存管理中遇到的问题 setStorageSync 存储大小限制错误解决方案
前端·后端
光而不耀@lgy32 分钟前
C++初登门槛
linux·开发语言·网络·c++·后端
方圆想当图灵1 小时前
由 Mybatis 源码畅谈软件设计(七):SQL “染色” 拦截器实战
后端·mybatis·代码规范
毅航1 小时前
MyBatis 事务管理:一文掌握Mybatis事务管理核心逻辑
java·后端·mybatis
我的golang之路果然有问题2 小时前
速成GO访问sql,个人笔记
经验分享·笔记·后端·sql·golang·go·database
柏油2 小时前
MySql InnoDB 事务实现之 undo log 日志
数据库·后端·mysql
写bug写bug3 小时前
Java Streams 中的7个常见错误
java·后端
Luck小吕4 小时前
两天两夜!这个 GB28181 的坑让我差点卸载 VSCode
后端·网络协议
M1A14 小时前
全栈开发必备:Windows安装VS Code全流程
前端·后端·全栈