mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-10-22 18:03:45 +08:00
mod
This commit is contained in:
145
极客时间专栏/性能工程高手课/性能优化/19 | 性能优化六大原则:三要三不要,快速有效地进行优化.md
Normal file
145
极客时间专栏/性能工程高手课/性能优化/19 | 性能优化六大原则:三要三不要,快速有效地进行优化.md
Normal file
@@ -0,0 +1,145 @@
|
||||
<audio id="audio" title="19 | 性能优化六大原则:三要三不要,快速有效地进行优化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/06/01/06d0b1ac11cea369cec367f878074b01.mp3"></audio>
|
||||
|
||||
你好,我是庄振运。
|
||||
|
||||
今天我们进入了专栏的新模块:性能优化。在这个模块里,我会先从“性能优化的六大原则”开始讲起,然后再为你讲解实践中普遍采用的十个性能优化策略,并且分别针对CPU、系统、存储以及跨层这几个领域,讲讲具体的优化案例。
|
||||
|
||||
我们今天先探讨性能优化的原则。在讲具体原则之前,我想先给你讲一个有趣的往事。
|
||||
|
||||
我曾经负责过一个存储服务的性能优化和容量效率。那个服务的容量需求很大,但它的最大性能瓶颈不是CPU,而是存储的空间。
|
||||
|
||||
所以,虽然有很多人给我们各种建议,让我们花时间做CPU优化,我都尽量挡了回去。因为我知道CPU不是最大问题,所以坚持不懈地通过各种途径优化数据大小,甚至以牺牲CPU为代价。最后的结果很好,大幅度地降低了那个服务的容量需求。
|
||||
|
||||
在这个性能优化的场景,我们遵循了一个原则,那就是**优先优化最大性能瓶颈**。这其实就是马上要讲到的“三要”原则中的第一个。
|
||||
|
||||
## 性能优化的原则概述
|
||||
|
||||
在实际的性能优化中,我们需要考虑的因素很多,也经常需要在多个角度和目标间做一些平衡和取舍。为了帮助你把握这些,我个人总结出了六条原则,我把它们概括为:“**三要,三不要**”。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bc/bb/bc08d73063d641b231db155d7ffe26bb.jpg" alt="">
|
||||
|
||||
- 三个“要”原则是:要优先查最大的性能瓶颈,性能分析要确诊性能问题的根因,性能优化要考虑各种的情况。
|
||||
- 三个“不要”的原则是:不要做过度的、反常态的优化,不要过早做不成熟的优化,不要做表面的肤浅优化。
|
||||
|
||||
接下来,我们一个个地讲一下这六个优化原则。
|
||||
|
||||
## 三个“要”的原则
|
||||
|
||||
你可能已经发现了,这三个“要”的原则之间,其实是步步递进的关系。也就是首先需要查找最大性能瓶颈,然后确诊性能瓶颈产生的原因,最后针锋相对地提出最好的解决方案。
|
||||
|
||||
而这个最优解,往往是在考虑各种情况之后提出来,并最终被选中的。
|
||||
|
||||
### 要优先查最大的性能瓶颈
|
||||
|
||||
任何一个应用程序或者系统,总会有很多地方可以优化。
|
||||
|
||||
可是你知道要从何处下手吗?
|
||||
|
||||
其实你可以参考古人的观点,唐代的诗人杜甫说过:“射人先射马,擒贼先擒王”。会打仗的将军,一定会从“最值得打的地方”开始,性能优化工作也是如此。
|
||||
|
||||
我们永远都要优先从**最大的性能瓶颈**入手。
|
||||
|
||||
一般来讲,如果找到最大的性能瓶颈,并且解决了它,那这个系统的性能会得到最大的提升(参见[第4讲](https://time.geekbang.org/column/article/174462)帕累托法则)。反之,如果不解决最大的性能瓶颈,反而退而求其次,去解决了其他的性能问题,整个系统的性能或许会更高一些,但是提升的程度往往是非常有限的。
|
||||
|
||||
比如,一个应用程序的最大性能瓶颈是CPU的使用率太高;而其他种类型的资源,比如网络和存储,都很有富余。这种情况下,如果你去优化网络和存储方面,显然是不能大幅度地提升整体性能的。
|
||||
|
||||
不过这里有个稍微特殊的情况,就是**内存**。因为内存的分配和回收也会消耗一些CPU资源,如果这时去优化内存使用,很多时候,的确会帮助你降低CPU使用率,不过降低的幅度一定是很有限的。
|
||||
|
||||
归根结底,要降低CPU的使用率,**最有效的方法是做性能分析和剖析,找出程序中使用CPU最多的地方**,然后对症下药地做优化。
|
||||
|
||||
### 要确诊性能问题的根因
|
||||
|
||||
我们前面讲过,程序和系统如果在某个地方有性能瓶颈,肯定是这个地方的资源不够用了,不管是CPU、内存还是网络(参见[第14讲](https://time.geekbang.org/column/article/182915))。
|
||||
|
||||
所以,当我们确定了最大的性能瓶颈后,就需要对这一性能瓶颈做彻底的性能分析,找出资源不够使用的原因,也就是考察使用资源的地方。
|
||||
|
||||
一种资源被使用的地方往往有好几个,我们需要一个一个地去分析考虑。只有彻底分析了各种使用的情况,才能进一步找出最主要的,也是最可能优化的原因,对症下药。
|
||||
|
||||
有些资源使用的原因也许是完全合理的。对这些合理的使用,有些或许已经仔细优化过了,很难再做优化。而另外一些则有可能继续优化。对资源的不合理使用,我们就要尽量想办法去掉。
|
||||
|
||||
对于需要优化的地方,我们就需要进一步考虑**优化工作的投入产出比例**,就是**既考虑成本,也考虑带来的好处**。因为有些情况下,虽然你可以去优化,但获得的收益并不大,所以不值得去做。
|
||||
|
||||
另外要提醒你的是,确诊性能问题的原因有时候非常困难,需要做多方面的性能测试、假设分析并验证。
|
||||
|
||||
比如CPU的使用,有操作系统的原因,有应用程序的原因,但也有些CPU的问题,是在非常边缘的场景下才发生的。为了暴露问题,我们经常需要创造特殊的场景来重现遇到的性能问题。
|
||||
|
||||
### 要考虑各种情况下的性能
|
||||
|
||||
性能问题确诊原因后,我们就可以进入下一步,找解决方案了。一般说来,找一个解决方案并不难,甚至找好几个方案也不难;但是找出一个好的、最优的解决方案是真心不容易。
|
||||
|
||||
为什么这么说呢?
|
||||
|
||||
因为实际生产环境很复杂,而且会出现各种各样的特殊场景。
|
||||
|
||||
**针对某个具体场景提出的一个解决方案,多半并不能适应所有的场景。**
|
||||
|
||||
所以,对提出的各种方案进行评估时,我们必须考虑各种情况下这个方案可能的表现。如果一个方案在某些情况下会导致其他严重的问题,这个方案或许就不是一个好的方案。
|
||||
|
||||
但同时你也需要意识到,任何解决方案都有长短,有Tradeoff。如果苛求一个能在所有场景下都最优的解决方案,往往是缘木求鱼,是不现实的。
|
||||
|
||||
比如一种优化方案,可以让平均响应时间最小,但高百分位比较高。另外一种优化方案正好相反。那我们就需要考虑对自己来说哪种指标更重要。也就是说,我们经常需要在**不同性能指标间权衡**,以找到一个最优解能**达到总体和整体最优**。
|
||||
|
||||
这就需要我们**有一个整体的意识和判断**。
|
||||
|
||||
或许一个方案并不能面面俱到,在有些场景下性能不好。但是不同场景的出现概率不同,对其它模块造成的影响也不一样,并且最终客户的体验也不尽相同。这些因素都要考虑到取舍的决策过程中。
|
||||
|
||||
综合以上因素,在实际的优化过程中,我们经常会**反复权衡利弊和取舍来做最终决定**。
|
||||
|
||||
## 三个“不要”的原则
|
||||
|
||||
讲完了三个“要”的原则,我们接着来看三个“不要”的原则。
|
||||
|
||||
### 不要过度地反常态优化
|
||||
|
||||
性能优化的目标,是追求**最合适的性价比**或**最高的投入产出比**,在满足要求的情况下,尽量不要做过度的优化。过度的优化会增加系统复杂度和维护成本,使得开发和测试周期变长。虽然性能上带来了一定程度的提升,但是和导致的缺点来比,孰轻孰重尚不可知,需要仔细斟酌,衡量得失。
|
||||
|
||||
我的建议是,**根据产品的性能要求来决策**。
|
||||
|
||||
在设计产品时,我们对产品的性能会有一定的要求,比如吞吐量,或者客户响应时间要达到多少多少。如果达不到这个既定指标,就需要去优化。反之,如果能满足这些指标,那么就不必要花费太多时间精力去优化。
|
||||
|
||||
比如,我们要设计一个内部查询系统,预计最多只有一百个人同时在线使用的话,就完全不用按照百万在线用户的目标去过度优化。
|
||||
|
||||
更重要的是,多数的优化方法是并不是完美无缺的,是有缺点的,尤其是可能会对系统设计的简化性,对代码的可读性和可维护性有副作用。如果系统简化性和代码可读性更加重要,当然就更不能过度优化。
|
||||
|
||||
### 不要过早的不成熟优化
|
||||
|
||||
要体会这一原则,我们先引用著名计算机科学家高德纳(Donald Knuth)的一段话:“现实中的最大问题是,程序员往往花太多时间,来在错误的地方和错误的时间来试图提高效率和性能。过早的优化,是编程中所有邪恶和悲剧(或至少是大多数邪恶和悲剧)的根源。”
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/69/a7/6964528af8b4212dfa2291749b7415a7.png" alt="">
|
||||
|
||||
你只要稍微思考一下高德纳的话,就会发现,这句话在很多场景下都是很有道理的。
|
||||
|
||||
比如,在敏捷开发过程中,尤其是在面对一个全新的产品时,在业界没有先例和经验可遵循的情况下,最看重的特点是快速的迭代与试错,“尽快推出产品”是最重要的。这时,过早的优化很可能优化错地方,也就是优化的地方并非真正的性能瓶颈,因此让“优化工作”成为了无用功。而且,越早的优化就越容易造成负面影响,比如影响代码的可读性和维护性。
|
||||
|
||||
我个人认为,**如果一个产品已经在业界很成熟**,大家非常清楚它的生产环境特点和性能瓶颈,那么**优化的重要性可以适当提高**。否则的话,在没有实际数据指标的基础上,为了一点点的性能提升而进行盲目优化,的确是得不偿失的。
|
||||
|
||||
### 不要表面的肤浅优化
|
||||
|
||||
性能优化很忌讳表面和肤浅的优化,也就是那种“头痛医头,脚痛医脚”的所谓“优化”。如果对一个程序和服务没有全局的把握,没有理解底层运行机制,任何优化方案都很难达到最好的优化效果。
|
||||
|
||||
比如,如果你发现一个应用程序的CPU使用率并不高,但是吞吐率上不去,表面的优化方式可能是增大线程池来提升CPU使用率。这样的简单“优化”或许当时能马上看到效果,比如吞吐率也上去了,但是如果你仔细想想,就会发现如此的表面优化非常有问题。
|
||||
|
||||
这样的情况下,线程池开多大最合适?需不需要根据底层硬件和上层请求的变化而对线程池的大小调优呢?如果需要,那么手工调整线程池大小就是一个典型的“头痛医头”的优化。
|
||||
|
||||
为什么呢?
|
||||
|
||||
因为部署环境不会一成不变,比如以后CPU升级了,核数变多了,你怎么办?再次手工去调整吗?这样做很快会让人疲于奔命,难以应付,并且很容易出错。
|
||||
|
||||
对这样的场景,正确的优化方式,是彻底了解线程的特性,以优化线程为主。至于线程池的大小,最好能够自动调整。千万别动不动就手工调优。如果这样手工调整的参数多了,就会做出一个有很多可调参数的复杂系统,很难用,也很难调优,很不可取。就比如我们都熟悉的JVM调优,有上千个可调参数,非常被人诟病。
|
||||
|
||||
## 总结
|
||||
|
||||
唐朝的名相魏征说过:“求木之长者,必固其根本”。意思是说,如果要一棵树长得高,必须让它的根牢固。否则的话,正如魏征自己所说:“根不固而求木之长”,一定是“知其不可”。根基不牢固,就不可能长成参天大树。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b0/e9/b07a06f7da4768d7398e1a174326cce9.png" alt="">
|
||||
|
||||
同理,对现代互联网的服务和系统来说,性能问题是根本的问题。如果不知道系统的性能瓶颈,查不出性能根因,不知道如何解决,无法做合理的优化,这个服务和系统一定不会高效。
|
||||
|
||||
这一讲我们总结了六个性能优化的原则,这些原则的终极目的,就是找出性能的最大瓶颈,查出根因,并作做相应的最优优化,从而让我们的系统这棵树长高、长大。
|
||||
|
||||
## 思考题
|
||||
|
||||
- 今天介绍的六大原则里面,你认为哪个原则最重要?为什么?
|
||||
- 你过去的工作中有没有因为没有遵循这几个原则而吃后悔药的时候?比如你在选择一个数据结构的具体实现时(比如Set),有没有考虑各种场景下的性能?
|
||||
|
||||
欢迎你在留言区分享自己的思考,与我和其他同学一起讨论,也欢迎你把文章分享给自己的朋友。
|
151
极客时间专栏/性能工程高手课/性能优化/20 | 性能优化十大策略:如何系统地有层次地优化性能问题?.md
Normal file
151
极客时间专栏/性能工程高手课/性能优化/20 | 性能优化十大策略:如何系统地有层次地优化性能问题?.md
Normal file
@@ -0,0 +1,151 @@
|
||||
<audio id="audio" title="20 | 性能优化十大策略:如何系统地有层次地优化性能问题?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8c/91/8ce7e55b982a131a6c3809e00333a291.mp3"></audio>
|
||||
|
||||
你好,我是庄振运。
|
||||
|
||||
上一讲中,我们聊了性能优化的六大原则。原则有了,但是在针对实际的性能问题的时候,用什么样的解决方案才可以提升性能呢?这就需要你了解**具体的优化策略**了。
|
||||
|
||||
现实中的性能问题和具体领域千差万别,我也不可能面面俱到。但是为了帮助你理解,我总结了十大常用的优化策略。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5c/84/5cc1f7f09fb87ec47cccaeda6948d484.png" alt="">
|
||||
|
||||
我将这十大策略分成五个类别,每个类别对应两个相关策略,帮助你掌握。这五个类别是:时空相互转换、并行/异步操作、预先/延后处理、缓存/批量合并、算法设计和数据结构。我们现在一个个来讲。
|
||||
|
||||
## 时空转换
|
||||
|
||||
第一个策略类别是“时空转换”。我们看科幻电影和小说的时候,经常会看到时空转换这个题材。性能优化里面有两个策略恰好组成了这个类别,包括“用时间换空间”和“用空间换时间”这两个看似互相对立的策略。
|
||||
|
||||
### 1.用时间换空间
|
||||
|
||||
用时间换空间的策略,出发点是内存和存储这样的“空间”资源,有时会成为最稀缺的资源,所以需要尽量减少占用的空间。比如,一个系统的最大性能瓶颈如果是内存使用量,那么减少内存的使用就是最重要的性能优化。
|
||||
|
||||
这个策略具体的操作方法有几种:
|
||||
|
||||
- 改变应用程序本身的数据结构或者数据格式,减少需要存储的数据的大小;
|
||||
- 想方设法压缩存在内存中的数据,比如采用某种压缩算法,真正使用时再解压缩;
|
||||
- 把一些内存数据,存放到外部的、更加便宜的存储系统里面,到需要时再取回来。
|
||||
|
||||
这些节省内存空间的方法,一般都需要付出时间的代价。
|
||||
|
||||
除了内存,还有一种常见的场景是,降低数据的大小来方便网络传输和外部存储。压缩的方法和算法有很多种, 比如现在比较流行的ZStandard(ZSTD)和LZ4。这些算法之间有空间和时间的取舍。
|
||||
|
||||
衡量任何压缩算法,基本上看三个指标:**压缩比例**、**压缩速度**以及**使用内存**。
|
||||
|
||||
如果系统的瓶颈在网络传输速度或者存储空间大小上,那就尽量采取高压缩比的算法,这样用时间来换空间,就能够节省时间或者其他方面的成本。
|
||||
|
||||
### 2.用空间换时间
|
||||
|
||||
“用空间换时间”就是对“用时间换空间”策略反其道而行之。有些场景下,时间和速度更加重要,但是空间尚有富余,这时我们就可以考虑用空间来换时间。
|
||||
|
||||
这里要注意的一点是,我们后面还会讲一条关于使用缓存的策略。虽然缓存的策略理论上也是一种“空间换时间”的方式,但我们在这里把它分开来讲,这是因为缓存策略的“空间”定义与一般的“空间换时间”不同。一般来讲,“缓存”使用的空间,和原来的空间不在同一个层次上,添加的缓存往往比原来的空间高出一个档次。而我们这里“空间换时间”的策略,里面的“空间”是和原来的空间相似的空间。
|
||||
|
||||
互联网的服务往往规模很大,比如全国的服务甚至是全球的服务。用户分布在各地,它们对访问时间的要求很高,这就要求被访问的数据和服务,要尽量放在离他们很近的地方。“空间换时间”就是对数据和服务进行多份拷贝,尽可能地完美覆盖大多数的用户。我们前面讲过的CDN内容分发网络技术就可以归类于此。
|
||||
|
||||
其实我们部署的任何大规模系统,都或多或少地采用了用空间换时间的策略,比如在集群和服务器间进行负载均衡,就是同时用很多个服务器(空间)来换取延迟的减少(时间)。
|
||||
|
||||
## 预先和延后处理
|
||||
|
||||
优化策略的第二大类是“预先和延后处理”,这一类别也有两个互相对立的策略。一个是预先或者提前处理,另外一个是延后或者惰性处理。
|
||||
|
||||
### 3.预先/提前处理
|
||||
|
||||
预先/提前处理策略同样也表现在很多领域,比如网站页面资源的提前加载。Web标准规定了至少两种提前加载的方式:preload和prefetch,分别用不同的优先级来加载资源,可以显著地提升页面下载性能。
|
||||
|
||||
很多文件系统有预读的功能,就是提前从磁盘读取额外的数据,为下次上层应用程序读数据做准备。这个功能对顺序读取非常有效,可以明显地减少磁盘请求的数量,从而提升读数据的性能。
|
||||
|
||||
CPU和内存也有相应的预取操作,就是将内存中的指令和数据,提前存放到缓存中,从而加快处理器执行速度。缓存预取可以通过硬件或者软件实现,也就是分为**硬件预取**和**软件预取**两类。
|
||||
|
||||
硬件预取是通过处理器中的硬件来实现的。该硬件会一直监控正在执行程序中请求的指令或数据,并且根据既定规则,识别下一个程序需要的数据或指令并预取。
|
||||
|
||||
软件预取是在程序编译的过程中,主动插入预取指令(prefetech),这个预取指令可以是编译器自己加的,也可以是我们加的代码。这样在执行过程中,在指定位置就会进行预取的操作。
|
||||
|
||||
### 4.延后/惰性处理
|
||||
|
||||
延后/惰性处理策略和前面说的预先/提前处理正好相反。就是尽量将操作(比如计算),推迟到必需执行的时刻,这样很可能避免多余的操作,甚至根本不用操作。
|
||||
|
||||
运用这一策略最有名的例子,就是COW(Copy On Write,写时复制)。假设多个线程都想操作一份数据,一般情况下,每个线程可以自己拷贝一份,放到自己的空间里面。但是拷贝的操作很费时间。系统如果采用惰性处理,就会将拷贝的操作推迟。如果多个线程对这份数据只有读的请求,那么同一个数据资源是可以共享的,因为“读”的操作不会改变这份数据。当某个线程需要修改这一数据时(写操作),系统就将资源拷贝一份给该线程使用,允许改写,这样就不会影响别的线程。
|
||||
|
||||
COW最广为人知的应用场景有两个。一个是Unix系统fork调用产生的子进程共享父进程的地址空间,只有到某个子进程需要进行写操作才会拷贝一份。另一个是高级语言的类和容器,比如Java中的CopyOnWrite容器,用于多线程并发情况下的高效访问。
|
||||
|
||||
## 并行/异步操作
|
||||
|
||||
优化策略的第三大类是“并行/异步操作”。并行和异步两种操作虽然看起来很不一样,其实有异曲同工之妙,就是都把一条流水线和处理过程分成了几条,不管是物理上分还是逻辑上分。
|
||||
|
||||
### 5.并行操作
|
||||
|
||||
并行操作是一种**物理上**把一条流水线分成好几条的策略。直观上说,一个人干不完的活,那就多找几个人来干。并行操作既增加了系统的吞吐量,又减少了用户的平均等待时间。比如现代的CPU都有很多核,每个核上都可以独立地运行线程,这就是并行操作。
|
||||
|
||||
并行操作需要我们的程序有扩展性,不能扩展的程序,就无法进行并行处理。这里的并行概念有不同的粒度,比如是在服务器的粒度(所谓的横向扩展),还是在多线程的粒度,甚至是在指令级别的粒度。
|
||||
|
||||
绝大多数互联网服务器,要么使用多进程,要么使用多线程来处理用户的请求,以充分利用多核CPU。另外一种情况就是在有IO阻塞的地方,也是非常适合使用多线程并行操作的,因为这种情况CPU基本上是空闲状态,多线程可以让CPU多干点活。
|
||||
|
||||
### 6.异步操作
|
||||
|
||||
异步操作这一策略和并行操作不同,这是一种**逻辑上**把一条流水线分成几条的策略。
|
||||
|
||||
我们首先在编程的领域澄清一下概念:同步和异步。同步和异步的区别在于一个函数调用之后,是否直接返回结果。如果函数挂起,直到获得结果才返回,这是同步;如果函数马上返回,等数据到达再通知函数,那么这就是异步。
|
||||
|
||||
我们知道Unix下的文件操作,是有block和non-block的方式的,有些系统调用也是block式的,如:Socket下的select等。如果我们的程序一直是同步操作,那么就会非常影响性能。采用异步操作的话,虽然稍微增加一点程序的复杂度,但会让性能的吞吐率有很大提升。
|
||||
|
||||
现代的语言往往对异步操作有比较好的支持,使得异步编程变得更加简单,可读性也更好。
|
||||
|
||||
## 缓存/批量合并
|
||||
|
||||
“缓存/批量合并”是优化策略中的第四大类。缓存和批量合并这两个策略,有些场景下会同时起作用,所以我把它们放在一起。
|
||||
|
||||
### 7.缓存数据
|
||||
|
||||
缓存的本质是加速访问。这是一个用得非常普遍的策略,几乎体现在计算机系统里面每一个模块和领域,CPU、内存、文件系统、存储系统、内容分布、数据库等等,都会遵循这样的策略。
|
||||
|
||||
我们最熟悉的应该就是CPU的各级缓存了。在文件系统、存储系统和数据库系统里面,也有快速缓存来存储经常访问的数据,目的是尽量提高缓存命中率,从而避免访问比较慢的存储介质。
|
||||
|
||||
对于一个基于Web的应用服务,前端会有浏览器缓存,有CDN存放在边缘服务器上,有反向代理提供的静态内容缓存;后端则还会有服务器本地缓存。
|
||||
|
||||
程序设计中,对于可能重复创建和销毁,且创建销毁代价很大的对象(比如套接字和线程),也可以缓存,对应的缓存形式,就是连接池和线程池等。
|
||||
|
||||
对于消耗较大的计算,也可以将计算结果缓存起来,下次可以直接读取结果。比如对递归代码的一个有效优化手段,就是缓存中间结果。
|
||||
|
||||
### 8.批量合并处理
|
||||
|
||||
在有IO(比如网络IO和磁盘IO)的时候,合并操作和批量操作往往能提升吞吐量,提高性能。
|
||||
|
||||
我们最常见的是批量IO读写。就是在有多次IO的时候,可以把它们合并成一次读写数据。这样可以减少读写时间和协议负担。比如,GFS写文件的时候,尽量批量写,以减少IO开销。
|
||||
|
||||
对数据库的读写操作,也可以尽量合并。比如,对键值数据库的查询,最好一次查询多个键,而不要分成多次。
|
||||
|
||||
涉及到网络请求的时候,网络传输的时间可能远大于请求的处理时间,因此合并网络请求也很有必要。上层协议呢,端到端对话次数尽量不要太频繁(Chatty),否则的话,总的应用层吞吐量不会很高。
|
||||
|
||||
## 更先进算法和数据结构
|
||||
|
||||
优化策略中的最后一个大类就是“更先进算法和数据结构”。这两个策略是紧密配合的,比如先进的算法有时候会需要先进的数据结构;而且它们往往和程序的设计代码直接相关,所以放在一起。
|
||||
|
||||
### 9.先进的算法
|
||||
|
||||
同一个问题,肯定会有不同的算法实现,进而就会有不同的性能。比如各种排序算法,就是各有千秋。有的实现可能是时间换空间,有的实现可能是空间换时间,那么就需要根据你自己的实际情况做权衡。
|
||||
|
||||
对每一种具体的场景(包括输入集合大小、时间空间的要求、数据的大小分布等),总会有一种算法是最适合的。我们需要考虑实际情况,来选择这一最优的算法。
|
||||
|
||||
### 10.高效的数据结构
|
||||
|
||||
和算法的情况类似,不同的数据结构的特性,也是千差万别。
|
||||
|
||||
没有一个数据结构是在所有情况下都是最好的,比如你可能经常用到的Java里面列表的各种实现,包括各种口味的List、Vector、LinkedList,它们孰优孰劣,取决于很多个指标:添加元素、删除元素、查询元素、遍历耗时等等。我们同样要权衡取舍,找出实际场合下最适合的高效的数据结构。
|
||||
|
||||
## 总结
|
||||
|
||||
各种性能问题的解决,需要采用一些策略;而且不同的人和不同的场景中,会采用有时相同有时迥异的策略,恰如韩愈所说的“草树知春不久归,百般红紫斗芳菲”。但花草树木争奇斗艳,说到底是因为“知春不久归”。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ad/c2/adf36cb5087d32aecb3d9872f093cbc2.png" alt="">
|
||||
|
||||
同样的道理,这些性能优化策略,有时候很容易想到,有时候并不是那么直观。所以,我们需要系统地有层次地思考,而这一讲就是帮助你建立这样的思路。
|
||||
|
||||
通过总结十大策略,希望你可以多从不同角度,思考同一个问题;有时候一个问题看似无解,但多方位思考,可能会突然发现非常好的解决方案。
|
||||
|
||||
陆游曾经说:“山重水复疑无路,柳暗花明又一村”。我们做性能优化的时候,也会经常有这样的感觉的。
|
||||
|
||||
## 思考题
|
||||
|
||||
这十大策略也许你已经在工作中使用过,你曾经用过哪些呢?你自己归纳过它们吗?
|
||||
|
||||
你现在正在使用的编程语言,有没有对一种数据结构(比如列表,集合)提供了很多种不同的实现方法,它们之间在不同场景下的性能对比如何?
|
||||
|
||||
欢迎你在留言区分享自己的思考,与我和其他同学一起讨论,也欢迎你把文章分享给自己的朋友。
|
130
极客时间专栏/性能工程高手课/性能优化/21 | CPU案例:如何提高LLC(最后一级缓存)的命中率?.md
Normal file
130
极客时间专栏/性能工程高手课/性能优化/21 | CPU案例:如何提高LLC(最后一级缓存)的命中率?.md
Normal file
@@ -0,0 +1,130 @@
|
||||
<audio id="audio" title="21 | CPU案例:如何提高LLC(最后一级缓存)的命中率?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/23/48/23ca1986b418b75c84f2a41d1cfc8c48.mp3"></audio>
|
||||
|
||||
你好,我是庄振运。
|
||||
|
||||
前面两讲中,我介绍了性能优化的六大原则和十大策略。从今天开始,我们来通过具体案例的解决方案讲解,了解这些原则和策略是如何应用的。
|
||||
|
||||
首先,我们要来探讨的是一个CPU相关的性能优化案例。
|
||||
|
||||
这个性能案例,是关于CPU的最后一级缓存的。你应该知道,最后一级缓存(一般也就是L3),如果命中率不高的话,对系统性能会有极坏的影响(相关基础知识建议回顾[第15讲](https://time.geekbang.org/column/article/183357))。所以对这一问题,我们要及时准确地监测、暴露出来。
|
||||
|
||||
至于具体解决方案,我这里建议采取三种性能优化策略,来提高最后一级缓存的命中率。分别是:**紧凑化数据结构**、**软件预取数据**和**去除伪共享缓存**。它们分别适用于不同的情况。
|
||||
|
||||
## 性能问题:最后一级缓存(LLC)不命中率太高
|
||||
|
||||
一切问题的解决都要从性能问题开始入手,我们首先来看看**最后一级缓存不命中率太高**这个性能问题本身。
|
||||
|
||||
缓存的命中率,是CPU性能的一个关键性能指标。我们知道,CPU里面有好几级缓存(Cache),每一级缓存都比后面一级缓存访问速度快。最后一级缓存叫LLC(Last Level Cache);LLC的后面就是内存。
|
||||
|
||||
当CPU需要访问一块数据或者指令时,它会首先查看最靠近的一级缓存(L1);如果数据存在,那么就是缓存命中(Cache Hit),否则就是不命中(Cache Miss),需要继续查询下一级缓存。
|
||||
|
||||
缓存不命中的比例对CPU的性能影响很大,尤其是最后一级缓存的不命中时,对性能的损害尤其严重。这个损害主要有两方面的性能影响:
|
||||
|
||||
第一个方面的影响很直白,就是**CPU的速度**受影响。我们前面讲过,内存的访问延迟,是LLC的延迟的很多倍(比如五倍);所以LLC不命中对计算速度的影响可想而知。
|
||||
|
||||
第二个方面的影响就没有那么直白了,这方面是关于**内存带宽**。我们知道,如果LLC没有命中,那么就只能从内存里面去取了。LLC不命中的计数,其实就是对内存访问的计数,因为CPU对内存的访问总是要经过LLC,不会跳过LLC的。所以每一次LLC不命中,就会导致一次内存访问;反之也是成立的:每一次内存访问都是因为LLC没有命中。
|
||||
|
||||
更重要的是,我们知道,一个系统的内存带宽是有限制的,很有可能会成为性能瓶颈。从内存里取数据,就会占用内存带宽。因此,如果LLC不命中很高,那么对内存带宽的使用就会很大。内存带宽使用率很高的情况下,内存的存取延迟会急剧上升。更严重的是,最近几年计算机和互联网发展的趋势是,后台系统需要对越来越多的数据进行处理,因此**内存带宽越来越成为性能瓶颈**。
|
||||
|
||||
## LLC不命中率的测量
|
||||
|
||||
针对LLC不命中率高的问题,我们需要衡量一下问题的严重程度。在Linux系统里,可以用Perf这个工具来测量LLC的不命中率(在[第15讲](https://time.geekbang.org/column/article/183357)中提到过)。
|
||||
|
||||
那么Perf工具是怎么工作的呢?
|
||||
|
||||
它是在内部使用性能监视单元,也就是PMU(Performance Monitoring Units)硬件,来收集各种相关CPU硬件事件的数据(例如缓存访问和缓存未命中),并且不会给系统带来太大开销。 这里需要你注意的是,PMU硬件是针对每种处理器特别实现的,所以支持的事件集合以及具体事件原理,在处理器之间可能有所不同。
|
||||
|
||||
PMU尤其可以监测LLC相关的指标数据,比如LLC读写计数、LLC不命中计数、LLC预先提取计数等指标。具体用Perf来测量LLC各种计数的命令格式是:
|
||||
|
||||
```
|
||||
perf stat -e LLC-loads,LLC-load-misses,LLC-stores,LLC-store-misses
|
||||
|
||||
```
|
||||
|
||||
下图显示的是一次Perf执行结果。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/15/1e/15211eb7e1e6da1d46b66e7cebdf1e1e.png" alt="">
|
||||
|
||||
我们可以看到,在这段取样时间内,有1951M(19.51亿)次LLC的读取,大约16%是不命中。有313M(3.13亿)次LLC的写入,差不多24%是不命中。
|
||||
|
||||
## 如何降低LLC的不命中率?
|
||||
|
||||
那么如何降低LLC的不命中率,也就是提高它的命中率呢?根据具体的问题,至少有三个解决方案。而且,这三个方案也不是互相排斥的,完全可以同时使用。
|
||||
|
||||
第一个方案,也是最直白的方案,就是**缩小数据结构**,让数据变得紧凑。
|
||||
|
||||
这样做的道理很简单,对一个系统而言,所有的缓存大小,包括最后一级缓存LLC,都是固定的。如果每个数据变小,各级缓存自然就可以缓存更多条数据,也就可以提高缓存的命中率。这个方案很容易理解。
|
||||
|
||||
举个例子,开源的C++ [Folly库](https://github.com/facebook/folly/tree/master/folly)里面有很多类,比如F14ValueMap,就比一般的标准库实现小很多,从而占用比较少的内存;采用它的话,自然缓存的命中率就比较高。
|
||||
|
||||
第二个方案,是**用软件方式来预取数据**。
|
||||
|
||||
这个方案也就是通过合理预测,把以后可能要读取的数据提前取出,放到缓存里面,这样就可以减少缓存不命中率。“用软件方式来预取数据”理论上也算是一种“**用空间来换时间**”的策略(参见[第20讲](https://time.geekbang.org/column/article/187540)),因为付出的代价是占用了缓存空间。当然,这个预测的结果可能会不正确。
|
||||
|
||||
第三个方案,是具体为了解决一种特殊问题:就是伪共享缓存。伪共享缓存这个问题,我会在后面详细讲到。这个方案也算是一种“**空间换时间**”的策略,是通过让每个数据结构变大,牺牲一点存储空间,来解决伪共享缓存的问题。
|
||||
|
||||
除了最直白的缩小数据结构,另外两个解决方案(用软件方式来预取数据、去除伪共享缓存)都需要着重探讨。
|
||||
|
||||
### 软件提前预取指令
|
||||
|
||||
我们先展开讨论一下第二种方案,也就是用软件提前预取指令。
|
||||
|
||||
现代CPU其实一般都有**硬件指令**和**数据预取**功能,也就是根据程序的运行状态进行预测,并提前把指令和数据预取到缓存中。这种硬件预测针对连续性的内存访问特别有效。
|
||||
|
||||
但是在相当多的情况下,程序对内存的访问模式是随机、不规则的,也就是不连续的。硬件预取器对于这种随机的访问模式,根本无法做出正确的预测,这就需要使用**软件预取**。
|
||||
|
||||
软件预取就是这样一种预取到缓存中的技术,以便及时提供给CPU,减少CPU停顿,从而降低缓存的不命中率,也就提高了CPU的使用效率。
|
||||
|
||||
现代CPU都提供相应的预取指令,具体来讲,Windows下可以使用VC++提供的_mm_prefetch函数,Linux下可以使用GCC提供的__builtin_prefetch函数。GCC提供了这样的接口,允许开发人员向编译器提供提示,从而帮助GCC为底层的编译处理器产生预取指令。这种策略在硬件预取不能正确、及时地预取数据时,极为有用。
|
||||
|
||||
但是软件预取也是有代价的。
|
||||
|
||||
一是预取的操作本身也是一种CPU指令,执行它就会占用CPU的周期。更重要的是,预取的内存数据总是会占用缓存空间。因为缓存空间很有限,这样可能会踢出其他的缓存的内容,从而造成被踢出内容的缓存不命中。如果预取的数据没有及时被用到,或者带来的好处不大,甚至小于带来的踢出其他缓存相对应的代价,那么软件预取就不会提升性能。
|
||||
|
||||
我自己在这方面的实践经验,有这么几条:
|
||||
|
||||
1. 软件预取最好只针对绝对必要的情况,就是对会实际严重导致CPU停顿的数据进行预取。
|
||||
1. 对于很长的循环(就是循环次数比较多),尽量提前预取后面的两到三个循环所需要的数据。
|
||||
1. 而对于短些的循环(循环次数比较少),可以试试在进入循环之前,就把数据提前预取到。
|
||||
|
||||
### 去除伪共享缓存
|
||||
|
||||
好了,我们接着来讨论第三个方案:去除伪共享缓存。
|
||||
|
||||
什么是伪共享缓存呢?
|
||||
|
||||
我们都知道,内存缓存系统中,一般是以缓存行(Cache Line)为单位存储的。最常见的缓存行大小是64个字节。现代CPU为了保证缓存相对于内存的一致性,必须实时监测每个核对缓存相对应的内存位置的修改。如果不同核所对应的缓存,其实是对应内存的同一个位置,那么对于这些缓存位置的修改,就必须轮流有序地执行,以保证内存一致性。
|
||||
|
||||
但是,这将导致核与核之间产生竞争关系,因为一个核对内存的修改,将导致另外的核在该处内存上的缓存失效。在多线程的场景下就会导致这样的问题。当多线程修改看似互相独立的变量时,如果这些变量共享同一个缓存行,就会在无意中影响彼此的性能,这就是**伪共享**。
|
||||
|
||||
你可以参考下面这张Intel公司提供的图,两个线程运行在不同的核上,每个核都有自己单独的缓存,并且两个线程访问同一个缓存行。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/42/c9/42be053ba1c46fece881b97b1f328ac9.png" alt="">
|
||||
|
||||
如果线程0修改了缓存行的一部分,比如一个字节,那么为了保证缓存一致性,这个核上的整个缓存行的64字节,都必须写回到内存;这就导致其他核的对应缓存行失效。其他核的缓存就必须从内存读取最新的缓存行数据。这就造成了其他线程(比如线程1)相对较大的停顿。
|
||||
|
||||
这个问题就是**伪共享缓存**。之所以称为“伪共享”,是因为,单单从程序代码上看,好像线程间没有冲突,可以完美共享内存,所以看不出什么问题。由于这种冲突性共享导致的问题不是程序本意,而是由于底层缓存按块存取和缓存一致性的机制导致的,所以才称为“伪共享”。
|
||||
|
||||
我工作中也观察到好多次这样的伪共享缓存问题。经常会有产品组来找我们,说他们的产品吞吐量上不去,后来发现就是这方面的问题。所以,我们开发程序时,不同线程的数据要尽量放到不同的缓存行,避免多线程同时频繁地修改同一个缓存行。
|
||||
|
||||
举个具体例子,假如我们要写一个多线程的程序来做分布式的统计工作,为了避免线程对于同一个变量的竞争,我们一般会定义一个数组,让每个线程修改其中一个元素。当需要总体统计信息时,再将所有元素相加得到结果。
|
||||
|
||||
但是,如果这个数组的元素是整数,因为一个整数只占用几个字节,那么一个64字节的缓存行会包含多个整数,就会导致几个线程共享一个缓存行,产生“伪共享”问题。
|
||||
|
||||
这个问题的解决方案,是**让每个元素单独占用一个缓存行**,比如64字节,也就是按缓存行的大小来对齐(Cache Line Alignment)。具体方法怎么实现呢?其实就是插入一些无用的字节(Padding)。这样的好处,是多个线程可以修改各自的元素和对应的缓存行,不会存在缓存行竞争,也就避免了“伪共享”问题。
|
||||
|
||||
## 总结
|
||||
|
||||
这一讲,我们介绍了CPU方面的优化案例,重点讨论了**如何降低LLC的缓存不命中率**。我们提出了三个方案,分别是紧凑化数据、软件指令预取和去除伪共享缓存。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fc/ba/fc86555b197673e40096980571af44ba.png" alt="">
|
||||
|
||||
尤其是第三个方案解决的伪共享缓存问题,对大多数程序员和运维人员而言,不太容易理解。为什么难理解?是因为它牵扯了软件(比如多线程)和硬件(比如缓存一致性和缓存行的大小)的交互。
|
||||
|
||||
当多线程共用同一个缓存行,并且各自频繁访问时,会导致严重的称为“伪共享”的性能问题。这种问题,恰如清代词人朱彝尊的两句词,“共眠一舸听秋雨,小簟轻衾各自寒”。所以需要我们狠狠心,把它们强行分开;“棒打鸳鸯”,让它们“大难临头各自飞”,其实呢,是为了它们都好。
|
||||
|
||||
## 思考题
|
||||
|
||||
硬件指令预取的基本原理是什么?为什么有时候非常有效,但有时候会有害呢?分别试举出一个具体的模块开发中的例子,来说明为什么有效和有害。
|
||||
|
||||
欢迎你在留言区分享自己的思考,与我和其他同学一起讨论,也欢迎你把文章分享给自己的朋友。
|
155
极客时间专栏/性能工程高手课/性能优化/22 | 系统案例:如何提高iTLB(指令地址映射)的命中率?.md
Normal file
155
极客时间专栏/性能工程高手课/性能优化/22 | 系统案例:如何提高iTLB(指令地址映射)的命中率?.md
Normal file
@@ -0,0 +1,155 @@
|
||||
<audio id="audio" title="22 | 系统案例:如何提高iTLB(指令地址映射)的命中率?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/81/80/813b746df3bd4337203baf624fe26a80.mp3"></audio>
|
||||
|
||||
你好,我是庄振运。
|
||||
|
||||
我们今天继续探讨性能优化的实践,介绍一个系统方面的优化案例。这个案例涉及好几个方面,包括CPU的使用效率、地址映射、运维部署等。
|
||||
|
||||
开发项目时,当程序开发完成后,生成的二进制程序需要部署到服务器上并运行。运行这个程序时,我们会不断衡量各种性能指标。而生产实践中,我们经常发现一个问题:是指令地址映射的不命中率太高(High iTLB miss rate),导致程序运行不够快。我们今天就探讨这个问题。
|
||||
|
||||
在我过去的生产实践中,针对这一问题,曾经采取的一个行之有效的解决方案,就是同时进行**二进制程序的编译优化**和**采用大页面的部署优化**。我下面就详细地分享这两个优化策略,并介绍如何在公司生产环境中,把这两个策略进行无缝整合。
|
||||
|
||||
## 为什么要关注指令地址映射的不命中率?
|
||||
|
||||
我们先来看看**为什么需要关注iTLB的命中率**。
|
||||
|
||||
在以往从事的性能工作实践中,我观察到CPU资源是最常见的性能瓶颈之一,因此提高CPU性能,一直是许多性能工作的重点。
|
||||
|
||||
导致CPU性能不高的原因有很多,其中有一种原因就是**较高的iTLB不命中率**。这里的iTLB就是Instruction Translation Lookaside Buffer,也就是**指令转换后备缓冲区**。iTLB命中率不高,就会导致CPU无法高效运行。
|
||||
|
||||
那么TLB(转换后备缓冲区)又起到了什么作用呢?
|
||||
|
||||
我们知道,在虚拟内存管理中,内核需要维护一个地址映射表,将虚拟内存地址映射到实际的物理地址,对于每个内存里的页面操作,内核都需要加载相关的地址映射。在x86计算体系结构中,是用内存的页表(page table),来存储虚拟内存和物理内存之间的内存映射的。但是,内存页表的访问,相对于CPU的运算速度,那是远远不够快的。
|
||||
|
||||
所以,为了能进行快速的虚拟到物理地址转换,TLB(转换后备缓冲区)这种专门的硬件就被发明出来了,它可以作为内存页表的缓存。TLB有两种:**数据TLB**(Data)和**指令TLB**(Instruction),也就是iTLB和dTLB;因为处理器的大小限制,这两者的大小,也就是条目数,都不是很大,只能存储为数不多的地址条目。
|
||||
|
||||
为什么说TLB的命中率很重要呢?
|
||||
|
||||
这是因为,内存页表的访问延迟比TLB高得多;因此命中TLB的地址转换,比未命中TLB也就快得多。因为CPU无时无刻不在执行指令,所以iTLB的性能尤其关键。iTLB未命中率,是衡量因iTLB未命中而导致的性能损失的度量标准。当iTLB未命中率很高时,CPU将花费大量周期来处理未命中,这就导致指令执行速度变慢。
|
||||
|
||||
具体来讲,iTLB命中和不命中之间的访问延迟,差异可能是10到100倍。命中的话,仅需要1个时钟周期,而不命中,就需要10-100个时钟周期,因此iTLB不命中的代价是极高的。
|
||||
|
||||
我们可以用具体的数据来感受一下。假设这两种情况分别需要1和60个时钟周期,未命中率为1%,将导致平均访问延迟为1.59个周期,相比全部命中的情况(即1个周期)的访问延迟,足足高出59%。
|
||||
|
||||
## 如何提高指令地址映射的命中率?
|
||||
|
||||
对于iTLB命中率不高的系统,如果能提高命中率,可以大大提高CPU性能并加快服务运行时间。我们在生产过程中实践过两种方案,下面分别介绍。
|
||||
|
||||
第一种方案,是优化软件的二进制文件来减少iTLB不命中率。
|
||||
|
||||
一般而言,根据编译源代码的不同阶段(即编译、链接、链接后等阶段),分别存在三种优化方法。这样的例子包括优化编译器选项,来对函数进行重新排序,以便将经常调用的所谓“热函数”放置在一起,或者使用FDO(Feedback-Directed Optimization,就是基于反馈的优化)来减少代码区域的大小。
|
||||
|
||||
FDO是什么呢?简单来说,就是把一个程序放在生产环境中运行,剖析真实的生产数据,并且用这些信息来对这个程序进行精准地优化。比如,可以确切地知道在生产环境中,每个函数的调用频率。
|
||||
|
||||
那么要如何进行二进制的优化呢?
|
||||
|
||||
我们可以通过编译优化来将频繁被访问的指令汇总到一起,放在二进制文件中的同一个地方,以提高空间局部性,这样就可以提高iTLB命中。这块放置频繁访问指令的区域,就叫**热区域**(Hot Text)。
|
||||
|
||||
在热区域的指令,它们的提取和预取会更快地完成。还记得我们在[第4讲](https://time.geekbang.org/column/article/174462)学过的**帕累托法则**吗?根据对许多服务的研究,帕累托法则在这里依然适用。
|
||||
|
||||
通常情况下,有超过80%的代码是“冷的指令”,其余的是“热指令”。通过将热指令与冷指令分开,昂贵的微体系结构资源(比如iTLB和缓存)就可以更有效地处理二进制文件的热区域,从而提高系统性能。
|
||||
|
||||
具体的二进制优化过程,包括以下三个大体步骤:
|
||||
|
||||
首先,是通过分析正在运行的二进制文件来识别热指令。我们可以用Linux的perf工具来达成此目的。你有两种方法来进行识别:可以使用堆栈跟踪,也可以使用[LBR](https://lwn.net/Articles/680985/)(Last Branch Record,最后分支记录)。LBR比较适宜,是因为它的好处是能提高数据质量,并减少数据占用量。
|
||||
|
||||
其次,根据函数的访问频率,对配置文件函数进行排序。我们可以使用名为[HFSort](https://github.com/facebook/hhvm/tree/master/hphp/tools/hfsort)的工具,来为热函数创建优化表单。
|
||||
|
||||
最后,链接器脚本将根据访问顺序,优化二进制文件中的函数布局。
|
||||
|
||||
这些步骤执行完毕后的结果就是一个优化的二进制文件。我说明一下,如果这里面提到的工具你没有用过,也没有关系。这里知道大体原理就行了,当你真正用到的时候,可以再仔细去研究。
|
||||
|
||||
第二种方案就是采用大页面。什么是大页面呢?
|
||||
|
||||
现代计算机系统,除了传统的4KB页面大小之外,通常还支持更大的页面大小,比如x86_64上分别为2MB和1GB。这两种页面都称为大页面。使用较大的页面好处是,减少了覆盖二进制文件的工作集所需的TLB条目数,从而用较少的页面表就可以覆盖所有用到的地址,也就相应地降低了采用页面表地址转换的成本。
|
||||
|
||||
在Linux上,有两种获取大页面的方法:
|
||||
|
||||
1. 手工:预先为应用程序预留大页面;
|
||||
1. 自动:使用透明大页面,也就是[THP](https://www.kernel.org/doc/Documentation/vm/transhuge.txt)(Transparent Huge Pages)。
|
||||
|
||||
THP,就像名字一样,是由操作系统来自动管理大页面,不需要用户去预留大页面。THP的显著优点是**不需要对应用程序做任何更改**;但是也有缺点,就是**不能保证大页面的可用性**。预留大页面的方式,则需要在启动内核时应用配置。假如我们想保留64个大页面,每个2MB,就用下面的配置。
|
||||
|
||||
```
|
||||
hugepagesz = 2MB, hugepages = 64
|
||||
|
||||
```
|
||||
|
||||
我们在服务器上运行程序时,需要将相应的二进制文件加载到内存中。二进制文件由一组函数指令组成,它们共同位于二进制文件的文本段中,每个页面都尝试占用一个iTLB条目来进行虚拟到物理页面的转换。
|
||||
|
||||
如果内存页(比如4KB)很小,那么对于一定大小的程序,需要加载的内存页就会较多,内核会加载更多的映射表条目,而这会降低性能。通常在执行过程中,我们使用4KB的普通页面。如果使用“大内存页”,页面变大了(比如2MB是4KB的512倍),自然所需要的页数就变少了,也就大大减少了由内核加载的映射表的数量。这样就提高了内核级别的性能,最终提升了应用程序的性能。这就是大页面为什么会被引入的原因。
|
||||
|
||||
由于服务器通常只有数量有限的iTLB条目,如果文本段太大,大于iTLB条目可以覆盖的范围,则会发生iTLB不命中。
|
||||
|
||||
例如,[Intel HasWell](https://en.wikipedia.org/wiki/Haswell_(microarchitecture))架构中4KB页面有128个条目,每个条目覆盖4KB,总共只能覆盖512KB大小的文本段。如果应用程序大于512KB,就会有iTLB不命中,从而需要去访问内存的地址映射表,这就比较慢了。iTLB未命中的处理是计入CPU使用时间的,所以等待访问内存地址映射的过程,就实际上浪费了CPU时间。
|
||||
|
||||
我们提出的第二个方案,就是使用大页面来装载程序的热文本区域。通过在大页面上放置热文本,可以进一步提升iTLB命中率。使用大页面iTLB条目时,单个TLB条目覆盖的代码是标准4K页面的512倍。
|
||||
|
||||
更重要的是,当代的CPU体系结构,通常为大页面提供一些单独的TLB条目,如果我们不使用大页面,这些条目将处于空闲状态。所以,通过使用大页面,也可以充分利用那些TLB条目。
|
||||
|
||||
## 如何获得最佳优化结果?
|
||||
|
||||
我们总共提出了两个方案,就是**采用热文本**和**采用大页面放置**。这两个其实是互补的优化方案,它们可以独立工作,也可以整合起来一起作用,这样可以获得最佳的优化结果。
|
||||
|
||||
采用热文本和大页面放置的传统方法需要多个步骤,比如在链接阶段,将源代码和配置文件数据混合在一起,并进行各种手动配置和刷新,这就导致整个过程非常复杂。这样的整合方案也就很难广泛应用到所有的系统中。
|
||||
|
||||
我们在生产中构建了一个流程,来自动化整个过程。这样,该解决方案就成为能被几乎所有服务简单采用的方案,而且几乎是免维护的解决方案。这个解决方案的流程图如下,整个系统包含三大模块:程序剖析(Profiling)、编译链接(Linking)和加载部署(loading)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/50/f4/50307c9f2a002e2063a632c8dddf13f4.png" alt="">
|
||||
|
||||
### 程序剖析
|
||||
|
||||
剖析模块显示在图的顶部。这个模块定期,比如每周执行一次数据收集作业,以剖析测量正在运行的服务。
|
||||
|
||||
Dataswarm是我们曾经采用的数据收集框架,这是Facebook自己开发和使用的数据存储和处理的解决方案。这个作业剖析了服务的运行信息(例如,热函数),并且对配置文件进行控制以使其开销很小。最后,它会把分析好的数据发送到名为[Everstore](https://www.usenix.org/system/files/conference/osdi14/osdi14-paper-muralidhar.pdf)的永久存储,其实这是一个基于磁盘的存储服务。
|
||||
|
||||
### 编译链接
|
||||
|
||||
在构建服务包时,链接程序脚本会从Everstore检索已配置的热函数,并根据配置文件,对二进制文件中的功能进行重新排序。这个模块的运行结果就是已经优化的二进制程序。
|
||||
|
||||
### 加载部署
|
||||
|
||||
加载服务二进制文件时,操作系统会尽最大努力,在大页面上放置热文本。如果没有可用的大页面,则放在常规内存页面上。
|
||||
|
||||
## 生产环境的性能提升
|
||||
|
||||
这样的解决方案实际效果如何呢?
|
||||
|
||||
我们曾经在Facebook的生产环境中广泛地采用这一优化策略。通过几十个互联网服务观察和测量,我们发现,应用程序和服务器系统的性能都得到了不错的提升,应用程序的吞吐量差不多提高了15%,服务等待时间减少了20%。
|
||||
|
||||
我们也观察了系统级别的指标。系统级别的指标,我们一般考虑主机cpu使用情况和iTLB不命中率。 iTLB的不命中率几乎降低了一半,CPU使用率降低了5%到10%。我们还估计,在这里面约有一半的CPU使用率降低是来自热文本,另一半来自大页面。
|
||||
|
||||
为了帮你更好的认识和体会性能的提升,我下面展示一个具体的互联网服务,在采用这个解决方案后的性能对比。
|
||||
|
||||
这张图显示了iTLB不命中率的变化。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6b/96/6bb6e77b54a6d91b6ace387309fa5e96.png" alt="">
|
||||
|
||||
在应用该解决方案之前,iTLB不命中率在峰值期间高达每百万条指令800个,如蓝色线表示。采用我们的解决方案优化部署后,iTLB不命中率几乎下降了一半。具体而言,在峰值期间,最高的iTLB不命中率降低为每百万条指令425次,如黄线表示,相对优化以前下降了49%,差不多是一半。
|
||||
|
||||
应用程序级别指标,显示在下图中。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/26/ce/26ff2cf5472f3fef27503107ad3161ce.png" alt="">
|
||||
|
||||
蓝色曲线为优化前,黄色曲线为优化后。我们可以看到,应用程序请求查询延迟的中位数(P50)下降最多25%,P90百分位数下降最多10%,P99下降最多60%。
|
||||
|
||||
应用程序吞吐量(QPS)如下图所示,优化后增长了15%,也就是说,峰值吞吐量从3.6万QPS增加到了4.1万QPS。你可以看到,应用程序级别的吞吐量和访问延迟都得到了改善。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c0/c3/c09f61e1ee303ac2ff01c30cfc3542c3.png" alt="">
|
||||
|
||||
## 总结
|
||||
|
||||
这一讲我们讨论了如何有效地降低指令地址映射的不命中率太高的问题。
|
||||
|
||||
完整的解决方案包括两部分:**编译优化**和**部署优化**。对编译的优化,是进行指令级别的划分,把经常访问的指令放在一起,形成Hot Text区域。对程序部署的优化,是采用大页面。这两个部分在真正的生产环境中可以一起使用。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e8/79/e8286137049016140a5433d5cdbf0979.png" alt="">
|
||||
|
||||
唐代的诗人张籍曾经鼓励一个出身寒门的朋友说:“越女新妆出镜心,自知明艳更沉吟。齐纨未足时人贵,一曲菱歌敌万金”。最后两句的意思是,大家追求的畅销东西,比如齐国的珍贵丝绸,恰如社会上横流的物欲,虽然贵重,但是见得多了也就不足为奇了。倒是平时不流行的东西,比如一首好听的采菱歌曲,更值得人称道看重。
|
||||
|
||||
操作系统的内存页面管理也有类似的道理,虽然普通的4KB页面容易管理,几乎每个程序都在用,大家已经习以为常;但是在某些部署场景下,大页面的使用会让系统性能大增,颇有点惊艳的效果。
|
||||
|
||||
## 思考题
|
||||
|
||||
Linux操作系统中,一个常用的大页面,相当于多少个普通页面的大小?相对于普通页面,大页面有哪些优点,又有哪些缺点呢?
|
||||
|
||||
欢迎你在留言区分享自己的思考,与我和其他同学一起讨论,也欢迎你把文章分享给自己的朋友。
|
120
极客时间专栏/性能工程高手课/性能优化/23 | 存储案例:如何降低SSD峰值延迟?.md
Normal file
120
极客时间专栏/性能工程高手课/性能优化/23 | 存储案例:如何降低SSD峰值延迟?.md
Normal file
@@ -0,0 +1,120 @@
|
||||
<audio id="audio" title="23 | 存储案例:如何降低SSD峰值延迟?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/68/19/68e9fe050a1a60c1cbeaf1a002630a19.mp3"></audio>
|
||||
|
||||
你好,我是庄振运。
|
||||
|
||||
我们之前讲过,存储系统的性能很关键(参见[第17讲](https://time.geekbang.org/column/article/185154))。我们这一讲就探讨存储方面的优化案例,是关于SSD性能的。
|
||||
|
||||
现在很多公司里面的高性能存储系统,一般都是基于SSD的,这主要归功于SSD价格在近几年的大幅度下降。但是,SSD也不是包治百病的灵丹妙药,也有自己的特殊性能问题。我们今天就重点讲述两点:**SSD的损耗**和**IO访问延迟偶尔过大**的问题。
|
||||
|
||||
这里的第二个问题可能听起来很让人吃惊:不是说SSD延迟很低吗?
|
||||
|
||||
一般情况下,是的。但是特殊情况下就不一定了,这个就说来话长了,它和SSD的内部原理有关。我们会一步步地探讨问题形成的原因和解决的策略。
|
||||
|
||||
## SSD为什么会损耗?
|
||||
|
||||
我们的[第17讲](https://time.geekbang.org/column/article/185154)是关于存储系统的,讲过SSD的工作原理和性能。为了防止你忘记,我们就在这里快速地回顾一下其中的一个重要概念:**写入放大**。
|
||||
|
||||
什么是写入放大呢?当写入SSD的物理数据量,大于应用程序打算写入SSD的逻辑数据量时,就会造成“写入放大”。
|
||||
|
||||
如果是传统硬盘HDD,就不会有写入放大的问题。那么SSD为什么会有写入放大呢?这是因为SSD内部的工作原理和硬盘很不一样。
|
||||
|
||||
我们知道,HDD是可以直接写入覆盖的。和HDD不同,SSD里面的页面只能写入一次,要重写的话,必须先回收擦除,而且只能在“块”这个级别进行擦除。因此呢,SSD内部就需要不断地移动所存储的数据,来清空需要回收的块。也就是说,SSD内部需要进行块级别的“垃圾回收”。垃圾收集器必须有效地在SSD内部不断地回收块,回收以前使用的页面空间,然后才能在这个块上写入新数据。
|
||||
|
||||
因此,对SSD的写入需求,比对HDD的写入需求更高。
|
||||
|
||||
写入放大的缺点是什么?就是**会更快地损耗SSD的生命**。
|
||||
|
||||
每个SSD都有固定数量的擦除周期,如果在很短时间内写到SSD太多数据,就会导致SSD损耗太快,有可能过早烧坏SSD。换句话说,很高的写入速率,可能会导致SSD在到达其预期使用寿命之前就发生故障。
|
||||
|
||||
所以,我们要注意一个常用的指标叫:年损耗率(Burn Rate)。这个指标是怎么定义的呢?是用SSD的预期寿命推导出来的。比如一个SSD预期寿命是4年。那么每年可以损耗25%,这就是年损耗率。
|
||||
|
||||
## 如何减少SSD损耗?
|
||||
|
||||
前面讲的“写入放大”,其实也可以用一个相应的具体指标来衡量,就是“**写入放大系数**”;它代表物理写入SSD的数据与应用程序写入的逻辑数据之比。比如,如果写入放大系数是2,就表示写入每10KB的逻辑数据,SSD实际上写了20KB。为了控制SSD的年损耗率,我们需要尽量降低写入放大系数。
|
||||
|
||||
那么如何减少写入放大系数呢?常见的方法有两种:
|
||||
|
||||
1. 是**保留一定的空闲存储空间**,这是因为写入放大系数是和SSD存储空闲率相关的。
|
||||
1. 是**使用Trim**。
|
||||
|
||||
这两种方法可以同时使用,我们下面分别介绍。
|
||||
|
||||
我们先简单说一下第一种方法。每个SSD都有一定数量的预留空间,这个空间不是SSD可用容量的一部分。这样做是有原因的。尽管我们可以使用工具来调整SSD卡上的可用容量,但是我不建议你减少预配置的可用空间,因为这将降低写入性能,并可能大大缩减SSD的使用寿命。
|
||||
|
||||
我们在存储数据到SSD时候,也不要存得太满,也就是不要追求太高的空间使用率。那么我们将SSD可用存储容量的使用率目标定为多少比较合适呢?一般来说,我们可以定为80%至85%,以保持较低的写入放大率。
|
||||
|
||||
**SSD的空闲可用空间越多,内部垃圾收集的开销就越低,就越有可能降低写入放大系数。**但是这种关系不是线性的,所以存在着收益递减的问题。
|
||||
|
||||
第二种方法是用Trim。我首先为你讲解一下什么是Trim。
|
||||
|
||||
Trim是个命令,是操作系统发给SSD控制器的特殊命令。使用Trim命令,操作系统可以通知SSD某些页面存储的数据不再有效了。比如,对于文件删除操作,操作系统会将文件的扇区标记为空闲,以容纳新数据,然后就可以将Trim命令发送到SSD。
|
||||
|
||||
Trim命令有什么好处呢?
|
||||
|
||||
SSD收到Trim命令后,SSD内部的控制器会更新其内部数据页面地图,以便在写入新数据时不去保留无效页面。并且,在垃圾回收期间不会复制无效页面,这样就实现了更有效的垃圾收集,也就减少了写操作和写入放大系数,同时获得了更高的写吞吐量,延长了驱动器的使用寿命。
|
||||
|
||||
Trim命令和机制虽然看起来很美好,但是实际中会产生一些问题。原因在于,不同的SSD厂商对Trim命令的处理方式,以及具体的垃圾回收机制很不一样;有的实现还不错,有的就差强人意了,因此Trim的性能在每种SSD那里会有所不同。我们后面会提到,有些SSD的厂商的某些SSD,因为对Trim的支持不太好,会造成某些情况下性能非常差。
|
||||
|
||||
还要注意的是,默认情况下,操作系统一般不启用Trim。因此,当文件系统删除文件时,它只是将数据块标记为“未使用”。但是SSD控制器并不知道设备上的哪些页面可用,因此无法真正释放设备上的无效空间。所以,在没有启动Trim的情况下,一旦SSD设备的可用容量填满,即使文件系统知道设备上有可用容量,SSD也会认为它自己已经存满。
|
||||
|
||||
那么怎么启动Trim呢?要在SSD上启用连续Trim,必须在mount这块SSD的时候使用“Discard”安装选项。如果一块SSD已经安装了,想启动Trim,那就需要卸载后重新安装,“Discard”选项才能生效。也就是说,使用remount命令是不起作用的。
|
||||
|
||||
所以,对于单个系统而言,最好在grub中启用mount选项,并重新启动。
|
||||
|
||||
## 想减少SSD损耗,却导致访问延迟过大?
|
||||
|
||||
Trim的使用,虽然带来了**降低SSD损耗**的好处,但也带来了一些坏处,特别是**IO访问可能延迟加大**的问题。
|
||||
|
||||
为什么Trim会影响应用程序性能呢?
|
||||
|
||||
原因和SSD内部的实际机制有关。每个SSD内部都有一个FTL(Flash Translation Layer)映射表,该表将操作系统的逻辑块地址(LBA,Logical Block Address)映射到SSD上的物理页面地址(PPA,Physical Page Address)。映射表在驱动器被写入时不断更新,以后每个读取和写入IO都要引用。
|
||||
|
||||
一般来说,映射表是存储在SSD驱动器的RAM中,以便快速访问;但是它的副本也存储在SSD中,目的是在电源故障时能够保留LBA到PPA的映射。随着SSD上面内容和数据的不断变化,这些变化包括新写入IO或垃圾回收,RAM中的映射表也不断更新,并且持续写入SSD中。
|
||||
|
||||
如果在文件系统上启用了Discard选项,那么每次删除文件时,都会生成Trim命令。因为每次Trim都会更改映射表,所以对映射表的更改也就实际地记录到SSD中。这项操作可能需要花费比较长的时间,比如几毫秒的时间才能完成,在这个更改过程中,普通的数据读取和写入I /O会阻塞,并且阻塞到所有的映射表调整都被完全处理为止。当今业界的大多数SSD都是这样工作的。
|
||||
|
||||
上面我们看到,由于Trim的处理会阻塞普通的数据读取和写入I /O,直到Trim完成映射表记录才返回,所以Trim的延迟对普通读写I O的延迟具有重大影响,尤其对高分位数(比如P99、P99.9)的读写IO延迟影响更大。减少Trim延迟就是减少IO延迟。所以,我们需要尽量减少Trim的等待处理时间。
|
||||
|
||||
另外值得你注意的是,每个SSD厂商和每款SSD,对Trim的具体处理方式都可能不同,颇有些厂商的某些SSD具有严重的问题。我们生产实践中碰到过好几种这样的SSD,比如有厂商的一种SSD在大量删除数据时有很大的延迟。这就要求我们在选购SSD时候,要特别小心,尤其是要做彻底的性能测试。
|
||||
|
||||
## 如何避免Trim带来的延迟?
|
||||
|
||||
我们刚才讲了用Trim的好处和坏处。好处是可以减少SSD的损耗,延长SSD的寿命;坏处是会造成应用程序的IO读写延迟变大。
|
||||
|
||||
那么怎么才能尽量避免Trim带来的坏处呢?我们这里谈两种方式:一是对Discard选项本身的调优,二是使用[fstrim](http://man7.org/linux/man-pages/man8/fstrim.8.html)命令。这两种方式分别对应使用Discard被启用和不被启用的两种情况。
|
||||
|
||||
第一种方式是在启用了Discard后,对Discard的调优。对于已经启用Discard的场景下,Trim命令默认是没有大小限制,也就是说,一次发送会尽可能多的删除命令。但是如果一次删除的数据太多,SSD可能需要很长的时间才能返回,其他读写IO就会感受到很大的延迟。
|
||||
|
||||
那么我们就可以微调了,这里我们就可以借助另外一个参数,discard_max_bytes对Discard进行调优。这个参数是一个操作系统内核参数,从名字也听得出,它可以指定一次Trim的最大数据量。
|
||||
|
||||
调整这个参数的优点,是可以根据实际可接受IO延迟的需要,来随意微调。举个例子,假如可接受IO延迟比较大,那就可以设置一个较大的discard_max_byes数值,比如2GB。使用这个参数的坏处是,当有大文件删除时,如果没有相应的重新调整参数,Trim的吞吐量会受影响。
|
||||
|
||||
第二种方式,是在没有启用Discard的场景下,采用fstrim来调优。fstrim也是一个命令,它可以控制Trim,来删除掉SSD上的文件系统不再使用的数据。默认情况下,fstrim将删除文件系统中所有未使用的块,但是这个命令有其他的选项,根据删除范围或大小来进行微调。
|
||||
|
||||
这个命令一般用于Discard没有被启用的场景下。为了达到最好的效果,都是周期性的,或者采用外部事件触发来运行这个命令,比如用Cron来每天固定时间运行;或者每当SSD存储使用率到了某个大小就运行。
|
||||
|
||||
下图展示了一个实际生产环境中的性能数据。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/00/76/0032afc607be8d57a6b049bd541a6576.png" alt="">
|
||||
|
||||
这是一个采用fstrim而降低IO延迟的例子。横轴是时间,纵轴是对SSD进行读操作的IO延迟。红色箭头是运行fstrim的时间。我们可以看到,在fstrim后,IO延迟大幅度地降低了。
|
||||
|
||||
采用fstrim这个方式的优点是,可以根据实际需要来决定何时运行,并且更好地微调和控制Trim的工作。
|
||||
|
||||
这个方式也有缺点,就是如果不够小心,运行这个命令时可能导致很长时间的SSD读写挂起阻塞,在这个阻塞的过程中,SSD完全没有响应,不能读写。我见过几次这样的生产例子,阻塞了好几分钟甚至几个小时的时间,整个SSD完全不能写入和读取数据。
|
||||
|
||||
## 总结
|
||||
|
||||
SSD不断地重写会损坏其存储能力,就如同一口宝刀,不断地征战砍伐后,也会有缺口。这让我想起了唐代诗人马戴写的一首气势磅礴的《出塞词》:“金带连环束战袍,马头冲雪度临洮。卷旗夜劫单于帐,乱斫胡兵缺宝刀。”
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/97/b0/973542a2647ce99fb76e06a2416560b0.png" alt="">
|
||||
|
||||
为了延长SSD的寿命,我们可以采用Trim方式,以去除不必要的内部重写。
|
||||
|
||||
但是这种方式在某些特殊情况下,会增大外部IO的访问延迟。解决这一问题的方法是对Trim进行调优。我们这一讲就集中探讨了几种调优解决方案,来解决这一特殊情况下的问题。
|
||||
|
||||
## 思考题
|
||||
|
||||
你们公司部署SSD了吗?有没有遇到关于损耗过大的问题,以及Trim的问题和相关讨论?最后采取的解决方案是什么呢?
|
||||
|
||||
欢迎你在留言区分享自己的思考,与我和其他同学一起讨论,也欢迎你把文章分享给自己的朋友。
|
185
极客时间专栏/性能工程高手课/性能优化/24 | 跨层案例:如何优化程序、OS和存储系统的交互?.md
Normal file
185
极客时间专栏/性能工程高手课/性能优化/24 | 跨层案例:如何优化程序、OS和存储系统的交互?.md
Normal file
@@ -0,0 +1,185 @@
|
||||
<audio id="audio" title="24 | 跨层案例:如何优化程序、OS和存储系统的交互?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/03/7f/03d05af82ee01088dd22fb482e10687f.mp3"></audio>
|
||||
|
||||
你好,我是庄振运。
|
||||
|
||||
我们前面几讲介绍了性能优化的原则和策略,并且集中探讨了CPU、内存和存储三个最关键的领域。
|
||||
|
||||
今天我们来讲一个**比较复杂的JVM场景和超大延迟的性能问题**;这是本模块,也就是性能优化模块的最后一讲。
|
||||
|
||||
我们会一步步地探讨这个性能问题的表象、问题的重现、性能分析的过程和解决方案。这个性能问题的复杂性,表现在它牵扯了计算机技术的很多层次——从最上层的应用程序,到中间层JVM的机制,再到操作系统和文件系统的特性,最后还涉及到硬件存储的特点。
|
||||
|
||||
更重要的是,这几个层次互相影响,最后导致了平时我们不容易看到的严重性能问题——非常大的JVM卡顿。
|
||||
|
||||
今天我会把问题的核心和分析过程阐述清楚,而对于其他的一些背景和更多的性能数据,你可以参考我发表在[IEEE Cloud](https://ieeexplore.ieee.org/document/7820334)上的论文。
|
||||
|
||||
## 生产环境下偶尔很大的响应延迟是怎么回事?
|
||||
|
||||
我们先来看看这个性能问题的表象:就是在生产环境中,偶尔会出现非常大的响应延迟。
|
||||
|
||||
由于大多数互联网业务都是面向在线客户的(例如在线游戏和在线聊天),所以,确保客户相应的低延迟非常重要。各种研究也都表明,200毫秒延迟,是多数在线用户可以忍受的最大延迟。因此,确保低于200毫秒(甚至更短)的延迟,已经成为定义的SLA(服务水平协议)的一部分。
|
||||
|
||||
鉴于Java的普及和强大功能,当今的互联网服务中有很大一部分都在运行Java。Java程序的一个问题是JVM卡顿,也就是大家常说的STW(Stop The World)、JVM(Java虚拟机)暂停。根据我的经验,尽管我们或许已经仔细考虑了很多方面来优化,但Java应用程序有时仍会遇到很大的响应延迟。
|
||||
|
||||
这个STW的产生和JVM的运行机制是直接相关的。
|
||||
|
||||
Java应用程序在JVM中运行,使用的内存空间叫**堆**。JVM负责管理应用程序在内存里面的对象。堆空间经常被GC回收(垃圾收集),这个过程是JVM操作的。Java应用程序可能在GC和JVM活动期间停止,这就会给应用程序带来STW暂停。
|
||||
|
||||
这些GC和JVM活动信息很重要,根据启动JVM时提供的JVM选项,各种类型的相关信息,都将记录到GC的日志文件中。
|
||||
|
||||
尽管某些GC引起的STW暂停众所周知(比如JVM导致的Full GC),但是我们在生产中发现,其他因素,比如**操作系统本身,也会导致一些相当大的STW暂停**。
|
||||
|
||||
比如我举个例子。文中的图片就显示了一个STW暂停和GC日志,这个暂停时间超过了11秒;我就经常在生产环境中看到这样的STW暂停。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/98/21/9811fd17d7055b2f25308a36f729b921.png" alt="">
|
||||
|
||||
你注意一下图片中有较大的字体的那行,里面显示了User、Sys和Real的计时,分别对应着用户、系统和实际的计时。比如“Real=11.45”就表示实际的STW暂停是11.45秒钟。
|
||||
|
||||
在这个GC日志中,这种暂停11秒钟的STW非常讨厌,因为这样大的暂停是不能容忍的。而且这个问题很难理解,完全不能用GC期间的应用程序活动和垃圾回收活动来解释。
|
||||
|
||||
从日志中我们也看到,这个JVM的堆并不大,只有4GB,垃圾收集基本不会超过1秒钟。正如图中显示的那样,用户和系统时间都可以忽略不计,User和Sys的暂停时间分别是0.18秒钟和0.01秒钟,但是实际上JVM暂停了11.45秒钟!
|
||||
|
||||
因此,GC所做的工作量,根本无法解释如此之大的暂停值。
|
||||
|
||||
## 搭建测试环境重现问题
|
||||
|
||||
为了搞清这个问题,我们做了彻底的性能分析和各种测试。为了去掉很多其他的干扰因素,以便方便根因分析,我们首先希望**实验室环境中重现该问题**,这样就比较方便从根本上解释原因。
|
||||
|
||||
出于**可控制性**和**可重复性**的考虑,我们使用了自己设计的一个简单Java程序。为了方便对比,我们根据**有没有后台背景IO活动**,而测试了两种场景(这里的后台背景IO就是各种磁盘IO)。不存在后台IO的场景是基准场景,而引入后台IO的另一场景,将重现我们观测到的性能问题。
|
||||
|
||||
我们使用的Java程序的逻辑也很简单直白,就是不断地分配和删除特定大小的对象。程序一直在不断分配对象,当对象数目达到某阈值时,就会删除堆中的对象。堆的大小约为1GB。每次运行固定时间,是5分钟。
|
||||
|
||||
为了真实地模拟生产环境,我们在第二种场景中注入后台IO。 这些IO由bash脚本生成,该脚本就是不断复制很大的文件。在我们的实验室环境中,后台工作模块能够产生每秒150MB的磁盘写入负载,差不多可以使服务器配备的镜像硬盘驱动器饱和。这个应用程序和后台IO脚本的源代码在[GitHub](https://github.com/zhenyun/JavaGCworkload)上开源。
|
||||
|
||||
我们考虑的主要性能指标,是关于应用程序的STW暂停,具体考虑了两个指标:
|
||||
|
||||
1. 总暂停时间,即所有STW暂停的总暂停时间;
|
||||
1. 较大的STW暂停计数和。
|
||||
|
||||
下面让我们一起来看看结果。
|
||||
|
||||
场景A是基准场景,Java程序在没有后台IO负载的情况下运行。我们在实验室环境中执行了许多次运行,得到的结果基本是一致的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9e/c9/9e14b3630deefda85d0b1b4128d15fc9.png" alt="">
|
||||
|
||||
图片中显示的是一个持续5分钟的运行,就是沿着5分钟的时间线,显示了所有JVM STW暂停的时间序列数据。我们观察到,所有暂停都非常小,并且STW暂停都不会超过0.25秒。 STW的总暂停时间约为32.8秒。
|
||||
|
||||
场景B是有后台IO负载情况下运行相同的Java程序。在实际的生产过程中,IO负载可能来自很多地方,比如操作系统、同一机器上的其他应用程序,或者来自同一Java应用程序的各种IO活动。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d3/35/d3a781387eb9ebc20513146a63a4ad35.png" alt="">
|
||||
|
||||
在图片中,我们同样沿着5分钟的时间线,显示了所有JVM STW暂停的时间序列数据。我们发现,当后台IO运行时,相同的Java程序,在短短5分钟的运行中,看到1个STW暂停超过3.6秒,3个暂停超过0.5秒!结果是,STW的总暂停时间为36.8秒,比基准场景多了12%。
|
||||
|
||||
而STW总暂停时间多,也就意味着应用程序的实际工作吞吐量比较低,因为JVM多花了时间在STW暂停上面。
|
||||
|
||||
## 响应延迟的根本原因在哪里?
|
||||
|
||||
为了弄清楚STW暂停的原因,我们接着进行了深入的分析。
|
||||
|
||||
我们发现STW大的暂停是由GC日志记录,write()调用被阻塞导致的。这些write()调用,虽然以缓冲写入模式(即非阻塞IO)发出,但由于操作系统有“回写”IO的机制,所以仍然可能被操作系统的“回写”IO阻塞。
|
||||
|
||||
操作系统的“回写”机制是什么呢?就是文件系统定期地把一些被改变了的磁盘文件,从内存页面写回存储系统。
|
||||
|
||||
具体来说,当缓冲的write()需要写入文件时,它首先需要写入OS缓存中的内存页面。这些内存页是有可能被“回写”的OS缓存机制锁定的;而且当后台IO流量很重时,该机制可能导致这些内存页面被锁定相当长的时间。
|
||||
|
||||
为了彻底查清原因,我们使用Linux下的Strace工具,来剖析函数调用和STW暂停的时间相关性。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5a/4e/5a307738734b433b0d82723c016e9d4e.png" alt="">
|
||||
|
||||
这个图表示的是**JVM STW暂停**,和strace工具报告的**JVM进行write()系统调用的延迟**。图片集中显示了一个1.59秒的JVM STW暂停的快照。
|
||||
|
||||
我们仔细检查了两个时间序列数据,发现尽管JVM的GC日志记录使用缓冲写入,但是GC暂停和write()延迟之间有极大的相关性。
|
||||
|
||||
这些时间序列的相关性表明,由于某些原因,GC日志记录的缓冲写入仍然被阻塞了。
|
||||
|
||||
那这个原因是什么呢?我们就要通过仔细阅读分析JVM GC的日志和Strace的输出日志(如下图所示)来寻找。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/53/f6/53fd4368c2fc468b368f938ca3aaf2f6.png" alt="">
|
||||
|
||||
让我沿着时间轴线来具体解释一下图片中的数据。
|
||||
|
||||
在时间35.04秒时,也就是第 2 行日志,一个新的JVM新生代GC启动,并用了0.12秒才完成。新生代GC在35.17秒时结束,并且JVM尝试发出write()系统调用(第4行),将新生代GC统计的信息输出到GC日志文件。write()调用被阻塞1.47秒,所以最后在时间36.64(第5行)结束,总共耗时1.47秒。当write()调用在36.64返回给JVM时,JVM记录此STW暂停为1.59秒(即0.12 + 1.47)(第3行)。
|
||||
|
||||
这些数据表明,GC日志记录过程,恰好位于JVM的STW暂停路径上,而日志记录所花费的时间也是STW暂停的一部分。如果日志记录,也就是write()调用被阻塞,那么就会导致STW暂停。换句话说,实际的STW暂停时间由两部分组成:
|
||||
|
||||
1. 实际的GC时间,例如,新生代GC时间。
|
||||
1. GC日志记录时间,例如,write()执行时被OS阻塞的时间。
|
||||
|
||||
我用下图来清楚地表示它们之间的关系。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6f/16/6fb8cb8e982ec471bb371fb42d2c9416.png" alt="">
|
||||
|
||||
左边是JVM的活动,右边是操作系统的活动。时间T1时垃圾回收开始,T2时GC结束,并开始调用write()写日志。这之间的延迟,就是GC的延迟。T3时write()调用开始,T4时write()调用返回。这之间的延迟,就是OS导致的阻塞延迟。所以,总的STW暂停就是这两部分延迟的和,也就是T4-T1。
|
||||
|
||||
那么接下来的一个很难理解的问题是:为什么非阻塞IO,还会被阻塞?!
|
||||
|
||||
在深入研究各种资源(包括操作系统内核源代码)后,我们意识到,非阻塞IO写入还是可能会停留,并被阻塞在内核代码执行过程中。具体原因有好几个,其中包括:**页面写入稳定**(Stable Page Writing)和**文件系统日志提交**(Journal Logging)。下面分别说明。
|
||||
|
||||
JVM写入GC日志文件时,首先会改变(也就是“弄脏”)相应的文件缓存页面。即使以后通过操作系统的写回机制,将缓存页面写到磁盘文件中,被改变内存中的缓存页面,仍然会由于稳定的页面写入而导致**页面竞争**。
|
||||
|
||||
根据页面写入稳定的机制,如果页面处于OS回写状态,则对该页面的write()必须等待回写完成。这是为了避免将部分全新的页面保留到磁盘(会导致数据不一致),来确保磁盘数据的一致性。
|
||||
|
||||
对于日志文件系统,在文件写入过程中会生成适当的日志。当附加到GC日志文件,而需要分配新的文件块时,文件系统需要首先将日志数据保存到磁盘。在日志保存期间,如果操作系统具有其他IO活动,则可能需要等待。如果后台IO活动繁重,则等待时间可能会很长。
|
||||
|
||||
## 解决方案如何落地?
|
||||
|
||||
我们已经看到,由于操作系统的各种机制的原因,包括页面缓存写回、日志文件系统等,JVM可能在GC日志期间,被长时间阻塞。
|
||||
|
||||
那么怎么解决这个问题呢?
|
||||
|
||||
我们思考了三种方案可以缓解这个问题,分别是:
|
||||
|
||||
1. 修改JVM;
|
||||
1. 减少后台IO;
|
||||
1. 将GC日志记录与其他IO分开。
|
||||
|
||||
修改JVM是将GC日志记录活动,与导致STW暂停的关键JVM GC进程分开。这样一来由GC日志阻塞引起的问题就将消失。
|
||||
|
||||
比如JVM可以将GC日志放到另一个线程中,该线程可以独立处理日志文件的写入,也就不会造成另外一个应用程序线程的STW暂停。这个方案的缺点是,采用分线程方法,可能会在JVM崩溃期间丢失最后的GC日志信息。
|
||||
|
||||
第二种方案是减少后台IO。
|
||||
|
||||
后台IO引起的STW暂停的程度,取决于后台IO的强度。因此,可以采用各种方法来降低这些IO的强度。比如在JVM应用程序运行的服务器上,不要再部署其他IO密集型应用程序。
|
||||
|
||||
第三种方案是将GC日志与其他IO分开。
|
||||
|
||||
对延迟敏感的应用程序,例如为交互式用户提供服务的在线应用程序,通常无法忍受较大的STW暂停。这时可以考虑将GC日志记录到其他地方,比如另外一个文件系统或者磁盘上。
|
||||
|
||||
比如这个文件系统可以是临时文件系统(tmpfs,一种基于内存的文件系统)。它具有非常低的写入延迟的优势,因为它不会引起实际的磁盘写入。但是,基于tmpfs的方法存在持久性问题。由于tmpfs没有备份磁盘,因此在系统崩溃期间,GC日志文件将丢失。
|
||||
|
||||
而另一种方法是将GC日志文件放在更快的磁盘上,例如SSD。我们知道,就写入延迟和IOPS而言,SSD具有更好的IO性能。
|
||||
|
||||
## 如何证明解决方案是否有效?
|
||||
|
||||
得出方案后,我们就需要对它们进行验证了。
|
||||
|
||||
在我们提出的三种方案中,第一种方案,也就是改进JVM,是暂时难以实现的,因为它需要修改JVM的实现机制。第二种方案呢,是不言自明。如果没有背景IO或者有较少背景IO,那么自然背景IO的影响就变小了;或者减少GC日志输出的频率,就不会有那么多次STW暂停了。
|
||||
|
||||
因此,我们这里直接来验证第三种方案:将GC日志记录与其他IO分开。
|
||||
|
||||
我们的方法,是将GC日志文件放在SSD文件系统上来验证这种方案。我们运行与前面实验场景中相同的Java应用程序和后台IO负载。下图中显示了一个5分钟运行时段的所有STW暂停和相应的时间戳。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8e/35/8e79fbff8312b961d1d9a65ab3d07335.png" alt="">
|
||||
|
||||
对于STW暂停的信息,我们注意到所有的JVM的暂停都非常小,所有暂停都在0.25秒以下。可以说,延迟暂停方面的性能,得到了很大的提高,这表明,如果这样分开GC日志文件,即使有很大的后台IO负载,也不会导致JVM程序发生较大的STW暂停;这样的结果也就验证了这一方案的有效性。
|
||||
|
||||
## 总结
|
||||
|
||||
今天我们探讨了一个跨层的性能分析和优化案例。由于计算机几个层面的技术互相影响,实际生产环境中,会出现非常大的响应时间延时,严重影响公司业务。这些层面包括应用程序、JVM机制、操作系统和存储系统。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ee/7a/eee73e11928fdfbe864008124aa2957a.png" alt="">
|
||||
|
||||
我们通过合理的性能测试和性能分析,包括搭建合适的测试环境进行问题重现、详细的根因分析,最终提出了几种解决方案。简单来说,就是JVM在GC时会输出日志文件,写入磁盘时会因为背景IO而被阻塞。把日志文件和背景IO分开放在不同磁盘,从而让它们互相不影响的话,就能大幅度降低STW延迟。
|
||||
|
||||
从这个案例我们也可以再次了解,从事性能优化工作需要通晓几乎所有层面的知识。而且不光要知识面广,还要能深入下去,才能进行彻底的根因分析。
|
||||
|
||||
唐代诗人刘禹锡有一首诗说:“莫道谗言如浪深,莫言迁客似沙沉。千淘万漉虽辛苦,吹尽狂沙始到金。”对待这样复杂的性能问题,我们也需要不怕困难,不惧浪深沙沉。因为只有经过千淘万漉的辛苦,才能淘到真金,发现问题的本质并彻底解决它。
|
||||
|
||||
## 思考题
|
||||
|
||||
既然这个问题是Java应用程序偶尔发生大延迟,原因是JVM垃圾回收GC的Log日志,写到硬盘的文件系统引起的,我们如何可以让JVM把GC日志写到内存去吗?
|
||||
|
||||
>
|
||||
Tips:考虑一下JVM参数。
|
||||
|
||||
|
||||
欢迎你在留言区分享自己的思考,与我和其他同学一起讨论,也欢迎你把文章分享给自己的朋友。
|
Reference in New Issue
Block a user