Linux 进程与内存管理

在讨论 Linux 的性能问题时,我们常常会遇到这样一些问题:

  • Linux 能否满足硬实时的需求?
  • 多核环境下多线程如何执行?
  • 系统的内存究竟耗到哪里去了?
  • 我写的应用程序究竟耗了多少内存?
  • 什么是内存泄漏?如何判定内存是否真的泄漏?
  • CPU 速度、内存大小和系统性能之间究竟是什么关系?
  • 内存和 I/O 之间存在着怎样的联系?

要回答这些问题,需要先弄清楚进程调度和内存管理究竟能解决什么。下面把 Linux 进程调度(及配套的进程管理)与内存管理各自要回答的问题梳理一遍。

进程调度与进程管理:

  1. Linux 的进程和线程如何创建、退出?进程退出时,自己没有释放的资源(如未 free 的内存)会怎样?
  2. 什么是写时拷贝(Copy-on-Write)?
  3. Linux 的线程如何实现?与进程的本质区别是什么?
  4. Linux 能否满足硬实时的需求?
  5. 进程如何睡眠等待资源,此后又如何被唤醒?
  6. 进程的调度延时是多少?
  7. 调度器追求的吞吐率和响应延迟之间是什么关系?CPU 消耗型和 I/O 消耗型进程各自的诉求是什么?
  8. Linux 如何区分进程优先级?实时调度策略和普通调度策略有什么区别?
  9. nice 值的作用是什么?nice 值低有什么优势?
  10. Linux 可以被改造成硬实时吗?有哪些方案?
  11. 多核、多线程的情况下,Linux 如何实现进程的负载均衡?
  12. 这么多线程,究竟哪个线程在哪个 CPU 核上跑?有没有办法把某个线程固定到某个 CPU 上?
  13. 多核下如何实现中断、软中断的负载均衡?
  14. 如何利用 cgroup 对进程分组,并调控各个 group 的 CPU 资源?
  15. CPU 利用率和 CPU 负载之间是什么关系?CPU 负载高一定意味着用户体验差吗?

内存管理:

  1. Linux 系统的内存用掉了多少,还剩余多少?free 命令的每一个数字是什么意思?
  2. 为什么要有 DMA、NORMAL、HIGHMEM 这些 zone?每个 zone 的大小由谁决定?
  3. 系统的内存是如何被内核和应用瓜分掉的?
  4. 底层的内存管理算法 buddy 是怎么工作的?它和内核里的 slab 分配器是什么关系?
  5. 频繁的内存申请和释放是否会导致内存碎片化?后果是什么?
  6. Linux 内存耗尽后,系统会发生什么情况?
  7. 应用程序的内存是什么时候拿到的?malloc() 成功后,是否真的拿到了内存?应用程序的 malloc()/free() 与内核究竟是什么关系?
  8. 什么是 lazy 分配机制?应用的内存为什么会以最懒惰的方式延后拿到?
  9. 我写的应用究竟耗费了多少内存?进程的 VSS/RSS/PSS/USS 分别是什么概念?
  10. 内存为什么要做文件系统的缓存?如何做?缓存何时放弃?
  11. free 命令里显示的 buffers 和 cached 分别是什么?二者有何区别?
  12. 交换分区、虚拟内存究竟是什么?它们针对的是哪类内存?什么是匿名页?
  13. 进程耗费的内存、文件系统的缓存何时回收?回收算法是否类似 LRU?
  14. 怎样追踪和判定内存泄漏?找到泄漏源后如何定位?
  15. 内存大小如何影响系统性能?CPU、内存、I/O 三者如何互动并决定系统的关键性能?

上述问题若能回答清楚,就建立起了一个基本的概念框架;当 Linux 出现吞吐低、延迟大、响应慢等问题时,也就能找到一个可能的排查方向。本文并不打算逐一展开这 30 个问题,而是聚焦其中最核心的几对矛盾,建立思维框架、导入排查方向。

吞吐 vs. 响应

思考调度器时,首先要理解一个事实:任何操作系统的调度器设计都只追求两个目标——吞吐率大延迟低。这两个目标有些类似于零和博弈:

  • 吞吐率要大,势必要把更多时间花在真正做有用功上,而不是浪费在频繁的进程上下文切换上;
  • 延迟要低,势必要求高优先级进程能随时抢占进来、打断别人。

但抢占会引起上下文切换,而上下文切换对吞吐率而言是一种消耗。这个消耗可以低到 2us 甚至更低,但更大的消耗其实不是切换本身,而是切换会引起大量的 cache miss——例如刚才还在运行应用 A,现在切到应用 B,CPU 的 cache 就很难命中 B 的数据。

不抢占,响应就差;抢占,吞吐就下降。Linux 既不是一个完全照顾吞吐的系统,也不是一个完全照顾响应的系统。它作为一个软实时操作系统,实际上是想达到某种平衡,同时给用户提供一定的配置能力:在内核编译时,Kernel Features ---> Preemption Model 选项可以让我们在编译内核时选择更倾向吞吐还是更倾向响应——越往上选,吞吐越好;越往下选,响应越好。服务器上一个月也难得用一次鼠标,而桌面则显然要求一定的响应以保证 UI 表现。

但即便选择了最后一个选项 “Preemptible Kernel (Low-Latency Desktop)”,Linux 仍然不是硬实时的。因为在 Linux 中,有三类区间不可抢占调度:

  • 中断;
  • 软中断;
  • 持有类似 spin_lock 这样的锁而关掉本核调度的情况。

举例说明:一个普通进程在 T1 时刻持有 spin_lock 进入临界区(该核调度被关闭),T2 时刻被中断打断,T3 时刻 IRQ1 里唤醒了一个 RT 进程(若是硬实时 RTOS,此时 RT 进程应当能立即抢入);IRQ1 之后又执行了 IRQ2,到 T4 时刻两个中断都结束了,RT 进程依然不能执行(因为普通进程还在 spin_lock 里),直到 T5 时刻普通进程释放 spin_lock 后,RT 进程才抢入。

从 T3 到 T5 究竟要多久是不确定的,这就无法满足硬实时系统”可预期、确定性延迟”的要求,因此 Linux 不是硬实时操作系统。Linux 的 preempt-rt 补丁试图把中断、软中断线程化,变成可抢占的区间,并把会关掉本核调度器的 spin_lock 替换为可调度的 mutex,从而在 T3 时刻唤醒 RT 进程时让它立即抢占进入,避免 T3~T5 之间延迟的非确定性。

CPU 消耗型 vs. I/O 消耗型

Linux 中运行的进程可分为两类:

  • CPU 消耗型:持续计算,CPU 利用率高;
  • I/O 消耗型:大部分时间在睡眠等待 I/O,CPU 利用率低。

一般而言,I/O 消耗型任务对延迟比较敏感,应该被优先调度。例如,正在编译大型项目的同时,等待鼠标输入的用户界面迟迟得不到响应;当鼠标一点击,系统应该优先打断正在编译的进程去响应鼠标这个 I/O 事件,这样用户体验才合理。

对于 RT 进程,Linux 按照 SCHED_FIFO 和 SCHED_RR 策略调度:优先级高的先执行;优先级高的睡眠后,低优先级的才执行。同等优先级下,SCHED_FIFO 先就绪的先跑到睡眠、后就绪的接着跑;SCHED_RR 则进行时间片轮转。RT 进程的调度有”高优先级未睡眠、低优先级靠边站”的特点。

不过 Linux 的绝大多数进程都不是 RT 进程,而是采用 SCHED_NORMAL 策略。普通进程的优先级用 nice 来描述——nice 越高,优先级越低。普通进程并不是 nice 低的一定阻塞 nice 高的,而是按照如下公式调度:

vruntime = pruntime * NICE_0_LOAD / weight

其中 NICE_0_LOAD 为 1024,也就是 nice 为 0 的进程的权重;vruntime 是进程的虚拟运行时间,pruntime 是物理运行时间,weight 是权重,权重完全由 nice 决定,见下表:

进程 pruntime Weight vruntime
T1 8 1024(nice=0) 8*1024/1024=8
T2 10 526(nice=3) 10*1024/526=19
T3 20 1024(nice=0) 20*1024/1024=20
T4 20 820(nice=1) 20*1024/820=24

当 RT 进程都睡过去后(一个特例是 RT 进程累计运行过久时普通进程也会得到少量运行时间),Linux 开始调度 NORMAL 进程。它倾向于调度 vruntime 最小的普通进程。由公式可知,vruntime 要小,要么分子小(爱睡、I/O 型进程,pruntime 不易增长),要么分母大(nice 值低、优先级高、权重大)。这样一个简单的公式,就同时兼顾了普通进程的优先级与 CPU/I/O 消耗情况。

上面的例子中,目前 T1 的 vruntime 最小(这是它爱睡的结果),于是 T1 先被调度。假设 T1 被调度后又执行了 12 个 pruntime,它的 vruntime 将增大 delta*1024/weight(这里 delta 为 12,weight 为 1024),于是 T1 的 vruntime 变为 20。此时 vruntime 最小的反而是 T2(19),于是 Linux 接下来倾向于调度 T2——尽管 T2 的 nice 值大于 T1、优先级低于 T1,但它的 vruntime 现在只有 19。

所以,普通进程的调度,是综合考虑它”爱干活还是爱睡”和它的 nice 值之后的结果。鉴于此,去问”一个普通进程的调度延迟究竟有多大”,这个问题本身意义不大——它完全取决于当前系统里还有谁在跑,取决于被唤醒进程的 nice 值和它之前是否频繁睡眠。明白了这一点,就不会再去问一些没有确定答案的问题了。

分配 vs. 占据

Linux 的设计哲学是允许应用程序”犯错”。因此下面这类问题无需担心:进程 malloc() 了内存还没 free() 就退出,之前分配的内存是不是泄漏了?答案是:不可能。进程退出时,其所有资源都会被内核释放,否则 Linux 无法应对形形色色存在缺陷的开源软件。

同样的,在应用程序里 malloc() 成功的那一刻,也不要以为真的拿到了内存。此时进程的 VSS(Virtual Set Size,虚拟地址空间)会增大,但 RSS(Resident Set Size,驻留内存)会随着每一页被真正写入而缓慢增大。所以”分配成功”那一刻,顶多只是获得了虚拟地址空间的承诺,与实际是否占据物理内存暂时没有直接关系。

例如,初始的堆是 8KB,这 8KB 已经被写过,所以堆的 VSS 和 RSS 都是 8KB。此后调用 brk() 把堆扩大到 16KB,但实际占据的 RSS 仍然是 8KB——因为新增的页还没有被写,根本没有真正从内存条上拿到物理内存。直到写入新增的页,堆的 RSS 才变为 12KB。这就是 Linux 针对应用的 lazy 分配机制,其出发点同样是防止应用程序的”错误”造成浪费。代码段、堆、栈的内存都是这样懒惰地拿到的,即按需分页(demand paging)。

以一个 1GB 内存的 32 位 Linux 系统为例,关闭 swap,并通过修改 overcommit_memory 为 1 来允许申请不超过进程虚拟地址空间的内存:

$ sudo swapoff -a
$ sudo sh -c 'echo 1 >/proc/sys/vm/overcommit_memory'

此后,应用可以申请一个比实际内存还大的内存:例如在 1GB 的机器上申请 2GB 内存可以成功,但在写入到一定程度后,系统出现 out-of-memory,对应进程作为 oom_score 最大(最”该死”)的进程被系统杀死。

隔离 vs. 共享

Linux 进程究竟耗费了多少内存,是一个相当复杂的概念。除了上面的 VSS、RSS 外,还有 PSS 和 USS,这也是 Linux 不同于 RTOS 的显著特点之一。Linux 各进程既要做到隔离,隔离中又要实现共享——例如 1000 个进程都使用 libc,libc 的代码段在内存里显然只应有一份。

假设有 3 个进程:pid 为 1044 的 bash、pid 为 1045 的 bash 和 pid 为 1054 的 cat。每个进程通过自己的页表把虚拟地址空间指向内存条上的物理地址,每次切换进程,即切换一份独特的页表。仅以此例而言,进程 1044 的 VSS 和 RSS 分别是:

vss = 1 + 2 + 3
rss = 4 + 5 + 6

4+5+6 就真的是进程 1044 耗费的内存吗?显然不准确,因为 4 被 3 个进程共享、5 被 2 个进程共享。共享的内存不能只让 1044 一个进程”背黑锅”。由此衍生出 PSS(Proportional Set Size,按比例计算的驻留内存)的概念。仅以此例而言,进程 1044 的 PSS 为:

pss = 4/3 + 5/2 + 6

最后,还有进程 1044 独占且驻留的内存 USS(Unique Set Size):

uss = 6

所以分析 Linux 时,不能模棱两可地停留在表面,想当然地问”Linux 进程耗费了多少内存”——因为这个问题本身没有一个单一答案,它取决于你问的是 VSS、RSS、PSS 还是 USS。

小结

本文从”吞吐 vs. 响应”、”CPU 消耗型 vs. I/O 消耗型”、”分配 vs. 占据”、”隔离 vs. 共享”四对核心矛盾出发,梳理了 Linux 进程调度与内存管理的思维框架。前面提出的 30 个问题,本文只回答了其中极少一部分;目的不在于逐一展开,而在于建立思维、导入方向。


   转载规则


《Linux 进程与内存管理》 吴杭沉 采用 知识共享署名 4.0 国际许可协议 进行许可。
 上一篇
enable_shared_from_this 的使用场景 enable_shared_from_this 的使用场景
enable_shared_from_this 是一个模板类,定义于头文件 <memory>,其原型为: template< class T > class enable_shared_from_this; 作用std::
2020-04-28
下一篇 
TCP为什么需要TIME_WAIT TCP为什么需要TIME_WAIT
问题背景下图所示的状态机展示了通信双方建立 TCP 连接之后的状态转换过程(图片来源: tcpipguide.com)。 通信双方主动发起关闭连接的一端,存在 TIME_WAIT 状态,被动接受关闭连接的一端,会进入 CLOSE_WAIT
2020-04-16
  目录