性能调优(3)—— 方法论与可观测性

Posted by 皮皮潘 on 12-17,2024

性能优化方法

  1. 不要做:消除不必要的工作。
  2. 做,但不要再做:缓存。
  3. 做少点:将刷新、轮询或更新的频率调低。
  4. 稍后再做:回写缓存。
  5. 在不注意的时候做:安排工作或在非工作时间进行。
  6. 同时做:从单线程切换到多线程。
  7. 做得更便宜:购买更快的硬件。

应用程序优化

  1. On CPU 分析:基于 Perf Record + 火焰图
  2. Off CPU 分析:针对 schedule 进行 trace,通过 ebpf 设置 off cpu 阈值(大于 1us 才记录)并在内核空间聚合调用栈可以降低对应分析带来的开销。除此之外,追踪器们(除了 strace)还利用缓冲跟踪大大减少了跟踪的开销,其中事件被写入一个共享的内核环形缓冲区,用户级别的跟踪器定期读取缓冲区,减少了用户和内核之间的上下文切换,降低了开销.同时由于存在 RingBuffer,因此也可以调整对应的 RingBuffer 的到校记录最近 30s 的记录,并在出现问题的时候停止 Trace,并从 RringBuffer 读取
  3. USE 方法,针对关键组件分析,比如:线程池,文件数量,缓存大小等
  4. 线程状态分析
  5. 锁状态分析
  6. 系统调用分析,主要针对:网络(tcp_),文件系统(ext4_),磁盘 IO(blk_),锁(futex_),内存分配(mmap,brk)
  7. 业务负载画像:业务负载画像的目的是理解实际运行的业务负载。你不需要对最终的性能结果进行分析,比如系统的延迟到底受到多少影响。“消除不必要的工作”是在性能优化中收益最显著的一种,通过研究业务负载的构成就可以找到这样的优化点。
    开展业务负载画像的推荐步骤如下:
    1. 负载是谁产生的(比如,进程ID、用户ID、进程名、IP地址)?
    2. 负载为什么会产生(代码路径、调用栈、火焰图)?
      3.负载的组成是什么(IOPS、吞吐量、负载类型)?
    3. 负载怎样随着时间发生变化(比较每个周期的摘要信息)?

Linux 60s 分析

  1. uptime
  2. dmesg | tail
  3. vmstat 1
  4. mpstat -P ALL 1
  5. pidstat 1
  6. iostat -xz 1
  7. free -m
  8. sar -n DEV 1
  9. sar -n TCP, ETCP 1
    10.top

Tracing 相关

原理介绍

Trace 的本质其实就是带有 Timestamp 的事件的记录,核心事件主要有三类:

  • 调用事件
  • 返回事件
  • 切换事件

后处理程序会将一个个事件点转化为一个个时间段,每个事件都会结束上一个时间段,并开始新的时间段(调用事件开始的时间段就是对应的调用名,返回事件和切换事件开始的时间段则通过栈顶获取对应的调用名)

其中对偶的调用事件和返回事件(系统调用,中断,异常等)可以很方便地转化为连续的时间段。不过由于存在中断,时间段会被切割成多段,因此在处理返回事件的时候,需要基于每个 PID 对应的栈进行恢复(抛出并结束当前栈顶对应调用事件的时间段,并重新开始新栈顶对应调用事件的时间段)

而对于切换事件(比如:进程切换),虽然无法转化为时间段,但是它可以用来切换当前 CPU 上的 PID 以及栈,并且分割时间段,另外需要注意的是,A CPU 上的一个调用事件会由于调度(切换事件)而在 B CPU 上重新执行,这里需要好好注意各个时间段的生成

使用 Trace 工具的时候一定要考虑好如下三点:

  1. 跟踪的目标是什么
  2. 跟踪的频率与时长是多少
  3. 跟踪的开销是多大(额外的 CPU 与存储空间),好的经验法则是不超过 1%,2% ~ 10% 可能也是可以接受的,但是 20% 肯定不行

飞行器记录模式

这里可以介绍一下飞行记录器模式:为了减少开销, trace 产生的事件都是直接记录在内存中的(为了避免写竞争,每个 CPU 会有一批独属的64KB 的缓存块)。当停止 trace 后,对应的处理函数会将二进制数据落盘到磁盘,再进行后处理(事件点转化为带时间序列的 Tracing 图)并重新存储到新的可读文件。由于内存大小有限,因此一般只能存短期的事件,而考虑到某些偶发性的延迟问题,飞行记录器模式就是将事件记录到 RingBuffer 中,并不断丢弃超过大小的事件直到遇到异常事件,此时往往能保存对应事件前一小段时间的序列

追踪点

tracepoint、 kprobes、USDT、uprobes 是注入 probe handler 调用的机制。

  1. tracepoint 是在 Linux 内核的一些关键函数中开发时埋下的 hook 点,这样在 tracing 的时候,我们就可以在这些固定的点上挂载 probe handler,然后查看内核的信息

  2. kprobe 相较于 tracepoint 通过动态指令替换实现了可以动态地在所有的内核函数(除了 inline 函数)上挂载任意 probe handler 进而获取对应的信息

  3. USDT 是一些大型的用户态应用,比如:glibc, mysql,jvm 等事先在用户态 lib 中打入的点

  4. uprobes 则是再用户态函数上动态打入的点,其实现有点类似于 kprobe

probe handler 在内核态执行,抓取到的追踪数据往往需要传递到用户态做分析使用,perf_event、trace_event_ring_buffer、eBPF Map 都是从内核态向用户态传递数据的方式。

trace_event_ring_buffer 是通过虚拟文件系统 TraceFS 的方式暴露追踪数据。eBPF Map 有多种实现方式,有基于 perf event 的、有基于系统调用的,有基于 BPF ringbuffer 的。

Perf 使用

perf 底层通过 perf_event_open系统调用,结合硬件事件(PMC)和软件事件记录,开启对于特定事件的记录和采样。该系统调用返回一个 fd 用于对记录到的事件进行进一步加工,从而用于分析性能。event 是 perf 工作的基础,主要有两种:有使用硬件的 PMU 里的 event,也有在内核代码中注册的 event。而perf 使用这些 event ,也有计数和采样两种方式。

perf stat 这个命令就是来查看 event 的数目的,这里我们可以额外加上"-e"参数,指定某一个 event 来看它的计数,比如 page-faults

perf record 命令就是采样记录事件发生时的一些额外信息,比如记录当前栈结构,从而找到热点函数(一直在栈顶的函数),记录当时的 IP 记录的执行的指令,记录当时的进程等。perf record 在不加 -e 指定 event 的时候,它缺省的 event 就是 Hardware event cycles, 但是 cycles 事件对应的 PMC(Performance Monitoring Counter 寄存器)在每个 cpu cycle 都会增加,因此实际上,perf 利用硬件性能计数器的溢出中断机制,设置一个初始计数值,当 cycles 计数达到这个值时触发中断,再记录样本通过增加 -g 参数可以获取整个栈帧结构从而生成火焰图

由于每次事件发生都记录导致的开销过大,因此采用了采样去实现数据获取,采样本身也主要有两种实现(彼此互斥):
第一种是按照 event 的数目(-c),比如每发生 10000 次 cycles event 就记录一次 IP、进程等信息, perf record 中的 -c 参数可以指定每发生多少次,就做一次记录。

第二种是定义一个频率(-F), perf record 中的 -F 参数就是指定频率的,比如 perf record -e cycles -F 99 -- sleep 1 ,就是指采样每秒钟做 99 次,后面的 sleep 1 并不是 sleep 命令,而是参数,代表总共采样 1 秒。

第一种方式可以进一步地和 -e 参数结合:

  1. -e 参数的作用:

    • -e 用于指定要监测的事件类型。
    • 可以是硬件事件(如 cycles, instructions)或软件事件(如 context-switches)。
  2. -c 参数与 -e 的关系:

    • -c 指定触发一次采样所需的事件计数。
    • 对于硬件事件,-c 直接控制采样频率。
    • 对于软件事件,-c 的作用可能有所不同。
  3. 软件事件(如 context-switches)的特殊性:

    • 软件事件通常不像硬件事件那样频繁。
    • 对于 context-switches,每次发生上下文切换时都会触发事件。
  4. 不指定 -c 的默认行为:

    • 对于 context-switches 这样的软件事件,如果不指定 -c,perf 通常会记录每一个事件。
    • 即默认 -c 1,每发生一次上下文切换就记录一次。
  5. 指定 -c 的效果:

    • 如果指定 -c N(N > 1),则每 N 次上下文切换才记录一次。
    • 例如:perf record -e context-switches -c 100 会每 100 次上下文切换记录一次。

在 perf record 运行结束后,会在磁盘的当前目录留下 perf.data 这个文件,里面记录了所有采样得到的信息。然后我们再运行 perf report 命令,查看函数或者指令在这些采样里的分布比例,不过很多时候,为了更加直观地看到各个函数的占比,我们会用 perf script 命令把 perf record 生成的 perf.data 转化成分析脚本,然后用 FlameGraph 工具来读取这个脚本,生成火焰图。

ftrace 使用

如果我们通过 perf 发现了一个内核函数的调用频率比较高,就可以通过 function tracer 工具继续深入,这样就能大概知道这个函数是在什么情况下被调用到的

perf 和 ftrace 的区别在于前者是通过采样的方式实现的,并可以用来生成火焰图,它更加关注粗粒度的哪些函数耗时更多,后者则是精确地记录每一个事件,然后以时间轴生成 Tracing 图,它更加关注细粒度的执行逻辑关系

# echo nop > current_tracer 关闭并清空ftrace

# echo do_mount > set_ftrace_filter 设置 filter 并追踪 do_mount 方法(可以追踪任何内核方法)

# echo ‘!do_mount ‘ > set_ftrace_filter 关闭 filter

# echo 1 > options/func_stack_trace 打开 stack 功能

# echo function > current_tracer 开启 ftrace 并记录 function

ftrace 实现

"-pg -mfentry"这两个参数的作用是,给编译出来的每个函数开头都插入一条指令"callq "

而"-mrecord-mcount"参数在最后的内核二进制文件 vmlinux 中附加了一个 mcount_loc 的段,这个段里记录了所有"callq "指令的地址。这样我们很容易就能找到每个函数的这个入口点。

为了记录额外信息,在内核启动初始化的时候,ftrace 又申请了新的内存来存放 mcount_loc 段中原来的地址信息,外加对每个地址的控制信息,最后释放了原来的 mcount_loc 段。

为了避免 call 在没有 trace 时带来的性能损失,ftrace 在内核启动的时候做了一件事,就是把内核每个函数里的第一条指令"callq "(5 个字节),替换成了"nop"指令(0F 1F 44 00 00),也就是一条空指令,表示什么都不做。

我们需要用 function tracer 来 trace 某一个函数的时候,比如"echo do_mount > set_ftrace_filter"命令执行之后,do_mount() 函数的第一条指令就会被替换成调用 ftrace_caller 的指令。

eBPF 实现

一个 eBPF 的程序分为两部分,第一部分是内核态的代码,这部分的代码之后会在内核 eBPF 的虚拟机中执行。第二部分是用户态的代码,它的主要功能是负责加载内核态的代码,以及在内核态代码运行后通过 eBPF maps 从内核中读取数据。

然后我们看看 eBPF 内核态程序的编译,因为内核部分的代码需要被编译成 eBPF bytecode 二进制文件,也就是 eBPF 的虚拟机指令,而在 Linux 里,最常用的 GCC 编译器不支持生成 eBPF bytecode,所以这里必须要用 Clang/LLVM 来编译,编译后的文件就是 foo_kern.o。

foo_user.c 编译链接后就会生成一个普通的用户态程序,它会通过 bpf() 系统调用做两件事:第一是去加载 eBPF bytecode 文件 foo_kern.o,使 foo_kern.o 这个 eBPF bytecode 在内核 eBPF 的虚拟机中运行;第二是创建 eBPF maps,用于内核态与用户态的通讯。

接下来,在内核态,eBPF bytecode 会被加载到 eBPF 内核虚拟机中,执行 BPF 程序之前,BPF Verifier 先要对 eBPF bytecode 进行很严格的指令检查。检查通过之后,再通过 JIT(Just In Time)编译成宿主机上的本地指令。

编译成本地指令之后,eBPF 程序就可以在内核中运行了,比如挂载到 tracepoints hook 点,或者用 kprobes 来对内核函数做分析,然后把得到的数据存储到 eBPF maps 中,这样 foo_user 这个用户态程序就可以读到数据了。

BCC 提供了一套基于 c + python 去快去编写并编译 eBPF 程序的框架,并提供了一系列基于上述框架实现的工具,通过 apt install bpfcc-tools 安装,存在 /sbin 目录下,并且都以 -bcc 为后缀

Bpftrace 则提供了一种基于类似 awk 的语法,从而快速利用 eBPF 实现动态追踪的方法,可以作为简单的命令行工具或者入门级编程工具来使用。

Perfetto

Perfetto 是一个集成了多个数据源(ftrace, /proc, /sys, customize)的收集器,分析处理器和可视化界面。

在分析处理收集到的各个 Event 记录点的时候,Perfetto 会自动对数据进行处理并尽可能将对偶的事件(sys_enter, sys_exit)和有前后关联的事件(sched/switch)的时间戳转化为时间段,并且也会根据一些关键信息(相同的 TID, PID)对于不同数据源的信息做合并。

如果对于 Perfetto 自动分析处理的数据不满意,我们也可以通过写 SQL 得到对应的 Metric,表格,时间线并进一步作可视化.

参考

  1. 《性能之巅》
  2. 《BPF 之巅》