随着计算机硬件的飞速发展,内存的容量越来越大,服务器从最开始的几G到现在的几百G,各大公司从来都不会在内存上面吝啬,因为内存是服务稳定性的基础,为了省一点点内存而失去服务的稳定性,不可谓不是因小失大。所以现代服务程序基本上是能靠堆机器和资源解决就靠机器和资源解决。
计算机专业的同学应该都知道操作系统页置换swap的设计理念,当物理内存不足时,会通过LRU策略将内存中最久为使用的页置换回磁盘的swap分区,这种置换操作由于涉及到磁盘操作,因此会对性能有较大损失,所以大部分公司都会选择关闭swap置换来防止不必要的性能损失,宁可让服务OOM重启也不愿由于SWAP带来的长期性能损失(随着分布式理念及容器化技术的成熟,服务的重启代价越来越低)。
上述做法对于有充足内存的进程来说,确实没有什么问题,随着容器技术的火热,cgroup渐渐进入我们的视野,内存的管理粒度从全机管理细化到单cgroup管理,我们可以将一个或者一组进程关联到一个cgroup来管理对应的资源,这些资源包括CPU,MEM,BLKIO,NETWORK,这也是我们计算机中最关心的几种关键资源,我们针对进程服务的稳定性、高效性调优,基本上就是在思考如何处理好这些资源的利用。有了cgroup后,我们又回到了资源紧张的时代,我们不再是随随便便有几百G内存可用了,特别是容器化部署,可能分给我们容器cgroup的内存就1~2G,如何精细化的管理好内存又开始变得重要起来了。
这里有一个很典型的例子可以分享一下:曾经我刚毕业在宜信大数据做平台工程师的时候,经常会遇到一些GO服务或者java服务,在接近内存cgroup上限的时候,会有一段时间出现很高的IO read utils,基本将读IO打满了,但是内存占用又达不到被OOMkiller杀掉的阈值,所以会有较长的时间影响整个机器的服务(整个物理/虚拟机器是共享IO的,由于IO read utils占用100%,其他进程对对应disk有io读请求时会有影响)。当时对linux的整体机制细节了解甚微,对于这一问题的出现,丈二和尚摸不着头脑,内存不足产生那么高的IO read是几个意思?而且我们当时也是禁掉了机器的swap的,通过监控来看进程也确实没有产生swap。所以带着这个问题我在网上看了很多资料,但是最终都没有找到自己想要的答案,这也就让自己产生了带着这个问题去系统学习一下linux内核的想法,时至今日,我终于能对这个问题有比较深刻的理解了,所以在这里分享一下。
这里面涉及到到知识点主要包括:(后续等我有时间了会将各个部分结合内核源码详细的写一下文章,目前除了虚拟文件系统外其他还没写)
- linux虚拟内存管理
- linux页面回收和页面置换
- cgroup memory manage
- linux 虚拟文件系统
- linux process loading so
linux虚拟内存管理
linux用户空间虚拟地址空间主要包括以下几个方面:
- stack区(进程栈,从上往下分配,大小上限一般为128M)。
- mmap区(用于各种映射:private,share,anonymous,是文件映射的主要区域)。
- heap区(用于anonymous匿名映射,是用户内存分配的主要区域)。
- 数据段、代码段之类的,不做详细介绍。
这里面重点简单分析一下heap及mmap:
heap
当用户空间可用虚拟空间不足(没有足够大小的连续地址块可供分配)时,就会通过brk系统调用扩大heap的堆顶地址大小,在这个过程中,会伴随着一个虚拟地址空间管理对象vm_area_struct(虚拟地址块的起始地址等元信息)的创建,但是并没有完成虚拟地址到物理地址的映射,只有在上层内存管理lib malloc内存发现page fault的时候才会通过匿名映射将真正的物理页与虚拟地址进行映射关联,并将映射关系写入页表中。
当用户空间释放内存时,如果释放的虚拟地址是处于heap的非顶部,那么就会在heap内产生空洞,这里还涉及到空洞的vm_area_struct合并操作,不做详细讨论,后续内存分配就会优先从这些空洞中找合适的块进行分配,否则就进行brk系统调用扩展虚拟地址。如果释放的虚拟地址(或者合并之后)是处于heap的顶部,内核就自动收缩heap。
详细信息见http://man7.org/linux/man-pages/man2/brk.2.html
其中heap堆顶地址大小,一般就是我们说的VIRT,内部的非空洞实际物理内存占用会统计到RES中(RES还包括后续统计的mmap)。
mmap
内存映射其实也是heap内存管理的基础,之所以还划分专门的mmap区,是由于文件映射及线程栈空间分配一般都会从这个区划分,这样的划分能够更清晰进行虚拟地址空间管理。mmap主要包括私有映射,共享映射及匿名映射,可以通过pmap -x #PID命令及 cat /proc/#PID/maps查看进程mmap信息。
文件映射mmap最常见的用途主要包括:
- 加载进程binary文件及依赖的共享依赖库so文件(这是上述示例产生的关键)。
- 共享内存IPC。
- 内核文件缓存映射。(open file时会内核建立page到file的映射,但是是在内核空间)
mmap相较于write/read系统调用具有更高效的特点,减少了从用户空间到内核空间memory copy的操作,因此对于有些性能要求很高的IO操作可以通过mmap来做,而另外一个用于文件之间内存零拷贝的操作我们是sendfile(该操作直接基于两个fd在内核层面进行数据拷贝,不需要在用户空间先read再write)。
mmap占用的内存也会计入RES的统计中,其中以share方式映射的mmap内存大小会计入SHR统计。
还可以通过 cat /proc/#PID/smaps查看具体的mmap统计信息:
7ffba85b2000-7ffba8799000 r-xp 00000000 08:06 136724 /lib/x86_64-linux-gnu/libc-2.27.so
Size: 1948 kB // 虚拟内存
KernelPageSize: 4 kB
MMUPageSize: 4 kB
Rss: 952 kB //实际内存
Pss: 9 kB // 使用此库者太多,无法统计。 share映射按比例计算,比如有3个进程,me当期进程的PSS就除以3
Shared_Clean: 952 kB
Shared_Dirty: 0 kB
Private_Clean: 0 kB
Private_Dirty: 0 kB
Referenced: 952 kB
Anonymous: 0 kB
LazyFree: 0 kB
AnonHugePages: 0 kB
ShmemPmdMapped: 0 kB
Shared_Hugetlb: 0 kB
Private_Hugetlb: 0 kB
Swap: 0 kB
SwapPss: 0 kB
Locked: 9 kB // 重点关注,是解决上述问题的关键
详细信息见http://man7.org/linux/man-pages/man2/mmap.2.html
这里有一个MAP_LOCKED的FLAG可以重点看一下,对应上面的locked字段,这个我后面会提到。
我们通过查看进程status来分析一下各个字段:
cat /proc/$PID/status
Name: ash
State: S (sleeping)
Tgid: 1990
Pid: 1990
PPid: 1
TracerPid: 0
Uid: 0 0 0 0
Gid: 0 0 0 0
FDSize: 32 // 打开fd文件描述符上线
Groups: 0
VmPeak: 1592 kB // 堆虚拟内存占用峰值
VmSize: 1592 kB // 堆虚拟内存大小
VmLck: 0 kB // 被lock的大小,被lock的页无论如何都不会被换出或者释放,通过mlock可以申请这种内存
VmPin: 0 kB
VmHWM: 552 kB // 实际物理内存大小峰值
VmRSS: 552 kB // 实际物理内存大小
VmData: 268 kB // 数据段大小
VmStk: 136 kB // 栈大小
VmExe: 688 kB // 程序binary大小
VmLib: 472 kB // 依赖动态共享内存库so大小
VmPTE: 16 kB // 页表项内存大小
VmSwap: 0 kB // swap内存占用大小
Threads: 1
...
通过对linux虚拟内存管理,我们可以建立两个信息:这也是解释上述案例的关键
- 对于应用程序本身内存分配是通过mmap匿名映射分配的。没有后备存储介质
- 对于动态依赖共享库是通过mmap私有或者共享映射加载的。存在后备存储介质
有了上述两个信息后我们继续来看linux回收和页置换,也就是我们标题中提到的swap。
页回收和页置换
在最早期的linux版本中,在内存不足时会置换整个进程,如果这样做的话,上下文切换代价加大,性能特别差,所以就产生了一种机制,当内存吃紧时,只置换进程中的一些页。
所以内核就需要一个机制来判断具体回收哪些页才能保证效率高而且不用频繁的回收和加载页,这里就要介绍一下LRU(latest recently used)算法,通过历史演推未来,过去不咋使用的页在未来使用的概率也相对可能性较低。LRU的基本原理简单,但是其复杂性对于内核来说还是太重了(需要处理大量的数据结构),所以需要一种更加简单的机制来代替原始实现,而且这个值不需要太过精确。
内核为了简化LRU的逻辑,提供了一种类似于二次复活的策略,来近似模拟LRU,通过两个FLAG来判断一个页是否可以被回收,这两个FLAG分别称为ACTIVE位及REFERENCE位。标志着四种状态:
- unreferenced & unactive // 很久没被访问
- referenced & unactive // 之前不活跃,但是最近被访问了一次
- unreferenced & active // 之前活跃,但是最近一次扫描未被访问
- referenced & active // 之前活跃,最近也被访问了
linux将物理内存页放入到两大类列表中管理:inactive和active,当物理内存达到一定阈值时,将inactive中的内存页进行回收或者置换。
具体状态转移见下图:
kswapd定期扫描内存中所有已分配的物理页,调用page_referenced,如果已标记为unreferenced,则将对应页标记为unactive,如果之前是active的,则通过shrink_active_list将其放回到inactive队列;如果之前是referenced,则置为unreferenced。
当一个页被访问时,就会调用mark_page_accessed,如果已标记为referenced,则标记为active,如果之前是inactive的,则通过active_page将其放入active队列;如果之前是unreferenced,则置为referenced。
通过上述状态转移可以清晰的获知一个页如果被认为可回收的话就是两次连续扫描内没有访问,而一个进入inactive队列的页要想被当做active必须在一个扫描周期内连续访问两次以上。
为了更好的区分对于缓存的访问以及进程内部的内存使用,linux又将内存lru队列细分为inactive_anon,active_anon,inactive_file,active_file四类,另外还有一类unevictable(被lock住不能置换的页),具体见如下:
enum lru_list {
LRU_INACTIVE_ANON = LRU_BASE,
LRU_ACTIVE_ANON = LRU_BASE + LRU_ACTIVE,
LRU_INACTIVE_FILE = LRU_BASE + LRU_FILE,
LRU_ACTIVE_FILE = LRU_BASE + LRU_FILE + LRU_ACTIVE,
LRU_UNEVICTABLE,
NR_LRU_LISTS
};
一般来说我们禁掉swap只会禁掉对于匿名映射的置换回收,而对于文件映射相关的访问我们得必须能够置换回收,因为内核文件缓存内存访问是属于这一类的,如果禁掉文件映射内存回收,那么缓存机制将不能很好的工作,Linux会在内存充足时将大力的内存用于文件缓存,如果这一部分不能被置换回收,那么可用空间将会变得少的可怜,而禁用文件缓存将会极大降低linux文件访问性能。一般做法都是通过禁掉swap文件分区来禁掉匿名映射的内存置换策略。
内存回收的时机
搞清楚了内存回收选择的内存页Page的策略后,那么内核是什么时候来触发页面的真正回收呢?
- 这里就要介绍一下kswapd,它会定期扫描各个NUMA下内存节点pglist_data中的所有mem_cgroup内存分配情况。每个Page会关联到一个mem_cgroup对象,page的分配和释放都会更新mem_cgroup中的usage(内存分配总量anon+file)信息,当mem_cgroup的内存使用usage达到mem_cgroup的min值则认为无药可救,不管了,否则通过shrink_list操作进行标记和队列移动,并在结尾时回收掉inactive队列中的内存页。
- 还有一种情况就是当内存比较紧张时,内核检测到某个操作期间内存严重不足,将调用try_to_free_pages,该函数检查当前内存域zone(存在一个内存节点中)所有页,并释放最不常用的那些,其释放内存的原理和机制和kswapd的做法一致,唯一不同的是kswapd会扫描节点中的所有域zone。
问题分析
现在对于linux的一些机制有了大致的了解了,那就来分析一下之前描述的场景,为啥当内存接近cgroup上限,离被OOMkiller杀掉还存在一点距离时,为何会产生大量的io read?
想必大家结合之前的描述可能一定的见解了:
- 进程通过私有映射加载进程binary及依赖共享库,这些处于active_file lru list中
- 进程正常分配内存或者程序bug终于达到了一个阈值,内核检测到内存不足,但是没达到被OOMkiller杀掉的条件,此时内核尝试通过调用try_to_free_pages尝试释放最近不常使用的页。
- 由于关闭了swap,inactive_anon没有后备device可供写回,那么可供回收的内存页就只能是inactive_file,也就是我们依赖通过private mmap方式加载的so共享依赖库。
- 由于内存不足,try_to_free_pages调用会比较频繁,那么active_file中的page有更大的概率被认为是inactive(具体见上述回收页选择策略),这样就会回收掉so相关的页。
- 然而真实情况是so中的数据随着代码的执行是比较被常访问的页,因此又需要从文件中加载回内存。
- 这样就形成了一个恶性循环,导致当内存在趋近OOM的时候会出现一个特别恶心的中间状态,我们称之为页颠簸。
问题解决
那么有没有什么好的方式来解决这个问题呢?
首先,需要讨论一下在内存充裕的今天,关闭swap是否是一个明智之举,这个我觉得得具体问题具体分析,swap不是一个简单的extra memory,而是对于内存使用率的一种优化策略,将内存中的低频内存置换出去,这样对于伙伴系统,文件缓存系统的管理也是比较友好的,对于资源利用率也是一种很不错的策略。但他确实也存在一些弊端,后备swap device访问的低效性,如果出现页颠簸时产生的影响是不可忽视的,所以是否采用swap得针对不同的进程做不同的分析。
对于IO密集型的进程,真正活跃内存其实比较少,大量时间都是在等IO,所以其内部的不常访问的内存完全可以先置换到磁盘里面。而且这类应用对于性能要求并没有那么高。
对于计算密集型的进程,内存大部分时间是活跃的,如果置换回磁盘的话,很快就可能要被置换回,大大影响了性能。
如果有人觉得:我们公司就是有钱,内存这点资源完全不在话下,当出现接近OOM的时候肯定是程序BUG了直接重启好了,让他快速被杀掉重新启动是一种更好的做法,那么有没有在禁掉swap的情况下,避免出现之前提到的那种由于so共享内存库的页面回收导致的页颠簸情况呢?
这里我们就要提一下之前介绍mmap的时候提到的MAP_LOCKED FLAG,这个FLAG的语义就是锁定被分配的PAGE内存页,使其无法被置换,通过这样方式被mmap的页就不能被回收了,这样当内存不足时,不会回收该页,这样就不会产生页颠簸,如果内存不足直接将进程kill重启就好了。
那么我们这里有一种思路就是希望进程在加载so的时候,将加载so对应的物理内存页锁定,这样对应的页就不能被换出了,但是查找了很多资料以及阅读dlopen dlsym官方文档相关参数都没有设lock相关的参数,因此这条路似乎走不通。
目前我能想到的方法就是通过mlockall系统将所有的内存页都锁住,使其无法被swap out,这也是ES 中一种禁止swap策略的一种。
具体做法就是:
在进程启动的main函数最开始调用一把mlockall with MCL_CURRENT | MCL_FUTURE ,这样已经分配以及后续分配的所有内存都会被lock住,其实就跟禁用了swap一样一样的。具体可以见例子
关于mlock,可以看看这篇文章,这篇文章里面的很多内容我觉得过时了,我觉得mlock并没有那么恐怖,我们目前关swap的做法其实跟mlock没啥差别,这么多公司不都用的好好的吗,不过他也有他的考虑。
如果是平台方想要一种更通用的方式,比如基于docker的pass平台可以将docker container entry的cmd指定为一个shell,shell上调用mlockall系统调用然后execv到真正的进程程序。(这只是我的个人思路哈,没测试)
总结
swap存在肯定有其存在的道理,一股脑的禁掉swap,并不是一个特别明智合理的处理方式,对于很多公司来说基本上都是一些web服务,基本都是io密集型应用,禁掉swap并没有太多收益,相反如果不lock住so加载时的mmap内存,还会出现页颠簸的场景出现。总而言之,尽量不要禁掉swap,如果选择禁用掉swap,就得想办法用mlockall锁定当前进程的其他内存分配,以防止由于private file映射出现的页颠簸。