mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-10-21 01:13:45 +08:00
mod
This commit is contained in:
154
极客时间专栏/性能优化高手课/性能设计篇/01 | 性能建模设计:如何满足软件设计中的性能需求?.md
Normal file
154
极客时间专栏/性能优化高手课/性能设计篇/01 | 性能建模设计:如何满足软件设计中的性能需求?.md
Normal file
@@ -0,0 +1,154 @@
|
||||
<audio id="audio" title="01 | 性能建模设计:如何满足软件设计中的性能需求?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5e/62/5eb7ac394af16b746790de2391e8a562.mp3"></audio>
|
||||
|
||||
你好,我是尉刚强。今天是课程的第一讲,我想先和你一起来学习下基于性能的建模设计方法。
|
||||
|
||||
基于性能对软件进行建模和设计的目的呢,其实是为了保证软件产品最终交付的性能,跟一开始的设计预期相匹配。然而,在实际的软件建模和设计过程中,很多人其实都忽视了性能的评估分析,导致生成的软件性能差,被客户频繁投诉,甚至有可能导致产品失败,给公司带来严重的后果。
|
||||
|
||||
所以这节课,我们就来看看如何在软件设计阶段做好性能的评估分析,通过一定的方法提前识别出软件设计中潜在的性能问题,并指导优化设计,从而更好地满足软件设计中的性能需求。
|
||||
|
||||
学会了这个方法之后,你不仅可以提前获取产品的性能预估表现,还可以用它来指导软硬件资源的选型设计,甚至在一些场景下,如果客户对产品要求的性能目标不合理,你也可以利用这个方法来推动他调整性能目标。
|
||||
|
||||
那么具体是什么方法呢?答案就是**软件执行模型**和**系统执行模型**这两种对系统建模的方法思路。
|
||||
|
||||
软件执行模型是一种静态分析模型,一般不需要考虑多用户和资源竞争等动态情况,我们可以用它来分析评估系统的理想响应时间;而系统执行模型则是需要重点考虑多用户、资源竞争等情况的动态分析模型,我们可以利用它来分析和评估系统吞吐量。虽然这两个模型的关注点不同,但我们可以借此识别出软件设计中存在的一些性能问题。
|
||||
|
||||
所以接下来,我们就从软件执行模型开始,来看看如何在软件建模与设计的过程中,最大化地满足性能需求吧。
|
||||
|
||||
## 软件执行模型
|
||||
|
||||
前面我说过,软件执行模型是一种静态分析模型,我们在分析过程中主要关注执行步骤和流程即可。而传统的UML时序图承载的与性能不相关的额外信息比较多,所以这里呢,你可以选择使用**执行图**来表示软件执行模型。
|
||||
|
||||
执行图是一种对软件执行过程、步骤的可视化描述手段,你可以通过推理来计算这些步骤流程的开销,从而帮助你预估软件的性能表象。
|
||||
|
||||
不过在使用执行图评估和分析性能之前,我们还需要知道执行图的大概结构。它主要是由节点和箭头组成的,这里我列举了一些常见的节点类型,你可以先熟悉下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/23/1d/233a9934d8013cb7eec0c633b71eca1d.jpg" alt="">
|
||||
|
||||
其中,扩展节点代表的是还需要进一步被细化的节点,它可以用另外单独的执行子图做进一步的描述;并行节点代表着多个并行执行的节点单元,只有当所有的节点都执行完毕之后,才能执行后续的操作;分割节点代表着有多个异步执行的节点单元,它并不需要等待所有节点执行结束,就可以开始后续的操作。
|
||||
|
||||
好了,理解这些节点类型的语义之后,我们接下来看看如何用这些节点类型来表示一个执行图。
|
||||
|
||||
### 简单的软件执行模型是什么样子的?
|
||||
|
||||
下面给出的是一个简单的执行图,节点中的数字代表的是一个权重,你可以用它来表示CPU执行时长、数据库操作次数、磁盘操作次数等,这里我们先假设它代表的是CPU执行时长,以便于理解接下来的计算过程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/27/ff/2721843a8b592d1a9454316c45fda6ff.jpg" alt="">
|
||||
|
||||
现在,我们具体来看看这个执行图的操作步骤:
|
||||
|
||||
- 第一步是基本节点,执行开销为t1。
|
||||
- 第二步是循环节点,其中n代表的是循环次数,同时你可以看到这里的循环体是一个扩展节点。扩展节点可以使用另外一个执行子图来表示,上图中的扩展执行子图是由一个分支选择节点组成的,其中包含的两个分支节点开销分别为t3和t4,而这个分支选择节点本身开销为t2。
|
||||
- 第三步是并行节点,它包含了三个基本节点,执行开销分别为t5, t6, t7。
|
||||
- 最后一步也是基本节点,开销为t8。
|
||||
|
||||
然后我们就可以根据这个执行图,估算出采用这种软件设计后的平均处理时延为:`t1+t8+max(t5,t6,t7)+n((p1*t3)+(p2*t4)+t2)`。同样的,你还可以估算出最短和最长的处理时延,分别如下:
|
||||
|
||||
- 最短处理时延:`t1+t8+max(t5,t6,t7)+n*(min(t3,t4)+t2)`。
|
||||
- 最长处理时延:`t1+t8+max(t5,t6,t7)+n*(max(t3,t4)+t2)`。
|
||||
|
||||
这里你可能有一个疑问,**在软件设计阶段怎么能估算出这些值呢?**
|
||||
|
||||
是这样的,首先你不能期望获取准确的评估值,因为在开始软件设计的阶段,你能获取的前期测量信息不仅有限,而且可能不是非常准确的。
|
||||
|
||||
但是,你可以根据软件设计去估算出一些值,比如根据业务逻辑分析数据库的操作次数,再结合数据库性能指标,来估算开销等。通过这样的估算,你就已经能够提前识别出一些性能设计上的问题了。
|
||||
|
||||
### 如何利用软件执行模型分析评估系统性能?
|
||||
|
||||
OK,现在我们已经了解了对于软件设计来说,执行图是一种能够有效且方便地分析评估处理时延的手段。那么接下来,我就通过一个真实的人工智能对话引擎的软件设计案例,来带你详细了解下,使用执行图分析性能并引导软件设计的过程。
|
||||
|
||||
>
|
||||
注意:这是一个被大幅简化后的真实案例,其中介绍的相关测量评估数据并不是真实的,只是为了阐述问题而已。
|
||||
|
||||
|
||||
下图是这个对话业务的语义树模型,这个语义树模型代表的是人类对话场景的一个模拟过程,就像两个人在聊天过程中,一方可能会接着对方最近几句中的其中一句进行回复。语义树中的每个节点代表着一个语义,然后智能对话引擎就可以通过一个智能计算模型,来判别这个语义是否匹配。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/56/bf/56fa3c10eb04f888cfc99a9130fdyybf.jpg" alt="">
|
||||
|
||||
其中,黄色节点代表着已经识别的用户对话上下文语义,红色虚线箭头代表用户对话发生的顺序,而绿色节点则是接下来用户对话可能发生的语义。当然了,在一个实际的对话执行引擎中,包含的功能其实有很多,这里我们先就一个简化后的智能对话引擎,来查看下它的执行图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/80/f0/8018620a3dc266496093d7db12eed9f0.jpg" alt="">
|
||||
|
||||
从图上你可以看到,对话引擎在执行过程中,首先要进行语音识别,将用户的语音转换为文字,然后转换为词向量。紧接着就需要遍历执行所有智能语义模型,来计算匹配度,然后根据一定的算法选择出最佳匹配的语义。最后还需要查询相关的数据库,构造用户的响应文字信息,并转换成语音发送给用户。
|
||||
|
||||
到这里,你就可以使用前面介绍的模型求解的方法,来预估下该智能对话引擎的响应反馈时延。
|
||||
|
||||
在这个对话执行引擎中,语音识别、词向量转换、构造回复(多次查询或修改数据库)、文字转语音都是相对比较确定的,粗略估算分析可以控制在50ms以内。而中间循环使用各个节点的语义计算模型,需要的时间开销会比较大。
|
||||
|
||||
假设你经过初步的测量,单个的语义分析模型的计算,需要的时间开销约为10ms,而针对一些复杂的对话业务场景,系统中待识别的语义计算模型数(假设为n),可能有几百个。所以,这部分处理时间可能已经超过了1秒钟,无法满足用户的响应时延性能要求。
|
||||
|
||||
那么,为了更好地解决这个问题,你就可以在设计时采用并发架构,将语义计算模型任务拆分到多个核上来运行。这样,调整后的软件执行图就如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/81/f5/81863e81d77859962230feb0c5c2e5f5.jpg" alt="">
|
||||
|
||||
这里我们可以看到,调整后系统会同时启动6个并发任务来执行语义计算模型,由于每个语义计算模型的执行时间比较接近,所以在静态分析的过程中,理论上可以将中间循环的处理时间提升近6倍,从而就很好地满足了软件的性能需求。
|
||||
|
||||
但是,**在系统真实的运行过程中,真的可以达到理论上的性能提升效果吗?**
|
||||
|
||||
其实不一定,如果这个软件系统是运行在一个仅有两个CPU硬件核的机器上,那么程序中虽然启动了6个并发任务,其最大的加速比也只能到2,所以不可能达到理论上6倍处理时延的提升。
|
||||
|
||||
由此你肯定也就发现了,**不考虑软硬件资源使用状况的软件执行模型,是存在一定局限性的**,所以我们还需要通过一定的手段,来减少性能评估与真实运行时的偏差,而这就是我接下来要给你介绍系统执行模型的原因。
|
||||
|
||||
好,下面我们就具体来看看吧。
|
||||
|
||||
## 系统执行模型
|
||||
|
||||
我们开发的所有软件都是运行在一系列硬件资源上的,比如CPU、内存、磁盘、网络等,而系统执行模型作为一种动态分析模型,实际上就是**针对多用户和硬件资源竞争场景下的动态建模过程**。
|
||||
|
||||
这也就是说,我们在使用系统执行模型对软件运行态进行建模的过程中,其实可以将系统中的关键资源抽象成一个队列服务器模型。其中,服务器代表具体的硬件资源,而队列则代表处于排队状态的用户作业。这样,针对存在排队处理的业务逻辑,我们在数据中就可以使用**QNM**(Queuing Network Model,排队网络模型)进行分析。
|
||||
|
||||
QNM是针对排队问题的一种数学建模分析方法,我们可以借助这个模型,来帮助模拟与分析系统的运行态性能,从而可以有针对性地对软件设计进行调整和优化。下面展示的就是一个比较简单的QNM拓扑图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b8/d0/b893671a59a92b1bfyy280d0c60d12d0.jpg" alt="">
|
||||
|
||||
在这个拓扑图中,描述的过程是当用户作业到达后,首先排队访问CPU,紧接着的流程是一个选择逻辑(图上的黑点),这里有两个分支,一个是直接退出,另一个是接着排队访问磁盘,然后再继续访问CPU资源。QNM模型支持的元素类型比较多,比如还有延迟等待、普通队列等,不过在互联网服务场景下,一般不需要构造特别复杂的QNM模型,所以这里我就不去深入介绍了。
|
||||
|
||||
这里你需要注意的是,在进行系统执行建模时,除了处理CPU、磁盘等硬件资源可以使用排队服务来建模外,一些外部依赖的数据库、第三方服务等,也满足排队与服务器模型的特性,因此同样可以使用这种方式来建模。
|
||||
|
||||
那么现在,我们回到前面介绍的智能对话引擎案例当中。因为这个系统的核心关键资源是CPU,所以针对这个CPU资源,我们建立的QNM模型如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/39/y9/3910793427794602fde0b61e2655byy9.jpg" alt="">
|
||||
|
||||
可以发现,这是一个最简单的、只考虑CPU资源排队竞争的QNM模型,当系统接收到业务请求后,经过CPU的排队并处理之后,就结束退出了。
|
||||
|
||||
接下来,我们就可以基于之前的执行图,然后基于测量或估算的方法,来计算获取的单个对话任务的服务时间,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5c/ff/5c6ffe8cb35412f33fba261e905596ff.jpg" alt="">
|
||||
|
||||
那么,在这个对话引擎的执行过程中,我们就可以基于一些前期测量数据,预估出每个节点的执行时间。这里假设用户平均需要识别的智能语义模型数目n为10,然后我们可以根据图中的语义逻辑,来推算出单个用户回复需要占用的CPU服务时间为0.125s。
|
||||
|
||||
**不过这里还有一个问题**:在真实的业务负载场景下,该引擎的对话响应时延,是否也可以达到理论值0.125s呢?其实很多时候都是不能的,这是因为当系统中同时存在很多个对话请求时,会因为竞争使用CPU资源而存在排队等待的情况,从而就会增加对话响应时延。
|
||||
|
||||
所以接下来,我就借助系统执行模型,来带你分析评估下这个智能引擎的动态运行性能,这样你就会更清晰地了解到,考虑资源竞争的系统执行建模分析,与软件执行静态建模之间存在的差异。另外,我还会对比该系统在不同CPU核数的场景下,其平均的响应时间表现是怎样的,这样你就会明白,**不同软件与硬件选型也可以直接反映在性能评估的结果值当中**。
|
||||
|
||||
这里首先你要知道的是,**系统执行模型所做的性能评估分析,通常只能分析系统处于稳态情况下的性能表现**,毕竟在非稳态的场景下,我们很难可以准确地评估性能,而且分析的意义也不大。所以,针对这个对话执行引擎来说,我们可以先假设用户对话到达速率在某个恒定速率,然后再来分析对话响应时延。
|
||||
|
||||
>
|
||||
补充:在该对话引擎中,我们设定所有的用户对话请求处理都是可并行的,所以可以使用QNM来进行分析。
|
||||
|
||||
|
||||
好,下面我们就来看看这个具体的计算过程,如下图所示,其中系统到达速率5作业/s,代表的含义是每秒钟会有5个用户对话请求到达:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/80/6f/80f18c06a1db2821ed75140a6334fe6f.jpg" alt="">
|
||||
|
||||
图中左边的数学公式,是QNM模型的通用数学分析公式(具体的公式证明你可以参考下[QNM的相关资料或论文](https://scholar.google.com/scholar?q=Queuing+network+model&hl=zh-CN&as_sdt=0&as_vis=1&oi=scholart),这里我就不展开介绍公式的原理了),这样接下来,你就可以计算出不同CPU核数下的平均响应时延值。
|
||||
|
||||
不过,查看上面的计算结果,你可能会发现两个比较奇怪的现象:
|
||||
|
||||
1. 单核场景下,系统响应时间0.454远大于CPU执行时间0.125。这其实是因为当系统处于动态运行过程中,有可能会由于多个任务竞争使用CPU资源,从而引发了排队的时延问题。
|
||||
1. 在增加CPU核数后,响应时间的提升速度明显大于并发提升的速度。这其实是因为缓解了排队现象,从而导致响应时延变少。
|
||||
|
||||
所以到这里,我们应该能够发现,相比软件执行模型,使用系统执行模型来分析和评估系统的响应时间,才能够帮助我们更准确地分析动态负载场景下的性能表现,从而支持在软件设计的调整和优化。
|
||||
|
||||
## 小结
|
||||
|
||||
今天这一讲,我重点给你介绍了软件执行模型和系统执行模型两种对系统建模的方法思路。这里你需要明确一点,就是在使用软件执行模型来对系统性能进行静态分析时,会相对容易一些,但它只能分析单个用户场景下的理论性能表现,而使用系统执行模型则可以帮你动态分析多用户和资源竞争场景下的性能表现。
|
||||
|
||||
而且在课程中,我并没有非常深入地去讲解这两种模型的所有建模细节,这是因为在软件设计阶段,你首先应该有基于性能进行建模与分析的意识,然后才是去学习如何正确使用这些性能建模的方法。
|
||||
|
||||
所以,我希望你在学习了今天的课程之后,能够在实际的软件设计过程中,提前识别出性能关键点,并寻找到合适的性能建模方法来实现性能评估。另外,你还可以在软件生命周期中,去持续矫正这个系统性能模型,从而达成持续地支撑后续各种设计与实现优化的目标。
|
||||
|
||||
## 思考题
|
||||
|
||||
在互联网云服务场景中,当应用服务器实例CPU负荷,超过一定门限(如80%)就会触发弹性扩容,而不会等到满负荷时候才触发,这是为什么呢?
|
||||
|
||||
欢迎在留言区分享你的答案和思考。如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
|
213
极客时间专栏/性能优化高手课/性能设计篇/02 | 并行设计(上):如何利用并行设计挖掘性能极限?.md
Normal file
213
极客时间专栏/性能优化高手课/性能设计篇/02 | 并行设计(上):如何利用并行设计挖掘性能极限?.md
Normal file
@@ -0,0 +1,213 @@
|
||||
<audio id="audio" title="02 | 并行设计(上):如何利用并行设计挖掘性能极限?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/59/ef/5969bf293f25f7dd811cc3efe8e0a0ef.mp3"></audio>
|
||||
|
||||
你好,我是尉刚强。
|
||||
|
||||
在计算机领域,由于CPU单核性能的增⻓逐渐停滞,而我们面临的业务问题复杂度却在不断地上升,为了更好地解决这个冲突,在CPU中增加核数就成为了一种默认的应对方案。而通常来说,我们会借助并行设计来充分发挥硬件多核上的运行性能。
|
||||
|
||||
不过,在CPU多核的场景下,要想通过并行设计将计算负载均衡到每个CPU核上,以此减少业务处理的时延,将软件性能提升至最大化,**依然存在着很大的挑战**。
|
||||
|
||||
为什么这么说呢?不知道你在实际的业务场景中有没有发现,由于并行拆分不合理,而导致产品性能不可控,甚至是恶化的现象非常普遍。另外,由于程序员普遍会存在串行编程的惯性思维,在并发同步互斥实现中引入的故障难以定位,也很容易导致产品在较长时间里处于不可用状态。而这些问题,都会对我们的软件性能产生直接影响。
|
||||
|
||||
所以这节课,我就来给你介绍6种针对不同业务问题的典型并行设计架构模式,以此让你在面对实际的业务问题时,能快速准确地挖掘业务中的并发性,找到适合产品的并行设计架构。而同步互斥作为并行设计中的一个难点,如果你希望能高效解决,需要对其有很深入的理解认识,我将在下一节课单独介绍。
|
||||
|
||||
## 并行计算模型
|
||||
|
||||
在开始讲解具体的并行设计架构模式之前,我想先带你了解一下并行计算模型。
|
||||
|
||||
因为当面对具体的业务问题时,如何将复杂的领域问题拆分成可并行的逻辑单元,并实现同步交互,是并行架构设计的关键。而并行计算模型,可以帮助我们建立起对并发系统抽象模型,以及各种基本概念的认识,从而更容易去理解后续的并行设计架构模式。
|
||||
|
||||
我们知道,在CPU多核运行的场景下,不同的并行计算单元如果共享相同的内存地址单元,就可能会导致各种同步互斥的问题,比如脏数据、死锁等。所以,在并行计算模型中,我们就需要隔离不同并发计算单元的内存数据,以尽量减少引入同步互斥的问题。
|
||||
|
||||
那么具体要怎么做呢?我们可以把并行计算模型抽象为两个层次:
|
||||
|
||||
1. 由结构数据和相应的计算逻辑组成并发执⾏单元,这样可以通过组合实现更复杂的业务;
|
||||
1. 基于各种手段(如内存、互斥量、消息队列、数据库等),对并发执⾏单元计算的结果进行交互同步,保证业务计算结果的确定性。
|
||||
|
||||
你可以参考一下这个模型的抽象视图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/68/91/68cb97d560b0c52d117e44c121158291.jpg" alt="">
|
||||
|
||||
这里你要注意的是,图中的两个并行执行单元代表了抽象的逻辑单元,并不是特指线程。并行执行单元的粒度可大可小,像函数、routine(协程)、actor、线程、进程、作业等,都可以作为并行执行单元。
|
||||
|
||||
那么现在,我们来思考一个问题:在调用软件并发调度框架,如java.util.concurrent.Executors的submit接口时,提交的Thread是一个真正创建的线程吗?
|
||||
|
||||
首先我们要知道,这个Thread是一个抽象的并行执行单元,实际上并不是真正的线程。而java.util.concurrent.Executors是Java语言基于线程封装的调度框架底座,它支持将Callable和Runner接口实现并映射到具体的线程运行单元上。所以说,**在设计并发架构时,我们不应该将并行执行单元片面地理解为线程。**
|
||||
|
||||
不过在Java语言中,也不是只能使用这种抽象粒度的并行执行单元来实现并行设计。Java中还有诸如Akka、Reactor等并发调度框架底座,来支撑更加轻量级的并行执行单元。比如,你可以使用Akka中的Actor进行并行系统设计,也可以基于Reactor库设计和实现并发程序,你甚至可以为特定应用场景专门定制并发调度底座。
|
||||
|
||||
也就是说,**在并行设计的过程中,我们不应该将并行执行单元限定在线程粒度上,而是应该根据处理的特定领域问题,选择合适的并行执行单元粒度,并选择或定制实现相应的并发调度框架。**
|
||||
|
||||
>
|
||||
另外,在使用Java设计实现并发程序的过程中,我们可以使用Java语言内置的并发库,如各种锁、并发集合等来实现同步与信息交互。但在多机分布式系统中,还需要依赖数据库、消息队列、网络传输等技术来实现信息交互。
|
||||
|
||||
|
||||
所以,接下来我介绍的6种并行设计架构模式,就是基于上述的并行计算模型来描述的。
|
||||
|
||||
## 并行设计架构模式
|
||||
|
||||
这里我想先说明一点,在软件领域中,我们⾯对的业务场景一定是纷繁多样的,只花一节课的时间我们不可能面面俱到、了解所有的业务场景。所以今天,我只想带你重点思考一个问题:如何根据业务场景进⾏并行设计,从⽽在最⼤程度上发挥硬件并⾏的能⼒。下面我介绍的6种并行设计架构模式,也都是基于这个问题而展开的。
|
||||
|
||||
那么,我是如何划分这6种并行架构⽅案的呢?答案是根据计算逻辑、结构数据、信息交互这三个维度的不同的规则性,拆分后得出的。要知道,这些架构之间并不是孤⽴的。对于特定领域的业务场景来说,很多时候需要组合⼏种架构模式来实现业务逻辑。
|
||||
|
||||
所以,你在学习这6种并⾏架构模式时,就需要了解这种架构的核⼼关注点是什么,以及它重点解决了什么问题。为了便于你理解后面会用到的几种并行架构设计的图例,这里我先说明一下图中各种元素的含义:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/92/59/921c254d07a81170366ec7b413d3fe59.jpg" alt="">
|
||||
|
||||
- **计算逻辑:**业务中的计算逻辑,可以在CPU上执行的代码段。
|
||||
- **结构数据:**拆分到并行执行单元中的独立数据,可以记录在内存中,也可以在数据库中。
|
||||
- **并行执行单元:**抽象并行执行实体,使用软件并发调度框架映射到底层CPU硬件线程上;并行计算单元中的数字,表示计算单元的工作量。
|
||||
- **并行执行单元输出:**并行执行单元的执行结果,需要使用同步与互斥手段实现并行执行单元的业务功能组合。
|
||||
- **保存并行执行单元队列:**不少软件并发调度框架,如java.util.concurrent.Executors内部已提供了待调度并行执行单元队列,但也有不少场景需要自己设计维护并行执行单元队列。
|
||||
- **CPU芯片硬件线程:** CPU多核场景下,支撑并行执行的CPU硬件线程。软件并行执行单元最终会映射到不同CPU硬件线程上才能实现真正并行。
|
||||
|
||||
除此之外,在介绍6种并行设计架构模式的过程中,我还会给你重点强调下该架构模式中的隐式约束条件。只有充分理解了这些约束条件,你才能在并行设计的过程中,避免引入一些故障,也能够降低代码开发的实现复杂度,并最大化地挖掘这种并行设计架构模式的性能。
|
||||
|
||||
好了,下面我们就开始吧。
|
||||
|
||||
### 1. 任务线性分解架构
|
||||
|
||||
第一种架构模式是任务线性分解架构,它是一种按照计算逻辑维度进行确定性拆分的并行架构设计模式。其大致的实现过程是这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7e/d2/7e84a5dd5296027141f86709c661a9d2.jpg" alt="">
|
||||
|
||||
首先可以看到,在图中的左侧,三个计算逻辑A、B、C是在相同的⼀个数据块上进行操作的。通过依赖分析,我们会发现A、B、C三个计算逻辑相对独⽴。因此,当单核处理性能存在瓶颈时,按照计算逻辑维度进⾏并⾏拆分,就能够进⼀步提升性能。
|
||||
|
||||
所以上图的右侧,就是按照计算逻辑拆分成的三个独⽴的并⾏执⾏单元,这样就可以映射到两个硬件线程较少的处理时延上。
|
||||
|
||||
>
|
||||
补充:作为一名Java工程师,需要你显式地映射绑定到硬件线程的场景可能比较少;但对于嵌入式工程师而言,映射绑定也是并行设计中非常关键的一个环节。
|
||||
|
||||
|
||||
实际上在很多的业务领域中,都存在需要根据同一个事件或数据,并行触发很多任务的场景。比如在电商购物场景下,当发生了一笔交易且交易成功后,就会同时触发填充邮件内容并通知责任人、按照多种维度统计数据及更新等多项任务。
|
||||
|
||||
通常,当这些触发的业务计算逻辑之间相互独立时,我们就可以通过创建多个并行执行单元,分别处理拆分后的不同子问题,并根据不同单元业务工作量的大小,建立与具体硬件线程的映射绑定关系。
|
||||
|
||||
这种并行设计架构模式相对比较简单,潜在的业务场景也比较多。比如,在Observer模式中处理的类似问题、在消息队列中一对多通信解决的业务问题等,通常都隐含着任务线性并发的可能性。
|
||||
|
||||
总而言之,任务线性分解架构比较适用于业务逻辑确定性的场景,你在实际应用时要注意以下几点:
|
||||
|
||||
- 在并⾏执⾏单元间,数据依赖可以通过⼀些⼿段进行消除或隔离,比如利用Thread Local变量,通过数据冗余来消除依赖;
|
||||
- 执⾏单元的⼯作量⽐较确定,容易与硬件线程建⽴绑定和映射关系;
|
||||
- 一般来说,做并⾏拆分我们需要先了解全局的业务功能,同时任务线性拆分的扩展性会差⼀些。
|
||||
|
||||
### 2. 任务分治架构
|
||||
|
||||
第二种架构模式是任务分治架构,它是一种按照计算逻辑进行动态拆分的并行架构设计模式。我们来看下它的设计特点:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b9/38/b98debbd1db3a94157362e20ac087b38.jpg" alt="">
|
||||
|
||||
通过图中的左侧,我们能够发现,在很多的业务场景下,计算逻辑并不是全局确定的。有些业务在计算过程中,还需要根据场景来判断是否拆分成更⼩的⼦问题进⾏求解。比如说,A计算过程中,会拆分出2个B⼦问题,⽽在这2个⼦问题的计算过程中,需要进⼀步拆分为3个C⼦问题来求解。
|
||||
|
||||
那么,针对这种场景进⾏并行设计时,就不能在系统运⾏前完成任务的拆分,而是需要动态创建任务,并借助任务队列来管理执⾏任务。这里的执⾏线程可以从队列中拉取任务,映射到硬件线程上执⾏。
|
||||
|
||||
这种并行设计架构模式的使用场景相对少一些。
|
||||
|
||||
我之前曾基于Akka框架,设计开发了一款智能对话引擎。在这个对话引擎系统中,用户对话的所有语义信息是有限的,当收到某个用户对话数据时,在特定上下文中,可能语义是全局语义中一个较小的子集。所以我需要在这个子集内选择语义匹配率最高的一个,然后进行回复。
|
||||
|
||||
另外,每个语义计算匹配率的计算逻辑与对话数据是独立的,所以为了实现用户对话消息的急速回复,我需要在该上下文下,动态创建出多个并行执行单元,分别计算语义匹配度,再汇总选择出匹配率最高的一个。而这个实现框架,就是基于任务分治架构进行设计的。
|
||||
|
||||
事实上,在Java的java.util.concurrent.Executors以及Akka等框架中,已经内置实现了并发任务队列,并支持与CPU等硬件线程映射,从而满足了大部分场景下的业务需求。但在一些实时性要求比较高、性能要求非常苛刻的场景下,比如股票交易等,任务队列以及硬件资源绑定关系,通常是需要单独设计实现的。
|
||||
|
||||
同样,这里我们也来了解下这种架构模式的隐式约束条件:
|
||||
|
||||
- 通常动态拆分的并⾏任务间,通信开销会比较⼤,你需要额外分析通信对性能的影响;
|
||||
- 动态拆分的并⾏任务间通常存在控制依赖,需要利用Fork-Join机制协调任务间同步;
|
||||
- 受制于计算路径跨度对并发性能的影响,最⼤化发挥并⾏性能⽐较困难。
|
||||
|
||||
### 3. 数据⼏何分解架构
|
||||
|
||||
第三种架构模式是数据几何分解架构,它是一种根据待处理的业务数据进行线性拆分的并行架构设计模式。同样,我们先来看下它的设计特点:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fe/b6/fe1da4e8c16ff66f61eb0f86dcc7d3b6.jpg" alt="">
|
||||
|
||||
数据⼏何分解与任务线性分解架构的⻛格⽐较接近,但⼏何分解架构的主要特点是**相同计算逻辑需要在不同的数据上进⾏运算**。如图中右侧所示,拆分成不同的并⾏计算单元后,计算逻辑是相同的(颜⾊相同),但是数据是不同的(颜⾊不同)。
|
||||
|
||||
在互联网微服务场景中,业务关键数据会记录到数据库表中。当数据规模比较大,需要对数据库表使用分表策略保存,这就是一种典型数据几何分解方式。针对这种场景,当接收到业务数据库表查询分析请求,需要基于同一个计算逻辑与不同数据库分表组合,创建出多个执行单元并行计算提升性能。
|
||||
|
||||
通常在业务发展中,待处理数据规模增加是一个非常重要的变化方向,通过弹性计算资源提升业务处理能力是核心关注点之一。而数据几何分解架构是解决这类问题的一种典型方法,有很多优点,应用非常广泛。
|
||||
|
||||
好,最后我们来看看数据几何分解架构的隐式约束条件:
|
||||
|
||||
- 一般来说,采⽤数据⼏何分解架构,其可⽀持的扩展性会⽐较强;
|
||||
- 这种性能架构模式⽐较适合于SPMD(Single Program Multi Data)架构,SPMD架构会使用一套相同的代码实体并行运行在多个硬件线程上,这样用户只需要管理一套代码实体即可,成本比较低。
|
||||
- 数据几何分解架构中,不同并⾏计算单元的更新数据间是独⽴的。
|
||||
|
||||
### 4. 递归数据架构
|
||||
|
||||
第四种架构模式是递归数据架构,它是一种在处理过程中对业务数据进行动态拆分的并行架构设计模式,其架构设计特点如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1e/38/1ea5e1e0d3322f814f3a3b005ec13e38.jpg" alt="">
|
||||
|
||||
从图中我们可以看到,业务处理的数据是树状或者图状组织的,而这就表明了线性⼏何拆分数据会比较困难。
|
||||
|
||||
因此,我们在实际应用时,就需要在遍历的过程中动态创建任务,然后对每个中间计算单元的运算结果逐步合并,计算得到最终的结果,如图中右侧所示。
|
||||
|
||||
MongoDB是目前应用非常广泛的开源文档性数据库,它支持将灵活的JSON格式业务数据保存到数据库中。在对业务记录JSON格式内的多个字段进行数据分析时,代码需要递归遍历JSON中所有嵌套字段并进行分析计算。为了最大化并发执行,减少处理时延,可以采用递归数据架构模式,在递归遍历字段过程中动态创建对应字段分析的并行执行单元。
|
||||
|
||||
这种架构应用场景也相对较少,主要针对非规则结构数据进行计算分析时使用,比如树状、有向图等数据结构。
|
||||
|
||||
同样,最后我们来看看这种架构的隐式约束条件:
|
||||
|
||||
- 这种架构模式下,计算任务单元需要动态创建,⽽且⼯作量不确定;
|
||||
- 一般来说,递归数据架构对应的算法是递归算法。
|
||||
|
||||
### 5. 数据流交互架构
|
||||
|
||||
第五种架构模式是数据流交互架构,它是从信息交互的维度出发,是一种在并行执行单元间单向交互的并行架构设计模式。我们来看下它的设计特点:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/aa/f0/aa85621028123270c0f655c23a5c85f0.jpg" alt="">
|
||||
|
||||
从上图中我们可以发现,这种业务场景的典型特⾊是:
|
||||
|
||||
1. ⼀个计算单元的输出刚好是另外⼀个计算单元的输⼊,并且消息交互是单向确定性的;
|
||||
1. 业务场景中还会源源不断接收到新的输⼊,需要使⽤相似的计算策略进行处理。
|
||||
|
||||
也就是说,针对这种场景,计算单元的确定性会⽐较强,我们可以静态规划与硬件线程的映射关系,⽽**设计的核⼼就是如何⾼效实现并发计算单元间的信息交互。**
|
||||
|
||||
具体怎么做呢?我给你举个例子。
|
||||
|
||||
在大数据领域中,ETL(Extract-Transform-Load)是一个非常典型的场景,它是用来描述将数据从来源端经过抽取(extract)、转换(transform)、加载(load)至目的端的过程。但业务数据处理需求通常是由多个ETL阶段组合完成的,因此针对这类场景,使用数据流交互架构会比较适合。
|
||||
|
||||
此外,在嵌入式领域,⽹络协议栈的报⽂处理、不同协议栈解析特定头部字节、完成业务处理后透传给下⼀层,也是使用数据流交互架构的一类典型场景。
|
||||
|
||||
这里你要知道,在数据流交互架构中,不同并行执行单元的处理消息速率通常是不一致的,你需要借助消息队列缓存来协调。而在Java中,并发的各种BlockingQueue就是前面这个问题中,消息队列的一种实现方式,也就是典型的生产者消费者模型处理的问题。
|
||||
|
||||
好,现在我们来看下这种架构的隐式约束条件:
|
||||
|
||||
- 通常,数据流交互架构中的计算业务是线性可拆分的,数据在时间线上是均匀、批量地向前推进且相互独⽴;
|
||||
- 在该架构下,计算任务的⼯作量确定性比较强,⽐较适合静态规划;
|
||||
- 当消息通信满⾜单向⽣产者消费者模式时,数据流交互架构可以避免使⽤互斥锁,达到消息的⾼效率交互。
|
||||
|
||||
### 6. 异步交互架构
|
||||
|
||||
最后一种架构模式是异步交互架构,它是一种并行执行单元间,交互关系比较复杂的并行架构设计模式,其设计特点如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/90/60/90bfb87a72d85cc725c0236ecc1eee60.jpg" alt="">
|
||||
|
||||
从图上我们可以发现,该业务场景的典型特⾊是这样的:
|
||||
|
||||
1. 同⼀个任务需要与多个任务进⾏消息交互;
|
||||
1. 同⼀个消息需要多个任务进⾏处理。
|
||||
|
||||
这种系统的计算逻辑可能需要进行全局拆分,也可能不能拆分,我们要根据实际情况进⾏处理。
|
||||
|
||||
我给你举个例子。在微服务架构中,微服务在完成一个REST请求业务功能的过程中,可能需要进行多次数据库操作,还可能需要多次调用其他微服务提供的REST接口。为了充分发挥性能,我们在将业务逻辑拆分为多个并行执行单元后,并行执行单元间的运行开销差异较大,就可以使用异步交互来实现业务功能。
|
||||
|
||||
这里请注意,要想最大化地发挥这种架构的性能,还需要实现一点:并行执行单元能够动态灵活地映射到特定的硬件CPU核上。比如说,Node.js后端业务async和Java语言中的Future并发机制,都是比较好的支撑异步交互架构的语言机制。
|
||||
|
||||
最后我们来看下它的隐式约束条件:
|
||||
|
||||
- 计算任务的⼯作量不确定性强,任务通常需要动态调整映射到对应硬件线程;
|
||||
- 消息交互需要使⽤异步机制提升性能;
|
||||
|
||||
## 小结
|
||||
|
||||
并⾏设计解决的是将复杂业务领域的问题拆分为多个相对较⼩的串⾏⼦问题,每个串行子问题对应一个并行执行单元,并通过⾼效解决⼦问题间的信息交互与同步,从而减少业务整体处理时延,最终满足业务的性能需求。
|
||||
|
||||
今天我以最大化地挖掘并行性能为出发点,给你介绍了6种⽐较典型的并行架构解决思路。不过在实际的业务场景中,当性能并不是系统关键因素时,如果你使用串行化代码实现就已经满足了性能要求,而且开发成本更低,那么这时你就需要权衡一下,是否还需要进行并行设计。
|
||||
|
||||
## 思考题
|
||||
|
||||
我们知道,对Java而言,有java.util.concurrent.Executors、Akka、Reactor等并发调度框架底座,那么这些框架之间有什么差异呢?在开发一个核心业务只有对数据库增删查改的微服务时,你会如何选择呢?
|
||||
|
||||
欢迎在留言区分享你的答案和思考。如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
|
208
极客时间专栏/性能优化高手课/性能设计篇/03 | 并行设计(下):如何高效解决同步互斥问题?.md
Normal file
208
极客时间专栏/性能优化高手课/性能设计篇/03 | 并行设计(下):如何高效解决同步互斥问题?.md
Normal file
@@ -0,0 +1,208 @@
|
||||
<audio id="audio" title="03 | 并行设计(下):如何高效解决同步互斥问题?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/aa/8f/aaed943e913c8b63f9b86c241b673b8f.mp3"></audio>
|
||||
|
||||
你好,我是尉刚强。
|
||||
|
||||
我曾经主导过一个性能优化的项目,该项目的主要业务逻辑是在线抢货并购买。在原来的设计方案中,我们为了保证库存数据的一致性,后端服务在请求处理中使用了Redis互斥锁,而这就导致系统的吞吐量受限于30TPS,不能通过弹性扩展来提高性能。
|
||||
|
||||
那我们是怎么解决这个问题的呢?后来我们使用无锁化来实现性能的拓展,系统吞吐量一下就提升至1000TPS,相比原来提升了30倍之多。
|
||||
|
||||
所以你看,**同步互斥是影响并发系统性能的关键因素之一,一旦处理不当,甚至可能会引起死锁或者系统崩溃的危险。**
|
||||
|
||||
这节课,我就会带你去发现并发系统中存在的同步互斥问题,一起思考、分析引起这些问题的根源是什么,然后我 会介绍各种同步互斥手段的内部实现细节,帮助你理解利用同步互斥的具体原理及解决思路。这样,你在深入理解同步互斥问题的本质模型后,就能够更加精准地设计并发系统中的同步互斥策略,从而帮助提升系统的关键性能。
|
||||
|
||||
好,接下来,我们就从并发系统中存在的同步互斥问题开始,一起来看看引起同步互斥问题的内在根源是什么吧。
|
||||
|
||||
## 并行执行的核心问题
|
||||
|
||||
从计算机早期的图灵机模型,到面向过程、面向对象的软件编程模型,软件工程师其实早已习惯于运用串行思维去思考和解决问题。而随着多核时代的来临,受制于硬件层面的并发技术的发展,为了更大地发挥CPU价值,就需要通过软件层的并行设计来进一步提升系统性能。
|
||||
|
||||
但是,现在大多数的软件工程师还习惯于用串行思维去解决问题,这就会导致设计实现的软件系统不仅性能非常差,还容易出故障。
|
||||
|
||||
比如说,我们可以来看下这个并发程序,找找它在执行期间都可能会存在什么问题:
|
||||
|
||||
```
|
||||
int number_1 = 0;
|
||||
int number_2 = 0;
|
||||
void atom_increase_call()
|
||||
{
|
||||
for (int i = 0; i < 10000; i++)
|
||||
{
|
||||
number_1++;
|
||||
number_2++;
|
||||
}
|
||||
}
|
||||
void atom_read_call()
|
||||
{
|
||||
int inorder_count = 0;
|
||||
for (int i = 0; i < 10000; i++)
|
||||
{
|
||||
if (number_2 > number_1)
|
||||
{
|
||||
inorder_count++;
|
||||
}
|
||||
}
|
||||
std::cout << "thread:3 read inorder_number is " << inorder_count
|
||||
<< std::endl;
|
||||
}
|
||||
int main()
|
||||
{
|
||||
std::thread threadA(atom_increase_call);
|
||||
std::thread threadB(atom_increase_call);
|
||||
std::thread threadC(atom_read_call);
|
||||
threadA.join();
|
||||
threadB.join();
|
||||
threadC.join();
|
||||
std::cout << "thread:main read number is " << number_1 << std::endl;
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行之后你会发现,由于代码在三个线程上并行执行,导致这个程序每次的运行结果可能都不相同,这种现象就被叫做**程序运行结果不确定性**,而这通常是业务所不能接受的。
|
||||
|
||||
这里我列举了其中两次执⾏结果,如下:
|
||||
|
||||
```
|
||||
| 第⼀次:
|
||||
thread:3 read inorder_number is 1
|
||||
thread:main read number_1 is 15379
|
||||
thread:main read number_2 is 15378
|
||||
|
||||
| 第⼆次:
|
||||
thread:3 read inorder_number is 13
|
||||
thread:main read number_1 is 15822
|
||||
thread:main read number_2 is 15821
|
||||
|
||||
```
|
||||
|
||||
通过分析这段代码的两次执行结果,我们可以看到该并发程序出现了两种现象:
|
||||
|
||||
1. 线程A和线程B中,number_1++、number_2++累计执行了20000次,那么结果应该为20000才对,但实际运行的结果却与20000的差距比较大。
|
||||
1. 线程A和线程B中,都是先执行number_1++,再执行number_2++,因此inorder_number的统计应该是0才合理,但最后的结果却不是0。这就说明了,number_1++与number_2++执行结果的生效,在跨线程下的顺序是不一致的。
|
||||
|
||||
那么现在,我们可以先来思考一下:为什么现象1中,number_1的值不是20000呢?我认为可能有两个原因:
|
||||
|
||||
- number_1在不同线程间的**缓存失效**了,导致大量写入操作与预期不一致,这就导致与实际值的偏差较大;
|
||||
- number_1++的操作执行包括了读取、修改两个阶段,中间有可能被中断,所以**不满足原子特性**,这样两个线程中number_1++操作互相干扰,从而就无法保证结果的正确性。
|
||||
|
||||
而导致inorder_number值不为0的原因比较多,比如说:
|
||||
|
||||
- 变量number_1和number_2在线程间的**缓存不一致**;
|
||||
- 由于编译器指令重排序优化,导致number_1++和number_2++**生成指令的顺序被打乱**;
|
||||
- 由于CPU级指令级并发技术,造成number_1++和number_2++并发执行,因而**无法保证执行顺序**。
|
||||
|
||||
如此一来,我们将以上所有问题进行汇总整理之后,其实可以发现引起并发系统执行结果不确定性的根源问题主要有三个,分别是**原⼦性破坏问题、缓存一致性问题、顺序一致性问题**。
|
||||
|
||||
**那么我们该怎么去解决并发系统中存在的这三个根源问题呢?**你肯定会想到,使用互斥锁呀!的确,互斥锁能够很好地解决上述三个问题。
|
||||
|
||||
下面,我们就一起来了解下互斥锁是如何解决上面描述的三个问题的,同时在此过程中,我们也来看看由于使用了互斥锁,都会引入什么样的性能开销。
|
||||
|
||||
## 互斥锁的原理与性能
|
||||
|
||||
首先,我们来理解下互斥锁的实现原理,下图就展示了一个互斥锁的处理过程:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/83/bb/83e1429890742eb106b124f94f11f5bb.jpg" alt="">
|
||||
|
||||
如图中所示,在Lock加锁后进入临界区前、退出临界区后并执行Unlock之前,这两处都增加了内存屏障指令(不同CPU架构与OS上的实现存在一些差异,但其基本原理是类似的)。这样,编译期间通过这两个内存屏障,就实现了以下功能:
|
||||
|
||||
1. 限制了临界区与非临界区之间的指令重排序;
|
||||
1. 保证在释放锁之前,临界区中的共享数据已经写入到了内存中,以此确保多线程间的缓存一致性。
|
||||
|
||||
由于临界区是互斥访问的,因此你可以认为临界区的业务逻辑在整体上是原子性且缓存一致的,而且跨线程间数据顺序的一致性约束,也被统一放到了临界区内来实现。虽然临界区间内的代码是乱序优化执行的,还存在非原子性操作等实现,不过这都不会影响到程序执行最终结果的不确定性。
|
||||
|
||||
另外,从图上你还可以看到,当互斥锁加锁失败后,执行线程会进入休眠态,直到互斥锁资源释放之后,才会被动地等待内核态重新调度去激活。
|
||||
|
||||
显而易见,线程长时间休眠会导致业务阻塞,从而就会影响到软件系统的性能。所以,在并发程序中使用互斥锁时,**一个重要的性能优化手段就是减少临界区的大小,以此减少线程可能的阻塞时间。**比如说,通过删除一些非冲突的业务逻辑,来减少临界区的执行代码时间。
|
||||
|
||||
不过这里请你再来思考一个问题:在通过减少临界区代码来优化性能的过程中,如果你发现临界区的执行时间,已经小于线程休眠切换的时间开销(通常线程休眠切换的开销大约在2us左右,不同机器在性能上会有一定差异,需要以实际机器的测试为准),那你还会选择互斥锁这种方式吗?
|
||||
|
||||
其实,这时候你应该考虑更换一种锁,来减少线程休眠切换消耗的时间。接下来我要带你了解的自旋锁(SpinLock),就可以帮助实现这个目的。自旋锁在Linux源码中使用很多,我来给你介绍一下它的基本原理与性能表现吧。
|
||||
|
||||
## 自旋锁的原理与性能
|
||||
|
||||
首先,我们还是来了解下自旋锁的实现原理,看看它的处理逻辑是怎么样的,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/45/7e/45cf24af9be70770fb9c25403c5d767e.jpg" alt="">
|
||||
|
||||
对比前面互斥锁的工作过程示意图,你可以发现,**自旋锁与互斥锁的逻辑差异主要体现在**:当加锁失败时,当前线程并不会进入休眠态。所以如果你使用自旋锁这种实现方式,如果临界区执行开销比较小,就可以赚取等待时间开销小于线程休眠切换开销的额外收益了。
|
||||
|
||||
**在自旋锁中,临界区的实现机制与互斥锁基本是一致的,因此它也能解决前面提到的并发系统中的三个根源问题。**
|
||||
|
||||
另外,与互斥锁一样,为了进一步提升软件性能,你也需要进一步减少线程间的数据依赖。这样,你通过设计优化之后,将线程之间的依赖数据减少到仅剩几个变量时,执行开销可能只需要几个指令周期就可以完成了。
|
||||
|
||||
不过这时使用锁机制,你还需要在每次数据操作的过程中进行加锁与解锁,这样额外开销的占比就会过大,其实就不太划算了。
|
||||
|
||||
那么既然如此,还有其他更加高效的解决方案吗?
|
||||
|
||||
当然有!请记住,**锁只是我们解决问题的手段,而不是我们需要解决的问题。**现在让我们再次回到问题本身,再来强化记忆一下并发系统内的三个本质问题:原⼦性破坏问题、缓存一致性问题、顺序一致性问题。
|
||||
|
||||
这里你需要意识到,在具体的并发业务场景中,可能并不需要你同时去解决这三个问题。比如多线程场景下的统计变量,两个线程会同时更新一个变量,那这里压根就不存在顺序一致性的问题。
|
||||
|
||||
因此,**你首先需要学会的是识别并发系统中待解决的问题,然后再去精准地寻找解决方案,这才是进一步提升系统性能的关键。**
|
||||
|
||||
那么,在实际的业务场景中,**最常见的引发并发系统执行结果不确定性的问题,其实是缓存一致性问题**,比如典型的生产者消费者问题。不过在嵌入式系统的业务场景中,C语言已经通过引入volatile变量解决了这个问题。
|
||||
|
||||
接下来,我们就通过使用volatile来解决问题的工作流程,来分析、了解下volatile是如何解决同步互斥中存在的问题的。
|
||||
|
||||
## volatile的原理与性能
|
||||
|
||||
volatile是一种特殊变量类型,它主要是为了解决并发系统中的**缓存一致性问题**。定义为volatile类型的变量,会被默认为是缓存失效状态,针对这个变量的读取、设置操作,都可以通过直接操作内存来实现,从而就规避了缓存一致性问题。
|
||||
|
||||
在C/C++语言中,volatile一直在沿用这种方式,但这种实现机制并没有完全解决并发系统中的原子性破坏和顺序一致性的问题。
|
||||
|
||||
而在Java语言中,JVM会在volatile变量的过程中添加内存屏障机制,从而可以部分解决顺序一致性的问题。其具体机制如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/82/4d/82beee492c3c29a8ebaa749b4d6b6c4d.jpg" alt="">
|
||||
|
||||
图中,变量x、y是volatile类型变量,初始值分别为1和2,Load代表的是对内存直接进行读取操作,而Store代表了对内存直接进行写入操作。在线程1内,volatile变量y执行写入操作时,会在生成的操作指令前添加写屏障指令;而线程2是在执行volatile变量y读取操作时,在生成的代码指令后添加了读屏障指令。
|
||||
|
||||
如此一来,通过写屏障就限制了线程1在执行过程中,Store x与Store y的写操作不能乱序;而读屏障就限制了线程2在执行过程中,Load y和Load x不能乱序。
|
||||
|
||||
因此,对于线程2来说,就只可能看到线程1执行过程中3个时间点的状态,分别为:
|
||||
|
||||
- State A :初始化状态,y=2,x =1。
|
||||
- State B :x刚设置完的中间状态,y=2,x =5。
|
||||
- State C :x, y都设置完的状态,y=8,x=5。
|
||||
|
||||
而如果线程1和线程2的其中任何一方没有使用内存屏障指令,就有可能导致线程2读到的数据顺序不一致,比如说获取到乱取的状态,y=8,x=2。实际上,这也是**无锁编程**(即不使用操作系统中锁资源的程序,而互斥锁需要使用操作系统的锁资源)中的一个典型问题解决方式。
|
||||
|
||||
但这里,你还需要注意的是:**volatile并没有完全实现原子性**。比如说,如果出现以下两种情况,就不满足原子性:
|
||||
|
||||
- 类似i++这种对数据的更新操作,CPU层面无法通过一条指令就更新完成,因此使用volatile也不能保证原子性;
|
||||
- 对32位的CPU架构而言,64位的长整型变量的读取和写入操作就无法在一条指令内完成,因此也无法保证原子性。
|
||||
|
||||
对于32位与64位CPU架构之间的差异而导致的原子性问题,我们就只能在使用过程中尽量去规避;而针对i++这种更新操作,大部分CPU架构都实现了一条特殊的CPU指令,来单独解决这个问题。
|
||||
|
||||
这个特殊指令就是**CAS指令**,它的实现语义如下:
|
||||
|
||||
```
|
||||
bool CAS(T* addr, T expected, T newValue)
|
||||
{
|
||||
if( *addr == expected )
|
||||
{
|
||||
*addr = newValue;
|
||||
return true;
|
||||
}
|
||||
else
|
||||
return false;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
该函数实现的功能是:如果当前值等于expect, 则更新值为newValue,否则不更新;如果更新成功就返回true,否则返回false。**这条指令就是满足原子性的。**
|
||||
|
||||
好了,现在我给你总结下前面的分析过程:在并发系统的同步互斥中,使用volatile可以实现读取和写入操作的原子性,使用CAS指令能够实现更新操作的原子性,然后再借助内存屏障实现跨线程的顺序一致性。
|
||||
|
||||
在Java语言中,正是基于volatile + CAS + 内存屏障的组合,实现了**Atomic类型**(如果想更深入理解Java的Atomic类型的原理与机制,可以参考阅读[这个文档](https://www.jianshu.com/p/37e32f42e94a)),从而支撑解决了并发中的三个本质问题。
|
||||
|
||||
C++在Atmoic实现的原理与Java Atomic是类似的,但在C++语言中,它定义了更加丰富的一致性内存模型,可以供我们灵活选择。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我带你学习了并发系统中,解决同步互斥问题的多种手段与原理,以此帮助你更好地优化同步互斥性能。不过,我并不希望你在实际的业务场景中,也直接去对比选择这节课所讲的解决方案,因为脱离了上下文场景下的优劣分析是没有实际意义的。
|
||||
|
||||
相反,我希望你通过今天的学习,能够更加深入地理解并发系统同步互斥问题本身,这样当面临具体问题时,你可以准确地抓住问题本质,找到最佳性能的解决方案。
|
||||
|
||||
## 思考题
|
||||
|
||||
思考一下,Redis上的变量set和get操作也是原子操作,也提供CAS指令,那么在跨机器的分布式系统设计中,是否也可以使用Redis进行无锁编程呢?
|
119
极客时间专栏/性能优化高手课/性能设计篇/04 | 缓存设计:做好缓存设计的关键是什么?.md
Normal file
119
极客时间专栏/性能优化高手课/性能设计篇/04 | 缓存设计:做好缓存设计的关键是什么?.md
Normal file
@@ -0,0 +1,119 @@
|
||||
<audio id="audio" title="04 | 缓存设计:做好缓存设计的关键是什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ca/25/ca4e63a31fb55918b2ef029187862225.mp3"></audio>
|
||||
|
||||
你好,我是尉刚强,今天我们来聊聊基于性能的缓存设计。
|
||||
|
||||
缓存就是一个临时储存数据的地方。当用户查询数据时,首先会在缓存中查找,如果找到了就直接使用;如果找不到,就再到数据的原始位置去寻找。所以,缓存本质上是一种用空间换时间的技术,通过数据在空间上的重复,来提升数据的访问速度。
|
||||
|
||||
不过,随着分布式和云计算技术的发展,数据存储技术也发生了翻天覆地的变化,而且不同存储技术在价格和性能上都存在很大的差异,所以在针对性能进行软件设计时,如果我们没有做好多层级的缓存设计,不仅可能浪费钱,而且获取的性能收益可能也不够理想。
|
||||
|
||||
所以在今天的课程上,我会结合互联网应用与服务场景,给你讲解如何做好缓存设计,并会剖析典型的缓存使用案例,帮助你建立起缓存技术原理的系统认识,以此指导你在产品的软件设计中,可以正确使用缓存来提升系统的性能。
|
||||
|
||||
## 缓存设计的通关之路
|
||||
|
||||
那么首先,我想从两个问题开始,来带你了解下缓存设计要在什么时候做,以及通过不同数据类型的特性对比分析,来跟你一起探讨如何才能做好缓存设计。
|
||||
|
||||
好,第一个问题:**在互联网应用服务中,使用缓存技术的目的就只是为了提升访问速度吗?**
|
||||
|
||||
其实我认为,并不是所有的缓存都只是为了提升速度,因为**在分布式系统中,缓存机制实际上是系统级性能设计的一个重要权衡手段。**比如当某个数据库的负载比较高,接近系统瓶颈时,我们就可以使用缓存技术,把负荷分担到其他数据库中,那么这里使用缓存的目的,主要就是负载均衡,而不是提升访问速度。
|
||||
|
||||
第二个问题:**一个大型系统中的数据种类会非常多,那么需要为每种数据都设计缓存机制吗?**
|
||||
|
||||
其实完全没有必要。在实际的业务场景下,系统包含的业务数据太多了,你不可能针对每种数据都设计和实现缓存机制,因为一方面是投入的软件成本太高,另一方面也很可能无法带来比较高的性能收益。所以,在进行缓存设计之前,**你首先需要识别出哪些数据访问对性能的影响比较大。**
|
||||
|
||||
那么我们该怎么去识别哪些数据是需要缓存机制呢?在[第1讲](https://time.geekbang.org/column/article/374786)中,我已经给你介绍了性能建模设计方法,也就是通过分析和评估手段,来识别出哪个数据访问操作对性能的影响比较关键,然后再进行缓存设计。
|
||||
|
||||
不过接下来,在识别出需要使用缓存机制的数据之后,你可能会发现,这些数据种类之间的特性差异非常大,如果使用同一种缓存设计的话,其实是很难发挥出软件性能的最佳状态的。
|
||||
|
||||
所以在这里,我给你总结了三种需要缓存机制的数据种类,分别是不变性数据、弱一致性数据、强一致性数据。了解这三种缓存数据种类的差异,以及对应的设计缓存机制的方法,你就掌握了缓存设计的精髓。
|
||||
|
||||
好,下面我们就来具体了解下吧。
|
||||
|
||||
### 不变性数据
|
||||
|
||||
首先是不变性数据,它代表数据永远不发生变化,或者是在较长一个时间段内不会发生变化,因此我们也可以认为这部分数据是不变的。
|
||||
|
||||
这类数据就是可以**优先考虑使用缓存技术**的一种数据类型,在实际的业务场景中也非常多。比如,Web服务中的静态网页、静态资源,或者数据库表中列数据与key的映射关系、业务的启动配置,等等,这些都可以认为是不变性数据。
|
||||
|
||||
而且,**不变性数据也意味着实现分布式一致性会非常容易**,我们可以为这些数据选择任意的数据存储方式,也可以选择任意的存储节点位置。因此,我们实现缓存机制的方式就可以很灵活,也会比较简单,比如说在Java语言中,你可以直接使用内存Caffeine,或者内置的结构体来作为缓存都可以。
|
||||
|
||||
另外这里你要注意,当你针对不变性数据进行缓存设计时,其中的缓存失效机制可以采用永久不失效,或者基于时间的失效方式。而在采用基于时间的失效方式的时候,你还需要根据具体的业务需求,在缓存容量和访问速度之间做好设计实现上的权衡。
|
||||
|
||||
### 弱一致性数据
|
||||
|
||||
第二种是弱一致性数据,它代表数据会经常发生变化,但是业务对数据的一致性要求不高,也就是说,不同用户在同一时间点上看到不完全一致的数据,都是可以接受的。
|
||||
|
||||
由于这类数据**对一致性的要求比较低**,所以在设计缓存机制时,你只需要实现最终一致性就可以了。这类数据在实际业务中也比较多,比如业务的历史分析数据、一些搜索查找返回数据等,即使最近的一些数据没有记录进去,关系也不大。
|
||||
|
||||
另外,快速识别这类数据还有一个方法,那就是使用数据库Replica(复制)节点中读取的数据,大部分都是这种类型的数据(很多数据库Replica节点的数据因为数据同步时延,是不满足强一致性要求的)。
|
||||
|
||||
针对弱一致性的数据,我们通常使用的缓存失效机制是基于时间的失效方式,同时因为弱一致性的特性,你可以比较灵活地选择数据存储技术,比如内存Cache,或者是分布式数据库Cache。你甚至可以基于负载均衡的调度,来设计多层级缓存机制。
|
||||
|
||||
### 强一致性数据
|
||||
|
||||
第三种缓存数据类型是强一致性数据,它是指代码数据会经常发生变化,而且业务对数据库的一致性要求非常高,也就是说当数据发生变更后,其他用户在系统中的任何地方,都应该看到的是更新后的数据。
|
||||
|
||||
那么,针对这种类型的数据,我一般是**不推荐你去使用缓存机制**,因为这类数据在使用缓存时会比较复杂,而且很容易会引入新的问题。比如说,用户可以直接提交和修改的各种数据内容,如果没有同步修改缓存中的数据,就会引发数据不一致性的问题,导致比较严重的业务故障。
|
||||
|
||||
不过在一些特殊的业务场景中,比如,在针对个别的数据访问频率非常高的情况下,我们还是需要通过设计缓存机制,来进一步提升性能。因此针对这类强一致性数据,在设计缓存机制时,你需要特别注意两点:
|
||||
|
||||
1. 这种数据的缓存一定要**采用修改同步的实现方式**。也就是说,所有的数据修改都必须确保可以同步修改缓存与数据库中的数据。
|
||||
1. 准确识别特定业务流程中,可以**使用缓存获取数据的时间**有多长。因为有些缓存数据(比如一次REST请求中,多个流程都需要使用的数据)只可以在单次业务流程中使用,不能跨业务流程使用。
|
||||
|
||||
好了,以上就是三种典型的数据种类的缓存设计思路了。这里你需要注意的是,使用缓存一定是以性能优化为目的,因此,你还需要使用**评估模型**来分析缓存是否达到了性能优化的目标。
|
||||
|
||||
那么具体是什么评估模型呢?我们来看一下这个性能评估模型的公式:**AMAT = Thit + MR * MP**。其中:
|
||||
|
||||
- AMAT(Average Memory Access Time),代表的是平均内存访问时间;
|
||||
- Thit,是指命中缓存之后的数据访问时间;
|
||||
- MR,是指访问缓存的失效率;
|
||||
- MP,是指缓存失效后,系统访问缓存的时间与访问原始数据请求的时间之和。
|
||||
|
||||
另外这里你可能会注意到,AMAT与原始数据访问之间的差值,代表的就是使用缓存所带来的访问速度的提升。而在一些缓存使用不当的场景下,增加的缓存机制很可能会造成数据访问速度下降的情况。所以接下来,我就通过真实的缓存设计案例,来带你理解如何正确地使用缓存,以此帮助你更好地提升系统性能。
|
||||
|
||||
## 缓存设计的典型使用场景
|
||||
|
||||
好,在开始介绍之前呢,我还想给你说明一下,在真实的业务中,缓存设计的场景其实有很多,这里我的目的主要是让你明确缓存设计的方法。因此,我会从两个比较典型的案例场景入手,来带你理解缓存的使用。
|
||||
|
||||
### 如何做好静态页面的缓存设计?
|
||||
|
||||
在Web应用服务中,一个重要的应用场景就是静态页面的缓存使用。这里的静态页面是指一个网站内,所有用户看到的都是一样的页面,除非重新部署否则一般不发生变更,比如大部分公司官网的首页封面等。
|
||||
|
||||
通常静态页面的访问并发量是比较大的,如果你不使用缓存技术,**不仅会造成用户响应时延比较长,而且会对后端服务造成很大的负载压力**。
|
||||
|
||||
那么针对静态页面,我们在使用缓存技术时,可以通过将静态缓存放到距离用户近的地方,来减少页面数据在网络上的传输时延。现在,我们来看一个针对静态页面使用缓存设计的示意图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3c/c5/3c1903a94f842387a5d160a62a9fe0c5.jpg" alt="">
|
||||
|
||||
如图上所示,针对静态网页,首先你就可以在软件后端服务的实例中使用缓存技术,从而避免每次都要重新生成页面信息。然后,由于静态网页属于不变性数据,所以你可以使用内存或文件级缓存。另外,针对访问量非常大的静态页面,为了进一步减少对后端服务的压力,你还可以将静态页面放在网关处,然后利用OpenResty等第三方框架增加缓存机制,来保存静态页面。
|
||||
|
||||
除此之外,在网页中很多的静态页面或静态资源文件,还需要使用浏览器的缓存,来进一步提升性能。
|
||||
|
||||
**注意**,这里我并不是建议你针对所有的静态页面,你都需要设计三层的缓存机制,而是你要知道,在软件设计阶段,一般就需要考虑如何做静态页面的缓存设计了。
|
||||
|
||||
### 后端服务如何设计数据库的多级缓存机制?
|
||||
|
||||
还有一个典型的缓存场景是针对数据库的缓存。现在的数据库通常都是分布式存储的,而且规模都比较大,在针对大规模数据进行查询与分析计算时,都需要花费一定的时间周期。
|
||||
|
||||
因此,我们可以先识别出这些计算结果中可以使用缓存机制的数据,然后就可以使用缓存来提升访问速度了。下面是一张针对数据库缓存机制的原理图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0e/ff/0ea7c9ca30aef5d38fe63c75151e37ff.jpg" alt="">
|
||||
|
||||
从图上我们可以看到,内存级Cache、分布式Cache都可以作为数据计算分析结果的缓存。而且,不同级的缓存访问速度是不一样的,内存级的Cache访问速度可以到微秒级别,甚至更好;分布式Cache访问速度通常可以小于毫秒级别;而针对原生数据库的查询与分析,通常是大于毫秒级别的。
|
||||
|
||||
因此,在具体设计缓存机制的时候,你就需要依据前面我介绍的缓存使用原理,识别出数据类型,然后选择并设计缓存实现机制。
|
||||
|
||||
另外,在使用缓存机制实现访问速度优化的过程中,我们的**主要关注点是不同层级缓存所带来的访问速度提升**,而在这里,不同层级缓存也是可以在一个数据库中的。比如,在我参与设计的一个性能优化项目中,其Cache策略就是,使用MongoDB中的另外一个Collection(集合),来作为缓存查询分析,以此优化性能。
|
||||
|
||||
所以,你在做缓存设计时,关注点应该放到不同的数据种类,以及不同层级缓存的性能评估模型上,而不是只关注数据库。只有这样,你才能设计出更好、更优的性能缓存方案。
|
||||
|
||||
## 小结
|
||||
|
||||
今天,我重点介绍了缓存技术的使用原理和典型应用场景,当你在进行软件业务系统性能设计时,可以结合今天学习的内容,识别出系统中各种可缓存的数据类型,然后有针对性地设计缓存方案,并且还可以根据评估模型,来进行前期的性能验证分析。
|
||||
|
||||
另外,在具体的缓存技术实现中,比如缓存替换算法、缓存失效策略等,通常这部分能力是内置于缓存库的配置选项当中的,或者选用第三方库即可,需要自定义设计算法的实现场景很少。所以这里你需要重点做的,就是选择合适的配置和策略即可。
|
||||
|
||||
## 思考题
|
||||
|
||||
对于一致性要求比较高的数据信息,在微服务多实例架构中,我们是否可以选择使用内存Cache呢?
|
||||
|
||||
欢迎在留言区分享你的答案和思考。如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
|
Reference in New Issue
Block a user