mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-02 07:13:45 +08:00
mod
This commit is contained in:
166
极客时间专栏/系统性能调优必知必会/基础设施优化/01 | CPU缓存:怎样写代码能够让CPU执行得更快?.md
Normal file
166
极客时间专栏/系统性能调优必知必会/基础设施优化/01 | CPU缓存:怎样写代码能够让CPU执行得更快?.md
Normal file
@@ -0,0 +1,166 @@
|
||||
<audio id="audio" title="01 | CPU缓存:怎样写代码能够让CPU执行得更快?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7b/1d/7b26aa9f7d8ba80b35c0ecaba009151d.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
这是课程的第一讲,我们先从主机最重要的部件CPU开始,聊聊如何通过提升CPU缓存的命中率来优化程序的性能。
|
||||
|
||||
任何代码的执行都依赖CPU,通常,使用好CPU是操作系统内核的工作。然而,当我们编写计算密集型的程序时,CPU的执行效率就开始变得至关重要。由于CPU缓存由更快的SRAM构成(内存是由DRAM构成的),而且离CPU核心更近,如果运算时需要的输入数据是从CPU缓存,而不是内存中读取时,运算速度就会快很多。所以,了解CPU缓存对性能的影响,便能够更有效地编写我们的代码,优化程序性能。
|
||||
|
||||
然而,很多同学并不清楚CPU缓存的运行规则,不知道如何写代码才能够配合CPU缓存的工作方式,这样,便放弃了可以大幅提升核心计算代码执行速度的机会。而且,越是底层的优化,适用范围越广,CPU缓存便是如此,它的运行规则对分布式集群里各种操作系统、编程语言都有效。所以,一旦你能掌握它,集群中巨大的主机数量便能够放大优化效果。
|
||||
|
||||
接下来,我们就看看,CPU缓存结构到底是什么样的,又该如何优化它?
|
||||
|
||||
## CPU的多级缓存
|
||||
|
||||
刚刚我们提到,CPU缓存离CPU核心更近,由于电子信号传输是需要时间的,所以离CPU核心越近,缓存的读写速度就越快。但CPU的空间很狭小,离CPU越近缓存大小受到的限制也越大。所以,综合硬件布局、性能等因素,CPU缓存通常分为大小不等的三级缓存。
|
||||
|
||||
CPU缓存的材质SRAM比内存使用的DRAM贵许多,所以不同于内存动辄以GB计算,它的大小是以MB来计算的。比如,在我的Linux系统上,离CPU最近的一级缓存是32KB,二级缓存是256KB,最大的三级缓存则是20MB(Windows系统查看缓存大小可以用wmic cpu指令,或者用[CPU-Z](https://www.cpuid.com/softwares/cpu-z.html)这个工具)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/de/87/deff13454dcb6b15e1ac4f6f538c4987.png" alt="">
|
||||
|
||||
你可能注意到,三级缓存要比一、二级缓存大许多倍,这是因为当下的CPU都是多核心的,每个核心都有自己的一、二级缓存,但三级缓存却是一颗CPU上所有核心共享的。
|
||||
|
||||
程序执行时,会先将内存中的数据载入到共享的三级缓存中,再进入每颗核心独有的二级缓存,最后进入最快的一级缓存,之后才会被CPU使用,就像下面这张图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/92/0c/9277d79155cd7f925c27f9c37e0b240c.jpg" alt="">
|
||||
|
||||
缓存要比内存快很多。CPU访问一次内存通常需要100个时钟周期以上,而访问一级缓存只需要4~5个时钟周期,二级缓存大约12个时钟周期,三级缓存大约30个时钟周期(对于2GHZ主频的CPU来说,一个时钟周期是0.5纳秒。你可以在LZMA的[Benchmark](https://www.7-cpu.com/)中找到几种典型CPU缓存的访问速度)。
|
||||
|
||||
如果CPU所要操作的数据在缓存中,则直接读取,这称为缓存命中。命中缓存会带来很大的性能提升,**因此,我们的代码优化目标是提升CPU缓存的命中率。**
|
||||
|
||||
当然,缓存命中率是很笼统的,具体优化时还得一分为二。比如,你在查看CPU缓存时会发现有2个一级缓存(比如Linux上就是上图中的index0和index1),这是因为,CPU会区别对待指令与数据。比如,“1+1=2”这个运算,“+”就是指令,会放在一级指令缓存中,而“1”这个输入数字,则放在一级数据缓存中。虽然在冯诺依曼计算机体系结构中,代码指令与数据是放在一起的,但执行时却是分开进入指令缓存与数据缓存的,因此我们要分开来看二者的缓存命中率。
|
||||
|
||||
## 提升数据缓存的命中率
|
||||
|
||||
我们先来看数据的访问顺序是如何影响缓存命中率的。
|
||||
|
||||
比如现在要遍历二维数组,其定义如下(这里我用的是伪代码,在GitHub上我为你准备了可运行验证的C/C++、Java[示例代码](https://github.com/russelltao/geektime_distrib_perf/tree/master/1-cpu_cache/traverse_2d_array),你可以参照它们编写出其他语言的可执行代码):
|
||||
|
||||
```
|
||||
int array[N][N];
|
||||
|
||||
```
|
||||
|
||||
你可以思考一下,用array[j][i]和array[i][j]访问数组元素,哪一种性能更快?
|
||||
|
||||
```
|
||||
for(i = 0; i < N; i+=1) {
|
||||
for(j = 0; j < N; j+=1) {
|
||||
array[i][j] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在我给出的GitHub地址上的C++代码实现中,前者array[j][i]执行的时间是后者array[i][j]的8倍之多(请参考[traverse_2d_array.cpp](https://github.com/russelltao/geektime_distrib_perf/tree/master/1-cpu_cache/traverse_2d_array),如果使用Python代码,traverse_2d_array.py由于数组容器的差异,性能差距不会那么大)。
|
||||
|
||||
为什么会有这么大的差距呢?这是因为二维数组array所占用的内存是连续的,比如若长度N的值为2,那么内存中从前至后各元素的顺序是:
|
||||
|
||||
```
|
||||
array[0][0],array[0][1],array[1][0],array[1][1]。
|
||||
|
||||
```
|
||||
|
||||
如果用array[i][j]访问数组元素,则完全与上述内存中元素顺序一致,因此访问array[0][0]时,缓存已经把紧随其后的3个元素也载入了,CPU通过快速的缓存来读取后续3个元素就可以。如果用array[j][i]来访问,访问的顺序就是:
|
||||
|
||||
```
|
||||
array[0][0],array[1][0],array[0][1],array[1][1]
|
||||
|
||||
```
|
||||
|
||||
此时内存是跳跃访问的,如果N的数值很大,那么操作array[j][i]时,是没有办法把array[j+1][i]也读入缓存的。
|
||||
|
||||
到这里我们还有2个问题没有搞明白:
|
||||
|
||||
1. 为什么两者的执行时间有约7、8倍的差距呢?
|
||||
1. 载入array[0][0]元素时,缓存一次性会载入多少元素呢?
|
||||
|
||||
其实这两个问题的答案都与CPU Cache Line相关,它定义了缓存一次载入数据的大小,Linux上你可以通过coherency_line_size配置查看它,通常是64字节。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7d/de/7dc8d0c5a1461d9aed086e7a112c01de.png" alt="">
|
||||
|
||||
因此,我测试的服务器一次会载入64字节至缓存中。当载入array[0][0]时,若它们占用的内存不足64字节,CPU就会顺序地补足后续元素。顺序访问的array[i][j]因为利用了这一特点,所以就会比array[j][i]要快。也正因为这样,当元素类型是4个字节的整数时,性能就会比8个字节的高精度浮点数时速度更快,因为缓存一次载入的元素会更多。
|
||||
|
||||
**因此,遇到这种遍历访问数组的情况时,按照内存布局顺序访问将会带来很大的性能提升。**
|
||||
|
||||
再来看为什么执行时间相差8倍。在二维数组中,其实第一维元素存放的是地址,第二维存放的才是目标元素。由于64位操作系统的地址占用8个字节(32位操作系统是4个字节),因此,每批Cache Line最多也就能载入不到8个二维数组元素,所以性能差距大约接近8倍。(用不同的步长访问数组,也能验证CPU Cache Line对性能的影响,可参考我给你准备的[Github](https://github.com/russelltao/geektime_distrib_perf/tree/master/1-cpu_cache/traverse_1d_array)上的测试代码)。
|
||||
|
||||
关于CPU Cache Line的应用其实非常广泛,如果你用过Nginx,会发现它是用哈希表来存放域名、HTTP头部等数据的,这样访问速度非常快,而哈希表里桶的大小如server_names_hash_bucket_size,它默认就等于CPU Cache Line的值。由于所存放的字符串长度不能大于桶的大小,所以当需要存放更长的字符串时,就需要修改桶大小,但Nginx官网上明确建议它应该是CPU Cache Line的整数倍。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4f/2b/4fa0080e0f688bd484fe701686e6262b.png" alt="">
|
||||
|
||||
为什么要做这样的要求呢?就是因为按照cpu cache line(比如64字节)来访问内存时,不会出现多核CPU下的伪共享问题,可以**尽量减少访问内存的次数**。比如,若桶大小为64字节,那么根据地址获取字符串时只需要访问一次内存,而桶大小为50字节,会导致最坏2次访问内存,而70字节最坏会有3次访问内存。
|
||||
|
||||
如果你在用Linux操作系统,可以通过一个名叫Perf的工具直观地验证缓存命中的情况(可以用yum install perf或者apt-get install perf安装这个工具,这个[网址](http://www.brendangregg.com/perf.html)中有大量案例可供参考)。
|
||||
|
||||
执行perf stat可以统计出进程运行时的系统信息(通过-e选项指定要统计的事件,如果要查看三级缓存总的命中率,可以指定缓存未命中cache-misses事件,以及读取缓存次数cache-references事件,两者相除就是缓存的未命中率,用1相减就是命中率。类似的,通过L1-dcache-load-misses和L1-dcache-loads可以得到L1缓存的命中率),此时你会发现array[i][j]的缓存命中率远高于array[j][i]。
|
||||
|
||||
当然,perf stat还可以通过指令执行速度反映出两种访问方式的优劣,如下图所示(instructions事件指明了进程执行的总指令数,而cycles事件指明了运行的时钟周期,二者相除就可以得到每时钟周期所执行的指令数,缩写为IPC。如果缓存未命中,则CPU要等待内存的慢速读取,因此IPC就会很低。array[i][j]的IPC值也比array[j][i]要高得多):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/29/1c/29d4a9fa5b8ad4515d7129d71987b01c.png" alt=""><br>
|
||||
<img src="https://static001.geekbang.org/resource/image/94/3d/9476f52cfc63825e7ec836580e12c53d.png" alt="">
|
||||
|
||||
## 提升指令缓存的命中率
|
||||
|
||||
说完数据的缓存命中率,再来看指令的缓存命中率该如何提升。
|
||||
|
||||
我们还是用一个例子来看一下。比如,有一个元素为0到255之间随机数字组成的数组:
|
||||
|
||||
```
|
||||
int array[N];
|
||||
for (i = 0; i < TESTN; i++) array[i] = rand() % 256;
|
||||
|
||||
```
|
||||
|
||||
接下来要对它做两个操作:一是循环遍历数组,判断每个数字是否小于128,如果小于则把元素的值置为0;二是将数组排序。那么,先排序再遍历速度快,还是先遍历再排序速度快呢?
|
||||
|
||||
```
|
||||
for(i = 0; i < N; i++) {
|
||||
if (array [i] < 128) array[i] = 0;
|
||||
}
|
||||
sort(array, array +N);
|
||||
|
||||
```
|
||||
|
||||
我先给出答案:先排序的遍历时间只有后排序的三分之一(参考GitHub中的[branch_predict.cpp代码](https://github.com/russelltao/geektime_distrib_perf/tree/master/1-cpu_cache/branch_predict))。为什么会这样呢?这是因为循环中有大量的if条件分支,而CPU**含有分支预测器**。
|
||||
|
||||
当代码中出现if、switch等语句时,意味着此时至少可以选择跳转到两段不同的指令去执行。如果分支预测器可以预测接下来要在哪段代码执行(比如if还是else中的指令),就可以提前把这些指令放在缓存中,CPU执行时就会很快。当数组中的元素完全随机时,分支预测器无法有效工作,而当array数组有序时,分支预测器会动态地根据历史命中数据对未来进行预测,命中率就会非常高。
|
||||
|
||||
究竟有多高呢?我们还是用Linux上的perf来做个验证。使用 -e选项指明branch-loads事件和branch-load-misses事件,它们分别表示分支预测的次数,以及预测失败的次数。通过L1-icache-load-misses也能查看到一级缓存中指令的未命中情况。
|
||||
|
||||
下图是我在GitHub上为你准备的验证程序执行的perf分支预测统计数据(代码见[这里](https://github.com/russelltao/geektime_distrib_perf/tree/master/1-cpu_cache/branch_predict)),你可以看到,先排序的话分支预测的成功率非常高,而且一级指令缓存的未命中率也有大幅下降。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/29/72/2902b3e08edbd1015b1e9ecfe08c4472.png" alt="">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/95/60/9503d2c8f7deb3647eebb8d68d317e60.png" alt="">
|
||||
|
||||
C/C++语言中编译器还给应用程序员提供了显式预测分支概率的工具,如果if中的条件表达式判断为“真”的概率非常高,我们可以用likely宏把它括在里面,反之则可以用unlikely宏。当然,CPU自身的条件预测已经非常准了,仅当我们确信CPU条件预测不会准,且我们能够知晓实际概率时,才需要加入这两个宏。
|
||||
|
||||
```
|
||||
#define likely(x) __builtin_expect(!!(x), 1)
|
||||
#define unlikely(x) __builtin_expect(!!(x), 0)
|
||||
if (likely(a == 1)) …
|
||||
|
||||
```
|
||||
|
||||
## 提升多核CPU下的缓存命中率
|
||||
|
||||
前面我们都是面向一个CPU核心谈数据及指令缓存的,然而现代CPU几乎都是多核的。虽然三级缓存面向所有核心,但一、二级缓存是每颗核心独享的。我们知道,即使只有一个CPU核心,现代分时操作系统都支持许多进程同时运行。这是因为操作系统把时间切成了许多片,微观上各进程按时间片交替地占用CPU,这造成宏观上看起来各程序同时在执行。
|
||||
|
||||
因此,若进程A在时间片1里使用CPU核心1,自然也填满了核心1的一、二级缓存,当时间片1结束后,操作系统会让进程A让出CPU,基于效率并兼顾公平的策略重新调度CPU核心1,以防止某些进程饿死。如果此时CPU核心1繁忙,而CPU核心2空闲,则进程A很可能会被调度到CPU核心2上运行,这样,即使我们对代码优化得再好,也只能在一个时间片内高效地使用CPU一、二级缓存了,下一个时间片便面临着缓存效率的问题。
|
||||
|
||||
因此,操作系统提供了将进程或者线程绑定到某一颗CPU上运行的能力。如Linux上提供了sched_setaffinity方法实现这一功能,其他操作系统也有类似功能的API可用。我在GitHub上提供了一个示例程序(代码见[这里](https://github.com/russelltao/geektime_distrib_perf/tree/master/1-cpu_cache/cpu_migrate)),你可以看到,当多线程同时执行密集计算,且CPU缓存命中率很高时,如果将每个线程分别绑定在不同的CPU核心上,性能便会获得非常可观的提升。Perf工具也提供了cpu-migrations事件,它可以显示进程从不同的CPU核心上迁移的次数。
|
||||
|
||||
## 小结
|
||||
|
||||
今天我给你介绍了CPU缓存对程序性能的影响。这是很底层的性能优化,它对各种编程语言做密集计算时都有效。
|
||||
|
||||
CPU缓存分为数据缓存与指令缓存,对于数据缓存,我们应在循环体中尽量操作同一块内存上的数据,由于缓存是根据CPU Cache Line批量操作数据的,所以顺序地操作连续内存数据时也有性能提升。
|
||||
|
||||
对于指令缓存,有规律的条件分支能够让CPU的分支预测发挥作用,进一步提升执行效率。对于多核系统,如果进程的缓存命中率非常高,则可以考虑绑定CPU来提升缓存命中率。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后请你思考下,多线程并行访问不同的变量,这些变量在内存布局是相邻的(比如类中的多个变量),此时CPU缓存就会失效,为什么?又该如何解决呢?欢迎你在留言区与大家一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
133
极客时间专栏/系统性能调优必知必会/基础设施优化/02 | 内存池:如何提升内存分配的效率?.md
Normal file
133
极客时间专栏/系统性能调优必知必会/基础设施优化/02 | 内存池:如何提升内存分配的效率?.md
Normal file
@@ -0,0 +1,133 @@
|
||||
<audio id="audio" title="02 | 内存池:如何提升内存分配的效率?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4d/72/4dd6f9a040c1699d6c4daf496517a472.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
上一讲我们提到,高频地命中CPU缓存可以提升性能。这一讲我们把关注点从CPU转移到内存,看看如何提升内存分配的效率。
|
||||
|
||||
或许有同学会认为,我又不写底层框架,内存分配也依赖虚拟机,并不需要应用开发者了解。如果你也这么认为,我们不妨看看这个例子:在Linux系统中,用Xmx设置JVM的最大堆内存为8GB,但在近百个并发线程下,观察到Java进程占用了14GB的内存。为什么会这样呢?
|
||||
|
||||
这是因为,绝大部分高级语言都是用C语言编写的,包括Java,申请内存必须经过C库,而C库通过预分配更大的空间作为内存池,来加快后续申请内存的速度。这样,预分配的6GB的C库内存池就与JVM中预分配的8G内存池叠加在一起,造成了Java进程的内存占用超出了预期。
|
||||
|
||||
掌握内存池的特性,既可以避免写程序时内存占用过大,导致服务器性能下降或者进程OOM(Out Of Memory,内存溢出)被系统杀死,还可以加快内存分配的速度。在系统空闲时申请内存花费不了多少时间,但是对于分布式环境下繁忙的多线程服务,获取内存的时间会上升几十倍。
|
||||
|
||||
另一方面,内存池是非常底层的技术,当我们理解它后,可以更换适合应用场景的内存池。在多种编程语言共存的分布式系统中,内存池有很广泛的应用,优化内存池带来的任何微小的性能提升,都将被分布式集群巨大的主机规模放大,从而带来整体上非常可观的收益。
|
||||
|
||||
接下来,我们就通过对内存池的学习,看看如何提升内存分配的效率。
|
||||
|
||||
## 隐藏的内存池
|
||||
|
||||
实际上,在你的业务代码与系统内核间,往往有两层内存池容易被忽略,尤其是其中的C库内存池。
|
||||
|
||||
当代码申请内存时,首先会到达应用层内存池,如果应用层内存池有足够的可用内存,就会直接返回给业务代码,否则,它会向更底层的C库内存池申请内存。比如,如果你在Apache、Nginx等服务之上做模块开发,这些服务中就有独立的内存池。当然,Java中也有内存池,当通过启动参数Xmx指定JVM的堆内存为8GB时,就设定了JVM堆内存池的大小。
|
||||
|
||||
你可能听说过Google的TCMalloc和FaceBook的JEMalloc,它们也是C库内存池。当C库内存池无法满足内存申请时,才会向操作系统内核申请分配内存。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/89/6a/893edd82d03c628fae83b95bd4fbba6a.jpg" alt="">
|
||||
|
||||
回到文章开头的问题,Java已经有了应用层内存池,为什么还会受到C库内存池的影响呢?这是因为,除了JVM负责管理的堆内存外,Java还拥有一些堆外内存,由于它不使用JVM的垃圾回收机制,所以更稳定、持久,处理IO的速度也更快。这些堆外内存就会由C库内存池负责分配,这是Java受到C库内存池影响的原因。
|
||||
|
||||
其实不只是Java,几乎所有程序都在使用C库内存池分配出的内存。C库内存池影响着系统下依赖它的所有进程。我们就以Linux系统的默认C库内存池Ptmalloc2来具体分析,看看它到底对性能发挥着怎样的作用。
|
||||
|
||||
C库内存池工作时,会预分配比你申请的字节数更大的空间作为内存池。比如说,当主进程下申请1字节的内存时,Ptmalloc2会预分配132K字节的内存(Ptmalloc2中叫Main Arena),应用代码再申请内存时,会从这已经申请到的132KB中继续分配。
|
||||
|
||||
如下所示(你可以在[这里](https://github.com/russelltao/geektime_distrib_perf/tree/master/2-memory/alloc_address)找到示例程序,注意地址的单位是16进制):
|
||||
|
||||
```
|
||||
# cat /proc/2891/maps | grep heap
|
||||
01643000-01664000 rw-p 00000000 00:00 0 [heap]
|
||||
|
||||
```
|
||||
|
||||
当我们释放这1字节时,Ptmalloc2也不会把内存归还给操作系统。Ptmalloc2认为,与其把这1字节释放给操作系统,不如先缓存着放进内存池里,仍然当作用户态内存留下来,进程再次申请1字节的内存时就可以直接复用,这样速度快了很多。
|
||||
|
||||
你可能会想,132KB不多呀?为什么这一讲开头提到的Java进程,会被分配了几个GB的内存池呢?这是因为**多线程与单线程的预分配策略并不相同**。
|
||||
|
||||
每个**子线程预分配的内存是64MB**(Ptmalloc2中被称为Thread Arena,32位系统下为1MB,64位系统下为64MB)。如果有100个线程,就将有6GB的内存都会被内存池占用。当然,并不是设置了1000个线程,就会预分配60GB的内存,子线程内存池最多只能到8倍的CPU核数,比如在32核的服务器上,最多只会有256个子线程内存池,但这也非常夸张了,16GB(64MB * 256 = 16GB)的内存将一直被Ptmalloc2占用。
|
||||
|
||||
回到本文开头的问题,Linux下的JVM编译时默认使用了Ptmalloc2内存池,因此每个线程都预分配了64MB的内存,这造成含有上百个Java线程的JVM多使用了6GB的内存。在多数情况下,这些预分配出来的内存池,可以提升后续内存分配的性能。
|
||||
|
||||
然而,Java中的JVM内存池已经管理了绝大部分内存,确实不能接受莫名多出来6GB的内存,那该怎么办呢?既然我们知道了Ptmalloc2内存池的存在,就有两种解决办法。
|
||||
|
||||
首先可以调整Ptmalloc2的工作方式。**通过设置MALLOC_ARENA_MAX环境变量,可以限制线程内存池的最大数量**,当然,线程内存池的数量减少后,会影响Ptmalloc2分配内存的速度。不过由于Java主要使用JVM内存池来管理对象,这点影响并不重要。
|
||||
|
||||
其次可以更换掉Ptmalloc2内存池,选择一个预分配内存更少的内存池,比如Google的TCMalloc。
|
||||
|
||||
这并不是说Google出品的TCMalloc性能更好,而是在特定的场景中的选择不同。而且,盲目地选择TCMalloc很可能会降低性能,否则Linux系统早把默认的内存池改为TCMalloc了。
|
||||
|
||||
TCMalloc和Ptmalloc2是目前最主流的两个内存池,接下来我带你通过对比TCMalloc与Ptmalloc2内存池,看看到底该如何选择内存池。
|
||||
|
||||
## 选择Ptmalloc2还是TCMalloc?
|
||||
|
||||
先来看TCMalloc适用的场景,**它对多线程下小内存的分配特别友好。**
|
||||
|
||||
比如,在2GHz的CPU上分配、释放256K字节的内存,Ptmalloc2耗时32纳秒,而TCMalloc仅耗时10纳秒(测试代码参见[这里](https://github.com/russelltao/geektime_distrib_perf/tree/master/2-memory/benchmark))。**差距超过了3倍,为什么呢?**这是因为,Ptmalloc2假定,如果线程A申请并释放了的内存,线程B可能也会申请类似的内存,所以它允许内存池在线程间复用以提升性能。
|
||||
|
||||
因此,每次分配内存,Ptmalloc2一定要加锁,才能解决共享资源的互斥问题。然而,加锁的消耗并不小。如果你监控分配速度的话,会发现单线程服务调整为100个线程,Ptmalloc2申请内存的速度会变慢10倍。TCMalloc针对小内存做了很多优化,每个线程独立分配内存,无须加锁,所以速度更快!
|
||||
|
||||
而且,**线程数越多,Ptmalloc2出现锁竞争的概率就越高。**比如我们用40个线程做同样的测试,TCMalloc只是从10纳秒上升到25纳秒,只增长了1.5倍,而Ptmalloc2则从32纳秒上升到137纳秒,增长了3倍以上。
|
||||
|
||||
下图是TCMalloc作者给出的性能测试数据,可以看到线程数越多,二者的速度差距越大。所以,**当应用场景涉及大量的并发线程时,换成TCMalloc库也更有优势!**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/56/37/56c77fdf3a130fce4c98943f494c9237.png" alt="" title="图片来源:TCMalloc : Thread-Caching Malloc">
|
||||
|
||||
那么,为什么GlibC不把默认的Ptmalloc2内存池换成TCMalloc呢?**因为Ptmalloc2更擅长大内存的分配。**
|
||||
|
||||
比如,单线程下分配257K字节的内存,Ptmalloc2的耗时不变仍然是32纳秒,但TCMalloc就由10纳秒上升到64纳秒,增长了5倍以上!**现在TCMalloc反过来比Ptmalloc2慢了1倍!**这是因为TCMalloc特意针对小内存做了优化。
|
||||
|
||||
多少字节叫小内存呢?TCMalloc把内存分为3个档次,小于等于256KB的称为小内存,从256KB到1M称为中等内存,大于1MB的叫做大内存。TCMalloc对中等内存、大内存的分配速度很慢,比如我们用单线程分配2M的内存,Ptmalloc2耗时仍然稳定在32纳秒,但TCMalloc已经上升到86纳秒,增长了7倍以上。
|
||||
|
||||
所以,**如果主要分配256KB以下的内存,特别是在多线程环境下,应当选择TCMalloc;否则应使用Ptmalloc2,它的通用性更好。**
|
||||
|
||||
## 从堆还是栈上分配内存?
|
||||
|
||||
不知道你发现没有,刚刚讨论的内存池中分配出的都是堆内存,如果你把在堆中分配的对象改为在栈上分配,速度还会再快上1倍(具体测试代码可以在[这里](https://github.com/russelltao/geektime_distrib_perf/tree/master/2-memory/benchmark)找到)!为什么?
|
||||
|
||||
可能有同学还不清楚堆和栈内存是如何分配的,我先简单介绍一下。
|
||||
|
||||
如果你使用的是静态类型语言,那么,不使用new关键字分配的对象大都是在栈中的。比如:
|
||||
|
||||
```
|
||||
C/C++/Java语言:int a = 10;
|
||||
|
||||
```
|
||||
|
||||
否则,通过new或者malloc关键字分配的对象则是在堆中的:
|
||||
|
||||
```
|
||||
C语言:int * a = (int*) malloc(sizeof(int));
|
||||
C++语言:int * a = new int;
|
||||
Java语言:int a = new Integer(10);
|
||||
|
||||
```
|
||||
|
||||
另外,对于动态类型语言,无论是否使用new关键字,内存都是从堆中分配的。
|
||||
|
||||
了解了这一点之后,我们再来看看,为什么从栈中分配内存会更快。
|
||||
|
||||
这是因为,由于每个线程都有独立的栈,所以分配内存时不需要加锁保护,而且栈上对象的尺寸在编译阶段就已经写入可执行文件了,执行效率更高!性能至上的Golang语言就是按照这个逻辑设计的,即使你用new关键字分配了堆内存,但编译器如果认为在栈中分配不影响功能语义时,会自动改为在栈中分配。
|
||||
|
||||
当然,在栈中分配内存也有缺点,它有功能上的限制。一是, 栈内存生命周期有限,它会随着函数调用结束后自动释放,在堆中分配的内存,并不随着分配时所在函数调用的结束而释放,它的生命周期足够使用。二是,栈的容量有限,如CentOS 7中是8MB字节,如果你申请的内存超过限制会造成栈溢出错误(比如,递归函数调用很容易造成这种问题),而堆则没有容量限制。
|
||||
|
||||
**所以,当我们分配内存时,如果在满足功能的情况下,可以在栈中分配的话,就选择栈。**
|
||||
|
||||
## 小结
|
||||
|
||||
最后我们对这一讲做个小结。
|
||||
|
||||
进程申请内存的速度,以及总内存空间都受到内存池的影响。知道这些隐藏内存池的存在,是提升分配内存效率的前提。
|
||||
|
||||
隐藏着的C库内存池,对进程的内存开销有很大的影响。当进程的占用空间超出预期时,你需要清楚你正在使用的是什么内存池,它对每个线程预分配了多大的空间。
|
||||
|
||||
不同的C库内存池,都有它们最适合的应用场景,例如TCMalloc对多线程下的小内存分配特别友好,而Ptmalloc2则对各类尺寸的内存申请都有稳定的表现,更加通用。
|
||||
|
||||
内存池管理着堆内存,它的分配速度比不上在栈中分配内存。只是栈中分配的内存受到生命周期和容量大小的限制,应用场景更为有限。然而,如果有可能的话,尽量在栈中分配内存,它比内存池中的堆内存分配速度快很多!
|
||||
|
||||
OK,今天我们从内存分配的角度聊了分布式系统性能提升的内容,希望学习过今天的内容后,你知道如何最快速地申请到内存,了解你正在使用的内存池,并清楚它对进程最终内存大小的影响。即使对第三方组件,我们也可以通过LD_PRELOAD环境变量,在程序启动时更换最适合的C库内存池(Linux中通过LD_PRELOAD修改动态库来更换内存池,参见[示例代码](https://github.com/russelltao/geektime_distrib_perf/tree/master/2-memory/benchmark))。
|
||||
|
||||
内存分配时间虽然不起眼,但时刻用最快的方法申请内存,正是高手与初学者的区别,相似算法的性能差距就体现在这些编码细节上,希望你能够重视它。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,留给你一个思考题。分配对象时,除了分配内存,还需要初始化对象的数据结构。内存池对于初始化对象有什么帮助吗?欢迎你在留言区与大家一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
127
极客时间专栏/系统性能调优必知必会/基础设施优化/03 | 索引:如何用哈希表管理亿级对象?.md
Normal file
127
极客时间专栏/系统性能调优必知必会/基础设施优化/03 | 索引:如何用哈希表管理亿级对象?.md
Normal file
@@ -0,0 +1,127 @@
|
||||
<audio id="audio" title="03 | 索引:如何用哈希表管理亿级对象?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/66/18/66f9041f6fc31d73e3d6c5783a852718.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
上一讲我们谈到,Ptmalloc2为子线程预分配了64MB内存池,虽然增大了内存消耗,但却加快了分配速度,这就是**以空间换时间**的思想。
|
||||
|
||||
在内存有限的单片机上运行嵌入式程序时,我们会压缩数据的空间占用,**以时间换空间**;但在面向海量用户的分布式服务中,**使用更多的空间建立索引,换取更短的查询时间**,则是我们管理大数据的常用手段。
|
||||
|
||||
比如现在需要管理数亿条数据,每条数据上有许多状态,有些请求在查询这些状态,有些请求则会根据业务规则有条件地更新状态,有些请求会新增数据,每条数据几十到几百字节。如果需要提供微秒级的访问速度,该怎么实现?(注意,以上非功能性约束并不苛刻,对于低ARPU,即每用户平均收入低的应用,使用更少的资源实现同等功能非常重要。)
|
||||
|
||||
这种情况你会面对大量数据,显然,遍历全部数据去匹配查询关键字,会非常耗时。如果使用额外的空间为这些数据创建索引,就可以基于索引实现快速查找,这是常用的解决方案。比如,我们用标准库里提供的字典类容器存放对象,就是在数据前增加了索引,其本质就是以空间换时间。
|
||||
|
||||
当然,索引有很多,哈希表、红黑树、B树都可以在内存中使用,如果我们需要数据规模上亿后还能提供微秒级的访问速度,**那么作为最快的索引,哈希表是第一选择。**
|
||||
|
||||
## 为什么选择哈希表?
|
||||
|
||||
为什么说哈希表是最快的索引呢?我们怎么**定量评价**索引快慢呢?
|
||||
|
||||
实地运行程序统计时间不是个好主意,因为它不只受数据特性、数据规模的影响,而且难以跨环境比较。巴菲特说过:“**近似的正确好过精确的错误。**”用**近似的时间复杂度**描述运行时间,好过实地运行得出的精确时间。
|
||||
|
||||
“时间复杂度”经过了详细的数学运算,它的运算过程我就不详细展开讲了。时间复杂度可以很好地反映运行时间随数据规模的变化趋势,就如下图中,横轴是数据规模,纵轴是运行时间,随着数据规模的增长,水平直线1不随之变化,也就是说,运行时间不变,是最好的曲线。用大O表示法描述时间复杂度,哈希表就是常量级的O(1),数据规模增长不影响它的运行时间,所以Memcached、Redis都在用哈希表管理数据。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e5/c8/e5e07bd2abe9f0f15df1b43fdf25f9c8.jpg" alt="" title="图片来自英文wiki">
|
||||
|
||||
为什么哈希表能做到O(1)时间复杂度呢?
|
||||
|
||||
首先,哈希表基于数组实现,而数组可以根据下标随机访问任意元素。数组之所以可以随机访问,是因为它**由连续内存承载**,且**每个数组元素的大小都相等**。于是,当我们知道下标后,把下标乘以元素大小,再加上数组的首地址,就可以获得目标访问地址,直接获取数据。
|
||||
|
||||
其次,哈希函数直接把查询关键字转换为数组下标,再通过数组的随机访问特性获取数据。比如,如果关键字是字符串,我们使用BKDR哈希算法将其转换为自然数,再以哈希数组大小为除数,对它进行求余,就得到了数组下标。如下图所示,字符串abc经过哈希函数的运算,得到了下标39,于是数据就存放在数组的第39个元素上。(注意,这是个**很糟糕**的哈希函数,它使用的基数是256,即2的8次方,下文我们会解释它为什么糟糕。)
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/41/59/419bc11f032ebcefaa6a3eb5c1a39759.jpg" alt="">
|
||||
|
||||
这样,**哈希函数的执行时间是常量,数组的随机访问也是常量,时间复杂度就是O(1)。**
|
||||
|
||||
实际上并非只有哈希表的时间复杂度是O(1),另一种索引“位图”,它的时间复杂度也是O(1)。不过本质上,它是哈希表的变种,限制每个哈希桶只有1个比特位,所以,虽然它消耗的空间更少,但仅用于辅助数据的主索引,快速判断对象是否存在。
|
||||
|
||||
位图常用于解决缓存穿透的问题,也常用于查找数组中的可用对象,比如下图中通过批量判断位图数组的比特位(对CPU缓存也很友好),找到数据数组中的对应元素。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bf/f5/bf2e4f574be8af06c285b3fc78d7b0f5.jpg" alt="">
|
||||
|
||||
当然,logN也是不错的曲线,随着数据规模的增长,运行时间的增长是急剧放缓的。红黑树的时间复杂度就是O(logN)。如果需求中需要做范围查询、遍历,由于哈希表没办法找到关键字相邻的下一个元素,所以哈希表不支持这类操作,我们可以选择红黑树作为索引。采用二分法的红黑树,检索1万条数据需要做14次运算,1亿条也只需要27次而已。
|
||||
|
||||
如果红黑树过大,内存中放不下时,可以改用B树,将部分索引存放在磁盘上。磁盘访问速度要比内存慢很多,但B树充分考虑了机械磁盘寻址慢、顺序读写快的特点,通过多分支降低了树高,减少了磁盘读写次数。
|
||||
|
||||
综合来看,不考虑范围查询与遍历操作,在追求最快速度的条件下,哈希表是最好的选择。
|
||||
|
||||
然而,在生产环境用哈希表管理如此多的数据,必然面临以下问题:
|
||||
|
||||
- 首先,面对上亿条数据,为了保证可靠性,需要做灾备恢复,我们可以结合快照+oplog方式恢复数据,但内存中的哈希表如何快速地序列化为快照文件?
|
||||
- 其次,简单的使用标准库提供的哈希表处理如此规模的数据,会导致内存消耗过大,因为每多使用一个8字节的指针(或者叫引用)都会被放大亿万倍,此时该如何实现更节约内存的个性化哈希表?
|
||||
- 再次,哈希表频繁发生冲突时,速度会急剧降低,我们该通过哪些手段减少冲突概率?
|
||||
|
||||
接下来,我们就来看看,如何解决以上问题,用哈希表有效地管理亿级数据。
|
||||
|
||||
## 内存结构与序列化方案
|
||||
|
||||
事实上**对于动态(元素是变化的)哈希表,我们无法避免哈希冲突。**比如上例中,“abc”与“cba”这两个字符串哈希后都会落到下标39中,这就产生了冲突。有两种方法解决哈希冲突:
|
||||
|
||||
1. **链接法**,落到数组同一个位置中的多个数据,通过链表串在一起。使用哈希函数查找到这个位置后,再使用链表遍历的方式查找数据。Java标准库中的哈希表就使用链接法解决冲突。
|
||||
1. **开放寻址法**,插入时若发现对应的位置已经占用,或者查询时发现该位置上的数据与查询关键字不同,开放寻址法会按既定规则变换哈希函数(例如哈希函数设为H(key,i),顺序地把参数i加1),计算出下一个数组下标,继续在哈希表中探查正确的位置。
|
||||
|
||||
我们该选择哪种方法呢?
|
||||
|
||||
由于生产级存放大量对象的哈希表是需要容灾的,比如每隔一天把哈希表数据定期备份到另一台服务器上。当服务器宕机而启动备用服务器时,首先可以用备份数据把哈希表恢复到1天前的状态,再通过操作日志oplog把1天内的数据载入哈希表,这样就可以最快速的恢复哈希表。所以,为了能够传输,首先必须把哈希表序列化。
|
||||
|
||||
链接法虽然实现简单,还允许**存放元素个数大于数组的大小**(也叫装载因子大于1),但链接法序列化数据的代价很大,因为使用了指针后,内存是不连续的。
|
||||
|
||||
**开放寻址法**确保所有对象都在数组里,就可以把数组用到的这段连续内存原地映射到文件中(参考Linux中的mmap,Java等语言都有类似的封装),再通过备份文件的方式备份哈希表。虽然操作系统会自动同步内存中变更的数据至文件,但备份前还是需要主动刷新内存(参考Linux中的msync,它可以按地址及长度来分段刷新,以减少msync的耗时),以确定备份数据的精确时间点。而新的进程启动时,可以通过映射磁盘中的文件到内存,快速重建哈希表提供服务。
|
||||
|
||||
**如果能将数据完整的放进数组,那么开放寻址法已经解决了序列化问题,所以我们应该选择开放寻址法**。
|
||||
|
||||
但是,有两个因素使得我们必须把数据放在哈希桶之外:
|
||||
|
||||
1. 每条数据有上百字节;
|
||||
1. 哈希表中一定会有很多空桶(没有存放数据)。空桶的比例越高(装载因子越小),冲突概率也会越低,但如果每个空桶都占用上百字节,亿级规模会轻松把浪费的内存放大许多倍。
|
||||
|
||||
**所以,我们要把数据从哈希表中分离出来,提升哈希表的灵活性(灵活调整装载因子)**。此时,该如何序列化哈希表以外的数据呢?最快速的序列化方案,还是像开放寻址法的散列表一样,使用定长数组存放对象,通过原地映射文件的方式序列化数据。由于数据未必是定长的,所以又分为两种情况。
|
||||
|
||||
**一、数据的长度是固定的。**可以用另一个数组D存放数据,其中D的大小是待存放元素的最大数量,注意,D可以远小于哈希数组的大小。如果哈希表是动态的,支持新建与删除元素操作,还需要把数组D中空闲的位置构建一个单链表,新建时从链表头取元素,删除时将元素归还至链表头部。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7e/e8/7e0636fc6d9a70d6d4de07da678da6e8.jpg" alt="">
|
||||
|
||||
**二、数据的长度并不固定。**此时,可以采用有限个定长数组存放数据,用以空间换时间的思想,加快访问速度。如下图中,D1数组存放长度小于L1的数据,D2数组存放长度在L1和L2之间的数据,以此类推。而哈希表数组H中,每个桶用i位存放该数据在哪个数组中,用j位存放数组下标。查找数据时,前i位寻找数组,后j位作为数组下标直接访问数据。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/17/82/17f3f4e9e949a49a4ce7a50bbf1d4f82.jpg" alt="">
|
||||
|
||||
在这两种情况里,哈希桶不需要存放8字节64位的地址。因为,或许数组D的大小不到1亿,也就是说,你最多只需要寻址1亿条数据,这样30位足够使用。要知道,减少哈希桶的尺寸,就意味着同等内存下可以扩大哈希数组,从而降低装载因子。
|
||||
|
||||
## 降低哈希表的冲突概率
|
||||
|
||||
虽然哈希冲突有解决方案,但若是所有元素都发生了冲突,哈希表的时间复杂度就退化成了O(N),即每查找一次都要遍历所有数据。所以,为了获得与数据规模无关的常量级时间,我们必须减少冲突的概率,而减少冲突概率有两个办法,**第一个办法是调优哈希函数,第二个办法就是扩容。**
|
||||
|
||||
我们先看调优哈希函数。什么是好的哈希函数呢?首先它的计算量不能大,其次应尽量降低冲突概率。回到开头的那个哈希函数:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/41/59/419bc11f032ebcefaa6a3eb5c1a39759.jpg" alt="">
|
||||
|
||||
这个哈希函数使得“abc”和“cba”两个关键字都落在了下标39上,造成了哈希冲突,是因为它**丢失了字母的位置信息**。BKDR是优秀的哈希算法,但它不能以2<sup>8</sup> 作为基数,这会导致字符串分布不均匀。事实上,我们应当找一个合适的**素数作为基数**,比如31,Java标准库的BKDR哈希算法就以它为基数,它的计算量也很小:n*31可以通过先把n左移5位,再减去n的方式替换(n*31 == n<<5 - n)。
|
||||
|
||||
一次位移加一次减法,要比一次乘法快得多。当然,图中的哈希函数之所以会丢失位置信息,是因为以2<sup>8</sup> 作为基数的同时,又把2<sup>8</sup>-1作为除数所致,数学较好的同学可以试着推导证明,这里只需要记住,**基数必须是素数**就可以了。
|
||||
|
||||
当哈希函数把高信息量的关键字压缩成更小的数组下标时,**一定会丢失信息**。我们希望只丢失一些无关紧要的信息,尽量多地保留区分度高的信息。这需要分析关键字的特点、分布规律。比如,对于11位手机号,前3位接入号区分度最差,中间4位表示地域的数字信息量有所增强,最后4位个人号信息量最高。如果哈希桶只有1万个,那么通过phonenum%10000,最大化保留后4位信息就是个不错的选择。
|
||||
|
||||
再比如,QQ 号似乎不像手机号的数字分布那么有特点,然而,如果静态的统计存量QQ号,就会发现最后1位为0的号码特别多(数字更讨人欢喜),区分度很低。这样,哈希函数应当主动降低最后1位的信息量,减少它对哈希表位置的影响。比如,QQ号%100就放大了最后1位的信息,增大了哈希冲突,而用QQ号%101(**101是素数,效果更好****)**作为哈希函数,就降低了最后1位的影响。
|
||||
|
||||
**接下来我们看看减少哈希冲突概率的第二个办法,扩容。**装载因子越接近于1,冲突概率就会越大。我们不能改变元素的数量,只能通过扩容提升哈希桶的数量,减少冲突。
|
||||
|
||||
由于哈希函数必须确保计算出的下标落在数组范围中,而扩容会增加数组的大小,进而影响哈希函数,因此,扩容前存放在哈希表中的所有元素,它们在扩容后的数组中位置都发生了变化。所以,扩容需要新老哈希表同时存在,通过遍历全部数据,用新的哈希函数把关键字放到合适的新哈希桶中。可见,扩容是一个极其耗时的操作,尤其在元素以亿计的情况下。
|
||||
|
||||
那么,在耗时以小时计的扩容过程中,如何持续提供正常服务呢?其实,只要把一次性的迁移过程,分为多次后台迁移,且提供服务时能够根据迁移情况选择新老哈希表即可。如果单机内存可以存放下新老两张哈希表,那么动态扩容不需要跨主机。反之,扩容过程将涉及新老哈希表所在的两台服务器,实现更为复杂,但原理是相同的。
|
||||
|
||||
## 小结
|
||||
|
||||
今天我们介绍了如何用哈希表管理上亿条数据。为什么选择哈希表?因为哈希表的运行时间不随着业务规模增长而变化。位图本质上是哈希表的变种,不过它常用于配合主索引,快速判断数据的状态。因为哈希表本身没办法找到关键字相邻的下一个元素,所以哈希表不支持范围查询与遍历。如果业务需要支持范围查询时,我们需要考虑红黑树、B树等索引,它们其实并不慢。当索引太大,必须将一部分从内存中移到硬盘时,B树就是一个很好的选择。
|
||||
|
||||
使用哈希表,你要注意几个关键问题。
|
||||
|
||||
1. 生产环境一定要考虑容灾,而把哈希表原地序列化为文件是一个解决方案,它能保证新进程快速恢复哈希表。解决哈希冲突有链接法和开放寻址法,而后者更擅长序列化数据,因此成为我们的首选 。
|
||||
1. 亿级数据下,我们必须注重内存的节约使用。数亿条数据会放大节约下的点滴内存,再把它们用于提升哈希数组的大小,就可以通过降低装载因子来减少哈希冲突,提升速度。
|
||||
1. 优化哈希函数也是降低哈希冲突的重要手段,我们需要研究关键字的特征与分布,设计出快速、使关键字均匀分布的哈希函数。在课程的第四部分,集群的负载均衡也用到了哈希函数及其设计思想,只不过,哈希桶从一段内存变成了一台服务器。
|
||||
|
||||
再延伸说一点,哈希表、红黑树等这些索引都使用了以空间换时间的思想。判断它们的时间消耗,我们都需要依赖时间复杂度这个工具。当然,索引在某些场景下也会降低性能。例如添加、删除元素时,更新索引消耗的时间就是新增的。但相对于整体的收益,这些消耗是微不足道的。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后留给大家一个思考题,你用过哪些其他类型的索引?基于怎样的应用场景和约束,才选择使用这些索引的?欢迎你在留言区与大家一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
127
极客时间专栏/系统性能调优必知必会/基础设施优化/04 | 零拷贝:如何高效地传输文件?.md
Normal file
127
极客时间专栏/系统性能调优必知必会/基础设施优化/04 | 零拷贝:如何高效地传输文件?.md
Normal file
@@ -0,0 +1,127 @@
|
||||
<audio id="audio" title="04 | 零拷贝:如何高效地传输文件?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f6/3d/f674dd9b550efc1310ee509bd656693d.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
上一讲我们谈到,当索引的大小超过内存时,就会用磁盘存放索引。磁盘的读写速度远慢于内存,所以才针对磁盘设计了减少读写次数的B树索引。
|
||||
|
||||
**磁盘是主机中最慢的硬件之一,常常是性能瓶颈,所以优化它能获得立竿见影的效果。**
|
||||
|
||||
因此,针对磁盘的优化技术层出不穷,比如零拷贝、直接IO、异步IO等等。这些优化技术为了降低操作时延、提升系统的吞吐量,围绕着内核中的磁盘高速缓存(也叫PageCache),去减少CPU和磁盘设备的工作量。
|
||||
|
||||
这些磁盘优化技术和策略虽然很有效,但是理解它们并不容易。只有搞懂内核操作磁盘的流程,灵活正确地使用,才能有效地优化磁盘性能。
|
||||
|
||||
这一讲,我们就通过解决“如何高效地传输文件”这个问题,来分析下磁盘是如何工作的,并且通过优化传输文件的性能,带你学习现在热门的零拷贝、异步IO与直接IO这些磁盘优化技术。
|
||||
|
||||
## 你会如何实现文件传输?
|
||||
|
||||
服务器提供文件传输功能,需要将磁盘上的文件读取出来,通过网络协议发送到客户端。如果需要你自己编码实现这个文件传输功能,你会怎么实现呢?
|
||||
|
||||
通常,你会选择最直接的方法:从网络请求中找出文件在磁盘中的路径后,如果这个文件比较大,假设有320MB,可以在内存中分配32KB的缓冲区,再把文件分成一万份,每份只有32KB,这样,从文件的起始位置读入32KB到缓冲区,再通过网络API把这32KB发送到客户端。接着重复一万次,直到把完整的文件都发送完毕。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/65/ee/6593f66902b337ec666551fe2c6f5bee.jpg" alt="">
|
||||
|
||||
不过这个方案性能并不好,主要有两个原因。
|
||||
|
||||
首先,它至少**经历了4万次用户态与内核态的上下文切换。**因为每处理32KB的消息,就需要一次read调用和一次write调用,每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态。可见,每处理32KB,就有4次上下文切换,重复1万次后就有4万次切换。
|
||||
|
||||
上下文切换的成本并不小,虽然一次切换仅消耗几十纳秒到几微秒,但高并发服务会放大这类时间的消耗。
|
||||
|
||||
其次,这个方案做了**4万次内存拷贝,对320MB文件拷贝的字节数也翻了4倍,到了1280MB。**很显然,过多的内存拷贝无谓地消耗了CPU资源,降低了系统的并发处理能力。
|
||||
|
||||
所以要想提升传输文件的性能,需要从**降低上下文切换的频率和内存拷贝次数**两个方向入手。
|
||||
|
||||
## 零拷贝如何提升文件传输性能?
|
||||
|
||||
首先,我们来看如何降低上下文切换的频率。
|
||||
|
||||
为什么读取磁盘文件时,一定要做上下文切换呢?这是因为,读取磁盘或者操作网卡都由操作系统内核完成。内核负责管理系统上的所有进程,它的权限最高,工作环境与用户进程完全不同。只要我们的代码执行read或者write这样的系统调用,一定会发生2次上下文切换:首先从用户态切换到内核态,当内核执行完任务后,再切换回用户态交由进程代码执行。
|
||||
|
||||
因此,如果想减少上下文切换次数,就一定要减少系统调用的次数。解决方案就是把read、write两次系统调用合并成一次,在内核中完成磁盘与网卡的数据交换。
|
||||
|
||||
其次,我们应该考虑如何减少内存拷贝次数。
|
||||
|
||||
每周期中的4次内存拷贝,其中与物理设备相关的2次拷贝是必不可少的,包括:把磁盘内容拷贝到内存,以及把内存拷贝到网卡。但另外2次与用户缓冲区相关的拷贝动作都不是必需的,因为在把磁盘文件发到网络的场景中,**用户缓冲区没有必须存在的理由**。
|
||||
|
||||
如果内核在读取文件后,直接把PageCache中的内容拷贝到Socket缓冲区,待到网卡发送完毕后,再通知进程,这样就只有2次上下文切换,和3次内存拷贝。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bf/a1/bf80b6f858d5cb49f600a28f853e89a1.jpg" alt="">
|
||||
|
||||
如果网卡支持SG-DMA(The Scatter-Gather Direct Memory Access)技术,还可以再去除Socket缓冲区的拷贝,这样一共只有2次内存拷贝。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0a/77/0afb2003d8aebaee763d22dda691ca77.jpg" alt="">
|
||||
|
||||
**实际上,这就是零拷贝技术。**
|
||||
|
||||
它是操作系统提供的新函数,同时接收文件描述符和TCP socket作为输入参数,这样执行时就可以完全在内核态完成内存拷贝,既减少了内存拷贝次数,也降低了上下文切换次数。
|
||||
|
||||
而且,零拷贝取消了用户缓冲区后,不只降低了用户内存的消耗,还通过最大化利用socket缓冲区中的内存,间接地再一次减少了系统调用的次数,从而带来了大幅减少上下文切换次数的机会!
|
||||
|
||||
你可以回忆下,没用零拷贝时,为了传输320MB的文件,在用户缓冲区分配了32KB的内存,把文件分成1万份传送,然而,**这32KB是怎么来的?**为什么不是32MB或者32字节呢?这是因为,在没有零拷贝的情况下,我们希望内存的利用率最高。如果用户缓冲区过大,它就无法一次性把消息全拷贝给socket缓冲区;如果用户缓冲区过小,则会导致过多的read/write系统调用。
|
||||
|
||||
那用户缓冲区为什么不与socket缓冲区大小一致呢?这是因为,**socket缓冲区的可用空间是动态变化的**,它既用于TCP滑动窗口,也用于应用缓冲区,还受到整个系统内存的影响(我在《Web协议详解与抓包实战》第5部分课程对此有详细介绍,这里不再赘述)。尤其在长肥网络中,它的变化范围特别大。
|
||||
|
||||
**零拷贝使我们不必关心socket缓冲区的大小。**比如,调用零拷贝发送方法时,尽可以把发送字节数设为文件的所有未发送字节数,例如320MB,也许此时socket缓冲区大小为1.4MB,那么一次性就会发送1.4MB到客户端,而不是只有32KB。这意味着对于1.4MB的1次零拷贝,仅带来2次上下文切换,而不使用零拷贝且用户缓冲区为32KB时,经历了176次(4 * 1.4MB/32KB)上下文切换。
|
||||
|
||||
综合上述各种优点,**零拷贝可以把性能提升至少一倍以上!**对文章开头提到的320MB文件的传输,当socket缓冲区在1.4MB左右时,只需要4百多次上下文切换,以及4百多次内存拷贝,拷贝的数据量也仅有640MB,这样,不只请求时延会降低,处理每个请求消耗的CPU资源也会更少,从而支持更多的并发请求。
|
||||
|
||||
此外,零拷贝还使用了PageCache技术,通过它,零拷贝可以进一步提升性能,我们接下来看看PageCache是如何做到这一点的。
|
||||
|
||||
## PageCache,磁盘高速缓存
|
||||
|
||||
回顾上文中的几张图,你会发现,读取文件时,是先把磁盘文件拷贝到PageCache上,再拷贝到进程中。为什么这样做呢?有两个原因所致。
|
||||
|
||||
第一,由于磁盘比内存的速度慢许多,所以我们应该想办法把读写磁盘替换成读写内存,比如把磁盘中的数据复制到内存中,就可以用读内存替换读磁盘。但是,内存空间远比磁盘要小,内存中注定只能复制一小部分磁盘中的数据。
|
||||
|
||||
选择哪些数据复制到内存呢?通常,刚被访问的数据在短时间内再次被访问的概率很高(这也叫“时间局部性”原理),用PageCache缓存最近访问的数据,当空间不足时淘汰最久未被访问的缓存(即LRU算法)。读磁盘时优先到PageCache中找一找,如果数据存在便直接返回,这便大大提升了读磁盘的性能。
|
||||
|
||||
第二,读取磁盘数据时,需要先找到数据所在的位置,对于机械磁盘来说,就是旋转磁头到数据所在的扇区,再开始顺序读取数据。其中,旋转磁头耗时很长,为了降低它的影响,PageCache使用了**预读功能**。
|
||||
|
||||
也就是说,虽然read方法只读取了0-32KB的字节,但内核会把其后的32-64KB也读取到PageCache,这后32KB读取的成本很低。如果在32-64KB淘汰出PageCache前,进程读取到它了,收益就非常大。这一讲的传输文件场景中这是必然发生的。
|
||||
|
||||
从这两点可以看到PageCache的优点,它在90%以上场景下都会提升磁盘性能,**但在某些情况下,PageCache会不起作用,甚至由于多做了一次内存拷贝,造成性能的降低。**在这些场景中,使用了PageCache的零拷贝也会损失性能。
|
||||
|
||||
具体是什么场景呢?就是在传输大文件的时候。比如,你有很多GB级的文件需要传输,每当用户访问这些大文件时,内核就会把它们载入到PageCache中,这些大文件很快会把有限的PageCache占满。
|
||||
|
||||
然而,由于文件太大,文件中某一部分内容被再次访问到的概率其实非常低。这带来了2个问题:首先,由于PageCache长期被大文件占据,热点小文件就无法充分使用PageCache,它们读起来变慢了;其次,PageCache中的大文件没有享受到缓存的好处,但却耗费CPU(或者DMA)多拷贝到PageCache一次。
|
||||
|
||||
所以,高并发场景下,为了防止PageCache被大文件占满后不再对小文件产生作用,**大文件不应使用PageCache,进而也不应使用零拷贝技术处理。**
|
||||
|
||||
## 异步IO + 直接IO
|
||||
|
||||
高并发场景处理大文件时,应当使用异步IO和直接IO来替换零拷贝技术。
|
||||
|
||||
仍然回到本讲开头的例子,当调用read方法读取文件时,实际上read方法会在磁盘寻址过程中阻塞等待,导致进程无法并发地处理其他任务,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9e/4e/9ef6fcb7da58a007f8f4e3e67442df4e.jpg" alt="">
|
||||
|
||||
异步IO(异步IO既可以处理网络IO,也可以处理磁盘IO,这里我们只关注磁盘IO)可以解决阻塞问题。它把读操作分为两部分,前半部分向内核发起读请求,但**不等待数据就位就立刻返回**,此时进程可以并发地处理其他任务。当内核将磁盘中的数据拷贝到进程缓冲区后,进程将接收到内核的通知,再去处理数据,这是异步IO的后半部分。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/15/f3/15d33cf599d11b3188253912b21e4ef3.jpg" alt="">
|
||||
|
||||
从图中可以看到,异步IO并没有拷贝到PageCache中,这其实是异步IO实现上的缺陷。经过PageCache的IO我们称为缓存IO,它与虚拟内存系统耦合太紧,导致异步IO从诞生起到现在都不支持缓存IO。
|
||||
|
||||
绕过PageCache的IO是个新物种,我们把它称为直接IO。对于磁盘,异步IO只支持直接IO。
|
||||
|
||||
直接IO的应用场景并不多,主要有两种:第一,应用程序已经实现了磁盘文件的缓存,不需要PageCache再次缓存,引发额外的性能消耗。比如MySQL等数据库就使用直接IO;第二,高并发下传输大文件,我们上文提到过,大文件难以命中PageCache缓存,又带来额外的内存拷贝,同时还挤占了小文件使用PageCache时需要的内存,因此,这时应该使用直接IO。
|
||||
|
||||
当然,直接IO也有一定的缺点。除了缓存外,内核(IO调度算法)会试图缓存尽量多的连续IO在PageCache中,最后**合并**成一个更大的IO再发给磁盘,这样可以减少磁盘的寻址操作;另外,内核也会**预读**后续的IO放在PageCache中,减少磁盘操作。直接IO绕过了PageCache,所以无法享受这些性能提升。
|
||||
|
||||
有了直接IO后,异步IO就可以无阻塞地读取文件了。现在,大文件由异步IO和直接IO处理,小文件则交由零拷贝处理,至于判断文件大小的阈值可以灵活配置(参见Nginx的directio指令)。
|
||||
|
||||
## 小结
|
||||
|
||||
基于用户缓冲区传输文件时,过多的内存拷贝与上下文切换次数会降低性能。零拷贝技术在内核中完成内存拷贝,天然降低了内存拷贝次数。它通过一次系统调用合并了磁盘读取与网络发送两个操作,降低了上下文切换次数。尤其是,由于拷贝在内核中完成,它可以最大化使用socket缓冲区的可用空间,从而提高了一次系统调用中处理的数据量,进一步降低了上下文切换次数。
|
||||
|
||||
零拷贝技术基于PageCache,而PageCache缓存了最近访问过的数据,提升了访问缓存数据的性能,同时,为了解决机械磁盘寻址慢的问题,它还协助IO调度算法实现了IO合并与预读(这也是顺序读比随机读性能好的原因),这进一步提升了零拷贝的性能。几乎所有操作系统都支持零拷贝,如果应用场景就是把文件发送到网络中,那么我们应当选择使用了零拷贝的解决方案。
|
||||
|
||||
不过,零拷贝有一个缺点,就是不允许进程对文件内容作一些加工再发送,比如数据压缩后再发送。另外,当PageCache引发负作用时,也不能使用零拷贝,此时可以用异步IO+直接IO替换。我们通常会设定一个文件大小阈值,针对大文件使用异步IO和直接IO,而对小文件使用零拷贝。
|
||||
|
||||
事实上PageCache对写操作也有很大的性能提升,因为write方法在写入内存中的PageCache后就会返回,速度非常快,由内核负责异步地把PageCache刷新到磁盘中,这里不再展开。
|
||||
|
||||
这一讲我们从零拷贝出发,看到了文件传输场景中内核在幕后所做的工作。这里面的性能优化技术,要么减少了磁盘的工作量(比如PageCache缓存),要么减少了CPU的工作量(比如直接IO),要么提高了内存的利用率(比如零拷贝)。你在学习其他磁盘IO优化技术时,可以延着这三个优化方向前进,看看究竟如何降低时延、提高并发能力。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,留给你一个思考题,异步IO一定不会阻塞进程吗?如果阻塞了进程,该如何解决呢?欢迎你在留言区与大家一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
107
极客时间专栏/系统性能调优必知必会/基础设施优化/05 | 协程:如何快速地实现高并发服务?.md
Normal file
107
极客时间专栏/系统性能调优必知必会/基础设施优化/05 | 协程:如何快速地实现高并发服务?.md
Normal file
@@ -0,0 +1,107 @@
|
||||
<audio id="audio" title="05 | 协程:如何快速地实现高并发服务?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/da/49/da9581b5c37a49649cdcaeb7485dc649.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
上一讲谈到,零拷贝通过减少上下文切换次数,提升了文件传输的性能。事实上高并发服务也是通过降低切换成本实现的,这一讲我们来看看它是如何做到的。
|
||||
|
||||
如果你需要访问多个服务来完成一个请求的处理,比如实现文件上传功能时,首先访问Redis缓存,验证用户是否登陆,再接收HTTP消息中的body并保存在磁盘上,最后把文件路径等信息写入MySQL数据库中,你会怎么做?
|
||||
|
||||
用阻塞API写同步代码最简单,但一个线程同一时间只能处理一个请求,有限的线程数导致无法实现万级别的并发连接,过多的线程切换也抢走了CPU的时间,从而降低了每秒能够处理的请求数量。
|
||||
|
||||
为了达到高并发,你可能会选择一个异步框架,用非阻塞API把业务逻辑打乱到多个回调函数,通过多路复用实现高并发,然而,由于业务代码过度关注并发细节,需要维护很多中间状态,不但Bug率会很高,项目的开发速度也上不去,产品及时上线存在风险。
|
||||
|
||||
如果想兼顾开发效率,又能保证高并发,协程就是最好的选择。它可以在保持异步化运行机制的同时,用同步方式写代码,这在实现高并发的同时,缩短了开发周期,是高性能服务未来的发展方向。
|
||||
|
||||
你会发现,解决高并发问题的技术一直在变化,从多进程、多线程,到异步化、协程,面对不同的场景,它们都在用各自不同的方式解决问题。我们就来看看,高并发的解决方案是怎么演进的,协程到底解决了什么问题,它又该如何应用。
|
||||
|
||||
## 如何通过切换请求实现高并发?
|
||||
|
||||
我们知道,主机上资源有限,一颗CPU、一块磁盘、一张网卡,如何同时服务上百个请求呢?多进程模式是最初的解决方案。内核把CPU的执行时间切分成许多时间片(timeslice),比如1秒钟可以切分为100个10毫秒的时间片,每个时间片再分发给不同的进程,通常,每个进程需要多个时间片才能完成一个请求。
|
||||
|
||||
这样,虽然微观上,比如说就这10毫秒时间CPU只能执行一个进程,但宏观上1秒钟执行了100个时间片,于是每个时间片所属进程中的请求也得到了执行,**这就实现了请求的并发执行。**
|
||||
|
||||
不过,每个进程的内存空间都是独立的,这样用多进程实现并发就有两个缺点:一是内核的管理成本高,二是无法简单地通过内存同步数据,很不方便。于是,多线程模式就出现了,多线程模式通过共享内存地址空间,解决了这两个问题。
|
||||
|
||||
然而,共享地址空间虽然可以方便地共享对象,但这也导致一个问题,那就是任何一个线程出错时,进程中的所有线程会跟着一起崩溃。这也是如Nginx等强调稳定性的服务坚持使用多进程模式的原因。
|
||||
|
||||
事实上,无论基于多进程还是多线程,都难以实现高并发,这由两个原因所致。
|
||||
|
||||
首先,单个线程消耗的内存过多,比如,64位的Linux为每个线程的栈分配了8MB的内存,还预分配了64MB的内存作为堆内存池(你可以从[[第2讲]](https://time.geekbang.org/column/article/230221) 中找到Linux系统为什么这么做)。所以,我们没有足够的内存去开启几万个线程实现并发。
|
||||
|
||||
其次,切换请求是内核通过切换线程实现的,什么时候会切换线程呢?不只时间片用尽,**当调用阻塞方法时,内核为了让CPU充分工作,也会切换到其他线程执行。**一次上下文切换的成本在几十纳秒到几微秒间,当线程繁忙且数量众多时,这些切换会消耗绝大部分的CPU运算能力。
|
||||
|
||||
下图以上一讲介绍过的磁盘IO为例,描述了多线程中使用阻塞方法读磁盘,2个线程间的切换方式。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a7/1e/a7729794e84cbb4a295454c6f2005c1e.jpg" alt="">
|
||||
|
||||
那么,怎么才能实现高并发呢?**把上图中本来由内核实现的请求切换工作,交由用户态的代码来完成就可以了**,异步化编程通过应用层代码实现了请求切换,降低了切换成本和内存占用空间。异步化依赖于IO多路复用机制,比如Linux的epoll或者Windows上的iocp,同时,必须把阻塞方法更改为非阻塞方法,才能避免内核切换带来的巨大消耗。Nginx、Redis等高性能服务都依赖异步化实现了百万量级的并发。
|
||||
|
||||
下图描述了异步IO的非阻塞读和异步框架结合后,是如何切换请求的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5f/8e/5f5ad4282571d8148d87416c8f8fa88e.jpg" alt="">
|
||||
|
||||
**然而,写异步化代码很容易出错。**因为所有阻塞函数,都需要通过非阻塞的系统调用拆分成两个函数。虽然这两个函数共同完成一个功能,但调用方式却不同。第一个函数由你显式调用,第二个函数则由多路复用机制调用。这种方式违反了软件工程的内聚性原则,函数间同步数据也更复杂。特别是条件分支众多、涉及大量系统调用时,异步化的改造工作会非常困难。
|
||||
|
||||
有没有办法既享受到异步化带来的高并发,又可以使用阻塞函数写同步化代码呢?
|
||||
|
||||
协程可以做到,**它在异步化之上包了一层外衣,兼顾了开发效率与运行效率。**
|
||||
|
||||
## 协程是如何实现高并发的?
|
||||
|
||||
协程与异步编程相似的地方在于,它们必须使用非阻塞的系统调用与内核交互,把切换请求的权力牢牢掌握在用户态的代码中。但不同的地方在于,协程把异步化中的两段函数,封装为一个阻塞的协程函数。这个函数执行时,会使调用它的协程无感知地放弃执行权,由协程框架切换到其他就绪的协程继续执行。当这个函数的结果满足后,协程框架再选择合适的时机,切换回它所在的协程继续执行。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e4/57/e47ec54ff370cbda4528e285e3378857.jpg" alt="">
|
||||
|
||||
看起来非常棒,然而,异步化是通过回调函数来完成请求切换的,业务逻辑与并发实现关联在一起,很容易出错。协程不需要什么“回调函数”,它允许用户调用“阻塞的”协程方法,用同步编程方式写业务逻辑。
|
||||
|
||||
那协程的切换是如何完成的呢?
|
||||
|
||||
实际上,**用户态的代码切换协程,与内核切换线程的原理是一样的。**内核通过管理CPU的寄存器来切换线程,我们以最重要的栈寄存器和指令寄存器为例,看看协程切换时如何切换程序指令与内存。
|
||||
|
||||
每个线程有独立的栈,而栈既保留了变量的值,也保留了函数的调用关系、参数和返回值,CPU中的栈寄存器SP指向了当前线程的栈,而指令寄存器IP保存着下一条要执行的指令地址。因此,从线程1切换到线程2时,首先要把SP、IP寄存器的值为线程1保存下来,再从内存中找出线程2上一次切换前保存好的寄存器值,写入CPU的寄存器,这样就完成了线程切换。(其他寄存器也需要管理、替换,原理与此相同,不再赘述。)
|
||||
|
||||
协程的切换与此相同,只是把内核的工作转移到协程框架实现而已,下图是协程切换前的状态:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a8/f7/a83d7e0f37f35353c6347aa76c8184f7.jpg" alt="">
|
||||
|
||||
从协程1切换到协程2后的状态如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/25/3f/25d2dcb8aa4569e5de741469f03aa73f.jpg" alt="">
|
||||
|
||||
创建协程时,会从进程的堆中(参见[[第2讲]](https://time.geekbang.org/column/article/230221))分配一段内存作为协程的栈。线程的栈有8MB,而协程栈的大小通常只有几十KB。而且,C库内存池也不会为协程预分配内存,它感知不到协程的存在。这样,更低的内存占用空间为高并发提供了保证,毕竟十万并发请求,就意味着10万个协程。当然,栈缩小后,就尽量不要使用递归函数,也不能在栈中申请过多的内存,这是实现高并发必须付出的代价。
|
||||
|
||||
由此可见,协程就是用户态的线程。然而,为了保证所有切换都在用户态进行,协程必须重新封装所有的阻塞系统调用,否则,一旦协程触发了线程切换,会导致这个线程进入休眠状态,进而其上的所有协程都得不到执行。比如,普通的sleep函数会让当前线程休眠,由内核来唤醒线程,而协程化改造后,sleep只会让当前协程休眠,由协程框架在指定时间后唤醒协程。再比如,线程间的互斥锁是使用信号量实现的,而信号量也会导致线程休眠,协程化改造互斥锁后,同样由框架来协调、同步各协程的执行。
|
||||
|
||||
**所以,协程的高性能,建立在切换必须由用户态代码完成之上,这要求协程生态是完整的,要尽量覆盖常见的组件。**比如MySQL官方提供的客户端SDK,它使用了阻塞socket做网络访问,会导致线程休眠,必须用非阻塞socket把SDK改造为协程函数后,才能在协程中使用。
|
||||
|
||||
当然,并不是所有的函数都能用协程改造。比如[[第4讲]](https://time.geekbang.org/column/article/232676) 提到的异步IO,它虽然是非阻塞的,但无法使用PageCache,降低了系统吞吐量。如果使用缓存IO读文件,在没有命中PageCache时是可能发生阻塞的。
|
||||
|
||||
这种时候,如果对性能有更高的要求,就需要把线程与协程结合起来用,把可能阻塞的操作放在线程中执行,通过生产者/消费者模型与协程配合工作。
|
||||
|
||||
实际上,面对多核系统,也需要协程与线程配合工作。因为协程的载体是线程,而一个线程同一时间只能使用一颗CPU,所以通过开启更多的线程,将所有协程分布在这些线程中,就能充分使用CPU资源。
|
||||
|
||||
除此之外,为了让协程获得更多的CPU时间,还可以设置所在线程的优先级,比如Linux下把线程的优先级设置到-20,就可以每次获得更长的时间片。另外,[[第1讲]](https://time.geekbang.org/column/article/230194) 曾谈到CPU缓存对程序性能的影响,为了减少CPU缓存失效的比例,还可以把线程绑定到某个CPU上,增加协程执行时命中CPU缓存的机率。
|
||||
|
||||
虽然这一讲中谈到协程框架在调度协程,然而,你会发现,很多协程库只提供了创建、挂起、恢复执行等基本方法,并没有协程框架的存在,需要业务代码自行调度协程。这是因为,这些通用的协程库并不是专为服务器设计的。服务器中可以由客户端网络连接的建立,驱动着创建出协程,同时伴随着请求的结束而终止。在协程的运行条件不满足时,多路复用框架会将它挂起,并根据优先级策略选择另一个协程执行。
|
||||
|
||||
因此,使用协程实现服务器端的高并发服务时,并不只是选择协程库,还要从其生态中找到结合IO多路复用的协程框架,这样可以加快开发速度。
|
||||
|
||||
## 小结
|
||||
|
||||
这一讲,我们从高并发的应用场景入手,分析了协程出现的背景和实现原理,以及它的应用范围。你会发现,协程融合了多线程与异步化编程的优点,既保证了开发效率,也提升了运行效率。
|
||||
|
||||
有限的硬件资源下,多线程通过微观上时间片的切换,实现了同时服务上百个用户的能力。多线程的开发成本虽然低,但内存消耗大,切换次数过多,无法实现高并发。
|
||||
|
||||
异步编程方式通过非阻塞系统调用和多路复用,把原本属于内核的请求切换能力,放在用户态的代码中执行。这样,不仅减少了每个请求的内存消耗,也降低了切换请求的成本,最终实现了高并发。然而,异步编程违反了代码的内聚性,还需要业务代码关注并发细节,开发成本很高。
|
||||
|
||||
协程参考内核通过CPU寄存器切换线程的方法,在用户态代码中实现了协程的切换,既降低了切换请求的成本,也使得协程中的业务代码不用关注自己何时被挂起,何时被执行。相比异步编程中要维护一堆数据结构表示中间状态,协程直接用代码表示状态,大大提升了开发效率。
|
||||
|
||||
在协程中调用的所有API,都需要做非阻塞的协程化改造。优秀的协程生态下,常用服务都有对应的协程SDK,方便业务代码使用。开发高并发服务时,与IO多路复用结合的协程框架可以与这些SDK配合,自动挂起、切换协程,进一步提升开发效率。
|
||||
|
||||
协程并不是完全与线程无关,首先线程可以帮助协程充分使用多核CPU的计算力,其次,遇到无法协程化、会导致内核切换的阻塞函数,或者计算太密集从而长时间占用CPU的任务,还是要放在独立的线程中执行,以防止它影响所有协程的执行。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,留给你一个思考题,你用过协程吗?觉得它还有什么优点?如果没有在生产环境中使用协程,原因是什么?欢迎你在留言区与我一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课有所收获,也欢迎把它分享给你的朋友。
|
||||
137
极客时间专栏/系统性能调优必知必会/基础设施优化/06 | 锁:如何根据业务场景选择合适的锁?.md
Normal file
137
极客时间专栏/系统性能调优必知必会/基础设施优化/06 | 锁:如何根据业务场景选择合适的锁?.md
Normal file
@@ -0,0 +1,137 @@
|
||||
<audio id="audio" title="06 | 锁:如何根据业务场景选择合适的锁?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5a/d0/5a3e4fb278572544e11504f5b9ecd7d0.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
上一讲我们谈到了实现高并发的不同方案,这一讲我们来谈谈如何根据业务场景选择合适的锁。
|
||||
|
||||
我们知道,多线程下为了确保数据不会出错,必须加锁后才能访问共享资源。我们最常用的是互斥锁,然而,还有很多种不同的锁,比如自旋锁、读写锁等等,它们分别适用于不同的场景。
|
||||
|
||||
比如高并发场景下,要求每个函数的执行时间必须都足够得短,这样所有请求才能及时得到响应,如果你选择了错误的锁,数万请求同时争抢下,很容易导致大量请求长期取不到锁而处理超时,系统吞吐量始终维持在很低的水平,用户体验非常差,最终“高并发”成了一句空谈。
|
||||
|
||||
怎样选择最合适的锁呢?首先我们必须清楚加锁的成本究竟有多大,其次我们要分析业务场景中访问共享资源的方式,最后则要预估并发访问时发生锁冲突的概率。这样,我们才能选对锁,同时实现高并发和高吞吐量这两个目标。
|
||||
|
||||
今天,我们就针对不同的应用场景,了解下锁的选择和使用,从而减少锁对高并发性能的影响。
|
||||
|
||||
## 互斥锁与自旋锁:休眠还是“忙等待”?
|
||||
|
||||
我们常见的各种锁是有层级的,最底层的两种锁就是互斥锁和自旋锁,其他锁都是基于它们实现的。互斥锁的加锁成本更高,但它在加锁失败时会释放CPU给其他线程;自旋锁则刚好相反。
|
||||
|
||||
**当你无法判断锁住的代码会执行多久时,应该首选互斥锁,互斥锁是一种独占锁。**什么意思呢?当A线程取到锁后,互斥锁将被A线程独自占有,当A没有释放这把锁时,其他线程的取锁代码都会被阻塞。
|
||||
|
||||
阻塞是怎样进行的呢?**对于99%的线程级互斥锁而言,阻塞都是由操作系统内核实现的**(比如Linux下它通常由内核提供的信号量实现)。当获取锁失败时,内核会将线程置为休眠状态,等到锁被释放后,内核会在合适的时机唤醒线程,而这个线程成功拿到锁后才能继续执行。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/74/8a/749fc674c55136bd455725b79c9e0c8a.jpg" alt="">
|
||||
|
||||
互斥锁通过内核帮忙切换线程,简化了业务代码使用锁的难度。
|
||||
|
||||
但是,线程获取锁失败时,增加了两次上下文切换的成本:从运行中切换为休眠,以及锁释放时从休眠状态切换为运行中。上下文切换耗时在几十纳秒到几微秒之间,或许这段时间比锁住的代码段执行时间还长。而且,线程主动进入休眠是高并发服务无法容忍的行为,这让其他异步请求都无法执行。
|
||||
|
||||
如果你能确定被锁住的代码执行时间很短,就应该用自旋锁取代互斥锁。
|
||||
|
||||
自旋锁比互斥锁快得多,因为它通过CPU提供的CAS函数(全称Compare And Swap),在用户态代码中完成加锁与解锁操作。
|
||||
|
||||
我们知道,加锁流程包括2个步骤:第1步查看锁的状态,如果锁是空闲的,第2步将锁设置为当前线程持有。
|
||||
|
||||
在没有CAS操作前,多个线程同时执行这2个步骤是会出错的。比如线程A执行第1步发现锁是空闲的,但它在执行第2步前,线程B也执行了第1步,B也发现锁是空闲的,于是线程A、B会同时认为它们获得了锁。
|
||||
|
||||
CAS函数把这2个步骤合并为一条硬件级指令。这样,第1步比较锁状态和第2步锁变量赋值,将变为不可分割的原子指令。于是,设锁为变量lock,整数0表示锁是空闲状态,整数pid表示线程ID,那么CAS(lock, 0, pid)就表示自旋锁的加锁操作,CAS(lock, pid, 0)则表示解锁操作。
|
||||
|
||||
多线程竞争锁的时候,加锁失败的线程会“忙等待”,直到它拿到锁。什么叫“忙等待”呢?它并不意味着一直执行CAS函数,生产级的自旋锁在“忙等待”时,会与CPU紧密配合 ,它通过CPU提供的PAUSE指令,减少循环等待时的耗电量;对于单核CPU,忙等待并没有意义,此时它会主动把线程休眠。
|
||||
|
||||
如果你对此感兴趣,可以阅读下面这段生产级的自旋锁,看看它是怎么执行“忙等待”的:
|
||||
|
||||
```
|
||||
while (true) {
|
||||
//因为判断lock变量的值比CAS操作更快,所以先判断lock再调用CAS效率更高
|
||||
if (lock == 0 && CAS(lock, 0, pid) == 1) return;
|
||||
|
||||
if (CPU_count > 1 ) { //如果是多核CPU,“忙等待”才有意义
|
||||
for (n = 1; n < 2048; n <<= 1) {//pause的时间,应当越来越长
|
||||
for (i = 0; i < n; i++) pause();//CPU专为自旋锁设计了pause指令
|
||||
if (lock == 0 && CAS(lock, 0, pid)) return;//pause后再尝试获取锁
|
||||
}
|
||||
}
|
||||
sched_yield();//单核CPU,或者长时间不能获取到锁,应主动休眠,让出CPU
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在使用层面上,自旋锁与互斥锁很相似,实现层面上它们又完全不同。自旋锁开销少,在多核系统下一般不会主动产生线程切换,很适合在用户态切换请求的编程方式,有助于高并发服务充分利用多颗CPU。但如果被锁住的代码执行时间过长,CPU资源将被其他线程在“忙等待”中长时间占用。
|
||||
|
||||
当取不到锁时,互斥锁用“线程切换”来面对,自旋锁则用“忙等待”来面对。**这是两种最基本的处理方式,更高级别的锁都会选择其中一种来实现,比如读写锁就既可以基于互斥锁实现,也可以基于自旋锁实现。**
|
||||
|
||||
下面我们来看一看读写锁能带来怎样的性能提升。
|
||||
|
||||
## 允许并发持有的读写锁
|
||||
|
||||
**如果你能够明确区分出读和写两种场景,可以选择读写锁。**
|
||||
|
||||
读写锁由读锁和写锁两部分构成,仅读取共享资源的代码段用读锁来加锁,会修改资源的代码段则用写锁来加锁。
|
||||
|
||||
读写锁的优势在于,当写锁未被持有时,多个线程能够并发地持有读锁,这提高了共享资源的使用率。多个读锁被同时持有时,读线程并不会修改共享资源,所以它们的并发执行不会产生数据错误。
|
||||
|
||||
而一旦写锁被持有后,不只读线程必须阻塞在获取读锁的环节,其他获取写锁的写线程也要被阻塞。写锁就像互斥锁和自旋锁一样,是一种独占锁;而读锁允许并发持有,则是一种共享锁。
|
||||
|
||||
**因此,读写锁真正发挥优势的场景,必然是读多写少的场景,否则读锁将很难并发持有。**
|
||||
|
||||
实际上,读写锁既可以倾向于读线程,又可以倾向于写线程。前者我们称为读优先锁,后者称为写优先锁。
|
||||
|
||||
读优先锁更强调效率,它期待锁能被更多的线程持有。简单看下它的工作特点:当线程A先持有读锁后,即使线程B在等待写锁,后续前来获取读锁的线程C仍然可以立刻加锁成功,因为这样就有A、C 这2个读线程在并发持有锁,效率更高。
|
||||
|
||||
我们再来看写优先的读写锁。同样的情况下,线程C获取读锁会失败,它将被阻塞在获取锁的代码中,这样,只要线程A释放读锁后,线程B马上就可以获取到写锁。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7b/c6/7b5f4e4bb3370b89b90c1bf83cb58fc6.jpg" alt="">
|
||||
|
||||
读优先锁并发性更好,但问题也很明显。如果读线程源源不断地获取读锁,写线程将永远获取不到写锁。写优先锁可以保证写线程不会饿死,但如果新的写线程源源不断地到来,读线程也可能被饿死。
|
||||
|
||||
那么,能否兼顾二者,避免读、写线程饿死呢?
|
||||
|
||||
**用队列把请求锁的线程排队,按照先来后到的顺序加锁即可,当然读线程仍然可以并发,只不过不能插队到写线程之前。**Java中的ReentrantReadWriteLock读写锁,就支持这种排队的公平读写锁。
|
||||
|
||||
如果不希望取锁时线程主动休眠,还可以用自旋锁实现读写锁。到底应该选择“线程切换”还是“忙等待”方式实现读写锁呢?除去读写场景外,这与选择互斥锁和自旋锁的方法相同,就是根据加锁代码执行时间的长短来选择,这里就不再赘述了。
|
||||
|
||||
## 乐观锁:不使用锁也能同步
|
||||
|
||||
事实上,无论互斥锁、自旋锁还是读写锁,都属于悲观锁。
|
||||
|
||||
什么叫悲观锁呢?它认为同时修改资源的概率很高,很容易出现冲突,所以访问共享资源前,先加上锁,总体效率会更优。然而,如果并发产生冲突的概率很低,就不必使用悲观锁,而是使用乐观锁。
|
||||
|
||||
所谓“乐观”,就是假定冲突的概率很低,所以它采用的“加锁”方式是,先修改完共享资源,再验证这段时间内有没有发生冲突。如果没有其他线程在修改资源,那么操作完成。如果发现其他线程已经修改了这个资源,就放弃本次操作。
|
||||
|
||||
至于放弃后如何重试,则与业务场景相关,虽然重试的成本很高,但出现冲突的概率足够低的话,还是可以接受的。可见,**乐观锁全程并没有加锁,所以它也叫无锁编程。**
|
||||
|
||||
无锁编程中,验证是否发生了冲突是关键。该怎么验证呢?这与具体的场景有关。
|
||||
|
||||
比如说在线文档。Web中的在线文档是怎么实现多人编辑的?用户A先在浏览器中编辑某个文档,之后用户B也打开了相同的页面开始编辑,可是,用户B最先编辑完成提交,这一过程用户A却不知道。当A提交他改完的内容时,A、B之间的并行修改引发了冲突。
|
||||
|
||||
Web服务是怎么解决这种冲突的呢?它并没有限制用户先拿到锁后才能编辑文档,这既因为冲突的概率非常低,也因为加解锁的代价很高。Web中的方案是这样的:让用户先改着,但需要浏览器记录下修改前的文档版本号,这通过下载文档时,返回的HTTP ETag头部实现。
|
||||
|
||||
当用户提交修改时,浏览器在请求中通过HTTP If-Match头部携带原版本号,服务器将它与文档的当前版本号比较,一致后新的修改才能生效,否则提交失败。如下图所示(如果你想了解这一过程的细节,可以阅读 [《Web协议详解与抓包实战》第28课](https://time.geekbang.org/course/detail/175-98914)):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1d/f0/1db3bb24d896fabeebf68359384214f0.jpg" alt="">
|
||||
|
||||
乐观锁除了应用在Web分布式场景,在数据库等单机上也有广泛的应用。只是面向多线程时,最后的验证步骤是通过CPU提供的CAS操作完成的。
|
||||
|
||||
乐观锁虽然去除了锁操作,但是一旦发生冲突,重试的成本非常高。所以,**只有在冲突概率非常低,且加锁成本较高时,才考虑使用乐观锁。**
|
||||
|
||||
## 小结
|
||||
|
||||
这一讲我们介绍了高并发下同步资源时,如何根据应用场景选择合适的锁,来优化服务的性能。
|
||||
|
||||
互斥锁能够满足各类功能性要求,特别是被锁住的代码执行时间不可控时,它通过内核执行线程切换及时释放了资源,但它的性能消耗最大。需要注意的是,协程的互斥锁实现原理完全不同,它并不与内核打交道,虽然不能跨线程工作,但效率很高。(如果你希望进一步了解协程,可以阅读[[第5讲]](https://time.geekbang.org/column/article/233629)。)
|
||||
|
||||
如果能够确定被锁住的代码取到锁后很快就能释放,应该使用更高效的自旋锁,它特别适合基于异步编程实现的高并发服务。
|
||||
|
||||
如果能区分出读写操作,读写锁就是第一选择,它允许多个读线程同时持有读锁,提高了并发性。读写锁是有倾向性的,读优先锁很高效,但容易让写线程饿死,而写优先锁会优先服务写线程,但对读线程亲和性差一些。还有一种公平读写锁,它通过把等待锁的线程排队,以略微牺牲性能的方式,保证了某种线程不会饿死,通用性更佳。
|
||||
|
||||
另外,读写锁既可以使用互斥锁实现,也可以使用自旋锁实现,我们应根据场景来选择合适的实现。
|
||||
|
||||
当并发访问共享资源,冲突概率非常低的时候,可以选择无锁编程。它在Web和数据库中有广泛的应用。然而,一旦冲突概率上升,就不适合使用它,因为它解决冲突的重试成本非常高。
|
||||
|
||||
总之,不管使用哪种锁,锁范围内的代码都应尽量的少,执行速度要快。在此之上,选择更合适的锁能够大幅提升高并发服务的性能!
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,留给你一道思考题,上一讲我们提到协程中也有各种锁,你觉得协程中可以用自旋锁或者互斥锁吗?如果不可以,那协程中的锁是怎么实现的?欢迎你在留言区与我探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
Reference in New Issue
Block a user