我在阅读 “小林coding” 公众号的图解操作系统系列文章后感觉受益良多,因此进行一些小总结,方便日后查阅。本篇是针对线程间互斥与同步的实现方法进行的总结。
原文链接:
面试官:你说说互斥锁、自旋锁、读写锁、悲观锁、乐观锁的应用场景
多个线程为了同个资源打起架来了,该如何让他们安分?
线程之间存在两种常见的协作关系:互斥和同步,为了实现进程/线程间正确的协作,操作系统必须提供实现线程协作的措施和方法,主要的方法有两种:加锁和信号量。本博文将对互斥和同步下锁/信号量的使用进行基本介绍。
当几个线程都想访问公共资源时,为了避免数据混乱,要求在某个时间只有一个线程能对公共资源进行操作。这种情况称为线程间的互斥。
在线程间互斥的情况下,在使用公共资源时线程就可以加一个锁,防止其他线程再访问公共资源。这就是锁的作用。也就是用锁来实现线程间的互斥。
常用的锁有互斥锁、自旋锁、读写锁(这三种统称为悲观锁)、乐观锁,其中互斥和自旋锁是两个基础锁,其他的锁是在这两者的基础上改进的高级锁。
当一个线程加锁后(锁的值置为1),另一个线程访问时即无法获取锁,此时CPU会命令该线程睡眠(即阻塞),并去运行其他就绪的线程(切换线程)。当锁被释放后(锁的值置为0),CPU才会重新使该线程处于就绪状态,并在合适的时候运行它。
该锁的缺点在于两次切换线程耗时较多,如果当前加锁线程很快就释放锁的话,互斥锁不是好的选择。
当线程发现无法获取锁的时候,就进入忙等待状态,它会一直占用CPU不停查看锁是否被释放。因此在单核CPU上使用自旋锁的时候,必须使用抢占式的调度器,来强行切换运行的线程。
忙等待的实现可以通过“测试并设置”这一原子操作实现,其C代码如下:
测试并设置实际上是返回了目标变量的当前值,并且将目标变量的值设置为新的值。在自旋锁的实现中,目标变量就是锁。
在一个进程要使用共享空间时,调用lock函数,此时若没有进程加锁,那么会跳出while循环,并且将锁置为1,当该进程出共享空间时,调用unlock函数释放锁;若当前有其他进程加了锁,那么该进程会一直在while循环中,直到 lock->flag变为0,它才能跳出while循环并能同时上锁。
这种锁应用于区分读、写操作的场景中,分为读锁和写锁。当一个线程要进行写操作时,会加写锁,这是一种独占锁,即上锁后其他进程均不能对共享资源进行读写操作;当一个线程进行读操作时,会加读锁,这属于共享锁,即允许多个线程同时加读锁,可以提高读数据的效率。但是加读锁的前提是没有线程加了写锁,加写锁的前提是没有线程加了读锁。
写锁就可以用互斥锁或是自旋锁来实现,它们两个都是独占锁。
读写锁有读优先锁和写优先锁,它们都要一定弊端,最好的方案是公平读写锁,将想要读写的线程依次放入队列中,依次完成它们的加锁请求,读写的优先级相同。
上图是读优先锁,当已经有线程加了读锁时,那么想加写锁的线程就要等待,但是其他想加读锁的线程就可以同时加读锁。这容易导致写线程的“饥饿”;
上图是写优先锁,当已经有线程加了读锁时,只要有线程要加写锁,那么其他想加读锁的线程就被阻塞,不能同时加读锁,这可能会造成读线程的”饥饿“。
实际上互斥锁、自旋锁、读写锁都属于悲观锁,即认为多个线程在共享资源处产生冲突的概率很高,因此必须通过加锁来限制某个时间只有一个线程使用共享资源。
与之相对的是乐观锁,它认为产生冲突的概率很低,因此在线程访问共享资源时不加任何锁,它的处理方式是先让线程操作共享资源,操作完成后去判断操作过程中共享资源是否被其他线程修改,如果有修改就撤销刚才的操作,如果没有就完成了这个操作。
比如说共享文档使用的就是乐观锁,它可以使多人同时在线修改文档~
除了锁以外,操作系统还提供“信号量”来完成进程/线程间的协调工作,信号量比锁的功能更加强大,它可以完成更复杂的线程间协调处理任务。除了实现互斥功能外,还可以用于实现线程间同步。
信号量指资源的数量,它具有两个原子操作,分别是P操作和V操作,算法会基于这两个操作及信号量的设计来完成一些复杂的进程/线程间协作。
P 操作是用在进入临界区之前,V 操作是用在离开临界区之后,这两个操作是必须成对出现的。
现在使用信号量及PV操作来完成简单的线程互斥管理:
在线程互斥管理中,资源的数量是1(因为同一时间只能有一个线程使用资源),就将互斥信号量s初始化为1。
具体实现如下图所示
这样就通过信号量完成了两个线程间的互斥。
进程/线程之间除了互斥这样的关系外,还有互相依赖作业的情况,此时称为两线程同步。
具体来说同步是指并发进程/线程在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通信息称为进程/线程同步。
比如说,线程 1 是负责读入数据的,而线程 2 是负责处理数据的,这两个线程是相互合作、相互依赖的。线程 2 在没有收到线程 1 的唤醒通知时,就会一直阻塞等待,当线程 1 读完数据需要把数据传给线程 2 时,线程 1 会唤醒线程 2,并把数据交给线程 2 处理。
同步就好比“操作A必须在操作B开始之前完成”这样的依赖关系。
线程之间的同步可以使用信号量来实现,在同步问题中,通常设置两种同步信号量来对应两个线程的运行条件,并将信号量的初始值设为0或者根据实际情况设置为其他值。
对于两个线程A、B,相比于互斥,同步是需要线程A在完成操作后将阻塞的线程B唤醒,而且线程B被阻塞的原因正是线程A刚刚还没有完成操作。
因此每个线程在运行时会对两种信号量分别进行P和V操作,而不是像互斥中只对一种信号量做PV操作。 这样才能实现两个线程之间的依赖关系~
我们以生产者—消费者系统为例,它的问题描述是:
这是一个同时存在互斥和同步的协调问题:
任何时刻只能有一个线程操作缓冲区,说明操作缓冲区是临界代码,需要互斥;
缓冲区空时,消费者必须等待生产者生成数据;缓冲区满时,生产者必须等待消费者取出数据。说明生产者和消费者需要同步。
因此设置三个信号量,其中一个互斥信号量,记为mutex;两个同步信号量,记为fullBuffers和emptyBuffers,那么具体的代码如下:
可以看到,生产者中对emptyBuffers进行了P操作用以检查是否槽全满,如果全满的话就要阻塞;在完成数据生成后,又对fullBuffers进行了V操作,这一步可以用来唤醒因槽空而阻塞的消费者。
消费者则对fullBuffers进行了P操作用以检查是否出现槽全空,如果槽全空就要阻塞;在完成数据读取后,对emptyBuffers进行了V操作,增加空槽的个数,这一步可以唤醒因槽满而阻塞的生产者。
从上面的过程中可以看出,在线程同步的关系下,信号量对于线程间的协调作用。
经典的同步问题有哲学家就餐问题和读者—写者问题,对于每个问题都有不同的信号量设计方法和各自的优劣,具体设计方法可以参考推文原文多个线程为了同个资源打起架来了,该如何让他们安分?
其中读者—写者问题就是读写锁基于信号量的实现,其中包括读优先、写优先以及公平读写三种方式。
锁和信号量的用法有很多,也会比较复杂,如果想深入理解还需要阅读操作系统相关书籍,这里推荐一本很多优秀的人都读过的《操作系统导论》~