本文打算写一些和锁有关的东西,谈一谈我对锁的原理和实现的理解,主要包含以下方面
其实同步与互斥都是计算机科学里面概念性的东西,它们和什么编程语言、操作系统其实都没什么关系。很多人会混淆这两个概念,但是其实这两个概念并不一样(其实也不深奥,我们在写代码的时候肯定都用到过这两种概念性的东西)
这个概念大家应该都清楚,就是说一个资源在任意一个时刻只能被一个进程/线程持有
我们先来看下wikipedia上是怎么解释同步的
同步(英语:Synchronization),指在一个系统中所发生的事件(event),之间进行协调,在时间上出现一致性与统一化的现象。在系统中进行同步,也被称为及时(in time)、同步化的(synchronous、in sync)。
其实就是说,在多进程/多线程里面,所有的操作都是并行的,而且进程/线程的调度都是由操作系统完成的,那么假如在进程A中有操作a,进程B中有操作b,那a和b的执行顺序并不是确定的,同步的语义就是指保证一定的执行顺序,比如保证在b的执行前a已经执行完。仔细考虑一下这种语义,想要实现同步,就必须要防止出现竞态,所以必然使用互斥的特点,同时为了实现有序性,肯定会用到和条件变量性质一样的东西(注意,条件变量是Linux里面的一个概念,后面会讲到,但是注意到它是和操作系统强相关的,是Linux内核提供的一个接口,它和互斥量/信号量不是一个级别的东西)
信号量和互斥量经常被人们混淆,但是其实信号量和互斥量是用来解决不一样的问题的,也就是它们的设计思想是不一样的
用代码举个例子(一些”形象”的例子往往具有迷惑性):在这里我假设你已经知道了信号量的机制,如果你确实忘了,那可以看下面我从别处搬过来的信号量的机制
这个例子是用来计算c = a + b的,但是get_a()、get_b()、calcalate_c()在不同的进程/线程中执行,我们希望保证在计算c之前已经执行过calculate_a()和calculate_b(),以保证得到预期的结果,这也就是我们的同步需求
~~~c
int a, b, c;
//may process in Application A
void get_a() {
a = calculate_a();
semaphore_increase();//函数封装,内部调用信号量的V操作
}
//may process in Application B
void get_b() {
b = calculate_b();
semaphore_increase();
}
//may process in Appication C
void calculate_c() {
semaphore_decrease();//函数封装,内部调用信号量的P操作
semaphore_decrease();
c = get_a() + get_b();
}
~~~
如果实在看不懂,可以稍后回来再看下这个例子,通过这样的代码我们可以保证得到预期的效果,达到最终逻辑上的顺序
信号量是一个整数,为了方便,我们把信号量计作S,信号量有两个原语,即P操作和V操作,打死都要记住,一定要记住P操作和V操作都是原子操作,到底如何实现P、V操作,这是操作系统需要考虑的,同时需要硬件/CPU指令集支持,下文会详细介绍有关内容
P操作:
V操作:
有一点很重要,信号量的操作只应该由内核去进行
上面的PV操作可能很多同学都是似懂非懂,那让我们看个例子,当信号量最大值为1时:
仔细看下上面的步骤,这不就是锁吗?
根据这个例子,你可能更容易理解这句话:
S大于等于零时代表可供并发进程使用的资源实体数,当S小于零时则表示正在等待使用临界区的进程数。
不信的话各位同学可以比对上面的例子去理解一下这句话的含义
这个例子其实也就是很多人所说的,当信号量最大值为1(或者说不需要信号量的计数能力时),这种简化了功能的信号量就被称为互斥量。因为没有了技术能力,互斥量只有两种状态0/1。需要注意的是,这种信号量只是互斥量的一种实现方式,在概念上来讲,二者并没有直接的关系(或许从一开始互斥量是从信号量演化而来的,但是后来互斥量被单独拉成一个概念),互斥量有很多种实现方式(因为互斥量很简单)。有关互斥量的东西下文还会详细说一下
很多同学会疑惑,信号量这么牛X,不就是在于PV操作是原子的吗?那它到底怎么保证的原子的操作啊?
其实不管什么样的功能/需求,到最后是一堆指令的集合,实现相应功能,PV操作的原子性也必然是这样的。想要实现原子操作,就需要指令集的支持,比如在x86指令集上,lock指令前缀就能实现原子操作。在介绍lock指令前缀之前我们先仔细想一下原子性的本质是什么
在这里我就不贴概念了,简单且不严谨的说,原子性就是在逻辑上一系列的指令都由CPU一次执行完毕,中间不发生进程/线程切换,但是记住,这只是逻辑上的,仔细想一想,只要与这一坨指令相关的内存数据在CPU执行过程中不被其他进程/线程进行读写访问,那你操作系统kernel再怎么切换,最终结果看起来依旧是原子的,也就是说只要相关数据的读写访问是互斥的那么表现出来的特征就是原子的
举个例子,如果我们希望i = i + 1是原子操作,那其实我们只需要让i = i + 1的读写访问是互斥的不就ok了?
根据上面的分析,我们很希望如果有某种指令可以锁住某一特定的地址空间,那就太完美了。可是在硬件层面来说,只针对某一个特定的内存地址太难了,但是比较简单的是锁住内存总线(听起来太狠了,实际上确实代价比较大) — 这就是lock指令做的事情,不过在后来的处理器中,intel为了减少开销,lock指令并不一定每次都会锁住总线,而是通过缓存锁和缓存一致性协议去完成这个一样的功能
lock 指令前缀只可以修饰部分指令,还有一些其他的规则,本文不再详细列举,仅列举一些可以修饰的指令:
ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B,CMPXCHG16B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, XCHG
刚才我们提到了,老一些的处理器上,lock指令前缀可以设置一个LOCK信号,锁住内存总线,让共享内存只被一个CPU独占,这样的话,会阻塞其他所有的CPU访问共享内存,效率很低,所以在后来的CPU版本上,lock指令前缀不再简单粗暴的锁住总线,而是采用缓存锁,所谓的缓存锁,其实就是当所访问的数据在CPU的缓存(L1、L2)中时,即命中缓存时,且命中的缓存行只有一行,则可以锁定缓存,然后利用缓存一致性协议,去保证逻辑上的原子性,也就是说当不命中缓存(即访问数据不在CPU缓存中)或者命中的缓存不在同一个缓存行中时,再或者CPU不支持缓存锁时,lock指令前缀依旧会在总线上声明LOCK信号,从而锁住总线
注意,在我的理解里面,缓存锁不是单纯的依赖缓存一致性,因为简单的MESI并不能保证原子性/互斥性,所以注意上面的表述:如果命中的缓存行处于独占的状态,则可以直接执行,也就是是说不处于独占的状态,需要一些额外的操作,具体的在下面介绍了MESI协议之后我会说一下我的想法
上面提及了缓存锁主要依赖于缓存一致性协议,其实有关缓存一致性协议的东西可以写很多很多,这里我试着尽量把这一部分讲清楚
先来考虑一下为什么需要缓存一致性:在多核处理器中,每个CPU有自己的cache(L1和L2),还有共享内存(L3、主存),因为CPU的处理速度远远高于IO速度,所以高速缓存对于提高CPU的利用率和吞吐量来说是必要的,这样对于频繁访问的数据,CPU可以在自己的高速缓存(L1、L2)中进行读写,但是这样就引入了一些问题:
注意,上述提到的共享内存是指所有CPU所共享的内存,按照目前的计算机结构,共享内存一般是指L3和主存。
MESI协议是一个简单的缓存一致性协议,根据MESI协议衍生出了其他一些缓存一致性协议,这里我们只讨论一下MESI:MESI是代表了缓存数据的四种状态的首字母,分别是Modified、Exclusive、Shared、Invalid,即MESI是一个很好的状态机(以下来自[1])
有了状态机,其实更需要的是根据不同的状态对缓存中的数据进行监听,这样才能达到我们期望的缓存一致性(以下借鉴自[1])
我们现在回到我们一开始的问题 — 缓存锁,也许有的同学会产生和我一样的疑问,这种缓存一致性协议怎么能保证数据访问的原子性呢?
网上一些博客描述的是直接通过缓存一致性就可以保证原子性,我认为这并不严谨,因为我理解的单单依赖缓存一致性协议无法达到原子性,比如两个CPU同时执行ADD [%eax], 1这个指令,而且二者访存阶段有交集(就是说CPU A还未执行到写回,CPU B也执行到了访存),单单依靠MESI,A和B的缓存行状态全都是S,依旧不是互斥的,这是多核并行引入的问题,因为指令的执行必然是连续的,CPU只有执行完一个指令才能完成上下文切换,但是在多核环境下,必须把一个指令拆分为多个步骤去分析。上述问题在单核环境下不存在。同时注意到我说的是ADD指令,不是i = i+1;
我觉得更准确的描述应该是上面提到的(这只是我自己的理解,因为我没有去看intel的指令手册): 如果执行lock前缀指令前,相关的数据命中缓存且在一个缓存行中,那就锁定这个数据,其他CPU无法访问(读/写),然后再利用缓存一致性协议,当发生写操作之后,其他所有的缓存中全部变成Invalid,就解决了上述问题
这是和硬件强相关的事情了,我就简单提及一下:
MESI中有各种监听,这种监听的实现大部分是通过窥探的方式实现的,简单来说就是,所有的CPU和共享内存传输数据都使用一条总线,CPU的缓存不只在与共享内存发生数据传输的时候才访问总线,而是实时监听总线上的数据情况,任何CPU的缓存与共享内存发生的任何数据传输都可以被任意一个CPU监听到,所以可以实现上述MESI各种监听
注意,共用的总线是CPU及其缓存和共享内存(L3、主存)之间的,CPU和CPU自己的高速缓存是不使用这个内存总线的
内存弱一致性是仅仅针对单核作出的缓存一致性约束,因为如果严格的内存一致性其实在绝大多数是不需要的,例如一个全局变量var的变化要通知所有的CPU core,并导致缓存无效性,但是其实只有一个CPU对这个变量感兴趣,那这个通知其实是无用的,所以弱一致性仅仅针对同步变量(这是弱一致性引入的概念)作出一致性约束(包括顺序一致性和内存一致性),x86就是应用的弱一致性模型,可以试验,在x86多核上使用Peterson算法对一个变量进行自增运算,并不能保证正确结果。
或许从一开始互斥量是从信号量演化而来的,但是后来互斥量被单独拉成一个概念,因为互斥量足够实现互斥锁,而且互斥量的概念也足够简单
互斥量是一个二元的变量,0代表解锁(可以获取),1代表加锁(已经被其他进程获取)。如果一个进程试图mutex_lock时mutex为1,那这个进程应该被阻塞,直到获得了mutex的进程自己调用mutex_unlock,其他进程才有机会进入临界区
互斥量因为只有两种状态,所以它有很多种实现方式,但无论什么样的实现方式,都只要内部封装,然后对外暴露接口lock和unlock就可以了
~assembly
ENTER_REGION:
TSL REGISTER, MUTEX
CMP RESGITER, #0
JNE ENTER_REGION
RET
~
TSL A, B:把B复制到寄存器A并且把B设置为一个非零值,注意,这个命令会锁住内存总线,保证原子性
把MUTEX复制到REGISTER中后,把REGISTER和0做比较,如果不是0,则循环,也就是忙等,这就是自旋锁,很明显,这种实现方式完全可以在用户空间就可以实现
再次强调,上面都是不同的实现方式,到底操作系统底层怎么实现,请去参考各个操作系统的kernel
当某个进程获取不到互斥量/信号量时,该进程都应该以某种方式等待互斥量/信号量的释放,上面我们看到有两种
我们都知道,在绝大多数情况下,都是采用休眠的方式来等待的;但是在某些情况下也会采用自旋的方式等待,比如Java的Hotspot就引入了自旋锁,在一定的情况下会采用自旋的方式等待,这是一种优化方式,用来在执行临界区耗时不长的情况下避免内核态的调度,关于Hotspot的自旋锁在这里先不讲了
如果各位同学真的理解了以上内容(我觉得各位同学也应该理解,哈哈),条件变量其实很简单,条件变量的级别本来就不够和信号量和互斥量相提并论,但是网上很多人喜欢把它和互斥量和信号量放在一起说,其实这样徒增了我们的理解难度
其实没有概念,条件变量就是linux提供的一个编程接口,又有一些人喜欢把条件变量叫做条件锁,其实说的都是一种东西。条件变量其实就是解决了一个问题:一个进程等待某个变量成立,另外一个线程对这个变量进行修改,而这个问题必须避免发生竞态,所以往往必须要使用一个mutex,从而使得等待和修改都在同一个互斥量的临界区里。这样说可能很多人理解还是有偏差的,我们直接列出来Linux提供的接口,我们只看最关键的两个接口函数
这只是其中两个接口函数,怎么初始化条件变量各位同学自行查阅有关资料
想一下下面的例子(伪代码+部分C)
~~~c
int flag = 0;
//cond是一个条件变量 mutex是一个互斥量
//process in thread/application A
void process_a() {
mutex_lock(mutex);
while (!flag) {
pthread_cond_wait(&cond, &mutex);
}
mutex_unlock(mutex);
}
//process in thread/application B
void process_b() {
mutex_lock(mutex);
flag = 1;
pthread_cond_signal(&cond);
mutex_lock(mutex);
}
~~~
结合这个例子我们来看下为什么这个接口要这么设计
条件变量就是为了处理一个进程等待某个变量成立,另外一个线程对这个变量进行修改这样的需求,这种需求必须防止竞态的发生(原因各位同学可以很容易分析出来),所以往往和mutex一起使用来保证互斥,所以有时有人也把它叫做条件锁
当你理解了这些东西的原理,才能很容易的理解一些看起来高大上的东西是怎么实现,其实它们都没那么难