内存泄漏是保留了对对象的非预期引用所造成的。
Goroutine泄露是指程序中启动的Goroutine无法正常退出,长期驻留在内存中,导致资源(如内存、CPU)逐渐耗尽的现象。类似于内存泄漏,但表现为未终止的 Goroutine 的累积。
内存泄漏问题分析
1.slice/string切片泄露
原因:
- slice底层结构是data,len和cap
- data是uintptr类型的
- 做切片之后,底层指向的还是原来的内存空间,旧的没有被释放 解决方案:
- 申请新的数组做深拷贝
2.不同版本的go内存释放策略不同(怀疑内存泄漏,实际不是内存泄漏,占用居高不下)
mp.weixin.qq.com/s/eCR7cIqvr…
mp.weixin.qq.com/s/7bpzvGLPd…
- 不同 GO 版本的释放策略
- 在GO1.12之前,默认选择MADV_DONTNEED策略进行内存回收,通过立即释放物理内存来降低RSS
- 在GO1.12~GO1.15,默认选择MADV_FREE策略进行内存回收(linux内核版本>=4.5),标记内存为可回收状态但延迟实际释放
- 在GO1.16及之后,又改回MADV_DONTNEED策略进行内存回收,重新强调RSS指标的实时性。
- 不同策略的释放机制
- MADV_DONTNEED:内核立即从进程页表中移除物理页映射(内核将会在合适的时机去释放内存), RSS(常驻内存)将会立即减少。如果再次申请内存,内核会重新分配一块新的空间。
- MADV_FREE:仅标记内存为可回收,内核在内存压力时才真正回收,分配给其他进程使用。进程的RSS不会立即减少。只能在linux内核版本4.5以上才能使用。
- 不同策略的实际差别
- 性能:MADV_FREE最开始只打标记,速度更快,延迟内存的分配和回收,减少物理页操作提升效率,可以提高内存管理的效率,但可能导致内存碎片。
- 可观测性:MADV_DONTNEED使RSS更真实反映内存使用,而MADV_FREE可能导致监控工具误报内存泄漏
- 为什么Go放弃使用性能更高的MADV_FREE
- 指标失真:
MADV_FREE使进程RSS长期保持高位,导致K8S误判内存压力,引发不必要的扩缩容操作 - 资源挤压:混合部署的环境里,Go进程占用大量标记为可回收的内存,导致同主机其他应用内存不足
- 依赖Linux4.5+版本,Go需要兼容更广泛的部署环境
- 内存管理失控:容器资源受限,内存很小,可能会导致Go进程还没有释放内存,就触发了OOM
- 误报内存泄漏:线上观测到内存占用过高,实际可能是延迟释放的特性导致的
- 性能收益有限:虽然MADV_FREE减少了系统调用次数,但在Go的垃圾回收机制下,内存复用的实际收益未达预期,反而增加了调试成本
- Go 团队最终选择优先保证内存使用的可观测性和稳定性,而非单纯追求理论性能。MADV_DONTNEED使RSS指标更真实反映内存状态,降低运维复杂度
- 指标失真:
- 使用建议
- 在1.12-1.15强制使用MADV_DONTNEED:设置环境变量
GODEBUG=madvdontneed=1 - 监控优化:1.16之后的版本,可以结合
/proc/self/smaps文件中的Pss指标(按比例计算共享内存)进行更精确的内存监控 - 容器化部署:在k8s环境中,可以调整进程OOM优先级,避免
MADV_DONTNEED导致的内存竞争。(即使进程的RSS较低,较低的oom_score_adj仍能使其在内存竞争中存活,避免因短暂内存压力被误杀。)
- 在1.12-1.15强制使用MADV_DONTNEED:设置环境变量
- 证明确实是这个问题
- 通过一个气球程序来占用内存,让操作系统进行内存回收
- 能被气球程序申请到的内存就是已经不被使用的内存(控制内存容量)(不能是k8s集群)
- 为什么MADV_FREE会导致内存碎片
- 物理页分散:标记页的释放时机不确定,内核在内存压力下可能仅释放部分页,导致相邻的空闲页无法合并为大块连续内存,影响后续大内存分配的成功率
- 清理优先级:内核优先回收未标记的活跃页,导致标记清理的页长期驻留内存,当进程再次申请内存的时候,内核可能被迫分配新的物理页,加剧碎片。
3.goroutine过多/阻塞
3.1 goroutine被阻塞无法释放
- 互斥锁/waitgroup/select/channel
- goroutine继续执行的条件无法被触发,会阻塞,协程没结束,协程里该释放的没释放
- 如果是channel,尽量设置1的缓冲,防止没有消费者了,生产者被阻塞
- I/O未设置超时时间,导致goroutine一直在等待
- 在请求第三方网络连接接口时,因网络问题一直没有接到返回结果,如果没有设置超时时间,则代码会一直阻塞。
- 上游服务设置了keep alive
- idletimeout、readtimeout没设置
- 如果上游持有本服务的连接不进行释放,那么服务端会一直维持着这个连接的存在,不进行回收,导致协程泄露
- 不只是http有这样的问题,thrift或者其他rpc也会这样,如果服务端不对连接设置timeout,就会被上游拖死。
- 在请求第三方网络连接接口时,因网络问题一直没有接到返回结果,如果没有设置超时时间,则代码会一直阻塞。
3.2 goroutine申请过多
- goroutine申请过多,增长速度快于释放速度,会导致goroutine越来越多
- 比如:一次请求就新建一个client,业务请求量大时client建立过多,来不及释放。
- cgo一般是单独开一个线程进行处理,是runtime不能管理的
4.for循环里使用time.After
- 在for里进行select,执行time.After
- 如果命中了别的case,这个timer不会丢弃,而是等待时间到之后,才会被GC(变成孤儿)
- 命中了别的case,执行完之后又到了select开头,导致快速触发newTimer
- 解决方法:不要在for循环里触发newTimer,而是在外边newTimer,在里边直接
<-timer.C - golang在1.23版本之后修复了这个问题
5.time.Ticker没有关闭
- 使用time.Ticker需要手动调用stop方法,否则将会造成永久性内存泄漏。
6.不关闭打开的文件/关闭过晚
- 有可能文件描述符达到最大限制,无法再打开新的文件或者连接,程序会报
too many open files的错误 - 如果关闭的过晚,也可能会出现处理过程中一直占用着文件描述符。
7.http.Response.Body未关闭
mp.weixin.qq.com/s/RoC3FXLz2… mp.weixin.qq.com/s/MwnArLI04…
-
不执行ioutil.ReadAll(resp.Body),网络连接无法归还到连接池。
-
不执行resp.Body.Close(),网络连接就无法被标记为关闭,也就无法正常断开。
-
尽量避免使用默认的transport,使用有idle connect timeout的transport。
-
复用连接池里同一个连接的要求
- 使用同一个transport,用完就关闭。
- 接收方ip、接收方端口、协议,五元组都相同。
-
(正常情况下我们的代码都会执行
ioutil.ReadAll(),但如果此时忘了resp.Body.Close(),确实会导致泄漏。但如果你调用的域名一直是同一个的话,那么只会泄漏一个读goroutine和一个写goroutine,这就是为什么代码明明不规范但却看不到明显内存泄漏的原因。)
8.runtime.SetFinalizer阻塞或循环引用
- 如果两个对象都设置了runtime.SetFinalizer
- runtime.SetFinalizer是顺序执行的
- 如果它们之间存在循环引用、或者某一个有阻塞
- 会出现泄露
9.defer晚
- 不论是对文件close,或者ticker进行stop,还是body进行close
- 尽量用直接写close来替代defer
- 如果占用连接过久没有释放,也会造成内存泄漏
- (在执行函数功能,还没执行defer,造成占用资源)
10.map不释放桶
mp.weixin.qq.com/s/ZHk4zjZdP… mp.weixin.qq.com/s/DxLEazUs-…
- 业务涨量,导致map变大,触发扩容之后,桶会变大
- 业务涨量结束之后,内存有降低,但还是比最开始高
- map存储的内容删除,但是桶无法缩减
- 解决方案
- 定时进行map深拷贝,但是在复制的时候会用两倍的内存,在内存低峰期可以接受
- 使用指针进行存储,并不解决会有大量存储桶的问题,而每个存储桶条目将为值保留指针的大小,能明显降低
- 业务低峰期定时重启
- (桶:bucket是一个由八个元素组成的固定大小数组。如果插入到已经满了的bucket中(bucket溢出),Go会创建另一个包含八个元素的buckets,并将前一个元素链接到该bucket)
11.错误的循环引用
- golang的垃圾回收用的是标记清除算法,对于循环引用的情况,只要A和B都无法从根出发进行访问,就会被回收。
- 但是如果,循环对象中的某个对象,仍然被程序的其他部分引用,这个循环引用永远不会被垃圾回收,出现内存泄漏
内存泄漏示例代码/解决代码
1.slice切片泄露
a指向的还是b原来的内存空间(没被用到的地方没有被释放)
var a []int
func f(b []int) []int {
a = b[:2]
return a
}
func main() {
...
}
做深拷贝
var a []int
var c []int // 第三者
func f(b []int) []int {
a = b[:2]
// 新的切片 append 导致切片扩容
c = append(c, b[:2]...)
fmt.Printf("a: %p\nc: %p\nb: %p\n", &a[0], &c[0], &b[0])
return a
}
4.for循环里使用time.After
timer不会丢弃,而是等待时间到之后,才会被GC
func main() {
ch := make(chan int, 10)
go func() {
in := 1
for {
in++
ch <- in
}
}()
for {
select {
case _ = <-ch:
// do something...
continue
case <-time.After(3 * time.Minute):
fmt.Printf("现在是:%d", time.Now().Unix())
}
}
}
不要在for循环里触发newTimer,而是在外边newTimer,在里边直接<-timer.C
func main() {
timer := time.NewTimer(3 * time.Minute)
defer timer.Stop()
...
for {
select {
...
case <-timer.C:
fmt.Printf("现在是:%d", time.Now().Unix())
}
}
}
5.time.Ticker没有关闭
func main(){
ticker := time.NewTicker(5 * time.Second)
go func(ticker *time.Ticker) {
for range ticker.C {
fmt.Println("Ticker1....")
}
fmt.Println("Ticker1 Stop")
}(ticker)
time.Sleep(20* time.Second)
//ticker.Stop()
}
6.不关闭打开的文件/关闭过晚
func main() {
files := make([]*os.File, 0)
for i := 0; ; i++ {
file, err := os.OpenFile("test.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
fmt.Printf("Error at file %d: %v\n", i, err)
break
} else {
_, _ = file.Write([]byte("Hello, World!"))
files = append(files, file)
}
}
}
7.http.Response.Body未关闭
func makeRequest() {
client := &http.Client{}
req, err := http.NewRequest(http.MethodGet, "http://localhost:8081", nil)
res, err := client.Do(req)
if err != nil {
fmt.Println(err)
}
_, err = ioutil.ReadAll(res.Body)
// defer res.Body.Close()
if err != nil {
fmt.Println(err)
}
}
10.map不释放桶
func main() {
n := 1_000_000
m := make(map[int][128]byte)
printAlloc()
for i := 0; i < n; i++ { // Adds 1 million elements
m[i] = [128]byte{}
}
printAlloc()
for i := 0; i < n; i++ { // Deletes 1 million elements
delete(m, i)
}
runtime.GC() // Triggers a manual GC
printAlloc()
runtime.KeepAlive(m) // Keeps a reference to m so that the map isn’t collected
}
func printAlloc() {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("%d KB\n", m.Alloc/1024)
}
内存泄漏问题排查
1.pprof
mp.weixin.qq.com/s/8UG7qJabq… mp.weixin.qq.com/s/vncOjgrSo…
1.1 使用方法
- 创建一个debug端口
package main
import (
"net/http"
_ "net/http/pprof"
)
func main() {
// 启动一个 HTTP 服务器来提供 pprof 数据
go func() {
http.ListenAndServe("localhost:8080", nil)
}()
// do something
}
- 在特定位置获取heap快照
import "runtime/pprof"
pprof.WriteHeapProfile(writer)
使用 go tool pprof 命令访问本地服务的 /debug/pprof/profile 接口,CPU 采样数据会自动保存到 $HOME/pprof/ 目录下,同时也会直接进入分析命令行。
- cpu性能分析
go tool pprof http://127.0.0.1:6060/debug/pprof/profile?seconds=30
- heap/内存性能分析
go tool pprof http://127.0.0.1:6060/debug/pprof/heap?seconds=30
- goroutine分析
go tool pprof http://127.0.0.1:6060/debug/pprof/goroutine?seconds=30
- block分析
/debug/pprof/block
1.2 分析方法
cloud.tencent.com/developer/a…
Flat:函数自身运行耗时Flat%:函数自身耗时比例Sum%:指的就是每一行的flat%与上面所有行的flat%总和Cum:当前函数加上它所有调用栈的运行总耗时Cum%:当前函数加上它所有调用栈的运行总耗时比例- 举例说明
- 函数
demo由三部分组成:调用函数foo、自己直接处理一些事情、调用函数bar, - 其中调用函数
foo耗时1秒,自己直接处理事情耗时3秒,调用函数bar耗时2秒, - 那么函数
demo的flat耗时就是3秒,cum耗时就是6秒。
- 函数
2.goref
github.com/cloudwego/g…
mp.weixin.qq.com/s/ZJZwPphCW…
goref的优点:能准确展示内存引用分布和引用关系,是通过dlv来实现的。
- 安装 如果报unknown directive:toolchain问题,需要升级Go版本到1.21以上
go install github.com/cloudwego/goref/cmd/grf@latest
- 设置grf命令 会优先使用gobin的路径
echo $GOBIN
echo $GOPATH
- 示例
$ echo $GOBIN
/home/log/.GOPATH/bin
$ ll /home/log/.GOPATH/bin/grf
/home/log/.GOPATH/bin/grf
$
需要给PATH把gobin路径加上
export PATH=$PATH:/home/log/.GOPATH/bin
- 获取内存信息
grf attach {pid}
把输出文件放到开发环境
- 安装可视化工具
yum install graphviz
- 查看结果 需要外网访问则必要用0.0.0.0
go tool pprof -http=0.0.0.0:5079 ./grf.out
todo goref缺点,pprof缺点(性能等)