5 Applications

性能最好在最接近工作执行地点的地方进行调优:即在应用程序中。这些应用程序包括数据库、Web 服务器、应用服务器、负载均衡器、文件服务器等等。接下来的章节将从它们消耗的资源的角度探讨应用程序:CPU、内存、文件系统、磁盘和网络。本章节涉及应用程序层面。
应用程序本身可能变得极为复杂,特别是在涉及许多组件的分布式应用环境中。对应用程序内部的研究通常是应用程序开发人员的领域,可以包括使用第三方工具进行内省。对于那些研究系统性能的人,包括系统管理员,应用程序性能分析包括配置应用程序以最佳利用系统资源,描述应用程序如何使用系统以及分析常见病理学。
本章讨论了应用程序基础知识、应用程序性能的基本原理、编程语言和编译器,以及通用应用程序性能分析策略。
5.1 Application Basics
在深入研究应用程序性能之前,您应该熟悉应用程序的作用、基本特征以及其在行业中的生态系统。这构成了您理解应用程序活动的背景。它还为您提供了学习常见性能问题和调优的机会,并为进一步研究提供途径。为了了解这一背景,请尝试回答以下问题:
功能:应用程序的作用是什么?它是数据库服务器、Web 服务器、负载均衡器、文件服务器、对象存储等?
操作:应用程序提供哪些请求,或执行哪些操作?数据库提供查询(和命令),Web 服务器提供 HTTP 请求,等等。这可以作为一个速率来衡量,以评估负载和进行容量规划。
CPU 模式:应用程序是作为用户级软件还是内核级软件实现的?大多数应用程序是用户级的,作为一个或多个进程执行,但有些是作为内核服务实现的(例如 NFS)。
配置:应用程序如何配置,以及为什么这样配置?这些信息可能在配置文件中找到,也可能通过管理工具获得。检查是否已更改与性能相关的可调参数,包括缓冲区大小、缓存大小、并行性(进程或线程)以及其他选项。
指标:应用程序是否提供指标,比如操作速率?它们可以通过捆绑工具或第三方工具提供,通过 API 请求提供,或通过处理操作日志提供。
日志:应用程序创建哪些操作日志?哪些日志可以启用?日志中提供了哪些性能指标,包括延迟?例如,MySQL 支持慢查询日志,为每个超过一定阈值的查询提供有价值的性能详细信息。
版本:应用程序是否是最新版本?最近版本的发布说明中有性能修复或改进吗?
错误:应用程序是否有一个错误数据库?对于您的应用程序版本来说,“性能”错误是什么?如果您有当前的性能问题,请搜索错误数据库,看看以前是否发生过类似情况,以及是如何调查的,还有什么其他内容。
社区:是否有一个应用程序社区,用于分享性能发现?社区可能包括论坛、博客、Internet Relay Chat (IRC) 频道、聚会和会议。聚会和会议通常会在线发布幻灯片和视频,在之后几年内这些都是有用的资源。他们还可能有一个社区经理分享社区更新和新闻。
图书:是否有关于应用程序及/或其性能的图书?
专家:谁是该应用程序的公认的性能专家?了解他们的名字可以帮助您找到他们撰写的材料。
无论信息来源如何,您的目标是在高层次上理解应用程序——它的功能、运作方式以及性能。如果能找到的话,一张展示应用程序内部的功能图是非常有用的资源。下一节将涵盖其他应用程序基础知识:设定目标、优化常见情况、可观测性和大 O 表示法。
5.1.1 Objectives
一个性能目标为您的性能分析工作提供方向,并帮助您选择要执行的活动。如果没有明确的目标,性能分析就有可能变成一次随机的“搜寻探险”。
对于应用程序性能,您可以从应用程序执行的操作(如前所述)和性能目标开始。目标可能是:
延迟:低应用程序响应时间
吞吐量:高应用程序操作速率或数据传输速率
资源利用率:给定应用程序工作负载的效率
最好能够量化这些目标,使用可能来自业务或服务质量要求的指标。例如:
平均应用程序请求延迟为5毫秒
95% 的请求延迟不超过100毫秒
消除延迟异常值:零个请求超过1000毫秒
每台服务器每秒至少10,000个应用程序请求的最大吞吐量
每秒10,000个应用程序请求的平均磁盘利用率低于50%
选择了一个目标之后,您可以着手解决该目标的限制因素。对于延迟,限制因素可能是磁盘或网络I/O;对于吞吐量,它可能是CPU使用率。本章和其他章节中的策略将帮助您识别它们。
针对基于吞吐量的目标,请注意并非所有操作在性能或成本方面都是相等的。如果目标是某种操作速率,也许重要的是还要指定它们是什么类型的操作。这可能是根据预期或实测工作负载进行的分布。
第5.2节《应用程序性能技术》介绍了改善应用程序性能的常见方法。其中一些方法可能适用于某个目标,但对另一个目标则不适用;例如,选择更大的I/O大小可能会以延迟为代价提高吞吐量。请记住您正在追求的目标,以确定哪些主题最为适用。
5.1.2 Optimize the Common Case
软件内部结构可能非常复杂,具有许多不同的可能代码路径和行为。如果您查看源代码,这一点可能尤为明显:应用程序通常包含数万行代码,而操作系统内核则包含数十万行以上。随意选择要优化的区域可能需要大量工作,但收益却不多。
提高应用程序性能的一种有效方法是找到生产工作负载中最常见的代码路径,并从改进那些路径开始。如果应用程序受限于CPU,这可能意味着频繁使用CPU的代码路径。如果应用程序受限于I/O,您应该关注那些经常导致I/O的代码路径。这些可以通过对应用程序进行分析和性能分析来确定,包括研究堆栈跟踪,正如后续章节所述。应用程序可观察性工具也可以提供更高级别的上下文,以便理解常见情况。
5.1.3 Observability
正如本书的许多章节所描述的那样,操作系统中最大的性能提升可以通过消除不必要的工作来实现。这同样适用于应用程序。
然而,在选择应用程序时,有时会忽视这个事实。如果基准测试显示应用程序A比应用程序B快10%,可能会诱人选择应用程序A。然而,如果应用程序A是不透明的,而应用程序B提供了丰富的可观测性工具,长远来看,应用程序B很可能是更好的选择。这些可观测性工具使得能够看到并消除不必要的工作,同时主动工作也可以更好地理解和调整。通过增强的可观测性获得的性能提升可能会使最初的10%性能差异相形见绌。
5.1.4 Big O Notation
大O符号,通常作为计算机科学的一个学科进行教授,用于分析算法的复杂度,并模拟随着输入数据集规模扩大它们的性能表现。这有助于程序员在开发应用程序时选择更有效率和性能更好的算法([Knuth 76],[Knuth 97])。
常见的大O符号和算法示例列在表5.1中。

这种符号允许程序员估计不同算法的加速比,确定哪些代码区域将带来最大的改进。例如,在搜索一个包含100个项的排序数组时,线性搜索和二分搜索之间的差异是21倍(100 / log(100))。
这些算法的性能如图5.1所示,显示它们随着规模扩大的趋势。

这种分类有助于系统性能分析师理解,某些算法在规模上会表现非常糟糕。当应用程序需要为比以往任何时候都更多的用户或数据对象提供服务时,性能问题可能会出现,此时O(n^2)等算法可能会变得病态。解决方法可能是开发人员使用更高效的算法,或者以不同方式对人口进行分割。
大O符号确实忽略了为每个算法选择所产生的一些常数计算成本。对于输入数据大小n较小的情况,这些成本可能会占主导地位。
5.2 Application Performance Techniques
本节介绍了一些常用的技术,可通过这些技术改善应用程序的性能:选择I/O大小、缓存、缓冲、轮询、并发和并行、非阻塞I/O以及处理器绑定。请参考应用程序文档,了解其中使用了哪些技术,以及任何额外的特定于应用程序的功能。
5.2.1 Selecting an I/O Size
执行I/O操作涉及的成本可能包括初始化缓冲区、进行系统调用、上下文切换、分配内核元数据、检查进程特权和限制、将地址映射到设备、执行内核和驱动程序代码以进行I/O传输,最后释放元数据和缓冲区。小型和大型I/O都需要支付“初始化开销”。为了提高效率,每次I/O传输的数据量越大越好。增加I/O大小是应用程序用来提高吞吐量的常见策略。通常情况下,以单个I/O传输128K字节要比以128个1K字节的I/O传输更有效,考虑到任何固定的每个I/O的成本。特别是磁盘I/O由于寻道时间的原因,历来具有较高的每个I/O成本。
当应用程序不需要更大的I/O大小时,会出现一些不利影响。进行8K字节随机读取的数据库在使用128K字节的I/O大小时可能运行速度较慢,因为会浪费120K字节的数据传输。这会引入I/O延迟,可以通过选择更接近应用程序请求的较小I/O大小来降低。不必要地增大I/O大小也会浪费缓存空间。
5.2.2 Caching
操作系统使用缓存来提高文件系统读取性能和内存分配性能;应用程序通常也使用缓存以达到类似的目的。与其每次都执行昂贵的操作,不如将常见操作的结果存储在本地缓存中以备将来使用。一个例子是数据库缓冲区缓存,它存储了常见数据库查询的结果。
在部署应用程序时,一个常见任务是确定系统提供了哪些缓存,或者可以启用哪些缓存,然后配置它们的大小以适应系统。缓存的一个重要方面是它如何管理完整性,以确保查找不会返回过期数据。这被称为缓存一致性,可能是一个昂贵的操作,但理想情况下不会超过缓存提供的好处。
尽管缓存提高了读取性能,但它们的存储通常也被用作缓冲区以提高写入性能。
5.2.3 Buffering
为了提高写入性能,数据可能会在发送到下一级之前在缓冲区中合并。这会增加I/O大小和操作的效率。根据写入的类型,这也可能会增加写入延迟,因为缓冲区的第一个写入要等待后续的写入才能被发送。环形缓冲区(或循环缓冲区)是一种固定缓冲区的类型,可用于组件之间的连续传输,这些组件以异步方式对缓冲区进行操作。它可以使用起始和结束指针来实现,每个组件在数据追加或移除时都可以移动这些指针。
5.2.4 Polling
轮询是一种技术,系统通过在循环中检查事件的状态,并在检查之间暂停来等待事件发生。轮询可能存在一些潜在的性能问题:
- 重复检查造成的昂贵 CPU 开销
- 事件发生与下一次轮询检查之间的高延迟
当这成为一个性能问题时,应用程序可以改变自身行为以监听事件的发生,这会立即通知应用程序并执行所需的例程。
poll() 系统调用
有一个 poll() 系统调用用于检查文件描述符的状态,它具有与轮询类似的功能,但是它是基于事件的,因此不会承受轮询的性能成本。
poll() 接口支持将多个文件描述符作为数组,这要求应用程序在事件发生时扫描数组以找到相关的文件描述符。这种扫描的复杂度是O(n)(参见5.1.4节,大O符号表示法),其开销可能在规模化时成为性能问题。还有另一种可用的接口:在Linux上是epoll(),可以避免扫描,因此复杂度为O(1)。基于Solaris的系统具有类似的特性,称为事件端口,它使用port_get(3C)而不是poll()。
5.2.5 Concurrency and Parallelism
时间共享系统(包括所有源自Unix的系统)提供程序并发性:能够加载和开始执行多个可运行程序。虽然它们的运行时间可能重叠,但不一定在同一时刻在CPU上执行。这些程序中的每一个可以是一个应用程序进程。
除了同时执行不同的应用程序之外,应用程序内的不同功能也可以并发执行。可以使用多进程(多进程)或多线程(多线程)来实现这一点,每个进程或线程执行自己的任务。
另一种方法是基于事件的并发性,即应用程序在不同函数之间提供服务,并在事件发生时切换。例如,Node.js运行时采用了这种方式。这提供了并发性,但可能仅使用单个线程或进程,这最终可能成为可扩展性瓶颈,因为它只能利用一个CPU。
要充分利用多处理器系统,应用程序必须同时在多个CPU上执行。这就是并行性,应用程序可以通过多进程或多线程来实现。出于第6章中所述的原因,CPU、多线程(或相应的任务)更有效率,因此是首选的方法。
除了增加CPU工作的吞吐量,多线程(或进程)允许I/O并发执行,因为其他线程可以在一个线程因I/O而阻塞时执行。
由于多线程编程与进程共享相同的地址空间,线程可以直接读写相同的内存,无需更昂贵的接口(如多进程编程中的进程间通信(IPC))。为了保持数据的完整性,使用同步原语以防止多个线程同时读写数据时数据变得损坏。这些同步原语可以与哈希表结合使用,以提高性能。
同步原语
同步原语类似于交通信号灯,用于监控对内存的访问,就像交通信号灯调节对十字路口的通行一样。与交通信号灯一样,它们会阻止流量,导致等待时间(延迟)。常用的三种类型是:
- 互斥锁(Mutex locks):只有锁的持有者可以操作,其他人会被阻塞并在CPU外等待。
- 自旋锁(Spin locks):自旋锁允许持有者操作,而需要锁的其他线程在CPU上紧密循环旋转,检查锁是否被释放。虽然这些锁可以提供低延迟访问——被阻塞的线程永远不会离开CPU,在锁可用时几个周期内即可准备运行,但它们也会浪费CPU资源,因为线程在等待时会旋转。
- 读写锁(RW locks):读写锁通过允许多个读者或仅允许一个写者且没有读者来确保完整性。
互斥锁可能由库或内核实现为自适应互斥锁(adaptive mutex locks):自旋和互斥锁的混合体,如果持有者当前在另一个CPU上运行,则会旋转,否则会阻塞(或达到自旋阈值时)。自适应互斥锁经过优化,提供低延迟访问而不浪费CPU资源,在基于Solaris系统上已经使用多年。它们于2009年在Linux上实现,被称为自适应旋转互斥锁。研究涉及锁的性能问题可能是耗时的,通常需要熟悉应用程序源代码。这通常是开发人员的工作。
哈希表
可以使用一个锁的哈希表来为大量数据结构使用最佳数量的锁。虽然这里总结了哈希表,但这是一个假定具有编程背景的高级主题。
想象以下两种方法:
- 为所有数据结构使用单个全局互斥锁。虽然这个解决方案简单,但并发访问会遇到锁争用和等待时延。需要锁的多个线程会串行执行,而不是并发执行。
- 为每个数据结构使用一个互斥锁。虽然这样可以将争用减少到只有在真正需要时才会发生——对同一数据结构的并发访问,但为每个数据结构的创建和销毁锁会带来存储开销和CPU开销。
锁的哈希表是一个中间解决方案,适用于预计锁争用较轻的情况。创建固定数量的锁,并使用哈希算法选择哪个锁用于哪个数据结构。这避免了与数据结构的创建和销毁成本,也避免了只有一个锁的问题。
图5.2中示例的哈希表有四个条目,称为桶,每个桶包含自己的锁。

这个例子还展示了解决哈希冲突的一种方法,即两个或多个输入数据结构哈希到同一个桶的情况。在这里,创建了一系列数据结构来将它们全部存储在同一个桶下,它们将再次被哈希函数找到。如果哈希链变得太长并且按顺序遍历,这些哈希链可能会成为性能问题。哈希函数和表大小可以根据在许多桶中均匀分布数据结构的目标选择,以将哈希链长度保持在最小值。
理想情况下,哈希表桶的数量应等于或大于CPU的数量,以实现最大并行性的潜力。哈希算法可能非常简单,例如取数据结构地址的低位比特,并将其用作索引,指向一个具有2的幂大小的锁数组。这样简单的算法也很快,可以快速定位数据结构。
在内存中有一系列相邻的锁时,当锁落在同一个缓存行中时可能会出现性能问题。两个CPU同时更新同一缓存行中的不同锁将遇到缓存一致性开销,每个CPU会使另一个CPU的缓存中的缓存行失效。这种情况称为伪共享,通常通过在哈希锁中填充未使用的字节来解决,以便在内存中每个缓存行中只存在一个锁。
5.2.6 Non-Blocking I/O
Unix进程生命周期在第3章“操作系统”中有图示,显示了进程在I/O期间阻塞并进入睡眠状态。这个模型存在一些性能问题:
对于许多并发I/O,每个I/O在被阻塞时会消耗一个线程(或进程)。为了支持许多并发I/O,应用程序必须创建许多线程(通常每个客户端一个),这会导致线程创建和销毁的开销。
对于频繁且短暂的I/O,频繁的上下文切换开销会消耗CPU资源并增加应用程序的延迟。
非阻塞I/O模型异步发出I/O请求,而不会阻塞当前线程,当前线程可以执行其他工作。这已经成为Node.js的关键特性,Node.js是一个服务器端JavaScript应用环境,它指导开发人员以非阻塞方式编写代码。
5.2.7 Processor Binding
在NUMA环境中,让进程或线程保持在单个CPU上运行,并在执行I/O之后继续在同一CPU上运行,可能是有利的。这可以改善应用程序的内存局部性,减少内存I/O的周期,并提高整体应用程序性能。操作系统充分意识到了这一点,并设计成保持应用程序线程在相同的CPU上运行(CPU亲和性)。这些主题将在第7章“内存”中介绍。
一些应用程序通过将自身绑定到CPU来强制执行这种行为。对于某些系统,这可以显著提高性能。但当这些绑定与其他CPU绑定发生冲突时,比如设备中断映射到CPU,也可能降低性能。
当同一系统上有其他租户或应用程序运行时,特别要注意CPU绑定的风险。在云计算中的操作系统虚拟化中,我们遇到过这个问题,一个应用程序可以看到所有CPU,然后假定自己是服务器上唯一的应用程序而将自身绑定到某些CPU。当服务器被其他租户应用程序共享并进行绑定时,可能会出现冲突和调度器延迟,因为绑定的CPU正忙于其他租户,尽管其他CPU处于空闲状态。
5.3 Programming Languages
编程语言可能是编译型的或解释型的,也可以通过虚拟机执行。许多语言将“性能优化”列为特性,但严格来说,这些通常是执行该语言的软件的特性,而不是语言本身的特性。例如,Java HotSpot虚拟机软件包括一个即时(JIT)编译器,用于动态提高性能。
解释器和语言虚拟机还通过它们各自特定的工具提供不同级别的性能可观察性支持。对于系统性能分析师来说,使用这些工具进行基本的性能分析可能会带来一些快速的收益。例如,高CPU使用率可能被识别为垃圾回收(GC)的结果,然后可以通过一些常用的可调参数进行修复。或者可能是由于一个代码路径,在bug数据库中被发现为已知的错误,并通过升级软件版本进行修复(这种情况经常发生)。
接下来的章节描述了每种编程语言类型的基本性能特征。要了解有关单个语言性能的更多信息,请查找相关语言的文献。
5.3.1 Compiled Languages
编译将一个程序转换为机器指令,这些指令在运行时预先生成,并存储在称为二进制可执行文件的文件中。这些文件可以随时运行,无需重新编译。编译型语言包括C和C ++。有些语言可能同时具有解释器和编译器。
编译代码通常性能较高,并且在执行之前不需要进一步的翻译。操作系统内核几乎完全用C语言编写,只有少数关键路径用汇编语言编写。
对编译型语言的性能分析通常比较直接,因为执行的机器代码通常与原始程序密切匹配(尽管这取决于编译优化)。在编译过程中,可以生成一个符号表,将地址映射到程序函数和对象名称。随后,CPU执行的性能分析和跟踪可以直接映射到这些程序名称,从而使分析人员能够研究程序执行。堆栈跟踪及其包含的数值地址也可以映射和转换为函数名称,以提供代码路径的继承关系。
编译器可以通过使用编译优化来提高性能——优化选择和放置CPU指令的例程。
编译器优化
gcc(1)编译器提供了0到3的范围,其中3使用最多的优化。可以查询gcc(1)以查看不同级别使用的优化。例如:


完整的选项列表包括约180个选项,其中一些选项即使在-O0级别下也是启用的。
以-fomit-frame-pointer选项为例,该选项在这个列表中可以看到,在gcc(1)手册页中有如下描述:
对于不需要帧指针的函数,不要将帧指针保留在寄存器中。这避免了保存、设置和恢复帧指针的指令;它还在许多函数中提供了额外的寄存器。但这也使得在某些机器上无法进行调试。
这是一个权衡的例子:省略帧指针通常会破坏分析堆栈跟踪的工具的操作。
考虑到堆栈分析器的实用性,这个选项可能会牺牲很多后续很难找到的性能提升,这可能远远超过此选项最初提供的性能增益。在这种情况下,解决方法可以是使用-fno-omit-frame-pointer进行编译,以避免这种优化。
如果出现性能问题,可能会诱惑人们简单地将应用程序重新编译为较低的优化级别(例如从-O3到-O2),希望能够满足任何调试需求。但事实证明这并不简单:对编译器输出的更改可能是巨大且重要的,并且它们可能会影响您最初尝试分析的问题的行为。
5.3.2 Interpreted Languages
解释型语言在运行时通过将程序转化为操作来执行,这个过程会增加执行开销。解释型语言并不具备高性能的特点,通常用于其他因素更为重要的情况,比如编程和调试的便利性。Shell脚本是解释型语言的一个例子。
除非提供了可观察性工具,否则对解释型语言的性能分析可能会很困难。CPU分析可以展示解释器的操作,包括解析、转换和执行操作,但可能不会显示原始的程序函数名称,使得关键的程序上下文成为一个谜。这种解释器分析可能并非完全毫无意义,因为即使正在执行的代码看起来设计良好,解释器本身可能存在性能问题。
根据解释器的不同,程序上下文可能通过间接方式容易获取(例如,对解析器进行动态跟踪)。通常这些程序会通过简单地添加打印语句和时间戳来进行研究。更严格的性能分析并不常见,因为解释型语言通常并不是首选用于高性能应用程序。
5.3.3 Virtual Machines
语言虚拟机(也称为进程虚拟机)是一种模拟计算机的软件。一些编程语言,包括Java和Erlang,通常使用虚拟机(VMs)来执行,这为它们提供了一个与平台无关的编程环境。应用程序被编译为虚拟机指令集(字节码),然后由虚拟机执行。只要在目标平台上有虚拟机可用来运行编译后的对象,就可以实现代码的可移植性。
字节码是从原始程序编译而来,然后由语言虚拟机进行解释,将其转换为机器码。Java HotSpot虚拟机支持即时编译(JIT compilation),它会提前将字节码编译为机器码,以便在执行期间执行本机机器码。这既提供了编译代码的性能优势,又具备虚拟机的可移植性。
观察语言虚拟机通常是最困难的。当程序在CPU上执行时,可能经过了多个编译或解释阶段,原始程序的信息可能不容易获取。性能分析通常侧重于语言虚拟机提供的工具集,其中许多提供了DTrace探针,以及第三方工具。
5.3.4 Garbage Collection
一些语言使用自动内存管理,其中分配的内存不需要显式释放,而是留给一个异步垃圾收集过程来处理。尽管这使得编写程序更容易,但也可能存在一些缺点:
内存增长:对应用程序内存使用的控制较少,当对象未被自动识别为可释放时,内存可能会增长。如果应用程序变得过大,可能会达到其自身的限制或遇到系统分页,严重影响性能。
CPU成本:垃圾收集通常会间歇性地运行,并涉及搜索或扫描内存中的对象。这会消耗CPU资源,减少应用程序在短时间内可用的资源。随着应用程序内存的增长,GC消耗的CPU资源也可能增加。在某些情况和实现中,这可能会达到GC持续占据整个CPU的程度。
延迟异常值:在GC执行时,应用程序执行可能会暂停,导致偶尔出现高延迟的应用程序响应。这取决于GC类型:全停式、增量式或并发式。GC是性能调优的常见目标,可减少CPU成本和延迟异常值的发生。例如,Java虚拟机提供许多可调参数来设置GC类型、GC线程数、最大堆大小、目标堆空闲比例等。
如果调优效果不佳,问题可能是应用程序创建了太多的垃圾,或者存在引用泄漏。这些都是应用程序开发人员需要解决的问题。
5.4 Methodology and Analysis
本节描述了应用程序分析和优化的方法论。用于分析的工具要么在此处介绍,要么在其他章节中进行引用。这些主题在表5.2中进行了总结。

请参阅第2章《方法论》,了解更多一般方法和其中一些内容的介绍。另请参阅后续章节,以了解系统资源和虚拟化的分析。
这些方法论可以单独遵循,也可以组合使用。我建议按照表中列出的顺序依次尝试它们。
除此之外,还要寻找针对特定应用程序和开发语言的定制分析技术。这些技术可能会考虑应用程序的逻辑行为,包括已知问题,并带来一些快速的性能提升。
5.4.1 Thread State Analysis
目标是在高层次确定应用程序线程花费时间的地方,这可以立即解决一些问题并引导对其他问题的调查。这通过将每个应用程序的线程时间分为多个有意义的状态来完成。
两种状态
最少有两种线程状态:
- On-CPU:正在执行
- Off-CPU:等待轮到在 CPU 上运行,或者等待 I/O、锁、页面交换、工作等
如果大部分时间都花在 On-CPU 上,CPU 分析通常可以快速解释这一点(稍后会涉及)。这适用于许多性能问题,因此可能无需花时间测量其他状态。
如果发现时间花费在 Off-CPU 上,可以使用各种其他方法,尽管在没有更好的起点的情况下,这可能会耗费时间。
六种状态
以下是一个扩展列表,这次使用六种线程状态(以及不同的命名方案),这为 Off-CPU 情况提供了更好的起点:
- Executing:在 CPU 上执行
- Runnable:等待轮到在 CPU 上运行
- 匿名分页:可运行,但因等待匿名页输入而被阻塞
- Sleeping:等待 I/O,包括网络、块和数据/文本页输入
- Lock:等待获取同步锁(等待其他人)
- Idle:等待工作
这些状态被选为最少且有用的集合;你可能希望在列表中添加更多状态。例如,执行状态可以分为用户模式和内核模式执行,睡眠状态可以根据目标进行细分。(我不得不克制自己,以将列表保持在六个状态之内。)
通过减少这些状态中前五种的时间来提高性能,这会增加空闲时间(headroom)。其他条件相同的情况下,这意味着应用程序请求具有更低的延迟,并且应用程序可以处理更多负载。
一旦确定了线程花费时间的前五种状态,可以进一步调查它们:
- Executing:检查这是否是用户模式或内核模式时间,以及通过使用分析来确定 CPU 消耗的原因。分析可以确定哪些代码路径正在消耗 CPU 以及消耗了多长时间,其中包括在锁上自旋的时间。参见第5.4.2节,“CPU 分析”。
- Runnable:在这种状态下花费时间意味着应用程序需要更多的 CPU 资源。检查整个系统的 CPU 负载和应用程序的任何 CPU 限制(例如,资源控制)。
- 匿名分页:应用程序的主存储器不足可能导致匿名分页和延迟。检查整个系统的内存使用情况以及应用程序的任何内存限制。详见第7章,“内存”。
- Sleeping:分析应用程序所阻塞的资源。参见第5.4.3节,“系统调用分析”和第5.4.4节,“I/O 分析”。
- Lock:识别锁、持有它的线程以及持有者长时间持有它的原因。原因可能是持有者在另一个锁上被阻塞,这需要进一步解开。这是一个高级活动,通常由对应用程序及其锁定层次结构有深入了解的软件开发人员执行。
由于应用程序通常等待工作的方式,你经常会发现睡眠和锁定状态中的时间实际上是空闲时间。应用程序工作线程可能会在条件变量上等待工作(锁定状态),或者等待网络 I/O(睡眠状态)。因此,当你看到大量睡眠和锁定状态的时间时,请记得稍微深入一点,以检查这是否真的是空闲时间。
以下总结了如何在基于 Linux 和 Solaris 的系统上测量这些线程状态;本书的其他部分更详细地介绍了提到的工具和技术。注意新工具和工具选项的发展,特别是使查找这些内容更容易的新工具和工具选项。
Linux
在Linux系统中,执行的时间很容易确定:top(1)将其报告为%CPU。测量其他状态的时间可能需要一些工作,如下所示。
Kernel schedstats功能跟踪可运行状态,并通过/proc/*/schedstat公开。perf sched工具也可以提供了解可运行状态和等待时间的指标。
等待匿名分页(在Linux中称为交换)的时间可以通过启用内核延迟统计功能进行测量。它提供了分别针对交换和在内存回收期间阻塞的时间的状态。目前没有常用的工具来公开这些状态;然而,内核文档中包含了一个示例程序getdelays.c,可用于完成这个任务,该示例程序在第4章“观察性工具”中进行了演示。另一种方法是使用DTrace或SystemTap等跟踪工具。
通过其他工具可以粗略估计睡眠状态中的阻塞时间,例如pidstat -d用于确定进程是否正在进行磁盘I/O并可能处于睡眠状态。如果启用了延迟和其他I/O统计功能,则可以提供在块I/O上阻塞的时间,可以使用iotop(1)观察到这一点。使用DTrace或SystemTap等跟踪工具可以调查其他阻塞原因。应用程序也可能具有仪器化,或者可以添加仪器化来跟踪执行的显式I/O(磁盘和网络)时间。
如果应用程序在睡眠状态中长时间卡住(几秒钟),可以尝试使用pstack(1)确定原因。它会对线程及其用户堆栈跟踪进行单次快照,其中应包括睡眠线程及其休眠原因。但请注意,pstack(1)在执行此操作时可能会短暂暂停目标进程,因此请谨慎使用。
可以使用跟踪工具调查锁定时间。
Solaris
在基于Solaris的系统中,微状态计算统计数据(在第4章“观察性工具”中介绍)直接提供了大部分线程状态。可以使用prstat(1M)查看这些状态。

这八列,从USR到LAT,都是微状态计算线程状态,并将线程时间划分为百分比。这些列的总和为100%。以下是这些状态与我们感兴趣的状态的映射:
- 执行中:USR + SYS
- 可运行:LAT
- 匿名分页:DFL
- 睡眠:SLP
- 锁定:LCK
- 空闲:也包括在SLP + LCK中
虽然这不是完美匹配,但能够轻松达到这一步具有巨大的价值。可以使用DTrace来分离空闲时间,以检查线程离开CPU时的堆栈跟踪,以确定它正在等待什么。如果一个线程长时间处于睡眠状态(几秒钟),可以尝试使用pstack(1),但请注意,它会短暂暂停目标进程,因此请谨慎使用。
有关prstat(1M)和这些列的更多信息,请参阅第6章“CPU”。
5.4.2 CPU Profiling
在第6章“CPU”中的第6.5.4节“Profiling”中描述了CPU性能分析,该章节还提供了使用DTrace和perf(1)的详细示例。从应用程序角度总结的重要活动是性能分析。其目的是确定应用程序为何消耗CPU资源。一种有效的技术是对在CPU上运行的用户级堆栈跟踪进行采样并合并结果。堆栈跟踪显示了所采取的代码路径,可以揭示应用程序消耗CPU的高级和低级原因。
采样堆栈跟踪可能会生成数千行输出供检查,即使将输出总结为仅打印唯一堆栈时也是如此。快速了解性能分析的一种方法是使用火焰图对其进行可视化,在第6章“CPU”中有展示。
除了对堆栈跟踪进行采样外,还可以仅对当前运行的函数进行采样。在某些情况下,这足以确定应用程序为何使用CPU,并且产生的输出量要少得多,使阅读和理解更加迅速。本示例取自第6章“CPU”,使用了DTrace。


在这种情况下,在采样期间,ut_fold_ulint_pair()函数是CPU上运行时间最长的函数。
研究当前运行函数的调用者也可能很有用,一些性能分析软件(包括DTrace)可以轻松实现这一点。例如,如果先前的示例确定malloc()函数是CPU上运行时间最长的函数,这并不能告诉我们太多信息。malloc()的调用者应该更有趣并值得进行性能分析,而且也不需要捕获堆栈跟踪信息。
解析和虚拟机CPU使用情况的研究可能会很困难;执行软件到原始程序之间可能没有简单的映射关系。如何解决这个问题取决于语言环境:它可能支持启用调试功能来进行解析,或者可能有第三方工具。
以DTrace为例,它使用ustack辅助程序来查看虚拟机内部并将堆栈转换回原始程序。对于Java、Python和Node.js,都有相应的ustack辅助程序。
例如,使用DTrace的jstack()函数对Java进行CPU堆栈采样:

输出已被截断,仅显示了最频繁的堆栈,该堆栈被采样了十次。该堆栈显示了JVM(libjvm)的内部,每个函数都显示为C++签名。Java堆栈已从JVM中转换,这里用粗体标出,显示了负责此CPU代码路径的类和方法。对于此堆栈,它是java/io/DataOutputStream.write。请查看第6章“CPU”中的其他方法论和工具,以了解检查应用程序CPU使用情况的不同方式。
5.4.3 Syscall Analysis
线程状态分析方法首先描述了两种要研究的线程状态:在CPU上和离CPU。根据系统调用的执行情况来研究这些状态可能很有用,有时也更实际:
执行中:在CPU上(用户模式)
系统调用时间:在系统调用期间的时间(内核模式运行或等待)
系统调用时间包括I/O、锁和其他系统调用类型。其他线程状态,如可运行状态(等待CPU)和匿名页,被忽略在这个简化中。如果其中一个是真的(CPU饱和或内存饱和),可以通过USE方法在整个系统范围内进行识别。
执行状态可以通过之前提到的CPU性能分析方法进行研究。
系统调用(syscalls)可以以多种方式进行研究。目的是找出系统调用时间花在了哪里,包括系统调用的类型和调用的原因。
断点追踪
传统的系统调用跟踪方式涉及设置系统调用入口和返回的断点。这种方式具有侵入性,对于系统调用频率高的应用程序,其性能可能会恶化一倍。
根据应用程序的性能要求,这种跟踪方式在短时间内用于确定调用的系统调用类型可能是可接受的。
strace
在Linux上,可以使用strace(1)命令进行这种跟踪。例如:


所使用的选项包括(参见man手册获取完整列表):
- ttt:打印自纪元时代以来的时间的第一列,以秒为单位,具有微秒分辨率。
- T:打印最后一个字段(<time>),即系统调用的持续时间,以秒为单位,具有微秒分辨率。
- -p PID:跟踪此进程ID。还可以指定一个命令,以便strace(1)启动并跟踪它。
strace(1)的一个特性可以在输出中看到——将系统调用参数转换为人类可读的形式。这对于确定ioctl()的使用尤为有用。
这种strace(1)形式每个系统调用打印一行输出。可以使用-c选项总结系统调用活动:

输出包括:
- 时间:显示系统CPU时间花在哪里的百分比
- 秒:总系统CPU时间,以秒为单位
- 每次调用的微秒数:每次调用的平均系统CPU时间,以微秒为单位
- 调用次数:strace(1)期间的系统调用次数
- 系统调用:系统调用名称
如果开销不是如此大的问题,这将更有用。
为了说明这一点,使用dd(1)命令执行500万次1KB传输,并进行了有和无strace(1)的测试。没有:

dd(1)的输出包括运行时间和吞吐量统计信息。这个测试大约花了2秒才完成。
下面是相同的命令,同时strace(1)总结了系统调用的使用情况:

运行时间增加了73倍,吞吐量相应下降。
这是一个特别严重的情况,因为dd(1)执行了大量的系统调用。
truss
在基于Solaris的系统上,truss(1)命令扮演着这个角色。例如:

所使用的选项包括(查看man手册获取全部内容):
- d:打印时间戳的第一列,显示自纪元以来的秒数。
- E:打印时间戳的第二列,显示系统调用期间经过的时间,以秒为单位。
- -p PID:跟踪该进程ID。也可以指定一个命令,以便truss(1)启动并跟踪它。
输出每个系统调用打印一行,并将参数有用地转换为人类可读格式。时间戳仅具有0.1毫秒的分辨率,这使得它们的用途受到限制。
truss(1)还支持使用-c进行摘要模式:


秒列显示系统调用中的系统CPU时间。调用列显示计数。
truss(1)还可以使用-u选项执行一种形式的用户级函数调用的动态跟踪。例如,跟踪printf()调用:

与strace(1)类似,对于高频率的系统调用或跟踪函数调用,开销可能会很大,这使得大多数生产使用场景下禁止使用。
缓冲跟踪
通过缓冲跟踪,仪器化数据可以在内核中被缓冲,而目标程序继续执行。这与断点跟踪不同,后者会为每个跟踪点中断目标程序。
DTrace提供了缓冲跟踪和聚合功能,以减少跟踪开销,并允许编写用于系统调用分析的自定义程序。本节中展示了一些示例。在Linux 3.7中,perf(1)添加了一个trace子命令,用于执行系统调用(等等)的缓冲跟踪。
以下DTrace一行示例演示了一些基本的系统调用分析,并适用于基于Linux和Solaris的系统(在后者上演示)。在附录D中还有更多示例一行命令。
此一行命令跟踪进程信号(通过kill()系统调用),显示源PID和进程名称,以及目标PID和信号编号:

在跟踪过程中,这个命令捕获到一个bash进程向PID 2638发送了-9(SIGKILL)信号,以及一些来自postgres(PostgreSQL数据库)的信号。时间戳的包含可能有助于与其他活动进行关联。
这个一行命令统计了名称为"postgres"(PostgreSQL数据库)的进程的系统调用(使用聚合功能):

在跟踪过程中,llseek()系统调用被执行了最多——27,925次。
接下来的一行命令测量了PostgreSQL执行read()系统调用的持续时间(也称为延迟):


在跟踪过程中,大多数read()系统调用的持续时间在1到8微秒(1,024-8,191纳秒)之间。
read()系统调用作用于文件描述符,该文件描述符可以是文件系统对象或网络套接字。在各自的章节中演示了如何通过使用fds[] DTrace数组将文件描述符映射到它们的文件系统类型来识别每个文件描述符。
对于这个一行命令,如果内置的时间戳被改为vtimestamp,它将仅测量系统调用期间的CPU时间。这可以用来与持续时间进行比较,以查看系统调用是在内核代码中花费更多时间还是被I/O阻塞。
可以编写更复杂的DTrace脚本以不同方式表示系统调用的时间。例如(来自DTraceToolkit [3]):
- dtruss:DTrace版本的truss(1),全系统操作
- execsnoop:通过exec()系统调用跟踪新进程执行
- opensnoop:跟踪带有各种细节的open()系统调用
- procsystime:以各种方式总结系统调用时间
这些脚本已经解决了许多性能问题,通常是通过识别可以调整或消除的高级别进程活动来实现的。这是一种工作负载特性:工作负载是应用程序系统调用。
例如,以下显示了在基于云的系统上使用-v参数运行execsnoop以获取字符串时间戳:

时间戳显示这些进程都是在2秒的时间段内执行的。
大量短生命周期的进程可能会消耗CPU资源,并由于CPU交叉调用(在进程退出时拆除MMU上下文)而干扰其他应用程序。
5.4.4 I/O Profiling
与CPU性能分析的作用类似,I/O性能分析确定了I/O相关系统调用的执行原因和方式。这可以通过使用DTrace来实现,检查系统调用的用户级堆栈跟踪。
例如,这个一行命令跟踪PostgreSQL的read()系统调用,收集用户级堆栈跟踪,并对其进行聚合:

输出(截断)显示了用户级堆栈,然后是出现次数的计数。这些堆栈包括应用程序内部函数名称。您可能需要研究源代码才能理解这些内容,但您可能能够从名称中获取足够有用的含义。第一个堆栈包含XLogRead:它可能与某种类型的数据库日志相关。第二个堆栈包含PgstatCollectorMain.isra,听起来像是监控活动。
堆栈跟踪显示了系统调用的执行原因。同时,从工作负载特性方法论中研究其他属性也可能非常有用(参见第2章,方法论部分):
- 谁:进程ID,用户名
- 什么:I/O系统调用目标(例如,文件系统或套接字),I/O大小,IOPS,吞吐量(每秒字节数),其他属性
- 如何:随时间变化的IOPS
除了应用的工作负载外,还可以按照先前方法论所述研究产生的性能——系统调用延迟。
5.4.5 Workload Characterization
该应用程序对系统资源(CPU、内存、文件系统、磁盘和网络)以及通过系统调用对操作系统施加了工作负载。所有这些都可以使用在第2章方法论中介绍并在后续章节中讨论的工作负载特性方法论来进行研究。
此外,还可以研究发送到应用程序的工作负载。这主要关注应用程序提供的操作及其属性,可能是性能监控中包括的关键指标,并用于容量规划。
5.4.6 USE Method
正如在第2章方法论中介绍并在后续章节中应用的那样,USE方法检查所有硬件资源的利用率、饱和度和错误。通过显示某一资源已成为瓶颈,许多应用程序性能问题可以通过这种方式解决。
根据应用程序的不同,USE方法也可以应用于软件资源。如果您可以找到显示应用程序内部组件的功能图表,请考虑每个软件资源的利用率、饱和度和错误指标,并查看哪些是合理的。
例如,应用程序可能使用一组工作线程来处理请求,其中包括一个用于等待轮到的请求的队列。将其视为一个资源,那么这三个指标可以这样定义:
- 利用率:在一个时间间隔内忙于处理请求的平均线程数,占总线程数的百分比。例如,50%意味着平均有一半的线程正在忙于处理请求。
- 饱和度:在一个时间间隔内请求队列的平均长度。这显示了有多少请求在等待工作线程。
- 错误:由于任何原因而被拒绝或失败的请求。
接下来您需要找出如何测量这些指标。它们可能已经在应用程序的某个地方提供,或者可能需要添加或使用其他工具(如动态跟踪)进行测量。
像这个例子一样的排队系统也可以使用排队理论进行研究(参见第2章方法论)。
以另一个例子为例,考虑文件描述符。系统可能会设置限制,使其成为有限资源。这三个指标可能如下:
- 利用率:正在使用的文件描述符数量,占限制的百分比。
- 饱和度:取决于操作系统的行为:如果线程因等待文件描述符分配而阻塞,这可能是等待该资源的阻塞线程数量。
- 错误:分配错误,例如EFILE,“打开文件过多”。
对您的应用程序组件进行类似的重复练习,并跳过任何不合理的指标。
这个过程可能有助于您在转向其他方法(如深入分析)之前,制定一个用于检查应用程序健康状况的简短清单。
5.4.7 Drill-Down Analysis
对于应用程序,深入分析可以从检查应用程序提供的操作开始,然后深入到应用程序内部,看看它是如何执行这些操作的。对于I/O操作,这种深入分析可以进入系统库、系统调用和内核。
这是一项高级活动,将迅速导致应用程序内部的探索,最好是开源的,以便进行研究。动态跟踪工具(例如DTrace、SystemTap、perf(1))可以对这些内部进行工具化,某些语言比其他语言更容易实现。检查语言是否有适用于分析的自己的工具集,这可能更合适。
还有一些专门用于调查库调用的工具:在Linux上是ltrace(1),在基于Solaris的系统上是apptrace(1)(尽管其使用已让位于DTrace)。
5.4.8 Lock Analysis
对于多线程应用程序,锁可能成为一个瓶颈,抑制并行性和可扩展性。可以通过以下方式进行分析:
- 检查争用情况
- 检查持有时间是否过长
第一种方法可以确定当前是否存在问题。持有时间过长未必是问题,但随着更多的并行负载,它们可能成为问题。对于每个问题,尝试确定锁的名称(如果存在)和导致使用它的代码路径。
虽然有专门用于锁分析的工具,但有时您可以仅通过CPU性能分析解决问题。对于自旋锁,争用会显示为CPU使用率,并且可以通过对堆栈跟踪进行CPU性能分析来轻松识别。对于自适应互斥锁,争用通常涉及一些自旋,这也可以通过对堆栈跟踪进行CPU性能分析来识别。在这种情况下,请注意CPU性能分析只能提供故事的一部分,因为线程可能已经阻塞和休眠,同时等待锁。参见5.4.2节CPU性能分析。
在基于Solaris系统的专用锁分析工具示例包括:
- plockstat(1M):用户级锁分析
- lockstat(1M):内核级锁分析
这些命令具有类似的行为。它们也是使用DTrace实现的,可以直接用于更深入的锁分析。
以下是lockstat(1M)的示例用法:


在这里,lockstat(1M)跟踪争用事件(-C),使用五级堆栈跟踪(-s5),并通过执行协处理器(sleep(1))设置了5秒的超时。输出被重定向到文件以便更轻松地浏览(输出超过10万行)。
输出以自适应自旋时间和分布图开始,显示每个争用事件的时间,以及锁的名称和堆栈跟踪。最高的是zfs_range_unlock,它出现了14,144次争用,平均自旋时间为1,787纳秒。分布图显示有两次自旋时间超过1,048,576纳秒(在1到2毫秒范围内)。这些被阻塞的数量可以在输出的自适应互斥锁阻塞部分中看到。
对内核或用户级别锁进行跟踪会增加开销。这些特定工具基于DTrace,尽量将这种开销最小化。另外,正如前面所述,以固定速率(例如97赫兹)进行CPU性能分析将识别许多(但不是所有)锁问题,而不会带来每个事件的跟踪开销。
5.4.9 Static Performance Tuning
静态性能调优关注配置环境的问题。对于应用程序性能,需要检查静态配置的以下方面:
- 应用程序运行的版本是什么?是否有更新版本?它们的发布说明中是否提到了性能改进?
- 应用程序存在哪些已知的性能问题?是否有可搜索的错误数据库?
- 应用程序的配置是如何的?
- 如果与默认设置不同,那么进行不同配置或调优的原因是什么?(是基于测量和分析,还是凭猜测?)
- 应用程序是否使用对象缓存?它的大小是多少?
- 应用程序是否并发运行?它是如何配置的(例如,线程池大小)?
- 应用程序是否以特殊模式运行?(例如,可能已启用调试模式并降低性能)
- 应用程序使用了哪些系统库?它们的版本是多少?
- 应用程序使用了哪种内存分配器?
- 应用程序是否配置为在其堆中使用大页?
- 应用程序是经过编译的吗?使用的是哪个版本的编译器?有哪些编译器选项和优化?是64位的吗?
应用程序是否遇到错误,并且现在是否以降级模式运行?
- 系统是否有对CPU、内存、文件系统、磁盘或网络使用的限制或资源控制?(这在云计算中很常见。)
回答这些问题可能会揭示被忽视的配置选择。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值