“不吃凉粉让板凳”
内存泄漏可谓整个软件行业最痛最常见的问题之一,往往比较隐蔽,有时需要特殊的异常场景才能触发,有时是一种慢性病,需要长达几周或几个月才能暴露问题。内存的飙涨导致系统内存越来越吃紧,系统需要为新的内存申请而不断东挪西凑,这些内存钉子户也导致内存出现碎片,后来系统只有将部分内存内容 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 统计观察各个进程占用内存,发现系统内存飙涨前后,各个进程占用内存并没有明显的增长行为。
按照内存排序:
那系统内存哪里去了呢?
不过,在排查过程中,发现系统里有一个叫 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 这一项之和,命令如下:
这里要注意用 Pss,而不是 RSS(两者的区别可查看 man)。
系统内存使用情况:
那内核占用的内存就是:
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 之后,所有的报文都会被丢弃,不会进入接收队列。
于是,修改了公司内封装的网络库,原先的实现中,会在连接池中独立维护 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 的分配过程主流程摘录示例:
我们的一台机器中,往往会有多种进程同时进行网络收发,这背后伴随着频繁的 skb 的分配和销毁。
假如有些 skb 当了“钉子户”,迟迟不离开(不被用户态进程读取处理),那这个 frag 就一直得不到回收。同理,当 kernel 中存在很多种类似情况,导致 frag 上的碎片空间得不到利用,导致很多的 frag 都不能回收,这样,因为这种碎片空间的存在,就会导致系统占用内存出现放大效应,出现“内核内存泄漏”。但是,这种泄漏也不是没有底线的。内核约束了协议栈占用的内存空间,通过参数来控制(net.ipv4.udp_mem)、动态调整行为。
验证对比
下面分别验证旧的 UDP 连接和新增的 WUDP 连接的实验效果。
对于 server 端,在两台机器上分别执行 server 程序,监听 9743 端口,并向 client 端回包。
udp
clien 端,在这个 IP(xxx.xxx.xxx.76)的机器上执行, 开启 100 个进程,并发向 server 请求。
这时,观察 client 端机器中的 socket 接收队列的增长情况、系统已占用内存的增长情况如下:
socket 接收队列(上图第二列)增长达到极限
可以看到,每个 UDP socket 接收队列占用内存达到 8M。而系统的已占用内存,如下,也增长了 1G 多。
运行前:
运行 5 分钟之后:
kill 掉所有进程之后,内存恢复到最初水平:
wudp
类似的实验环境,这次试用新增的 WUDP 这一连接类型,也开启 100 个进程,并发向 server 请求。
socket 接收队列(上图第二列)只可以增长到 2k+字节
而系统的已占用内存,如下,仅仅有几十兆的增长,相比之前 1G 多的内存增长以及微不足道。
运行前:
运行 5 分钟之后:
作者介绍
余昌叶,腾讯音乐公司高级工程师,《腾讯知识奖》获得者,多篇专利发明者。
评论 3 条评论