线程的互斥与同步、锁与信号量

文章目录

  • 前言
  • 一、互斥
    • 1、加锁
      • 互斥锁
      • 自旋锁
      • 读写锁
      • 乐观锁
    • 2、信号量
      • 概念
      • 实现互斥
  • 二、同步
    • 1、信号量
  • 总结


前言

我在阅读 “小林coding” 公众号的图解操作系统系列文章后感觉受益良多,因此进行一些小总结,方便日后查阅。本篇是针对线程间互斥与同步的实现方法进行的总结。

原文链接:
面试官:你说说互斥锁、自旋锁、读写锁、悲观锁、乐观锁的应用场景
多个线程为了同个资源打起架来了,该如何让他们安分?


线程之间存在两种常见的协作关系:互斥和同步,为了实现进程/线程间正确的协作,操作系统必须提供实现线程协作的措施和方法,主要的方法有两种:加锁和信号量。本博文将对互斥和同步下锁/信号量的使用进行基本介绍。

一、互斥

当几个线程都想访问公共资源时,为了避免数据混乱,要求在某个时间只有一个线程能对公共资源进行操作。这种情况称为线程间的互斥。

1、加锁

在线程间互斥的情况下,在使用公共资源时线程就可以加一个锁,防止其他线程再访问公共资源。这就是锁的作用。也就是用锁来实现线程间的互斥。

常用的锁有互斥锁、自旋锁、读写锁(这三种统称为悲观锁)、乐观锁,其中互斥和自旋锁是两个基础锁,其他的锁是在这两者的基础上改进的高级锁。

互斥锁

当一个线程加锁后(锁的值置为1),另一个线程访问时即无法获取锁,此时CPU会命令该线程睡眠(即阻塞),并去运行其他就绪的线程(切换线程)。当锁被释放后(锁的值置为0),CPU才会重新使该线程处于就绪状态,并在合适的时候运行它。

该锁的缺点在于两次切换线程耗时较多,如果当前加锁线程很快就释放锁的话,互斥锁不是好的选择。

自旋锁

当线程发现无法获取锁的时候,就进入忙等待状态,它会一直占用CPU不停查看锁是否被释放。因此在单核CPU上使用自旋锁的时候,必须使用抢占式的调度器,来强行切换运行的线程。

忙等待的实现可以通过“测试并设置”这一原子操作实现,其C代码如下:

线程的互斥与同步、锁与信号量_第1张图片

测试并设置实际上是返回了目标变量的当前值,并且将目标变量的值设置为新的值。在自旋锁的实现中,目标变量就是锁。

在实际使用时可以通过如下方式实现自旋锁:
线程的互斥与同步、锁与信号量_第2张图片

在一个进程要使用共享空间时,调用lock函数,此时若没有进程加锁,那么会跳出while循环,并且将锁置为1,当该进程出共享空间时,调用unlock函数释放锁;若当前有其他进程加了锁,那么该进程会一直在while循环中,直到 lock->flag变为0,它才能跳出while循环并能同时上锁。

读写锁

这种锁应用于区分读、写操作的场景中,分为读锁和写锁。当一个线程要进行写操作时,会加写锁,这是一种独占锁,即上锁后其他进程均不能对共享资源进行读写操作;当一个线程进行读操作时,会加读锁,这属于共享锁,即允许多个线程同时加读锁,可以提高读数据的效率。但是加读锁的前提是没有线程加了写锁,加写锁的前提是没有线程加了读锁。

写锁就可以用互斥锁或是自旋锁来实现,它们两个都是独占锁。

读写锁有读优先锁和写优先锁,它们都要一定弊端,最好的方案是公平读写锁,将想要读写的线程依次放入队列中,依次完成它们的加锁请求,读写的优先级相同

线程的互斥与同步、锁与信号量_第3张图片

上图是读优先锁,当已经有线程加了读锁时,那么想加写锁的线程就要等待,但是其他想加读锁的线程就可以同时加读锁。这容易导致写线程的“饥饿”;

线程的互斥与同步、锁与信号量_第4张图片

上图是写优先锁,当已经有线程加了读锁时,只要有线程要加写锁,那么其他想加读锁的线程就被阻塞,不能同时加读锁,这可能会造成读线程的”饥饿“。

乐观锁

实际上互斥锁、自旋锁、读写锁都属于悲观锁,即认为多个线程在共享资源处产生冲突的概率很高,因此必须通过加锁来限制某个时间只有一个线程使用共享资源。

与之相对的是乐观锁,它认为产生冲突的概率很低,因此在线程访问共享资源时不加任何锁,它的处理方式是先让线程操作共享资源,操作完成后去判断操作过程中共享资源是否被其他线程修改,如果有修改就撤销刚才的操作,如果没有就完成了这个操作。

比如说共享文档使用的就是乐观锁,它可以使多人同时在线修改文档~

2、信号量

概念

除了锁以外,操作系统还提供“信号量”来完成进程/线程间的协调工作,信号量比锁的功能更加强大,它可以完成更复杂的线程间协调处理任务。除了实现互斥功能外,还可以用于实现线程间同步。

信号量指资源的数量,它具有两个原子操作,分别是P操作和V操作,算法会基于这两个操作及信号量的设计来完成一些复杂的进程/线程间协作。

  • P操作:对信号量减一,若得到的结果小于0,那么阻塞当前进程;否则就运行当前进程。
  • V操作:对信号量加一,若得到结果≤0,说明当前有被阻塞的进程,那么就唤醒一个阻塞进程。

P 操作是用在进入临界区之前,V 操作是用在离开临界区之后,这两个操作是必须成对出现的。

实现互斥

现在使用信号量及PV操作来完成简单的线程互斥管理:

在线程互斥管理中,资源的数量是1(因为同一时间只能有一个线程使用资源),就将互斥信号量s初始化为1

具体实现如下图所示

  1. 线程A想要进入临界区,于是对互斥信号量执行P操作,由于互斥信号量的初始值为 1,故在线程A执行 P 操作后 s 值变为0,表示临界资源为空闲,可分配给该线程,使之进入临界区。
  2. 若此时线程B也想进入临界区,也应先执行 P 操作,结果使 s 变为-1,这就意味着临界资源已被占用,因此,线程B被阻塞。
  3. 直到线程A退出临界区并执行 V 操作,释放临界资源而恢复 s 值为 0 后,才唤醒线程B,使之进入临界区,待它完成临界资源的访问后,又执行 V 操作,使 s 恢复到初始值 1。

这样就通过信号量完成了两个线程间的互斥。

线程的互斥与同步、锁与信号量_第5张图片

二、同步

进程/线程之间除了互斥这样的关系外,还有互相依赖作业的情况,此时称为两线程同步。

具体来说同步是指并发进程/线程在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通信息称为进程/线程同步

比如说,线程 1 是负责读入数据的,而线程 2 是负责处理数据的,这两个线程是相互合作、相互依赖的。线程 2 在没有收到线程 1 的唤醒通知时,就会一直阻塞等待,当线程 1 读完数据需要把数据传给线程 2 时,线程 1 会唤醒线程 2,并把数据交给线程 2 处理。

同步就好比“操作A必须在操作B开始之前完成”这样的依赖关系。

1、信号量

线程之间的同步可以使用信号量来实现,在同步问题中,通常设置两种同步信号量来对应两个线程的运行条件,并将信号量的初始值设为0或者根据实际情况设置为其他值。

对于两个线程A、B,相比于互斥,同步是需要线程A在完成操作后将阻塞的线程B唤醒,而且线程B被阻塞的原因正是线程A刚刚还没有完成操作。

因此每个线程在运行时会对两种信号量分别进行P和V操作,而不是像互斥中只对一种信号量做PV操作。 这样才能实现两个线程之间的依赖关系~


我们以生产者—消费者系统为例,它的问题描述是:

  • 生产者在生成数据后,放在一个缓冲区中;
  • 消费者从缓冲区取出数据处理;
  • 任何时刻,只能有一个生产者或消费者可以访问缓冲区;

这是一个同时存在互斥和同步的协调问题:
任何时刻只能有一个线程操作缓冲区,说明操作缓冲区是临界代码,需要互斥;

缓冲区空时,消费者必须等待生产者生成数据;缓冲区满时,生产者必须等待消费者取出数据。说明生产者和消费者需要同步。

因此设置三个信号量,其中一个互斥信号量,记为mutex;两个同步信号量,记为fullBuffers和emptyBuffers,那么具体的代码如下:

线程的互斥与同步、锁与信号量_第6张图片

可以看到,生产者中对emptyBuffers进行了P操作用以检查是否槽全满,如果全满的话就要阻塞;在完成数据生成后,又对fullBuffers进行了V操作,这一步可以用来唤醒因槽空而阻塞的消费者。

消费者则对fullBuffers进行了P操作用以检查是否出现槽全空,如果槽全空就要阻塞;在完成数据读取后,对emptyBuffers进行了V操作,增加空槽的个数,这一步可以唤醒因槽满而阻塞的生产者。


从上面的过程中可以看出,在线程同步的关系下,信号量对于线程间的协调作用。

经典的同步问题有哲学家就餐问题和读者—写者问题,对于每个问题都有不同的信号量设计方法和各自的优劣,具体设计方法可以参考推文原文多个线程为了同个资源打起架来了,该如何让他们安分?

其中读者—写者问题就是读写锁基于信号量的实现,其中包括读优先、写优先以及公平读写三种方式。


总结

锁和信号量的用法有很多,也会比较复杂,如果想深入理解还需要阅读操作系统相关书籍,这里推荐一本很多优秀的人都读过的《操作系统导论》~

你可能感兴趣的:(操作系统,操作系统,互斥和同步,信号量,锁)