天天看点

性能之巅:应用程序

《性能之巅》的一点读书笔记

文章目录

    • 1.应用程序基础
      • 1.1 目标
      • 1.2 大O标记法
    • 2.应用程序性能技术
      • 2.1 选择I/O尺度
      • 2.2 缓存(cache)
      • 2.3 缓冲(buffer)
      • 2.4 轮询
      • 2.5 并发和并行
      • 2.6 非阻塞I/O
      • 2.7 处理器绑定
    • 3.编程语言
      • 3.1 编译语言
      • 3.2 解释语言
      • 3.3 虚拟机
      • 3.4 垃圾回收
    • 4.方法和分析
      • 4.1 线程状态分析
      • 4.2 CPU剖析
      • 4.3 系统调用分析
      • 4.4 USE方法
      • 4.5 向下挖掘法
      • 4.6 锁分析

1.应用程序基础

1.1 目标

关于应用程序的性能,可以从应用程序执行什么操作和要实现怎样的性能目标入手,目标可能如下:

  • 延时:低应用响应时间
  • 吞吐量:高应用程序操作率或者数据传输率
  • 资源使用率:对于给定应用程序工作负载,高效的使用资源

1.2 大O标记法

大O标记法一般用于算法复杂度的分析,应该都不陌生

标记法 举例
O ( 1 ) O(1) O(1) 布尔判断
O ( l o g n ) O(log n) O(logn) 顺序数组二分查找
O ( n ) O(n) O(n) 线性查找
O ( n l o g n ) O(n log n) O(nlogn) 快排(非特殊情况)
O ( n 2 ) O(n^2) O(n2) 冒泡排序(非特殊情况)
O ( 2 n ) O(2^n) O(2n) 分解质因数(不优化情况)
O ( n ! ) O(n!) O(n!) 旅行商人问题穷举法

2.应用程序性能技术

2.1 选择I/O尺度

执行一次I/O操作具有一定的开销,主要包括初始化缓冲区、系统调用、上下文切换、分配内核元数据、检查进程权限和限制、映射地址到设备、执行内核和驱动代码来执行I/O。

什么是元数据?参考这里:Linux文件系统之元数据 - 简书 (jianshu.com)

日志文件系统(journaling file systems)可防止系统崩溃时导致的数据不一致问题。对文件系统元数据(metadata)的更改都被保存在一份单独的日志里,当发生系统崩溃时可以根据日志正确地恢复数据。所以说元数据就是数据的数据,任何文件系统中的数据分为数据和元数据。数据是指普通文件中的实际数据,而元数据指用来描述一个文件的特征的系统数据,诸如访问权限、文件拥有者以及文件数据块的分布信息(inode…)等等。在集群文件系统中,分布信息包括文件在磁盘上的位置以及磁盘在集群中的位置。用户需要操作一个文件必须首先得到它的元数据,才能定位到文件的位置并且得到文件的内容或相关属性。

“初始化开销”对于大型和小型I/O都是差不多的,所以从效率上来说,每次I/O传输的数据越多,效率越高。

增加I/O尺寸是应用程序提高吞吐量的常用策略。考虑到每次I/O的固定开销,一次I/O传输128KB比128次传输1KB高效得多。(当然如果程序不需要,无脑增加I/O尺度也没有任何帮助)

2.2 缓存(cache)

操作系统用缓存提高文件系统的读性能和内存的分配性能,应用程序使用缓存也出于类似的原因。将经常执行的操作的结果保存在本地缓存中以备后用,而非总是执行开销较高的操作。数据库缓冲区高速缓存就是一例,该缓存会保存经常执行的数据库查询结果。

缓存的一个重要的方面就是如何保证完整性,确保查询不会返回过期的数据,这称为缓存的一致性(cache coherency),而且保证缓存执行一致性的代价不能太高,不能高于缓存所带来的收益。

如何保证缓存一致性:参考链接:面试官:缓存一致性问题怎么解决? - 知乎 (zhihu.com)
  • 删缓存,更新数据库
  • 延时双删
  • 先更新,后删除缓存(使用消息队列实现或是binlog实现)

2.3 缓冲(buffer)

为了提高写性能(注意和cache的区别),数据在送入下一层级之前会合并放在缓冲区中。这样就增加了I/O大尺寸大小,提升了操作的效率。但是这样可能会增加写入延迟,因为如果是第一个写入缓冲区后,在发送之前,需要等待后续的写入。

关于环形缓冲区,参考链接:环形缓冲区_蜜汁程序员的博客-CSDN博客_环形缓冲区

环形缓冲区是一类用于组件之间连续数据传输的大小固定的缓冲区,缓冲区的操作是异步的,使用头指针和尾指针来实现,环形方式相比队列方式,少掉了对于缓冲区元素所用存储空间的分配、释放。这是环形缓冲区的一个主要优势。

2.4 轮询

轮询是系统等待某- - 事件发生的技术,该技术在循环中检查事件状态,两次检查之间有停顿。轮询有一些潜在的性能问题:

  • 重复检查的CPU开销高昂
  • 事件发生和下一次检查的延时较高(不能及时被监测)

举个栗子

poll系统调用,poll系统调用和select类似,也是指定时间内轮询一定数量的文件描述符,以测试其中是否有就绪者,函数原型如下:

此函数成功时,返回就绪文件描述符的总数,失败返回-1并设置errno。如果在超时时间内没有任何文件描述符就绪,将返回0

参数说明:

  • fds:是一个pollfd结构类型的数组,它指定所有我们感兴趣的文件描述符上发生的可读、可写和异常等事件。pollfd结构体定义如下:
struct pollfd
{
	int fd;        //文件描述符
  	short events;  //注册的事件
	short revents; //实际发生的事件,由内核填充
};
           
  • nfds:指定被监听事件 fds 的大小
  • timeout:超时时间

epoll,使用poll或select监听文件描述符列表,时间复杂度为 O ( n ) O(n) O(n),但是epoll使用了内核文件级别的回调机制,时间复杂度为 O ( 1 ) O(1) O(1)。参考链接:epoll原理详解及epoll反应堆模型 - 知乎 (zhihu.com)

epoll关键函数:

  • epoll_create

    : 创建一个epoll实例,文件描述符
  • epoll_ctl

    : 将监听的文件描述符添加到epoll实例中,实例代码为将标准输入文件描述符添加到epoll中
  • epoll_wait

    : 等待epoll事件从epoll实例中发生, 并返回事件以及对应文件描述符

调用

epoll_create

时,会创建一棵红黑树rbr,用于存储所有添加到epoll中的事件;此外还会创建一个双向链表rdllist,用于存储已经就绪的事件。

所有添加到epoll中的事件都会与设备(如网卡)驱动程序建立回调关系,也就是说相应事件的发生时会调用这里的回调方法。这个回调方法在内核中叫做

ep_poll_callback

,它会把这样的事件放到上面的rdllist双向链表中。

当调用

epoll_wait

检查是否有发生事件的连接时,只是检查eventpoll对象中的rdllist双向链表是否有epitem元素而已,如果rdllist链表不为空,则这里的事件复制到用户态内存(使用共享内存提高效率)中,同时将事件数量返回给用户。因此epoll_wait效率非常高。

epoll_ctl

在向epoll对象中添加、修改、删除事件时,从rbr红黑树中查找事件也非常快,也就是说epoll是非常高效的,它可以轻易地处理百万级别的并发连接。

并且epoll有EPOLLLT和EPOLLET两种触发模式,LT是默认的模式,ET是“高速”模式。

性能之巅:应用程序

2.5 并发和并行

并发与并行的概念:参考链接:并发和并行的区别 - 简书 (jianshu.com)

并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行。所以无论从微观还是从宏观来看,二者都是一起执行的。

性能之巅:应用程序

并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。

性能之巅:应用程序

同步原语:监管内存的访问,常见的类型如下

  • mutex锁(MUTual EXclusive):只有锁的持有者才能操作,其他线程会阻塞并等待CPU
  • 自旋锁:自旋锁只允许锁持有者操作,其他需要自旋锁的进程会在CPU循环自旋,检查锁是否被释放。虽然这样可以提供低时延的访问,但是被阻塞的线程不会离开CPU,时刻准备知道锁可用,线程的自选、等待也是对CPU资源的浪费。如果是单核处理器,一般不建议使用自旋锁,因为,在同一时间只有一个线程是处在运行状态,那如果运行线程发现无法获取锁,只能等待解锁,但因为自身不挂起,所以那个获取到锁的线程没有办法进入运行状态,只能等到运行线程把操作系统分给它的时间片用完,才能有机会被调度。这种情况下使用自旋锁的代价很高。
  • 读写锁:rr不冲突,rw和ww冲突
  • 自适应mutex锁:是mutex锁和自旋锁的结合,如果锁的持有者当前正运行在另外一个CPU上(多核处理器情况下),那么线程就会自旋(因为有机会获得锁),否则,线程就阻塞(或者是自旋时间阈值到了)。自适应互斥锁的优化支持了低延时范文的同时又不浪费CPU资源,在Linux系统、Java虚拟机等等场景中都有使用
  • 哈希表锁:假定我们要对一堆数据结构进行加锁,有如下两种方案
    • 为所有的数据结构值设定一个全局的mutex锁。方案简单,但是并发访问会有锁的竞争。
    • 为每个数据结构设定一个mutex锁。方案将锁的竞争减小,但是锁会有存储开销,为每个数据结构的创建和销毁锁也会有CPU开销。
    哈希表锁就是一种折衷的方案。创建固定数目的锁,用哈希算法来选择哪个锁用于哪个数据结构。如下所示:
    性能之巅:应用程序
    理想情况下,为了最大程度的并行,哈希表桶的数目应该大于等于CPU的数目。

2.6 非阻塞I/O

进程在I/O期间会阻塞并进入sleep状态,会存在两个性能问题:

  • 对于多路并发的I/O,每一个阻塞的I/O都会消耗一个线程(或进程)。
  • 对于频繁发生的短时I/O,频繁切换上下文的开销会消耗CPU资源并增加应用程序的延时。

非阻塞I/O模型是异步的发起I/O,而不是阻塞当前线程,线程可以执行其他工作

性能之巅:应用程序

2.7 处理器绑定

一些说明:

NUMA(Non Uniform Memory Access):非统一内存访问是一种用于多处理器的电脑内存体设计,通过提供分离的存储器给各个处理器,避免当多个处理器访问同一个存储器产生的性能损失来试图解决这个问题。

SMP(Symmetrical Multi-Processing):对称多处理在一个计算机上汇集了一组处理器(多CPU),各CPU之间共享内存子系统以及总线结构。

NUMA环境对于进程或线程保持运行在一颗CPU上是有优势的,线程执行I/O后,能像执行I/O之前那样运行在同一个CPU上,这提高了应用程序的内存本地性,可以减少进程在多个 CPU 之间交换运行带来的缓存命中失效(cache missing),减少内存I/O,提高应用程序的整体性能。

这样的设计本意就是让线程依附在同一颗CPU上(CPU亲和性,CPU affinity)

换个角度来看,对进程亲和性的设置也可能带来一定的问题,如破坏了原有 SMP 系统中各个 CPU 的负载均衡(load balance),这可能会导致整个系统的进程调度变得低效。

3.编程语言

3.1 编译语言

编译是在运行之前将程序生成机器指令,保存在二进制可执行文件里。这些文件可以在任何时间运行而无需再度编译。如C/C++。

可以使用编译器优化来提升性能,编译器优化能对CPU指令的选择和部署做优化。

以gcc编译器优化为例(参考链接:gcc优化选项_wangsuyu_1的博客-CSDN博客_gcc优化选项)

对于O1优化:

[email protected]:~$ gcc -Q -O1 --help=optimizers
The following options control optimizations:
  -O<number>
  -Ofast
  -Og
  -Os
  -faggressive-loop-optimizations       [enabled]
  -falign-functions                     [disabled]
  -falign-functions=
  -falign-jumps                         [disabled]
  -falign-jumps=
  -falign-labels                        [disabled]
  -falign-labels=
  -falign-loops                         [disabled]
  ...
           

而对于O3优化,相比之下就开启了更多的优化选项,如falign-functions,就是将函数分支对齐到2的幂次边界,提升运行效率。

[email protected]:~$ gcc -Q -O3 --help=optimizers
The following options control optimizations:
  -O<number>
  -Ofast
  -Og
  -Os
  -faggressive-loop-optimizations       [enabled]
  -falign-functions                     [enabled]
  -falign-functions=                    16
  -falign-jumps                         [enabled]
  -falign-jumps=                        16:11:8
  -falign-labels                        [enabled]
  -falign-labels=                       0:0:8
  -falign-loops                         [enabled]
  ...
           

完整的优化选项将近180项,这里只列出了开头几项,并且有的选项即使是O0也会启用。

并不是优化选项开的越多越好,比如其中的一个选项,-fomit-frame-pointer:对于不需要使用帧指针的函数不记录帧指针。该选项避免了保存、设置和恢复帧指针的指令,让函数多一个可用的寄存器。这是一个权衡的栗子,通常缺少帧指针,分析者无法对栈的跟踪做剖析。这可能会不利于后续的性能分析。

3.2 解释语言

解释语言程序的执行是将语言在运行时翻译成行为,这一过程会增加执行的开销。解释语言并不期望能表现出很高的性能,而是用于其他因素更重要的情况下,例如已于编程和调试。如shell脚本。

除非提供专门的观测工具,否则对解释语言做性能分析是很困难的。CPU剖析虽然能展示解释器的操作(如分局、翻译和执行),但是不能显示原始程序的函数名,关键程序的上下文仍然是个谜。通常通过简单的打印语句和时间戳来研究这些程序,更严格的性能分析并不常见,并且解释语言一般不是编写高性能应用的首选。

3.3 虚拟机

一些编程语言,如Java,是使用虚拟机执行,提供了平台独立的编程环境,因而具有很好的可移植性。代码首先编译成虚拟机指令集(字节码),再由虚拟机执行。

虚拟机一般是语言类型里最难观测的。在程序执行在CPU上之前,多个编译或解释的阶段都可能已经过去了,关于原始程序的信息可能无法得到。性能分析通常靠语言虚拟机提供的工具集来完成

3.4 垃圾回收

许多语言都有自动内存管理,分配的内存不需要显式释放,但也有一定缺点:

  • 内存增长:当没能自动识别出对象适合被释放时,内存的使用会增多。
  • CPU成本:GC通常会间歇地运行,还会搜索和扫描内存中的对象,这会消耗CPU的资源。
  • 延时异常值:GC执行期间应用程序的执行可能中止,偶尔出现高延时响应。

4.方法和分析

4.1 线程状态分析

分辨应用程序线程的时间用在了什么地方

我们可以将CPU的时间分成如下两个状态:

  • on-CPU:执行
  • off-CPU:等待下一轮上CPU

更详细的,可以对上述off-CPU做展开,得到如下6中状态

  • 执行:在CPU上
  • 可运行:等待轮到上CPU执行
  • 匿名换页:可运行,但是因等待匿名换页而受阻。
  • 睡眠:等待网络、块设备和数据/文本页换入等I/O
  • 锁:等待获取锁
  • 空闲:等待工作

通过减少这些状态中的前五项的时间,会得到性能提升,所以我们需要确定前五个状态所花的时间。

首先对于执行时间,可以直接通过top命令查看,%CPU为所占CPU资源百分比,TIME+为该进程占用CPU时间,如下图所示:

性能之巅:应用程序

内核的schestat功能会跟踪可运行的线程,并将信息显示在

/proc/<pid>/schedstat

中,

schedstat

的说明文档:linux/sched-stats.rst at master · torvalds/linux (github.com)

性能之巅:应用程序

三个参数说明:

  • CPU运行时间(ns)
  • CPU运行队列等待时间(ns)
  • CPU运行时间片数量

对于匿名换页时间,可以使用内核的延时核算(delay accounting)特性来测量,内核文档中有一个做这事的示例程序:linux/getdelays.c at master · torvalds/linux (github.com)

什么是匿名页?没有文件背景的页面,即匿名页(anonymous page),如堆、栈、数据段等,不是以文件形式存在,因此无法和磁盘文件交换,但是可以通过硬盘上划分额外的swap交换分区或使用交换文件进行交换(swap分区)。

编译的时候出现了一些问题,按照提示修改一下

性能之巅:应用程序

然后重新编译就好了,可以看到swap的耗时

性能之巅:应用程序

最后是睡眠IO时间,如果启用了延时核算和I/O核算的特性,可以得到I/O所造成的阻塞时间,如下所示,单位为时钟周期。

性能之巅:应用程序

锁可以用一些观测工具来监测

4.2 CPU剖析

剖析的目标是要判断应用程序是如何消耗CPU资源的,如下使用DTrace采样函数的调用次数:

性能之巅:应用程序

另一个有效的途径就是对CPU的用户栈进行采样,这能够从高层和底层两方面解释应用程序消耗CPU的原因,可以使用perf工具对栈进行跟踪,往往采样记录会非常庞大,最好使用火焰图进行可视化。

举个栗子:

对firefox进程进行栈跟踪

sudo perf record -F 99 -p 120792 -g -- sleep 30
           

然后查看跟踪结果:

sudo perf report -n --stdio
           
性能之巅:应用程序

perf安装:

首先安装

sudo apt install linux-tools-common
           
然后执行perf,会提示安装linux-tools-xxx,然后安装对于版本的linux-tools就可以了
sudo apt-get install linux-tools-5.4.0-42-generic
           

4.3 系统调用分析

断点跟踪

传统的系统调用跟踪是设置系统调用入口和返回的断点。对于某些系统调用频繁的应用程序,这是一个激进的方法,因为它们的性能可能会变差一个数量级。

使用strace命令跟踪进行的系统调用:

sudo strace -ttt -T -p 120792
           
性能之巅:应用程序

可以看到各个系统调用都被检测到了,第一列是UNIX时间戳,第二列为系统调用,最后一列为调用的耗时(单位秒)

也可以对系统调用进行统计:

sudo strace -c -p 120792
           
性能之巅:应用程序
  • %time:CPU时间占比
  • seconds:CPU时间(s)
  • usecs/call:每次调用的平均系统CPU时间(ms)
  • calls:调用次数
  • syscall:系统调用名

但是有时候对系统调用的跟踪存在一定的性能开销,对于dd命令执行五百万次1KB的传输,开启系统调用跟踪后导致运行时间增加了73倍,这是因为dd命令的系统调用率很高。

缓冲跟踪

当目标程序在持续执行的时候,监测数据就可以缓冲在内核里。这是与断点跟踪的区别,断点跟踪会在每一个断点中断目标程序中执行,使用缓冲跟踪可以一定程度上减少跟踪的开销。

使用perf的trace子命令可以执行系统调用的缓冲跟踪:

sudo perf trace
           
性能之巅:应用程序

4.4 USE方法

USE方法通过检查使用率、饱和率、错误等,来发现某一成为瓶颈的资源。

三个指标可以做如下定义(对于一个服务程序):

  • 使用率:在一定时间间隔内,忙于处理请求的线程平均数目。例如,50%意味着,平均有一半的线程忙于处理请求的工作。
  • 饱和度:在一定时间间隔内,请求队列的平均长度。
  • 错误:请求被拒绝或失败

4.5 向下挖掘法

对于应用程序,向下挖掘法可以检查应用程序的服务操作作为开始,然后向下至应用程序内部,观察是如何执行的。对于I/O,向下挖掘的程序可以进入系统库、系统调用,甚至是内核。

4.6 锁分析

对于多线程的应用程序,锁可能会成为阻碍并行化和扩展性的瓶颈。锁的分析可以通过:

  • 检查竞争
  • 检查过长的持锁时间

虽然有用于锁分析的专门工具,但有时使用CPU剖析就可以解决问题,对于自旋锁来说,竞争出现的时候,CPU使用率也会发生变化,使用栈跟踪的CPU剖析也很容易能够识别出来。这里书上介绍的锁分析工具都是Solaris系统上的,这里就不展开了。

继续阅读