首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

内存泄漏?从用户态跟踪到内核去

“不吃凉粉让板凳”

内存泄漏可谓整个软件行业最痛最常见的问题之一,往往比较隐蔽,有时需要特殊的异常场景才能触发,有时是一种慢性病,需要长达几周或几个月才能暴露问题。内存的飙涨导致系统内存越来越吃紧,系统需要为新的内存申请而不断东挪西凑,这些内存钉子户也导致内存出现碎片,后来系统只有将部分内存内容swap到磁盘才能解决问题了,之后便只能频繁在内存与磁盘间折腾,进而磁盘IO负载拉升、CPU负载拉升,导致系统性能每况愈下,吞吐能力降低,出现卡顿,直到某一天系统宕机了,或许才引起开发者的注意。

“不吃凉粉让板凳”,怎么找出这些不吃凉粉还一直霸占着板凳的客人呢?

对于内存泄漏,一般可以借助top或者脚本来周期性采集进程内存信息找出问题进程,然后对这个进程strace,结合代码review,基本都可以快速、圆满地解决问题。另外,还有强大的Valgrind 等可用于内存泄漏检测的工具。诚然,预防才是最好的解决方案,要尽量把问题在上线发布前挖掘出来,可以借助一些静态分析工具来扫描代码中存在内存泄漏隐患的地方,这类工具很多应用很成熟,这里不做进一步阐述。

Linux发生内存泄漏后,最后内核将触发OOM killer,之后系统到底是触发panic死机?还是选择性kill掉一些内存score比较高的进程?这些行为都可以通过内核的参数panic_on_oom、oom_kill_allocating_task来配置。

背景

线上环境机器遇到一个隐蔽的问题,某天机器突然挂掉了,看监控系统发现应该是系统使用内存一直飙涨导致的问题,但是后来登入机器去观察各个进程所占内存并无明显变化。如下图,一天多的时间,系统使用内存可以从4G涨到10G以上,然后接下来导致机器OOM,服务不可用。

系统已占用内存飙涨曲线

定位

这类内存飙涨问题,和系统请求量曲线并无明显关联,曲线单调递增,首先想到的是内存泄漏问题。

一般情况下,通过top监控都能找出那个内存泄漏的进程。

但是,假如top并不能找出问题进程呢?

这里用top、ps统计观察各个进程占用内存,发现系统内存飙涨前后,各个进程占用内存并没有明显的增长行为。

代码语言:javascript
复制
ps -eo pid,pmem,pcpu,rss,vsize,args

按照内存排序:

代码语言:javascript
复制
PID %MEM %CPU   RSS    VSZ COMMAND
 8580  0.2  0.0 35556  70884 ./friend_push_worker
 8578  0.2  0.0 34744  70744 ./friend_push_worker
 8576  0.2  0.0 34132  70768 ./friend_push_worker
 8574  0.2  0.0 33008  70868 ./friend_push_worker
 8572  0.2  0.0 32280  70756 ./friend_push_worker
 8570  0.2  0.0 31788  70748 ./friend_push_worker

那系统内存哪里去了呢?

不过,在排查过程中,发现系统里有一个叫friend_push的服务,这个服务杀死之后,系统占用内存就会恢复如初,这个服务启动之后,系统内存继续飙涨。

接下来,就围绕friend_push 的进程展开排查,系统内存从4G涨到10G时,去统计friend_push 的所有进程占用物理内存之和也就是几百兆而已。

综合看起来,用户态进程占用内存并无内存泄漏,那无非就是内核态占用内存出现了泄漏。对于内核态占用内存的多少,并没有直接的工具可以查看,在top下即便看到内核态进程,也是没有统计各个内核态进程占用的内存信息。

不过,linux有强大丰富的/proc系统,我们用的绝大多数linux统计的命令也是根据这里的数据做统计展示的,我们可以借助这里来计算出内核占用内存,有这样一个公式:

Total Mem = User + Kernel + shared + cache(/buffer) + free

其中:

  • Total Mem:机器总内存,是已知的;
  • User:系统所有用户态进程占用内存总和;
  • Kernel:内核占用内存总和;
  • shared + cache(/buffer) + free :通过free命令也可以查看到。

所以,要求出Kernel这一项的话,需要先求出User这一项,User这一项没有现成的工具可以查看到,需要借助于工具统计,可以累加所有进程的smaps下的Pss这一项之和,命令如下:

代码语言:javascript
复制
$grep Pss /proc/[1-9]*/smaps | awk '{total+=$2}; END {print total}'
2562230

这里要注意用Pss,而不是RSS(两者的区别可查看man)。

系统内存使用情况:

代码语言:javascript
复制
$ free -h
              total        used        free      shared  buff/cache   available
Mem:            13G        8G        3.3G        0.9G         2.2G         4.6G
Swap:            0B          0B          0B

那内核占用的内存就是:

13G - 2.56G - 3.3G - 0.9G - 2.2G - 4.6G = 4.6G

可以得出结论:在系统内存从4G涨到10G之后,内核占用物理内存的涨幅超过4G。

内核占用内存有泄漏?

这里该怎么进一步定为呢?内核内存泄漏有kmemleak可以使用,使用这个工具,需要内核支持,需要重编内核开启相应的选项,将相应模块编译进内核,然后重新安装系统到机器。 这样好像越绕越远了,还能从哪些角度出发呢?

从网络看看,这里用netstat观察各个连接占用内存的变化,观察到有一部分连接的Recv-Q(socket接收队列)一项一直在增长,这一部分连接有一个共同特征,都是绑定在friend_push进程 。

Recv-Q一直增长是为什么呢?入流量太大进程处理不过来吗?看机器整体网络负载并不高。并且,停止向friend_push进程发请求之后,Recv-Q会保持不变,并不会减少。

UDP socket的接收队列一直增长

所以,这些网络报文积压的原因,并不是进程处理不过来,而是进程根本没有处理!这里根据这个信息,再次从friend_push的代码着手,主要是查找网络收发有问题的地方,查找不会接收处理报文的地方,的确查到一处用udp协议来发送请求的地方,发送完之后,并没有接收。

至此,问题终于定位到了。

解决

问题原因是client进程发送请求,到达server进程之后server处理完请求之后进行了回包,而client并没有对这个报文进行接收处理,用户态进程不去读取,于是报文就一直积压在内核得不到释放。

先是采用了临时解决方案,将server进程代码修改,改为接收到请求之后不回包。这样,就不会导致client这一侧的机器内存一直飙涨。修复发布之后,效果很明显,如下图。

修复问题之后系统的已占用内存曲线

只写模式的UDP socket的实现

问题是得到了解决,但是怎么避免其他人踩坑呢?并且这种模式的确很不合理。

有没有手段能更合理地解决这一问题?也就是即便是对端回包,也不会影响本机内存占用情况。也就是说,能不能实现一种只写模式的socket(Write Only Mode),这种socket只可以发包,不可以接收数据,不可以接收自然也不会导致本机内存飙涨。

socket都是双工的,TCP socket提供了shutdown这一API可以使得socket变成半双工状态,但是对于UDP,内核并没有提供类似的API。这里采用了一个简接的方法,将 socket的接收buffer设为0,而socket默认的接收buffer一般是8M(这里要注意,使用setsockopt设置时,接收buffer有个最小值,虽然设置为0时api可以正常返回,但是实际在内核中,这个接收buffer依然会有几个KB大小,我这里实验得到的结果是2K,网上也有512字节等多种实验结果),这样之后,我们基本可以忽略socket的接收buffer占用的内存了,Recv-Q一项也不会增长太大了,超过2K之后,所有的报文都会被丢弃,不会进入接收队列。

代码语言:javascript
复制
$ man 2 setsockopt 
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);

于是,修改了公司内封装的网络库,原先的实现中,会在连接池中独立维护TCP、UDP两种连接信息,在此基础上实现了一种新的连接类型WUDP(Write-only mode UDP,只写模式的UDP)。

三种连接的连接池模型

和前两种连接类型一样来单独管理。上层应用使用时,在发起网络请求前,可以通过参数控制来选择不同的连接类型,如同本文中讲到的friend_push一样,只需要发包请求,并不需要收包处理,那就可以选择 WUDP,避免远端回包导致本机内存泄漏。

skb分配机制导致的内存放大效应

skb可谓是socket底层的最核心数据结构,其定义在include/linux/skbuff.h头文件中可以看到。伴随着一个个报文从网卡流入内核又被用户态进程读取处理,skb会分配、回收。而在linux内核,skb分配时,内核先分配一个较大的内存块(N页大小的大内存块(frag)),然后有网络包需要接收时,再从这个大内存块frag里划分小片的内存给每一个skb。如此重复。所以,一个frag里有很多个skb,需要所有的skb都释放之后,这个宿主frag才能被系统回收。

skb的分配过程主流程摘录示例:

代码语言:javascript
复制
net\core\pktgen.c
	fill_packet_ipv4
		__netdev_alloc_skb //__netdev_alloc_skb - allocate an skbuff for rx on a specific device
			if (fragsz <= PAGE_SIZE && !(gfp_mask & (__GFP_WAIT | GFP_DMA)))
				__netdev_alloc_frag( ); //按照2的N次方个page的大小来分配一个frag
			else
				__alloc_skb( );	//从slab分配
					kmem_cache_alloc_node

我们的一台机器中,往往会有多种进程同时进行网络收发,这背后伴随着频繁的skb的分配和销毁。

假如有些skb当了“钉子户”,迟迟不离开(不被用户态进程读取处理),那这个frag就一直得不到回收。同理,当kernel中存在很多种类似情况,导致frag上的碎片空间得不到利用,导致很多的frag都不能回收,这样,因为这种碎片空间的存在,就会导致系统占用内存出现放大效应,出现“内核内存泄漏”。但是,这种泄漏也不是没有底线的。内核约束了协议栈占用的内存空间,通过参数来控制(net.ipv4.udp_mem)、动态调整行为。

代码语言:javascript
复制
$ man 7 udp
net.ipv4.udp_mem
       udp_mem (since Linux 2.6.25)
              This is a vector of three integers governing the number of
              pages allowed for queueing by all UDP sockets.

              min       Below this number of pages, UDP is not bothered
                        about its memory appetite.  When the amount of
                        memory allocated by UDP exceeds this number, UDP
                        starts to moderate memory usage.

              pressure  This value was introduced to follow the format of
                        tcp_mem (see tcp(7)).

              max       Number of pages allowed for queueing by all UDP
                        sockets.

验证对比

下面分别验证旧的UDP连接和新增的WUDP连接的实验效果。

对于server端,在两台机器上分别执行server程序,监听9743端口,并向client端回包。

udp

clien端,在这个IP(xxx.xxx.xxx.76)的机器上执行, 开启100个进程,并发向server请求。

代码语言:javascript
复制
cat socketPoolNew.sh 
#!/bin/bash 
for i in {1..100};do 
	./socketPoolNew 5000000 udp 0 & 
done 

这时,观察client端机器中的socket接收队列的增长情况、系统已占用内存的增长情况如下:

代码语言:javascript
复制
	$ sh socketPoolNew.sh $ netstat -nup | sort -k 2 -r | grep 9734 

socket接收队列(上图第二列)增长达到极限

可以看到,每个UDP socket接收队列占用内存达到8M。而系统的已占用内存,如下,也增长了1G多。

运行前:

代码语言:javascript
复制
$ date;free -m
Thu Nov  7 22:50:36 CST 2019
              total        used        free      shared  buff/cache   available
Mem:          31915         910        4562       17070       26442       11568
Swap:             0           0           0

运行5分钟之后:

代码语言:javascript
复制
$ date;free -m
Thu Nov  7 22:55:37 CST 2019
              total        used        free      shared  buff/cache   available
Mem:          31915        2345        2048       17070       27521        9057
Swap:             0           0           0

kill掉所有进程之后,内存恢复到最初水平:

代码语言:javascript
复制
$ killall socketPoolNew
$ date;free -m
Thu Nov  7 22:58:56 CST 2019
              total        used        free      shared  buff/cache   available
Mem:          31915         906        4516       17070       26491       11522
Swap:             0           0           0

wudp

类似的实验环境,这次试用新增的WUDP这一连接类型,也开启100个进程,并发向server请求。

代码语言:javascript
复制
$ sh socketPoolNew.sh

socket接收队列(上图第二列)只可以增长到2k+字节

而系统的已占用内存,如下,仅仅有几十兆的增长,相比之前1G多的内存增长以及微不足道。

运行前:

代码语言:javascript
复制
$ date;free -m
Thu Nov  7 23:05:52 CST 2019
              total        used        free      shared  buff/cache   available
Mem:          31915         911        4545       17070       26457       11554
Swap:             0           0           0

运行5分钟之后:

代码语言:javascript
复制
date;free -m
Thu Nov  7 23:11:09 CST 2019
              total        used        free      shared  buff/cache   available
Mem:          31915         935        4544       17070       26458       11551
Swap:             0           0           0

作者介绍

余昌叶,腾讯音乐公司高级工程师,《腾讯知识奖》获得者,多篇专利发明者。

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/bSNpBm7h4GgGj9uTHM3d
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券