内存泄漏可谓整个软件行业最痛最常见的问题之一,往往比较隐蔽,有时需要特殊的异常场景才能触发,有时是一种慢性病,需要长达几周或几个月才能暴露问题。内存的飙涨导致系统内存越来越吃紧,系统需要为新的内存申请而不断东挪西凑,这些内存钉子户也导致内存出现碎片,后来系统只有将部分内存内容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统计观察各个进程占用内存,发现系统内存飙涨前后,各个进程占用内存并没有明显的增长行为。
ps -eo pid,pmem,pcpu,rss,vsize,args
按照内存排序:
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
其中:
所以,要求出Kernel这一项的话,需要先求出User这一项,User这一项没有现成的工具可以查看到,需要借助于工具统计,可以累加所有进程的smaps下的Pss这一项之和,命令如下:
$grep Pss /proc/[1-9]*/smaps | awk '{total+=$2}; END {print total}'
2562230
这里要注意用Pss,而不是RSS(两者的区别可查看man)。
系统内存使用情况:
$ 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这一侧的机器内存一直飙涨。修复发布之后,效果很明显,如下图。
修复问题之后系统的已占用内存曲线
问题是得到了解决,但是怎么避免其他人踩坑呢?并且这种模式的确很不合理。
有没有手段能更合理地解决这一问题?也就是即便是对端回包,也不会影响本机内存占用情况。也就是说,能不能实现一种只写模式的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之后,所有的报文都会被丢弃,不会进入接收队列。
$ 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可谓是socket底层的最核心数据结构,其定义在include/linux/skbuff.h头文件中可以看到。伴随着一个个报文从网卡流入内核又被用户态进程读取处理,skb会分配、回收。而在linux内核,skb分配时,内核先分配一个较大的内存块(N页大小的大内存块(frag)),然后有网络包需要接收时,再从这个大内存块frag里划分小片的内存给每一个skb。如此重复。所以,一个frag里有很多个skb,需要所有的skb都释放之后,这个宿主frag才能被系统回收。
skb的分配过程主流程摘录示例:
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)、动态调整行为。
$ 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端回包。
clien端,在这个IP(xxx.xxx.xxx.76)的机器上执行, 开启100个进程,并发向server请求。
cat socketPoolNew.sh
#!/bin/bash
for i in {1..100};do
./socketPoolNew 5000000 udp 0 &
done
这时,观察client端机器中的socket接收队列的增长情况、系统已占用内存的增长情况如下:
$ sh socketPoolNew.sh $ netstat -nup | sort -k 2 -r | grep 9734
socket接收队列(上图第二列)增长达到极限
可以看到,每个UDP socket接收队列占用内存达到8M。而系统的已占用内存,如下,也增长了1G多。
运行前:
$ 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分钟之后:
$ 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掉所有进程之后,内存恢复到最初水平:
$ 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这一连接类型,也开启100个进程,并发向server请求。
$ sh socketPoolNew.sh
socket接收队列(上图第二列)只可以增长到2k+字节
而系统的已占用内存,如下,仅仅有几十兆的增长,相比之前1G多的内存增长以及微不足道。
运行前:
$ 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分钟之后:
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
余昌叶,腾讯音乐公司高级工程师,《腾讯知识奖》获得者,多篇专利发明者。
领取专属 10元无门槛券
私享最新 技术干货