关于同步机制的一些问题探究与总结(上)

前言

  最近在学习并发模型的时候,时常遇到不同任务之间需要同步的情况,有一些问题比较典型,自己也不是非常清楚,这次打算把这些问题单独抽离出来,好好探究、总结一波。记录在此,希望可以便人便己。

这次探究的问题主要有以下内容:

  • 同步机制概论
    –重新审视程序并发执行的原因
    – 锁的粒度问题
  • 常见的同步机制比较
    – 原子操作
    – 自旋锁
    – 信号量
    – 互斥锁
    – 条件变量
    – 其他(大内核锁和顺序锁)
  • 再探究条件变量的使用
  • 中断和锁
  • 如何理解在进程上下文中获取锁和中断上下文中获取锁的区别?
  • 如何理解【1】中提到的给数据加锁而不是给代码加锁?
  • 如何理解【2】中说的“semaphore has no notion of ownership” ?
  • 其他…

^ _ ^||| , 突然感觉,埋下了不少待填的坑…


一、 同步机制概论

在这里插入图片描述

  long long ago, 在单处理器时代,程序就像一条奔腾的长河一般,一个接一个串行的执行下去。一个程序只能等待另一个程序执行完毕之后才可能有机会被处理器“临幸” 。并发(或者说并行)机制(从硬件级别到软件级别有很多种)的出现,让各个程序(任务)之间可以交叉的运行,这在某种程度上大大提高了任务的整体执行效率。
  效率提升的同时也带来了一个问题,这就是并发任务(在实际情况中的具体的载体是进程、线程等)之间的同步问题。马克思告诉我们,世界是物质的,物质之间是相互联系的。 计算机中的并发任务是现实世界问题的抽象实现,它们之间**大部分(注意不是全部)**是有着或多或少的联系,或是相互竞争、或是相互合作。
  人类世界很疯狂,并发的世界也不太稳定,如果没有一定的约束机制,想让各个并发任务之间有条不紊的完成我们期望的功能有点小困难。于是,类似于人类世界的法律和各种法规,先辈们也给并发的世界设计了各种各样的同步约束机制(广义的同步问题是包括互斥的,这里就统一讨论了):锁、信号量、条件变量、原子操作… 这些都是为了让并发的任务之间可以按照人类的设计完美的执行任务(当然,具体实施时,bug的出现不可避免)。

具体的同步机制在第二部分会有介绍。

下面主要讨论两个其他的小问题:

  • 重新审视程序并发执行的原因
  • 锁的粒度问题

1.1 重新审视程序并发执行的原因

先提一点,下文中所说的并发更多指的是软件层面的并发。

  我们平时最常见的并发就是多进程或者多线程程序,因为内核的调度,它们交替的执行。 From a high level, 这更多的算是用户级别的并发,从内核角度(这里主要指的是linux内核)来看,还有以下几种并发执行的情况【1】:

  1. 中断: 中断就像调皮的娃,随时都有可能给你来点情况。
  2. 软中断和tasklet: 内核可以在任何时刻唤醒或者调度软中断和tasklet, 打断当前正在执行的代码
  3. 内核的抢占:因为内核的抢占,内核中的任务可能会被另一个任务抢占。
  4. 睡眠阻塞:这个就相当于我们进程或者线程阻塞所造成的的调度。
  5. 对称处理器: 两个或者多个处理器可以同时执行代码。(这个算是真正的并行了)

  需要说明的是,以上几种情况也并不是孤立的,它们相互之间也有些联系,比如说,中断和睡眠阻塞之间就有一定联系(中断可以是唤醒的事件前提)。同时,就我目前来看以上几种情况,3,4 两种情况可以对应着用户层面的任务调度,第一种情况有些类似用户态下的信号(它们都是打断当前执行代码的异步事件)。

1.2 锁的粒度问题

在这里插入图片描述

  就我目前了解到的并发程序来说,锁大概是在同步机制中(在用户开发的上层软件中,底层的锁机制一般都是使用原子操作实现的)用的最多的了。 在不同的系统中,锁有不同的实现形式,而且加锁的粒度范围也各有不同。 往大了加,把整个任务级别都加锁,那就变成串行程序了。往小了加,只加到一条程序指令,那就变成原子操作了(大部分内核都提供了硬件级别的原子操作)。 真正在使用的过程中,我们的临界区(可以简单理解为加锁的范围)应该在可行(这个“可行”需要视具体的情况而定)的范围内尽可能的小(当然,至少得覆盖想要保护的数据),因为临界区太大会影响并发任务执行的效率。


二、常见的同步机制比较

  不同的内核提供了不同的同步机制,甚至有的语言在其语言层面上也提供了同步机制。但到目前来说,大同小异。 下面主要介绍几个常见的同步机制。

2.1 原子操作

在初学操作系统同步机制一章节的时候,开篇大都会举一个多线程计数的例子。 因为没有互斥访问共享的计数器,最终的计数器会出现各种各样的错误。 本质原因是把对计数器的操作隔离开了(read->add->rewrite),现代的内核系统大都提供了最基本的原子操作,在硬件级别(或者是仿硬件级别)提供了对基本数据类型(当然,不同的内核提供的可供原子操作的基本数据类型略有不同)的操作。 如linux内核提供了整型的原子操作和原子位操作。 如下所示。

在这里插入图片描述

linux提供的整型数据的原子操作

2.2 自旋锁(spin lock)

在这里插入图片描述

  在我本科学操作系统的时候,教材上是没有介绍自旋锁这种同步机制的。但是在linux内核中确提供了这种机制,而且据【1】中所说,这是linux 内核中最常见的锁。

如其名,这种锁在获取不到之时,不会阻塞睡眠,而会一直“while 循环在那”,一直转啊,一直转,直到锁可用。 操作系统学得好的同学一定会想到了,这不符合临界区使用的四个规则之一的“让权等待”,也就是说自旋锁就是忙等。

一开始我还挺怀疑,忙等多不好啊,浪费CPU时间,后来看了【1】中的介绍,哦忙等在有些条件下还是有作用的,尤其是在临界区比较小,加锁时间不太长的情况下(避免睡眠引起的上下文切换的开销)。简要来说就是,短期内轻量级加锁。

自旋锁还有一个作用是其可以使用在中断处理程序中,因为中断处理程序中是不能睡眠的,所以如果需要在中断处理函数中进行数据保护,使用自旋锁是可以考虑的方案(中断处理程序本身也需要尽快执行)。

linux中提供的自旋锁api如下图所示(来自【3】)。
在这里插入图片描述

除了普通的自旋锁之外,有的内核还根据read-write在同步(确切的说是互斥)上的不同影响(读读不互斥,读写互斥,写写互斥),还细分出了读写自旋锁,不是本文的重点这里就不多说了。

顺便说一句,spinlock 一般在用户态见到的不多,基本上是在内核态下使用。

2.3 信号量(semaphore)

在这里插入图片描述

  信号量在常见的同步机制中算是“big one” (这里说的big并不是指其体量,而是只其重要性)。和自旋锁的区别在于,它是一种睡眠锁,当获取信号量而不可得时(被其他的任务持有),相应的任务会被挂起到一个阻塞队列,然后“让位”于其他任务(线程或进程)。当信号量被释放之时,阻塞在队列上的任务会被唤醒(一般来说,如果有多个的话,只唤醒其中的一个),得以继续执行。

  和其他同步机制还有一个区别是,信号量机制有使用者数量的概念(usage count,其实也就是资源的数量),它允许指定数量的任务执行在临界区内。 当然,从某种程度上来说,允许多个任务都运行在临界区内,在实际中应用的场景不多,一般信号量都是作为二值信号量(概念上相当于下面说的互斥锁)来使用。

在使用上和自旋锁不同的是,信号量只能在任务上下文(主要是进程上下文)中使用,因为信号量可能导致任务睡眠的特性,所以其不适合在中断上下文中使用。

对比了自旋锁和信号量上的区别,可以总结出信号量一般适合用在临界区被长时间占用的情况(信号量持有的时间比较长),如果信号量被持有的时间比较短,使用信号量机制就不太划算了,因为睡眠、唤醒、维护等待队列等开销可能会比任务本身在临界区执行的时间还要长

同自旋锁一样,信号量也可以细分为普通信号量和读-写信号量,这里只提一句,也不多说了。

enen… ,关于信号量的具体使用,如信号量的PV操作,有名信号量、无名信号量等内容,网上有很多介绍,这里就不赘述了。

2.4 互斥锁(Mutex)

在这里插入图片描述

上面也有提到,信号量的用途虽然广泛,比较通用,但是很多场景下需要使用的仅仅是互斥访问的场景(同一时刻只有一个任务可以进入临界区),所以信号量发明出来之后很长一段时间都是最为二值信号量(资源的数量只有1)来使用。

后来为了简化操作,以及方便调试(这个功能我也不太清楚 ̄□ ̄||),前辈们就开发出了针对互斥用途的互斥锁。

互斥锁从概念上和使用上可以理解为二值信号量,它也是一种睡眠互斥锁。当任务获取不到这个锁时会睡眠,当其他任务释放锁时,睡眠等待的任务会被唤醒。

当然,互斥锁也和二值信号量有一些区别,主要体现在:

  • 优化的mutex效率要比信号量高【4】。
  • mutex的上锁和解锁必须在同一个上下文中,这种特性使得其只能作为狭义的互斥使用,而二值信号量还可以作为同步机制在不同的上下文中使用。【5】
  • 二值信号量会有一个优先级翻转的问题,现代的内核中mutex实现解决了这个问题。

同时,mutex也有和二值信号量类似的特点:

  • 它们都不能用在中断处理中(因为它们都可能会睡眠)(更准确的说,unlock 和post等释放操作是可以的,lock和wait是不行的)


既然二值信号量和互斥锁这么相像,那么具体怎么取舍呢?
【1】中说到了,只有可以,就使用互斥锁,特殊互斥锁无法达到目的的才考虑信号量


最后,贴一个以上三种同步机制的比较图。

在这里插入图片描述

2.5 条件变量(condition)

在这里插入图片描述

  如果说互斥锁是信号量互斥作用的简单版本,那么条件变量可以看成信号量同步作用(这里的同步指的就是狭义的同步,即任务之间协作关系)的简单解决方案。

条件变量使我们可以睡眠直到某种条件出现。它主要包括两个部分:
(1)、一个线程等待condition 成立而挂起(wait 信号)
(2)、另一个线程是condition 成立(signal 信号)

其实就是经典的同步动作。


那么它和信号量有什么区别呢?
enen…, 虽然大方向信号量和条件变量都可以用做线程间的同步作用,但是其还是有细微的区别。
(1)、作为信号量的同步作用某种程度上的简单代替,条件变量的实现效率更高些。
(2)、条件变量可以达到某种程度上的屏障同步作用,也就是多个线程都阻塞在一个条件之上,条件释放了,大家都可以得到通知,然后继续运行(使用信号量的boardcast)。


条件变量的使用需要有一个注意的地方,即在wait的过程中需要和一个mutex一起使用。其原因是为了防止信号的丢失,我在以前的文章中有简单说明【8】,这里在推荐几个文章【9】【10】,我就不详述了(* ^ ▽ ^ *)。

  
关于条件变量还有一个经典的问题: Signal With Mutex Locked Or Not?,即在加锁的情况下signal还是在解锁的情况下signal? 其代码表现如下所示:

加锁情况下signal

lock()
consdition = true;
/* other operation*/
signal();
unlock()

还是
解锁情况下signal

lock()
consdition = true;
/* other operation*/
unlock()
signal();

对于前者,会出现不必要的上下文切换(现在的内核利用condition queue 和mutex queue已经解决了这个问题); 对于后者则会出现wait线程不一定会抢到锁的情况。 具体的分析请参照【11】,介绍的比较清晰。


enen…
条件变量还有一个经典的问题:“spurious wakeups”即虚假唤醒问题。

这个我稍微描述一下,就是说一个线程被唤醒,一般是资源可以使用,代码可以继续往下跑,但是虚假唤醒这种情况指的是一个线程A被线程B唤醒,但是当其检查资源的时候,发现资源不可用。 为啥呢? 因为在其A被唤醒和准备取资源之间,一个悄悄摸摸的第三者C把资源取用了,A的这次唤醒就是无效的。一般来说,我们都会把wait放在一个大while 内部,来重复判断。如下所示
在这里插入图片描述

需要说明一点的是,虚假唤醒问题是个概率性比较小的情况,linux 内核为了简单和高效,并没有解决它。

2.6 其他

enen…, 以上基本上就是比较经典的同步机制。 除了以上机制之外,不同的系统可能还会提供一些额外的同步机制。下面以linux内核为例,简要介绍一下大内核锁和顺序锁

2.6.1 大内核锁(Big Kernel Lock)

据【1】中所说,大内核锁是一个全局自旋锁,使用它主要是为了方便实现从Linux最初的SMP过渡到细粒度的加锁机制(好吧,这一条我也不理解,╮(╯▽╰)╭)。

为什么说它是“大”锁呢?

因为BKL是一个全局的锁(注意,是“一个”而不是“一种”),它保护所有使用它来同步的临界区。一旦一个进程获得BKL,进入被它保护的临界区,不但该临界区被上锁,所有被它保护的临界区都一起被锁住。【12】 从某种程度上来说,它是一种能控制系统内所有任务之间同步的一种锁。

这种大内核锁在新的linux内核中已经不推荐使用,我觉得简单了解一下也就可以了。

2.6.2 顺序锁(sequence lock)

上面介绍的spinlock 和 信号量 都有一种针对读写不同特点的变种,即读写锁。 上文中没有介绍,这种读写锁更有利于读者,而对写者不太友好,可能会导致写者“饥饿”。

这种在linux 内核2.6版本之后引入的顺序锁则正好相反,从某种程度上来说,它更有利于写者,而不太利于读者的场景。它的基本原理就是利用一个特殊的计数序列值,当写入的时候,需要加锁并且增加这个序列值(当然一般还需要修改目标数据);而当读取的时候,不需要加锁,但是在读取之前,和读取之后都需要先读出这个序列值,用于判断在读出的过程中,目标数据被修改(如果被修改的话,前后的序列值一定会不同)。所以,一般情况下为了读出准确的数据,读者都会伴随着一个循环。

具体的使用如下图所示。
在这里插入图片描述

根据顺序锁的特性可以总结出,其一般适用于读者很多,写者很少,写优于读,目标数据也比较简单的场合。

2.7 小总结

内容比较多,文章比较长,这一部分先说到这。 最后来一个总结首尾。
在这里插入图片描述

如果,上面文章看懂了话,这幅图应该也ok的哦。


参考

【1】、《Linux 内核设计与实现》
【2】、Real-World Concurrency
【3】、带您进入内核开发的大门 | 自旋锁的使用
【4】、linux互斥锁简介(内核态)
【5】、Posix多线程-互斥量
【6】、二值信号量和互斥锁到底有什么区别?
【7】、Posix信号量与cond条件变量,到底该选谁?
【8】、并发模型第肆讲-pre threaded模型
【9】、Calling pthread_cond_signal without locking mutex
【10】、pthread_cond_wait 为什么需要传递 mutex 参数?
【11】、Linux条件变量pthread_condition细节(为何先加锁,pthread_cond_wait为何先解锁,返回时又加锁)
【12】、神奇的大内核锁
【13】、顺序锁(seqlock)

猜你喜欢

转载自blog.csdn.net/plm199513100/article/details/113644435
今日推荐