11 月 19 - 20 日 Apache Pulsar 社区年度盛会来啦,立即报名! 了解详情
写点什么

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

  • 2019-11-15
  • 本文字数:5273 字

    阅读完需:约 17 分钟

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

“不吃凉粉让板凳”

内存泄漏可谓整个软件行业最痛最常见的问题之一,往往比较隐蔽,有时需要特殊的异常场景才能触发,有时是一种慢性病,需要长达几周或几个月才能暴露问题。内存的飙涨导致系统内存越来越吃紧,系统需要为新的内存申请而不断东挪西凑,这些内存钉子户也导致内存出现碎片,后来系统只有将部分内存内容 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


其中:


  • Total Mem:机器总内存,是已知的;

  • User:系统所有用户态进程占用内存总和;

  • Kernel:内核占用内存总和;

  • shared + cache(/buffer) + free :通过 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   availableMem:            13G        8G        3.3G        0.9G         2.2G         4.6GSwap:            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 之后,所有的报文都会被丢弃,不会进入接收队列。


$ 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 的分配过程主流程摘录示例:


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 udpnet.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 请求。


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 -mThu Nov  7 22:50:36 CST 2019              total        used        free      shared  buff/cache   availableMem:          31915         910        4562       17070       26442       11568Swap:             0           0           0
复制代码


运行 5 分钟之后:


$ date;free -mThu Nov  7 22:55:37 CST 2019              total        used        free      shared  buff/cache   availableMem:          31915        2345        2048       17070       27521        9057Swap:             0           0           0
复制代码


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


$ killall socketPoolNew$ date;free -mThu Nov  7 22:58:56 CST 2019              total        used        free      shared  buff/cache   availableMem:          31915         906        4516       17070       26491       11522Swap:             0           0           0
复制代码


wudp

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


$ sh socketPoolNew.sh
复制代码



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


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


运行前:


$ date;free -mThu Nov  7 23:05:52 CST 2019              total        used        free      shared  buff/cache   availableMem:          31915         911        4545       17070       26457       11554Swap:             0           0           0
复制代码


运行 5 分钟之后:


date;free -mThu Nov  7 23:11:09 CST 2019              total        used        free      shared  buff/cache   availableMem:          31915         935        4544       17070       26458       11551Swap:             0           0           0
复制代码


作者介绍

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


2019-11-15 15:383298

评论 3 条评论

发布
用户头像
"13G - 2.56G - 3.3G - 0.9G - 2.2G - 4.6G = 4.6G"
这个加减法怎么看起来好好像不太对呀?
2019-11-18 11:38
回复
应该是13-2.56-3.3-0.9-2.2 吧
2020-08-18 15:00
回复
用户头像
tcp ack包是为了确认包不丢吧,现在不回包了,怎么保证这一点呢
2019-11-15 18:10
回复
没有更多了
发现更多内容

数字货币交易所功能,场外OTC交易所开发公司

13530558032

Spring Boot 监听 Redis Key 失效事件实现定时任务

Bruce Duan

Redis监听 监听过期键

完美!阿里P8仅用242页笔记,就由浅入深讲解了SQL概念

Java~~~

Java sql 阿里巴巴 SQL语法 sql查询

从红黑树的本质出发,彻底理解红黑树!

996小迁

Java 架构 面试 程序人生

从“小众”到“首选”,推动云原生产业落地华为云作用几何?

华为云开发者联盟

云计算 架构 容器

排名前 16 的 Java 工具类

Bruce Duan

java工具类

下笔如有神:这是一个基于营销行业的 AI 技术实践

京东科技开发者

人工智能 自然语言处理 nlp

MySQL全面瓦解—子查询和组合查询

比伯

Java 编程 程序员 架构 计算机

刷Github时发现了一本阿里大神的算法笔记!标星70.5K

Java架构师迁哥

多线程问的太深入不知道怎么回答,从volatile开始给你讲清楚

小Q

Java 学习 面试 volatile 多线程

亿级大表分库分表实战总结(万字干货,实战复盘)

学习 编程 架构 计算机网络

普渡科技荣登甲子光年“2020中国最具商业潜力的20家机器人Cool Vendor”

DT极客

【涂鸦物联网足迹】涂鸦云平台标准指令集说明

IoT云工坊

人工智能 cpu 物联网 智能家居 指令集

影响王兴的一本书

池建强

读书笔记 无限游戏 王兴

五面进军饿了么!复盘总结11月上半月大厂面试真题,押题命中率高达95%以上

Java架构追梦

Java 阿里巴巴 架构 面试

遥感影像处理有高招,“专治”各类花式并发的述求!

华为云开发者联盟

容器 k8s 遥感

【乘风破浪的开发者】丁一超:从AI实战营出发探索未知的AI世界

华为云开发者联盟

华为 AI modelarts

USDT币支付系统开发搭建,区块链承兑商支付平台

13530558032

亿级大表分库分表实战总结(万字干货,实战复盘)

比伯

Java 编程 程序员 架构 计算机

anyRTC AI降噪|让声音更清晰

anyRTC开发者

人工智能 AI 音视频 WebRTC RTC

拒招中国程序员后,开源平台 GitLab 又开始大规模封杀开发者账户

Java架构师迁哥

React Fiber 是什么?

局外人

react.js 大前端 React

关于linux操作系统中的buff/cache

程序员架构进阶

Linux cache buffer

Spring / Spring boot 异步任务编程 WebAsyncTask

Bruce Duan

异步任务编程 WebAsyncTask

在Android中使用DataBinding(Kotlin)

simon

android Android进阶 JetPack DataBinding 数据绑定

成年人的世界都不容易-看看做到年薪50万的程序员,到底有多累?

Java架构师迁哥

#不吐不快# IT职场里的奇葩经历

InfoQ写作社区官方

职场搞笑 奇葩的经历 热门活动

SQL数据库:子查询和关联子查询

正向成长

SQL子查询 SQL关联查询

背后技术:双11还能创造什么?

人工智能 数据挖掘 大数据 科技

快速理解二十三种设计模式(速记)

simon

设计模式 23种设计模式 Java设计模式

python+requests对app和微信小程序进行接口测试

测试人生路

Python 接口测试

内存泄漏?从用户态跟踪到内核去_文化 & 方法_余昌叶_InfoQ精选文章