探究golang的channel和map内存释放问题

前言:

       前两天朋友问我channel是否会造成内存泄露?我告诉他不会,一般来说 只要没有对象引用关系,那么go gc就会给你标记清除掉。 但这小哥不信,一直说是内存泄露,看了朋友的go代码才知道是个怎么一回事。 该文章后续仍在不断的更新修改中, 请移步到原文地址  http://xiaorui.cc/?p=5450

       简单说下他的问题,大家可以想下这么个场景,运行一个函数,他的逻辑是创建了大channel,扔给其他函数执行,但相关函数没有执行或者没有执行完就返回了。channel的buf里还堆积了大量数据,占用的对象是否被释放,go内存池里多余的mspan是否归还操作系统? 

测试代码

// xiaorui.cc

package main

import (
    "time"
    "fmt"
)

func main() {
    engine()
    fmt.Println("start")
    time.Sleep(360 * time.Second)

    engine()
    fmt.Println("start")
    time.Sleep(360 * time.Second)

    engine()
    fmt.Println("start")
    time.Sleep(3600 * time.Second)
}

func engine() {
    run()
}

func run() {
    incr := 100000000
    c := make(chan string, incr)

    s := "xxxxxxssssssssssssw"
    for index := 0; index < incr-10; index++ {
        c <- s
    }
    close(c)
}
...

上面的测试代码只是为了演示内存是否释放, 工作中不应该出现这类逻辑,算是bug了。

再次明确下答案,只要你的channel没有引用关系了,就算你没有close关闭或者chan有大量的堆积数据没有消费,最终会被gc释放。 通过runtime的memstats可以看到memory heap stats各个数据的状态。

但是我们直接通过top查看该进程的内存, 还是有1.5G的空间占用。 


在经过几次ForceGC和scavenge后,才会释放内存给操作系统。 尝试过多次,基本在15分钟左右。


在没有释放内存的时间窗口里,空闲的mspan没有释放回去,可能被mcache freelist拿着,可能被mcentral拿着,也就是说,没有归还给操作系统sys。

花样测试

我们可以测试一下被go内存池占用的mspan是否可以得到复用?

来一遍channel的填充,等待force gc和scvg后,再次填充,内存没有变化。
来一次channel的填充,等待gc后,再来一遍map的填充。内存也没有大小变化。
这两个例子说明,虽然占用的内存没有及时归还系统,但这个不属于内存泄露,因为在内存池里空闲的mspan依然是得到复用。内存泄露的定义是无法释放对象,明显这不是。。。

曾经的一个问题

上面说的channel和map,在没有引用关系的情况下,等待一段时间后内存会释放。

但是全局的channel和map会有啥体现? 我曾经测试过全局的channel在消费干净后,内存会在几次scvg之后被释放。 但是全局的大map在全部key被delete后不会释放干净,只会释放一部分内存,等了好久也没有继续释放。

我们知道go内存池为了避免频繁的malloc内存,减少系统调用,所以把内存放置到go内存池里。 但好几个大g被占用,说不过去。虽然Runtime会每隔2分钟进行强制GC,每隔5分钟调用scvg释放归还系统内存,但全局map总是释放不干净。

解决方案

我们优先应该想到的是怎么解除引用关系?

使用新对象替换全局对象或者是重置成nil,但是nil明显不合理,会造成panic。map和channel都是引用类型,没有引用关系了,自然就会被gc和scavenge。 

如果不能使用替换引用的方法,可以使用 runtime提供的 debug.FreeOSMemory 方法,  文档https://golang.org/pkg/runtime/debug/#FreeOSMemory在各类go社区里大家说这个方法危险,毕竟他前面有个debug。 但怎么就危险了,貌似没人说明白。 

我自己用FreeOSMemory的两个使用技巧:

第一种,监听自定义的信号,当接收signal时,回调 debug.FreeOSMemory

第二种,启一个协程专门来监控当前内存状态,在适当的时候进行debug.FreeOSMemory 。 

下面是GODEBUG=gctrace=1的日志,debug.FreeOSMemory调用之前的gctrace

// xiaorui.cc

scvg0: inuse: 2, idle: 5, sys: 7, released: 0, consumed: 7 (MB)
scvg0: inuse: 0, idle: 1526, sys: 1526, released: 0, consumed: 1526 (MB)

调用 debug.FreeOSMemory 之后的表现, 明显看到他释放了1.5G的内存。

// xiaorui.cc

scvg-1: 1526 MB released
scvg-1: inuse: 0, idle: 1526, sys: 1526, released: 1526, consumed: 0 (MB)
scvg0: inuse: 2, idle: 5, sys: 7, released: 0, consumed: 7 (MB)
scvg0: inuse: 0, idle: 1526, sys: 1526, released: 1526, consumed: 0 (MB)

下面的 free 日志是我手动信号调用 runtime.debug.FreeOSMemory() 打印的。

debug.FreeOSMemory 做了什么?

我们先看下sysmon()监控方法。在我们启动go服务的时候,有一个线程是用来专门跑sysmon()的。 sysmon不仅可以用来抢占P,而且可以做强制runtime.GC 和 scavenge内存方式逻辑。 下面是runtime/proc.go的代码,清楚的说明2分钟为强制GC,5分钟调用scavenge释放内存。

// xiaorui.cc

runtime/proc.go

var forcegcperiod int64 = 2 * 60 * 1e9

// Always runs without a P, so write barriers are not allowed.
//
//go:nowritebarrierrec
func sysmon() {
    lock(&sched.lock)
    sched.nmsys++
    checkdead()
    unlock(&sched.lock)

    // If a heap span goes unused for 5 minutes after a garbage collection,
    // we hand it back to the operating system.
    scavengelimit := int64(5 * 60 * 1e9)
    …
    // scavenge heap once in a while
    if lastscavenge+scavengelimit/2 < now {
        mheap_.scavenge(int32(nscavenge), uint64(now), uint64(scavengelimit))
        lastscavenge = now
        nscavenge++
    }
    ...

debug.FreeOSMemory的源码,发现他会先调用一次GC,然后调用mheap的scavenge方法。

// xiaorui.cc

runtime/mheap.go

//go:linkname runtime_debug_freeOSMemory runtime/debug.freeOSMemory
func runtime_debug_freeOSMemory() {
    GC()
    systemstack(func() { mheap_.scavenge(-1, ^uint64(0), 0) })
}

不管是sysmon和手动FreeOSMemory都调用mheap_.scavenge() ,为啥手动freeOSMemory就好用? 很明显他们之间的不同在于参数。 sysmon的释放有些严谨,freeOSMemory直接一串-1,0,0。 看起来是个最大值。

// xiaorui.cc

func (h *mheap) scavenge(k int32, now, limit uint64) {
	// Disallow malloc or panic while holding the heap lock. We do
	// this here because this is an non-mallocgc entry-point to
	// the mheap API.
        ...
	var sumreleased uintptr
	for i := 0; i < len(h.free); i++ {
		sumreleased += scavengelist(&h.free[i], now, limit)
	}
	sumreleased += scavengetreap(h.freelarge.treap, now, limit)
        ...
	}
}

总结:

     在golang runtime里面不释放的内存后面是可以复用的,没必要纠结非要释放干净。还是那句话,有问题看源码。


大家觉得文章对你有些作用! 如果想赏钱,可以用微信扫描下面的二维码,感谢!
另外再次标注博客原地址  xiaorui.cc