我曾经负责主导了一个性能优化的项目,此项目的主要业务逻辑在于在线抢货并购买。在起初的设计方案里,鉴于要保证库存数据的一致性,后端服务于请求处理时运用了 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
通过对这段代码的两次执行结果加以分析,我们能够看到该并发程序呈现出了两种现象:在线程 A 和线程 B 中,number_1++、number_2++ 累计执行了 20000 次,照理说结果应当是 20000 ,但实际运行的结果却与 20000 存在较大差距。
在线程 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++ 并发执行,从而无法保证执行顺序。
如此一来,我们将以上所有问题进行汇总梳理之后,实际上能够发现导致并发系统执行结果不确定性的根源问题主要有三个,分别是原子性破坏问题、缓存一致性问题、顺序一致性问题。那么我们应当如何去解决并发系统中存在的这三个根源问题呢?想必您肯定会想到,运用互斥锁呀!确实,互斥锁能够有效地解决上述三个问题。
下面,我们就一起来了解下互斥锁是如何解决上面描述的三个问题的,同时在此过程中,我们也来看看由于使用了互斥锁,都会引入什么样的性能开销。
图片
如图所示,在 Lock 加锁后进入临界区之前,以及退出临界区后并执行 Unlock 之前,这两个地方都增添了内存屏障指令(不同的 CPU 架构与 OS 上的实现存在一些差异,不过其基本原理是相似的)。
如此一来,在编译期间通过这两个内存屏障,实现了以下的功能:对临界区与非临界区之间的指令重排序进行了限制;确保在释放锁之前,临界区中的共享数据已经写入到内存中,以此保障多线程间的缓存一致性。
由于临界区是互斥访问的,所以您可以认为临界区的业务逻辑整体上是原子性且缓存一致的,并且跨线程间数据顺序的一致性约束,也统一在临界区内得以实现。
虽然临界区间内的代码是乱序优化执行的,还存在非原子性操作等情况,不过这都不会对程序执行最终结果的确定性造成影响。
另外,从图中您还能够看到,当互斥锁加锁失败后,执行线程会进入休眠状态,一直到互斥锁资源被释放,才会被动地等待内核态重新调度来激活。很明显,线程长时间的休眠会造成业务阻塞,进而影响到软件系统的性能。
所以,在并发程序中使用互斥锁时,一个重要的性能优化手段就是减小临界区的大小,以此来减少线程可能的阻塞时间。比如说,通过删掉一些非冲突的业务逻辑,来缩短临界区的执行代码时间。
不过在这里,请您再思考一个问题:在通过减少临界区代码来优化性能的过程中,如果您发现临界区的执行时间,已经小于线程休眠切换的时间开销(通常线程休眠切换的开销大概在 2us 左右,不同机器在性能上会有一定的差别,需要以实际机器的测试结果为准),那您还会选择互斥锁这种方式吗?实际上,这时候您应该考虑更换一种锁,以减少线程休眠切换所消耗的时间。接下来我要为您介绍的自旋锁(SpinLock),就能够帮助达成这个目的。自旋锁在 Linux 源码中被广泛使用,下面我来给您介绍一下它的基本原理与性能表现吧。
自旋锁的原理与性能
首先,我们还是来了解下自旋锁的实现原理,看看它的处理逻辑是怎么样的,如下图所示:
图片
对比前面互斥锁的工作过程示意图,您能够发现,自旋锁和互斥锁的逻辑差别主要在于:当加锁失败时,当前线程不会进入休眠状态。所以,如果您采用自旋锁这种实现方式,倘若临界区执行的开销较小,那么就能够获取等待时间开销小于线程休眠切换开销所带来的额外收益。
在自旋锁中,临界区的实现机制和互斥锁基本相同,所以它也能够解决前面所提及的并发系统中的三个根源问题。
另外,和互斥锁一样,为了进一步提高软件的性能,您也需要进一步降低线程间的数据依赖。这样,经过您设计优化之后,当把线程之间的依赖数据减少到只有几个变量时,执行的开销可能仅需要几个指令周期就能完成。但是在这种情况下使用锁机制,您还需要在每次数据操作的过程中进行加锁和解锁,如此一来,额外开销的占比就会过高,实际上是不太划算的。
那么既然这样,还有其他更为高效的解决办法吗?当然有!请牢记,锁只是我们解决问题的方式,而非我们需要解决的问题。
现在让我们再次回到问题本身,再来强化记忆一下并发系统内的三个本质问题:原子性破坏问题、缓存一致性问题、顺序一致性问题。在这里您需要明白,在具体的并发业务场景中,可能并不需要您同时去解决这三个问题。例如在多线程场景下的统计变量,两个线程同时更新一个变量,在这里根本就不存在顺序一致性的问题。因此,您首先需要学会的是辨别并发系统中有待解决的问题,然后再去精确地寻找解决办法,这才是进一步提升系统性能的关键所在。
那么,在实际的业务场景中,最常见的导致并发系统执行结果不确定性的问题,实际上是缓存一致性问题,比如典型的生产者消费者问题。不过在嵌入式系统的业务场景中,C 语言已经通过引入 volatile 变量解决了这个问题。接下来,我们就通过使用 volatile 来解决问题的工作流程,来分析、了解一下 volatile 是怎样解决同步互斥中存在的问题的。
volatile 的原理与性能
volatile 是一种特殊变量类型,它主要是为了解决并发系统中的缓存一致性问题。定义为 volatile 类型的变量,会被默认为是缓存失效状态,针对这个变量的读取、设置操作,都可以通过直接操作内存来实现,从而就规避了缓存一致性问题。在 C/C++ 语言中,volatile 一直在沿用这种方式,但这种实现机制并没有完全解决并发系统中的原子性破坏和顺序一致性的问题。而在 Java 语言中,JVM 会在 volatile 变量的过程中添加内存屏障机制,从而可以部分解决顺序一致性的问题。其具体机制如下图所示:
图片
图中,变量 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 类型的原理与机制,可以参考阅读这个文档),进而支撑解决了并发中的三个本质问题。C++ 在 Atmoic 实现的原理和 Java Atomic 是类似的,不过在 C++ 语言中,它定义了更为丰富的一致性内存模型,可供我们灵活选择