Linux 性能优化

系统变慢时,“慢”往往只是表象,真正的瓶颈可能在 CPU、内存或磁盘 I/O 中的任意一处,而这三者又互相牵连:内存不足会引发 Swap 换页,进而变成磁盘 I/O 压力;磁盘慢又会让进程卡在不可中断状态,反过来推高 CPU 的平均负载。

所以性能分析的思路是固定的:先看指标定位到大方向,再用对应的工具逐层下钻到进程。本文按 CPU → 内存 → I/O 的顺序,梳理每一层的关键指标、背后的原理和常用工具。

CPU

CPU 这一层最常用的入口指标是平均负载,它便宜、直观,但也最容易被误读,所以先从它讲起,再深入到背后真正消耗 CPU 的上下文切换。

平均负载

平均负载可以简单理解为平均活跃进程数,准确地说,是处于可运行状态不可中断状态的平均进程数:

  • 可运行状态:正在使用 CPU 或者正在等待 CPU 的进程,也就是常用 ps 命令看到的处于 R 状态(Running 或 Runnable)的进程
  • 不可中断状态:正处于内核态关键流程中的进程,并且这些流程是不可打断的,最常见的是等待硬件设备的 I/O 响应,也就是 ps 命令中看到的 D 状态(Uninterruptible Sleep,也称为 Disk Sleep)的进程

判断负载高不高,必须先知道有几个核。查看 CPU 个数:

1
2
root@imwl01:~# grep 'model name' /proc/cpuinfo |wc -l
4

4 核机器满负载对应的平均负载是 4,一般认为超过 70%(也就是 2.8)就可以开始排查 CPU 使用情况了。

uptime 查看:

1
2
root@imwl01:~# uptime
15:45:41 up 44 min, 2 user, load average: 0.12, 0.12, 0.17

各字段含义:

  • 15:45:41:当前时间
  • up 44 min:开机时间
  • 2 user:登录用户数
  • load average 后的三个数:过去 1 分钟、5 分钟、15 分钟的平均负载

三个值放在一起看才有意义:上面这台机器三个值都很接近,说明负载平稳;如果 1 分钟的值明显高于 15 分钟,说明负载正在快速上升;反之则说明高峰已经过去。

平均负载升高有两种可能的原因:

  1. CPU 密集型程序导致
  2. I/O 更加繁忙(对应的是上面提到的不可中断状态进程变多)

两者在平均负载上看不出区别,需要用工具进一步区分:

  • mpstat:查看 CPU 整体及每个核的使用情况,如 mpstat -P ALL 5 1
  • pidstat:查看具体进程的使用情况,如 pidstat -u 5 1

这两个命令由 sysstat 包提供,yum install -y sysstat 即可安装。

为什么三个数字要一起看

上面说”1 分钟明显高于 15 分钟就是负载在上升”,背后的依据是:这三个数不是简单的算术平均,而是指数移动平均(EWMA),各自带一个衰减常数。内核每 5 秒采样一次活跃进程数,然后按

1
load = load * exp(-5/T) + n * (1 - exp(-5/T))     # T 取 60 / 300 / 900 秒

的方式往前滚。所以 1 分钟那个值衰减快、对突发敏感;15 分钟那个衰减慢、滞后明显。一次持续 1 分钟的尖峰过去之后,1 分钟值很快掉回去,15 分钟值还要拖十几分钟才慢慢回落——这才是”比较三个数就能看出趋势方向”的数学来源,而不是某种经验法则。

也正因为是 EWMA,它对刚刚开始的问题反应是钝的:一台机器负载真出问题的头十几秒,三个数可能都还很好看。

“load 很高但 CPU 很闲”

这是这个指标最常见的困惑,原因就在上面那句”包括不可中断状态”。D 状态的进程根本没在用 CPU,它在等 I/O,但一样计进 load。所以 NFS 卡住、磁盘故障、iSCSI 断链这类场景下,load 能飙到几十而 CPU 使用率接近 0。

mpstat/pidstat 能区分是 CPU 还是 I/O,但它们给的是”用了多少”,回答不了”被拖慢了多少”。更直接的答案是 PSI(Pressure Stall Information,4.20 引入):

1
2
3
4
5
6
cat /proc/pressure/cpu
# some avg10=0.00 avg60=0.00 avg300=0.00 total=0
cat /proc/pressure/memory
cat /proc/pressure/io
# some avg10=12.34 ... 有部分任务因为等 IO 被阻塞的时间占比
# full avg10=3.21 ... 所有任务都被阻塞(整机停摆)的时间占比

它给出的是因为等某类资源而被拖慢的时间占比some 是”有任务在等”,full 是”全都在等”。这个语义比 load 直接得多:load 是 12 说明不了什么,而 io full avg10=30 明确告诉你有 30% 的时间整机都卡在 I/O 上。cgroup v2 里每个 cgroup 也有自己的 *.pressure 文件,能一直定位到具体是哪个容器在制造压力。

CPU 上下文切换

Linux 是一个多任务操作系统,支持远大于 CPU 数量的任务同时进行(并发或者并行)。系统需要事先帮 CPU 设置好寄存器和程序计数器,从而使 CPU 知道任务从哪儿加载、从哪儿开始运行。

  • CPU 寄存器:CPU 内置的容量小、速度极快的内存
  • 程序计数器:用来存储 CPU 正在执行的指令位置,或者即将执行的下一条指令位置

这两者合起来就是 CPU 上下文。所谓 CPU 上下文切换,就是先把前一个任务的 CPU 上下文(也就是 CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。

而这些保存下来的上下文,会存储在系统内核中,并在任务重新调度执行时再次加载进来。这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。

按切换对象的不同,可分为进程上下文切换、线程上下文切换和中断上下文切换三类。

进程上下文切换

进程的运行空间按特权等级分为内核空间和用户空间:

  • 内核空间(Ring 0):可以访问所有资源
  • 用户空间(Ring 3):只能访问受限资源,不能直接访问内存等硬件设备,需要通过系统调用陷入到内核中,才能访问这些特权资源

进程在用户空间运行时被称为进程的用户态,而陷入内核空间的时候被称为进程的内核态。从用户态到内核态的转变,需要通过系统调用来完成。

系统调用时,CPU 寄存器里原来用户态的指令位置需要先保存起来;接着,为了执行内核态代码,CPU 寄存器需要更新为内核态指令的新位置;最后才是跳转到内核态运行内核任务。系统调用结束后,CPU 寄存器需要恢复原来保存的用户态,然后再切换到用户空间继续运行进程。所以一次系统调用其实发生了两次 CPU 上下文切换:用户态 → 内核态 → 用户态。

不过要注意,系统调用过程中一直是同一个进程在运行,因此它通常被称为特权模式切换,而不是上下文切换。

真正的进程上下文切换,是指从一个进程切换到另一个进程:

1
进程 1 → 保存进程 1 的上下文 → 加载进程 2 的上下文 → 进程 2

线程上下文切换

线程是调度的基本单位,而进程则是资源拥有的基本单位,进程只是给线程提供虚拟内存、全局变量等资源。

  1. 当进程只有一个线程时,可以认为进程就等于线程
  2. 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源,这些资源在上下文切换时是不需要修改的
  3. 线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时是需要保存的

因此线程上下文切换分两种情况:

  1. 两个线程属于不同进程,切换过程就等同于进程上下文切换
  2. 两个线程属于同一进程,因为虚拟内存是共享的,切换时这些资源保持不动,只需要切换线程的私有数据、寄存器等不共享的数据

所以同一进程内线程之间切换消耗的资源,比进程之间切换要少。

中断上下文切换

中断处理会打断进程的正常调度和执行。对于同一个 CPU 来说,中断处理比进程拥有更高的优先级。

中断上下文切换并不涉及到进程的用户态,所以即便中断过程打断了一个正处在用户态的进程,也不需要保存和恢复这个进程的虚拟内存、全局变量等用户态资源。中断上下文其实只包括内核态中断服务程序执行所必需的状态,包括 CPU 寄存器、内核堆栈、硬件中断参数等。

自愿与非自愿上下文切换

  • 自愿上下文切换:是指进程无法获取所需资源导致的上下文切换。比如 I/O、内存等系统资源不足时,就会发生自愿上下文切换
  • 非自愿上下文切换:是指进程由于时间片已到等原因,被系统强制调度而发生的上下文切换。比如大量进程都在争抢 CPU 时,就容易发生非自愿上下文切换

vmstat 可以分析系统内存使用情况以及 CPU 上下文切换和中断的次数。看到异常时,可以按下面的对应关系判断:

  1. 自愿上下文切换变多了,说明进程都在等待资源,有可能发生了 I/O 等其他问题
  2. 非自愿上下文切换变多了,说明进程都在被强制调度,也就是都在争抢 CPU,说明 CPU 的确成了瓶颈
  3. 中断次数变多了,说明 CPU 被中断处理程序占用,还需要通过查看 /proc/interrupts 文件来分析具体的中断类型

DMA

Direct Memory Access(DMA)模块允许硬件设备直接通过 DMA 写内存,而不需要通过 CPU(占用 CPU 资源)。设备与内存之间的大批量数据搬运交给 DMA 之后,CPU 只需要在传输完成时处理一次中断,这也是减少中断和上下文切换开销的重要手段。

常用工具的输出解读

前面反复提到 mpstatpidstatvmstat,这里把它们的输出字段集中解释一遍。

mpstat

1
2
3
4
5
imwl@imwl:~$ mpstat
Linux 4.4.0-22000-Microsoft (imwl) 02/10/22 _x86_64_ (16 CPU)

15:00:36 CPU %usr %nice %sys %iowait %irq %soft %steal %guest %gnice %idle
15:00:36 all 1.54 0.00 2.12 0.00 0.22 0.00 0.00 0.00 0.00 96.13

括号里为 top 等工具中的常用缩写:

  1. user:用户态 CPU 时间,包含 guest(us)
  2. nice:低优先级(1-19)用户态 CPU 时间。优先级取值 -20 ~ 19,数值越高优先级越低(ni)
  3. system:内核态 CPU 时间(sy)
  4. idle:空闲时间,不包括等待 I/O 的时间(id)
  5. iowait:等待 I/O 的 CPU 时间(wa)
  6. irq:处理硬中断的 CPU 时间(hi)
  7. softirq:处理软中断的 CPU 时间(si)
  8. steal:当系统运行在虚拟机中的时候,被其他虚拟机占用的 CPU 时间(st)
  9. guest:代表通过虚拟化运行其他操作系统的时间,也就是运行虚拟机的 CPU 时间(guest)
  10. guest_nice:以低优先级运行虚拟机的时间(gnice)

其中 CPU 使用率 = 1 - 空闲时间 / 总 CPU 时间iowait 偏高就是把排查方向指向磁盘 I/O 的信号。

pidstat

1
2
3
4
5
6
7
imwl@imwl:~$ pidstat
Linux 4.4.0-22000-Microsoft (imwl) 02/10/22 _x86_64_ (16 CPU)

15:00:07 UID PID %usr %system %guest %wait %CPU CPU Command
15:00:07 0 1 0.00 0.11 0.00 0.00 0.11 0 init
15:00:07 1000 9 0.00 0.09 0.00 0.00 0.09 0 bash
15:00:07 1000 378 0.01 0.00 0.00 0.00 0.01 0 pidstat
  • UID:用户 ID
  • PID:进程识别号
  • PPID:当前进程的父进程 ID

vmstat

1
2
3
4
[root@test-1 ~]# vmstat
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
3 0 0 185534576 745272 22088512 0 0 3 22 1 1 4 2 93 0 0

vmstat 一条输出就横跨了本文的三个主题:procs 段的 rb 对应可运行和不可中断进程数,memoryswap 段对应内存与换页,io 段对应块设备读写,system 段的 incs 就是中断和上下文切换次数。

内存

如果 CPU 指标正常但系统依然卡顿,下一个要看的就是内存。内存问题的麻烦之处在于它很少单独出现:不够用会触发回收和 Swap,最终表现为磁盘 I/O 变慢和进程被 OOM 杀死。

查看系统内存使用情况

使用 free,默认单位为 KB:

1
2
3
4
root@imwl01:~# free
total used free shared buff/cache available
Mem: 4001696 514836 2834488 1572 652372 3230228
Swap: 0 0 0
  1. total:总内存大小
  2. used:已使用内存的大小,包含了共享内存
  3. free:未使用内存的大小
  4. shared:共享内存的大小,tmpfs 就是一种特殊的缓存
  5. buff/cache:缓存和缓冲区的大小。Buffer 是对磁盘数据的缓存,而 Cache 是文件数据的缓存,它们既会用在读请求中,也会用在写请求中
  6. available:新进程可用内存的大小,包含未使用 + 可回收

要按进程看,使用 top 后按 M 以内存排序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
root@imwl01:~# top
top - 16:42:46 up 11 min, 1 user, load average: 0.05, 0.03, 0.00
Tasks: 241 total, 1 running, 240 sleeping, 0 stopped, 0 zombie
%Cpu(s): 0.1 us, 0.0 sy, 0.0 ni, 99.9 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
MiB Mem : 3907.9 total, 2711.3 free, 512.0 used, 684.6 buff/cache
MiB Swap: 0.0 total, 0.0 free, 0.0 used. 3145.6 avail Mem

PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
2231 root 20 0 822836 84952 34176 S 0.3 2.1 0:04.40 node
1014 root 20 0 1383888 78828 51756 S 0.0 2.0 0:00.47 dockerd
2026 root 20 0 926520 62968 31896 S 0.3 1.6 0:02.57 node
1443 root 20 0 164268 53020 11428 S 0.0 1.3 0:00.48 jupyter-noteboo
2238 root 20 0 917196 52236 29564 S 0.3 1.3 0:02.18 node
938 root 20 0 1492628 42612 28680 S 0.3 1.1 0:01.27 containerd
2197 root 20 0 653708 39024 29936 S 0.0 1.0 0:00.18 node
926 root 20 0 1094772 38136 18964 S 0.0 1.0 0:01.51 snapd
524 root 19 -1 67148 22564 21424 S 0.0 0.6 0:00.28 systemd-journal
974 root 20 0 107908 20844 13176 S 0.0 0.5 0:00.08 unattended-upgr
749 root rt 0 345880 18264 8300 S 0.0 0.5 0:00.21 multipathd
920 root 20 0 29076 18132 10408 S 0.0 0.5 0:00.06 networkd-dispat
............................
  1. VIRT:进程虚拟内存的大小,只要是进程申请过的内存,即便还没有真正分配物理内存,也会计算在内
  2. RES:常驻内存的大小,也就是进程实际占用的物理内存大小,不包括已经被换出到 Swap 的部分,但包括共享内存,也就是说 SHR 是 RES 的一部分(上面 node 进程的 RES 是 84952、SHR 是 34176,%MEM 的 2.1% 也是按 RES 除以总内存 3907.9MiB 算出来的)
  3. SHR:共享内存的大小,比如与其他进程共同使用的共享内存、加载的动态链接库以及程序的代码段等
  4. %MEM:进程使用物理内存占系统总内存的百分比

由于 RES 会重复统计共享部分,直接累加各进程的 RES 会偏大。要统计所有进程使用的内存总量,应该用 Pss,即把共享内存平分到各个进程后,再加上进程本身的非共享内存大小的和:

1
2
3
# 使用 grep 查找 Pss 指标后,再用 awk 计算累加值
# 注意要用 ^Pss: 锚定,否则 SwapPss、Pss_Dirty 也会被匹配到,同一块内存被算两三遍
$ grep '^Pss:' /proc/[1-9]*/smaps | awk '{total+=$2}; END {printf "%d kB\n", total }'

不想自己拼命令的话,直接 smem -t 也能拿到按进程列出的 Pss 汇总。

虚拟内存

内存是稀缺的,随着应用膨胀,进程对内存的需求会越来越大,虚拟化技术就是为了解决内存不够用的问题。

虚拟化技术中,操作系统设计了虚拟内存(理论上可以无限大的空间),实际受限于 CPU 的处理能力,通常 64bit CPU 就是 2**64 个地址。应用使用的是虚拟内存,操作系统管理虚拟内存和真实内存之间的映射。

操作系统会将虚拟内存分成整齐的小块,每个小块称为一个页(Page)。之所以这样做,原因主要有以下两个方面:

  1. 一方面应用使用内存是以页为单位,整齐的页能够避免内存碎片问题
  2. 另一方面,每个应用都有高频使用的数据和低频使用的数据。这样做,操作系统就不必从应用角度去思考哪个进程是高频的,仅需思考哪些页被高频使用、哪些页被低频使用。如果是低频使用,就将它们保存到硬盘上;如果是高频使用,就让它们保留在真实内存中

因此如果一个应用需要非常大的内存,它申请的是虚拟内存中的很多个页,真实内存不一定需要够用。

大页则是指比普通页(4KB)更大的内存块,常见的大小有 2MB 和 1GB,通常用在使用大量内存的进程上,比如 Oracle、DPDK 等。

内存页

内存映射

  1. 物理内存:只有内核才可以直接访问
  2. 虚拟内存:Linux 内核给每个进程都提供了一个独立的虚拟地址空间,并且这个地址空间是连续的

内存映射其实就是将虚拟内存地址映射到物理内存地址。

内存映射

进程的虚拟内存空间按用途划分为若干段:

虚拟内存空间分布

内存分配与回收

分配

对小块内存(小于 128K),C 标准库使用 brk() 来分配,也就是通过移动堆顶的位置来分配内存。这些内存释放后并不会立刻归还系统,而是被缓存起来,这样就可以重复使用。

而大块内存(大于 128K),则直接使用内存映射 mmap() 来分配,也就是在文件映射段找一块空闲内存分配出去。

回收

内存紧张时,系统会按代价从低到高依次采取三种手段:

  1. 回收缓存,比如使用 LRU(Least Recently Used)算法,回收最近使用最少的内存页面
  2. 回收不常访问的内存,把不常用的内存通过交换分区直接写到磁盘中
  3. 杀死进程,内存紧张时系统还会通过 OOM(Out of Memory)直接杀掉占用大量内存的进程

内存泄漏

并不是所有内存区域都会泄漏:

  1. 只读段,包括程序的代码和常量,由于是只读的,不会再去分配新的内存,所以也不会产生内存泄漏
  2. 数据段,包括全局变量和静态变量,这些变量在定义时就已经确定了大小,所以也不会产生内存泄漏
  3. 栈,虽然大小一直在变,但栈帧会随着函数返回、局部变量出作用域自动弹出,回收由系统负责,所以也不会泄漏(栈这边的典型问题是溢出,不是泄漏)

真正会泄漏的是堆和内存映射段:堆上的内存要由程序显式 free,内存映射段里由程序自己动态分配和管理的那部分(比如共享内存)也一样。所以,如果程序在分配后忘了回收,就会导致泄漏问题。

Swap

Swap 技术允许把暂时用不到的内存数据先放到磁盘上,把物理内存腾出来给需要的进程。需要注意的是,把「完整的进程数据(正文段、数据段、堆栈段等)」整体换出去,是古早的整进程交换(swapping);Linux 的换出是以为粒度的,而且只有匿名页(堆、栈这类没有文件后备的内存)才会被写入 swap。文件页则是干净的直接回收、脏的回写到原来的文件里,程序的正文段属于文件页,回收时直接丢弃,下次需要再从可执行文件里重新读进来,并不占用 swap

它包含两个方向的动作:

  1. 换出:把进程暂时不用的内存数据存储到磁盘中,并释放这些数据占用的内存
  2. 换入:进程再次访问这些内存的时候,把它们从磁盘读到内存中来

按载体分为 swap 分区和 swap 文件两种类型。

Swap 技术在性能上存在着碎片、频繁切换等明显劣势。使用 Swap 技术,程序员需要清楚地知道自己的应用用多少内存,并且小心翼翼地使用内存,避免需要重新申请,或者研发不断扩容的算法,因此一般不推荐使用 Swap

相关的两个内核参数:

  1. /proc/sys/vm/min_free_kbytes:一旦剩余内存小于页低阈值,就会触发内存的回收
  2. /proc/sys/vm/swappiness:调整文件页和匿名页的回收倾向,取值 0-100,越低越不倾向使用 Swap。但即便设为 0,当剩余内存 + 文件页小于页高阈值时,仍然会发生 Swap

缓存命中率

缓存命中率指通过缓存获取数据的请求次数,占所有数据请求次数的百分比。因为内存的数据读写比磁盘读写速度快得多,所以可以依赖缓存提升速度,命中率也就成了衡量缓存是否有效的直接指标。

内存优化常见思路

  1. 禁止 Swap。如果必须开启 Swap,降低 swappiness 的值,减少内存回收时 Swap 的使用倾向
  2. 减少内存的动态分配。比如可以使用内存池、大页(HugePage)等
  3. 尽量使用缓存和缓冲区来访问数据。比如可以使用堆栈明确声明内存空间,来存储需要缓存的数据;或者用 Redis 这类的外部缓存组件,优化数据的访问
  4. 使用 cgroups 等方式限制进程的内存使用情况。这样可以确保系统内存不会被异常进程耗尽
  5. 通过 /proc/pid/oom_adj 调整核心应用的 oom_scoreoom_score 越小,进程就越不容易被系统杀死

定位流程与工具对照

内存定位流程

内存依据工具查指标

内存依据指标查工具

磁盘 I/O

磁盘为系统提供了最基本的持久化存储,而文件系统则在磁盘的基础上,提供了一个用来管理文件的树状结构。绝大多数应用并不直接和磁盘打交道,而是通过文件系统间接访问。

这条路径上有两种走法,也正好解释了前面 free 输出里 Buffer 与 Cache 的区别:

  • 读写普通文件时,I/O 请求会首先经过文件系统,然后由文件系统负责来与磁盘进行交互,对应的缓存是 Cache
  • 而在读写块设备文件时,会跳过文件系统,直接与磁盘交互,也就是所谓的“裸 I/O”,对应的缓冲是 Buffer

这个区别光看结论容易记反,跑两组对照实验就再也不会混了。开一个终端挂 vmstat 1 盯住 buffcache 两列,另一个终端分别做:

1
2
3
4
5
# 读块设备 → buff 列涨(注意选一块空闲盘,别对着挂载中的分区乱写)
dd if=/dev/sdb1 of=/dev/null bs=1M count=1024

# 读普通文件 → cache 列涨
dd if=/tmp/bigfile of=/dev/null bs=1M count=1024

写方向同理:dd of=/dev/sdb1buffdd of=/tmp/bigfilecache。每组之间记得 echo 3 > /proc/sys/vm/drop_caches 清一下,否则第二次读全命中缓存,什么都看不出来。

有一层历史背景值得补上,否则很容易把这两个词理解成两套独立的内存池:Linux 2.4 以后 buffer cache 和 page cache 已经合并了。今天的 “Buffer” 其实就是 page cache 中归属块设备的那一部分,底层是同一套页面管理。这解释了两件事:为什么 free 干脆把它们合成 buff/cache 一列显示;以及为什么 drop_caches 会把两者一起清掉——它们本来就是一个子系统。

正因为磁盘比内存慢几个数量级,I/O 问题很少以“磁盘慢”的形式直接暴露,而是通过前两节的指标间接体现出来:进程卡在 D 状态推高平均负载、mpstat 里的 iowait 升高、vmstatb 列和 bi/bo 列变大、内存不足触发 Swap 换页又制造出额外的磁盘读写。因此排查 CPU 或内存问题时,始终要把 I/O 作为可能的根因一起考虑。