This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View 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最大的三级缓存则是20MBWindows系统查看缓存大小可以用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 &lt; N; i+=1) {
for(j = 0; j &lt; 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 &lt; TESTN; i++) array[i] = rand() % 256;
```
接下来要对它做两个操作一是循环遍历数组判断每个数字是否小于128如果小于则把元素的值置为0二是将数组排序。那么先排序再遍历速度快还是先遍历再排序速度快呢
```
for(i = 0; i &lt; N; i++) {
if (array [i] &lt; 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缓存就会失效为什么又该如何解决呢欢迎你在留言区与大家一起探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View 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进程的内存占用超出了预期。
掌握内存池的特性既可以避免写程序时内存占用过大导致服务器性能下降或者进程OOMOut 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 Arena32位系统下为1MB64位系统下为64MB。如果有100个线程就将有6GB的内存都会被内存池占用。当然并不是设置了1000个线程就会预分配60GB的内存子线程内存池最多只能到8倍的CPU核数比如在32核的服务器上最多只会有256个子线程内存池但这也非常夸张了16GB64MB * 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))。
内存分配时间虽然不起眼,但时刻用最快的方法申请内存,正是高手与初学者的区别,相似算法的性能差距就体现在这些编码细节上,希望你能够重视它。
## 思考题
最后,留给你一个思考题。分配对象时,除了分配内存,还需要初始化对象的数据结构。内存池对于初始化对象有什么帮助吗?欢迎你在留言区与大家一起探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View 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中的mmapJava等语言都有类似的封装再通过备份文件的方式备份哈希表。虽然操作系统会自动同步内存中变更的数据至文件但备份前还是需要主动刷新内存参考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> 作为基数,这会导致字符串分布不均匀。事实上,我们应当找一个合适的**素数作为基数**比如31Java标准库的BKDR哈希算法就以它为基数它的计算量也很小n*31可以通过先把n左移5位再减去n的方式替换n*31 == n&lt;&lt;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. 优化哈希函数也是降低哈希冲突的重要手段,我们需要研究关键字的特征与分布,设计出快速、使关键字均匀分布的哈希函数。在课程的第四部分,集群的负载均衡也用到了哈希函数及其设计思想,只不过,哈希桶从一段内存变成了一台服务器。
再延伸说一点,哈希表、红黑树等这些索引都使用了以空间换时间的思想。判断它们的时间消耗,我们都需要依赖时间复杂度这个工具。当然,索引在某些场景下也会降低性能。例如添加、删除元素时,更新索引消耗的时间就是新增的。但相对于整体的收益,这些消耗是微不足道的。
## 思考题
最后留给大家一个思考题,你用过哪些其他类型的索引?基于怎样的应用场景和约束,才选择使用这些索引的?欢迎你在留言区与大家一起探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View 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-DMAThe 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一定不会阻塞进程吗如果阻塞了进程该如何解决呢欢迎你在留言区与大家一起探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View 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的任务还是要放在独立的线程中执行以防止它影响所有协程的执行。
## 思考题
最后,留给你一个思考题,你用过协程吗?觉得它还有什么优点?如果没有在生产环境中使用协程,原因是什么?欢迎你在留言区与我一起探讨。
感谢阅读,如果你觉得这节课有所收获,也欢迎把它分享给你的朋友。

View 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 &amp;&amp; CAS(lock, 0, pid) == 1) return;
if (CPU_count &gt; 1 ) { //如果是多核CPU“忙等待”才有意义
for (n = 1; n &lt; 2048; n &lt;&lt;= 1) {//pause的时间应当越来越长
for (i = 0; i &lt; n; i++) pause();//CPU专为自旋锁设计了pause指令
if (lock == 0 &amp;&amp; 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和数据库中有广泛的应用。然而一旦冲突概率上升就不适合使用它因为它解决冲突的重试成本非常高。
总之,不管使用哪种锁,锁范围内的代码都应尽量的少,执行速度要快。在此之上,选择更合适的锁能够大幅提升高并发服务的性能!
## 思考题
最后,留给你一道思考题,上一讲我们提到协程中也有各种锁,你觉得协程中可以用自旋锁或者互斥锁吗?如果不可以,那协程中的锁是怎么实现的?欢迎你在留言区与我探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。