首页 > 系统相关 >Go map 竟然也会发生内存泄露?

Go map 竟然也会发生内存泄露?

时间:2022-11-13 12:47:20浏览次数:77  
标签:map val MB 100w 内存 Go runtime

Go 程序运行时,有些场景下会导致进程进入某个“高点”,然后就再也下不来了。

比如,多年前曹大写过的一篇文章讲过,在做活动时线上涌入的大流量把 goroutine 数抬升了不少,流量恢复之后 goroutine 数也没降下来,导致 GC 的压力升高,总体的 CPU 消耗也较平时上升了 2 个点左右。

有一个 issue 讨论为什么 allgs(runtime 中存储所有 goroutine 的一个全局 slice) 不收缩,一个好处是:goroutine 复用,让 goroutine 的创建更加得便利,而这也正是 Go 语言的一大优势。

最近在看《100 mistakes》,书里专门有一节讲 map 的内存泄漏。其实这也是另一个在经历大流量后,无法“恢复”的例子:map 占用的内存“只增不减”。

之前写过的一篇《深度解密 Go 语言之 map》里讲到过 map 的内部数据结构,并且分析过创建、遍历、删除的过程。

在 Go runtime 层,map 是一个指向 hmap 结构体的指针,hmap 里有一个字段 B,它决定了 map 能存放的元素个数。

hamp 结构体代码如下:

type hmap struct {
	count     int
	flags     uint8
	B         uint8
	
	// ...
}

若我们想初始化一个长度为 100w 元素的 map,B 是多少呢?

用 B 可以计算 map 的元素个数:loadfactor * 2^B,loadfactor 目前是 6.5,当 B=17 时,可放 851,968 个元素;当 B=18,可放 1,703,936 个元素。因此当我们将 map 的长度初始化为 100w 时,B 的值应是 18。

loadfactor 是装载因子,用来衡量平均一个 bucket 里有多少个 key。

如何查看占用的内存数量呢?用 runtime.MemStats:

package main

import (
	"fmt"
	"runtime"
)

const N = 128

func randBytes() [N]byte {
	return [N]byte{}
}

func printAlloc() {
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	fmt.Printf("%d MB\n", m.Alloc/1024/1024)
}

func main() {
	n := 1_000_000
	m := make(map[int][N]byte, 0)
	printAlloc()

	for i := 0; i < n; i++ {
		m[i] = randBytes()
	}
	printAlloc()
	
	for i := 0; i < n; i++ {
		delete(m, i)
	}
	
	runtime.GC()
	printAlloc()
	runtime.KeepAlive(m)
}

如果不加最后的 KeepAlive,m 会被回收掉。

当 N = 128 时,运行程序:

$ go run main2.go
0 MB
461 MB
293 MB

可以看到,当删除了所有 kv 后,内存占用依然有 293 MB,这实际上是创建长度为 100w 的 map 所消耗的内存大小。当我们创建一个初始长度为 100w 的 map:

package main

import (
	"fmt"
	"runtime"
)

const N = 128

func printAlloc() {
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	fmt.Printf("%d MB\n", m.Alloc/1024/1024)
}

func main() {
	n := 1_000_000
	m := make(map[int][N]byte, n)
	printAlloc()

	runtime.KeepAlive(m)
}

运行程序,得到 100w 长度的 map 的消耗的内存为:

$ go run main3.go
293 MB

这时有一个疑惑,为什么在向 map 写入了 100w 个 kv 之后,占用内存变成了 461MB?

我们知道,当 val 大小 <= 128B 时,val 其实是直接放在 bucket 里的,按理说,写入 kv 与否,这些 bucket 占用的内存都在那里。换句话说,写入 kv 之后,占用的内存应该还是 293MB,实际上却是 461MB。

这里的原因其实是在写入 100w kv 期间 map 发生了扩容,buckets 进行了搬迁。我们可以用 hack 的方式打印出 B 值:

func main() {
	//...

	var B uint8
	for i := 0; i < n; i++ {
		curB := *(*uint8)(unsafe.Pointer(uintptr(unsafe.Pointer(*(**int)(unsafe.Pointer(&m)))) + 9))
		if B != curB {
			fmt.Println(curB)
			B = curB
		}

		m[i] = randBytes()
	}

	//...

	runtime.KeepAlive(m)
}

运行程序,B 值从 1 一直变到 18。搬迁的过程可以参考前面提到的那篇 map 文章,这里不再赘述。

而如果我们初始化的时候直接将 map 的长度指定为 100w,那内存变化情况为:

293 MB
293 MB
293 MB

当 val 小于 128B 时,初始化 map 后内存占用量一直不变。原因是 put 操作只是在 bucket 里原地写入 val,而 delete 操作则是将 val 清零,bucket 本身还在。因此,内存占用大小不变。

而当 val 大小超过 128B 后,bucket 不会直接放 val,转而变成一个指针。我们将 N 设为 129,运行程序:

0 MB
197 MB
38 MB

虽然 map 的 bucket 占用内存量依然存在,但 val 改成指针存储后内存占用量大大降低。且 val 被删掉后,内存占用量确实降低了。

总之,map 的 buckets 数只会增,不会降。所以在流量冲击后,map 的 buckets 数增长到一定值,之后即使把元素都删了也无济于事。内存占用还是在,因为 buckets 占用的内存不会少。

对于 map 内存泄漏的解法:

  • 重启;
  • 将 val 类型改成指针;
  • 定期地将 map 里的元素全量拷贝到另一个 map 里。

好在一般有大流量冲击的互联网业务大都是 toC 场景,上线频率非常高。有的公司能一天上线好几次,在问题暴露之前就已经重启恢复了,问题不大。

标签:map,val,MB,100w,内存,Go,runtime
From: https://www.cnblogs.com/qcrao-2018/p/16885760.html

相关文章

  • go语言安装
    1.下载go​​https://golang.org/dl/​​​或者​​https://studygolang.com/dl​​下载2.安装gotarzxvfgo1.8.1.linux-amd64.tar.gzmvgo/usr/localcd/usr/l......
  • SAP Hybris Category 显示在 Storefront 的判定条件
    默认情况下,只有标记为active的目录版本(catalogversion)在前端才可见。CategoryVisibility每个类别(Category)都有一个属性,用于指定允许查看和浏览该类别的用户或用户......
  • beego下swagger自动更新接口信息和commentsRouter_controllers.go
    (1)在beego环境中,当更新了controllers目录下面的接口后,在swagger的web页面虽然可以看到更新后的接口,但无法正常运行,这是因为swagger的路由信息还没有更新(2)要更新swagger的路......
  • Go语言声明语法
    GO语言声明语法原文地址介绍Go新手想知道为什么Go的声明语法与C语言等的传统声明语法不同。在这篇文章中,我们将比较这两种方法,并解释为什么Go的声明是这样的。C语法首......
  • 又臭又长的字符串处理(简易内存池)
    #include<iostream>#include<vector>#include<string>usingnamespacestd;intmax=100;//分配函数voidalloc(vector<vector<int>>&address,intsize){ if(si......
  • golang:break,continue,return,goto使用区别
    break条件满足,跳出整个循环continue条件满足,跳出这次循环,但继续执行后面的循环return返回数据,结束当前函数或程序运行gotogo语言的goto可以无条件的转移到程序中指......
  • MongoDB导入导出备份数据
    需要提前安装mongodb-database-tools参考:centos离线安装mongodb-database-tools导出数据常用的导出有两种:mongodump和mongoexport,两种方式的区别:1、mongodump导出的文件......
  • filter、map、reduce的作用
    --map函数的用法语法map(function,iterable1,iterable2)说明:function中参数值可以是一个,也可以是多个;iterable代表function运算中的参数值,有几个参数值就传入几个iterabl......
  • Django更换数据库和迁移数据方案
    前言双十一光顾着买东西都没怎么写文章,现在笔记里还有十几篇半成品文章没写完…今天来分享一下Django项目切换数据库和迁移数据的方案,网络上找到的文章方法不一,且使......
  • wsl升级到wsl2 不升级导致go dlv以及远程debug使用不了
    wsl升级到wsl22022.05.1020:44:42字数392阅读2,087https://docs.microsoft.com/zh-cn/windows/wsl/install-win10安装ubuntu20.04子系统通过微软商店、powers......