信号量 互斥量 条件变量

原文:https://blog.csdn.net/qq_32646795/article/details/78221005

本文打算写一些和锁有关的东西,谈一谈我对锁的原理和实现的理解,主要包含以下方面

  • 信号量
  • 互斥量
  • 条件变量

同步与互斥

其实同步与互斥都是计算机科学里面概念性的东西,它们和什么编程语言、操作系统其实都没什么关系。很多人会混淆这两个概念,但是其实这两个概念并不一样(其实也不深奥,我们在写代码的时候肯定都用到过这两种概念性的东西)

互斥

这个概念大家应该都清楚,就是说一个资源在任意一个时刻只能被一个进程/线程持有

同步

我们先来看下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操作 
}

扫描二维码关注公众号,回复: 631609 查看本文章

//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操作:

  • S减1;
  • 若S减1后仍大于或等于零,则进程继续执行;
  • 若S减1后小于零,则该进程被阻塞后进入与该信号相对应的队列中,然后转进程调度

V操作:

  • S加1;
  • 若相加结果大于零,则进程继续执行;
  • 若相加结果小于或等于零,则从该信号的等待队列中唤醒一等待进程,然后再返回原进程继续执行或转进程调度

有一点很重要,信号量的操作只应该由内核去进行

例子:当信号量最大值为1

上面的PV操作可能很多同学都是似懂非懂,那让我们看个例子,当信号量最大值为1时:

  • 如果有三个线程/进程 A、B、C,都想要执行P操作,又因为P操作是原子的,那么ABC都执行了P(且没有进程执行了V操作),那信号量S将会是-2,而且后执行P操作的两个现场全部都要被加入到sleep进程队列中去
  • 当成功执行P操作而且没有被挂起的进程执行了V操作之后,S变成了-1,这时候V操作还需要去sleep队列中去唤醒某个进程,至于到底唤醒哪一个,这取决于操作系统进程的调度方式了
  • 然后重复上面的第二步直到S等于1

仔细看下上面的步骤,这不就是锁吗

根据这个例子,你可能更容易理解这句话:

S大于等于零时代表可供并发进程使用的资源实体数,当S小于零时则表示正在等待使用临界区的进程数。

不信的话各位同学可以比对上面的例子去理解一下这句话的含义

这个例子其实也就是很多人所说的,当信号量最大值为1(或者说不需要信号量的计数能力时),这种简化了功能的信号量就被称为互斥量。因为没有了技术能力,互斥量只有两种状态0/1。需要注意的是,这种信号量只是互斥量的一种实现方式,在概念上来讲,二者并没有直接的关系(或许从一开始互斥量是从信号量演化而来的,但是后来互斥量被单独拉成一个概念),互斥量有很多种实现方式(因为互斥量很简单)。有关互斥量的东西下文还会详细说一下

实现

很多同学会疑惑,信号量这么牛X,不就是在于PV操作是原子的吗?那它到底怎么保证的原子的操作啊?

其实不管什么样的功能/需求,到最后是一堆指令的集合,实现相应功能,PV操作的原子性也必然是这样的。想要实现原子操作,就需要指令集的支持,比如在x86指令集上,lock指令前缀就能实现原子操作。在介绍lock指令前缀之前我们先仔细想一下原子性的本质是什么

原子性

在这里我就不贴概念了,简单且不严谨的说,原子性就是在逻辑上一系列的指令都由CPU一次执行完毕,中间不发生进程/线程切换,但是记住,这只是逻辑上的,仔细想一想,只要与这一坨指令相关的内存数据在CPU执行过程中不被其他进程/线程进行读写访问,那你操作系统kernel再怎么切换,最终结果看起来依旧是原子的,也就是说只要相关数据的读写访问是互斥的那么表现出来的特征就是原子的

举个例子,如果我们希望i = i + 1是原子操作,那其实我们只需要让i = i + 1的读写访问是互斥的不就ok了?

lock指令前缀

根据上面的分析,我们很希望如果有某种指令可以锁住某一特定的地址空间,那就太完美了。可是在硬件层面来说,只针对某一个特定的内存地址太难了,但是比较简单的是锁住内存总线(听起来太狠了,实际上确实代价比较大) — 这就是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同时访问相同的内存地址,就会出现难以预期的后果,比如两个CPU同时执行i++,其中i = 1,在两个CPU执行完之后,i可能等于2,这里可以多考虑一下指令执行的五个周期(取指、译码、执行、访存、写回),不熟悉的同学自己去找下吧,这个如果在写进来就要累死了
  • 现代处理器一般都采用回写的方式写回脏数据:脏数据简单来说就是缓存中被修改了的数据,之所以称之为”脏”,是因为缓存中的数据和共享内存中的数据不一致。而回写是指当修改了缓存中的数据时,不立即写回到共享内存,而是在之后的某个时间点写回到共享内存。回写的方式加大了缓存一致性的复杂性。

注意,上述提到的共享内存是指所有CPU所共享的内存,按照目前的计算机结构,共享内存一般是指L3和主存。

MESI协议是一个简单的缓存一致性协议,根据MESI协议衍生出了其他一些缓存一致性协议,这里我们只讨论一下MESI:MESI是代表了缓存数据的四种状态的首字母,分别是Modified、Exclusive、Shared、Invalid,即MESI是一个很好的状态机(以下来自[1])

  • M(Modified):被修改的。处于这一状态的数据,只在本CPU中有缓存数据,而其他CPU中没有。同时其状态相对于内存中的值来说,是已经被修改的,且没有更新到内存中。
  • E(Exclusive):独占的。处于这一状态的数据,只有在本CPU中有缓存,且其数据没有修改,即与内存中一致。
  • S(Shared):共享的。处于这一状态的数据在多个CPU中都有缓存,且与内存一致。
  • I(Invalid):要么已经不在缓存中,要么它的内容已经过时。为了达到缓存的目的,这种状态的段将会被忽略。一旦缓存段被标记为失效,那效果就等同于它从来没被加载到缓存中。

有了状态机,其实更需要的是根据不同的状态对缓存中的数据进行监听,这样才能达到我们期望的缓存一致性(以下借鉴自[1])

  • 一个处于M状态的缓存行,必须时刻监听所有试图读取该缓存行对应的主存地址的操作,如果监听到,则必须在此操作执行前把其缓存行中的数据写回CPU
  • 一个处于S状态的缓存行,必须时刻监听使该缓存行无效或者独享该缓存行的请求,如果监听到,则必须把其缓存行状态设置为I。
  • 一个处于E状态的缓存行,必须时刻监听其他试图读取该缓存行对应的主存地址的操作,如果监听到,则必须把其缓存行状态设置为S。
  • 只有E和M可以进行写操作而且不需要额外操作,如果想对S状态的缓存字段进行写操作,那必须先发送一个RFO(Request-For-Ownership)广播,该广播可以让其他CPU的缓存中的相同数据的字段实效,即变成Invalid状态

问题回归

我们现在回到我们一开始的问题 — 缓存锁,也许有的同学会产生和我一样的疑问,这种缓存一致性协议怎么能保证数据访问的原子性呢?

网上一些博客描述的是直接通过缓存一致性就可以保证原子性,我认为这并不严谨,因为我理解的单单依赖缓存一致性协议无法达到原子性,比如两个CPU同时执行ADD [%eax], 1这个指令,而且二者访存阶段有交集(就是说CPU A还未执行到写回,CPU B也执行到了访存),单单依靠MESI,A和B的缓存行状态全都是S,依旧不是互斥的,这是多核并行引入的问题,因为指令的执行必然是连续的,CPU只有执行完一个指令才能完成上下文切换,但是在多核环境下,必须把一个指令拆分为多个步骤去分析。上述问题在单核环境下不存在。同时注意到我说的是ADD指令,不是i = i+1;

我觉得更准确的描述应该是上面提到的(这只是我自己的理解,因为我没有去看intel的指令手册): 如果执行lock前缀指令前,相关的数据命中缓存且在一个缓存行中,那就锁定这个数据,其他CPU无法访问(读/写),然后再利用缓存一致性协议,当发生写操作之后,其他所有的缓存中全部变成Invalid,就解决了上述问题

有关MESI的实现

这是和硬件强相关的事情了,我就简单提及一下:

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,其他进程才有机会进入临界区

注意

  • mutex_unlock应该由获取了mutex的进程自己去unlock,即释放互斥量
  • 互斥量的概念很简单,甚至可以在用户空间就可以实现互斥量的功能,但是信号量只能被内核调度

互斥量的实现

互斥量因为只有两种状态,所以它有很多种实现方式,但无论什么样的实现方式,都只要内部封装,然后对外暴露接口lock和unlock就可以了

  • 通过最大值为1的信号量实现,很明显,这种实现方式是在内核空间
  • 通过TSL指令实现

~assembly 
ENTER_REGION: 
TSL REGISTER, MUTEX 
CMP RESGITER, #0 
JNE ENTER_REGION 
RET 
~

TSL A, B:把B复制到寄存器A并且把B设置为一个非零值,注意,这个命令会锁住内存总线,保证原子性

把MUTEX复制到REGISTER中后,把REGISTER和0做比较,如果不是0,则循环,也就是忙等,这就是自旋锁,很明显,这种实现方式完全可以在用户空间就可以实现

  • 通过XCHG指令实现:和TSL很相似,不再赘述

再次强调,上面都是不同的实现方式,到底操作系统底层怎么实现,请去参考各个操作系统的kernel

多说一些,忙等和休眠

当某个进程获取不到互斥量/信号量时,该进程都应该以某种方式等待互斥量/信号量的释放,上面我们看到有两种

  • 忙等(自旋):不停的循环,直到资源ready。这样的缺点就是如果资源的释放需要一定的时间,那么就要占用大量的CPU时间;优点就是在资源很快释放的情况下,不用发生内核态和用户态的切换,也不用进行sleep和唤醒,节省了时间开销。
  • 休眠(阻塞):进程/线程被阻塞,进入休眠(sleep)。基本原理也就是操作系统内核维护一个休眠的集合,然后当资源被释放之后,操作系统根据自己的调度策略,选择其一进行唤醒。这样的优缺点和忙等(自旋)正好相反。

我们都知道,在绝大多数情况下,都是采用休眠的方式来等待的;但是在某些情况下也会采用自旋的方式等待,比如Java的Hotspot就引入了自旋锁,在一定的情况下会采用自旋的方式等待,这是一种优化方式,用来在执行临界区耗时不长的情况下避免内核态的调度,关于Hotspot的自旋锁在这里先不讲了

条件变量

如果各位同学真的理解了以上内容(我觉得各位同学也应该理解,哈哈),条件变量其实很简单,条件变量的级别本来就不够和信号量和互斥量相提并论,但是网上很多人喜欢把它和互斥量和信号量放在一起说,其实这样徒增了我们的理解难度

概念

其实没有概念,条件变量就是linux提供的一个编程接口,又有一些人喜欢把条件变量叫做条件锁,其实说的都是一种东西。条件变量其实就是解决了一个问题:一个进程等待某个变量成立,另外一个线程对这个变量进行修改,而这个问题必须避免发生竞态,所以往往必须要使用一个mutex,从而使得等待和修改都在同一个互斥量的临界区里。这样说可能很多人理解还是有偏差的,我们直接列出来Linux提供的接口,我们只看最关键的两个接口函数

  • int pthread_cond_wait(pthread_cond_t *cond,pthread_mutex_t *mutex); 使当前进程休眠,并且会释放这个mutex,并且在被唤醒时重新获取mutex
  • int pthread_cond_signal(pthread_cond_t *cond); 唤醒等待对应条件变量的进程

这只是其中两个接口函数,怎么初始化条件变量各位同学自行查阅有关资料

想一下下面的例子(伪代码+部分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); 

~~~

结合这个例子我们来看下为什么这个接口要这么设计

  • 条件变量不是你要修改的那个变量:你要修改的,即要等待其他进程修改的变量在上述例子里是flag,而不是条件变量,其实上述例子表述起来就是进程A等待其他进程将flag设置1,然后执行,条件变量提供的只是使当前进程进入休眠,看上述例子,除了初始化意外,在我们的代码里,没有地方对cond进行赋值
  • cond条件变量是一种标识符:那你可能会说,那为什么函数签名里还需要一个条件变量类型的参数,去掉不行吗?假如去掉条件变量,那代码多处调用了wait方法,那signal应该唤醒哪个呢?所以说条件变量提供的是一种标识符的能力

小结

条件变量就是为了处理一个进程等待某个变量成立,另外一个线程对这个变量进行修改这样的需求,这种需求必须防止竞态的发生(原因各位同学可以很容易分析出来),所以往往和mutex一起使用来保证互斥,所以有时有人也把它叫做条件锁

总结

当你理解了这些东西的原理,才能很容易的理解一些看起来高大上的东西是怎么实现,其实它们都没那么难

参考

猜你喜欢

转载自blog.csdn.net/qq_33890670/article/details/79967231