Linux:临界资源、同步与互斥、锁、信号量

一、临界资源

1 、临界区的概念

临界区(Critical Section)是多线程或多进程编程中的一个概念,指的是代码中访问共享资源的一段区域。在这段区域内,一次只能有一个线程或进程在执行,以避免多个线程同时访问和修改共享资源,从而引发数据不一致或竞态条件(Race Condition)。

临界区的特点:

共享资源:临界区内通常包含对共享资源的访问,如全局变量、文件、数据库连接等。

互斥访问:为了保证数据的一致性和完整性,临界区内的代码一次只能由一个线程或进程执行。

同步机制:使用同步机制(如信号量、互斥锁、自旋锁等)来控制对临界区的访问。

不可中断性:执行临界区的线程或进程不能被其他线程中断,直到它完全离开临界区。

尽量减少执行时间:为了减少其他线程或进程的等待时间,应尽量缩短临界区内代码的执行时间。

避免优先级反转:在设计时应注意避免因线程优先级变化导致的优先级反转问题。

2 、互斥的概念

保证一个线程在临界区执行时,其他线程应该被阻止进入临界区

3 、同步的概念

在多进程/多线程中,有时需要多个进程/线程密切合作,共同完成一项任务。比如线程1是读数据,线程2是处理数据,这两个线程是相互合作、相互依赖的。线程 2 在没有收到线程 1 的唤醒通知时,就会一直阻塞等待,当线程 1 读完数据需要把数据传给线程 2 时,线程 1 会唤醒线程 2,并把数据交给线程 2 处理。同步,就是并发进程/线程在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通信息称为进程/线程同步。

二、线程同步&互斥

1、同步与互斥的概念

现代操作系统基本都是多任务操作系统,即同时有大量可调度实体在运行。在多任务操作系统中,同时运行的多个任务可能:都需要访问/使用同一种资源;

多个任务之间有依赖关系,某个任务的运行依赖于另一个任务。

【同步】:

是指散步在不同任务之间的若干程序片断,它们的运行必须严格按照规定的某种先后次序来运行,这种先后次序依赖于要完成的特定的任务。最基本的场景就是:两个或两个以上的进程或线程在运行过程中协同步调,按预定的先后次序运行。比如 A 任务的运行依赖于 B 任务产生的数据。

【互斥】:

是指散步在不同任务之间的若干程序片断,当某个任务运行其中一个程序片段时,其它任务就不能运行它们之中的任一程序片段,只能等到该任务运行完这个程序片段后才可以运行。最基本的场景就是:一个公共资源同一时刻只能被一个进程或线程使用,多个进程或线程不能同时使用公共资源。

2、互斥锁(同步)

在多任务操作系统中,同时运行的多个任务可能都需要使用同一种资源。这个过程有点类似于,公司部门里,我在使用着打印机打印东西的同时(还没有打印完),别人刚好也在此刻使用打印机打印东西,如果不做任何处理的话,打印出来的东西肯定是错乱的。

在线程里也有这么一把锁------互斥锁(mutex),互斥锁是一种简单的加锁的方法来控制对共享资源的访问,互斥锁只有两种状态,即上锁( lock )和解锁( unlock )。

【互斥锁的特点】:

  1. 原子性:把一个互斥量锁定为一个原子操作,这意味着操作系统(或pthread函数库)保证了如果一个线程锁定了一个互斥量,没有其他线程在同一时间可以成功锁定这个互斥量;

  2. 唯一性:如果一个线程锁定了一个互斥量,在它解除锁定之前,没有其他线程可以锁定这个互斥量;

  3. 非繁忙等待:如果一个线程已经锁定了一个互斥量,第二个线程又试图去锁定这个互斥量,则第二个线程将被挂起(不占用任何cpu资源),直到第一个线程解除对这个互斥量的锁定为止,第二个线程则被唤醒并继续执行,同时锁定这个互斥量。

【互斥锁的操作流程如下】:

  1. 在访问共享资源后临界区域前,对互斥锁进行加锁;

  2. 在访问完成后释放互斥锁导上的锁。在访问完成后释放互斥锁导上的锁;

  3. 对互斥锁进行加锁后,任何其他试图再次对互斥锁加锁的线程将会被阻塞,直到锁被释放。对互斥锁进行加锁后,任何其他试图再次对互斥锁加锁的线程将会被阻塞,直到锁被释放。

3、条件变量(同步)

与互斥锁不同,条件变量是用来等待而不是用来上锁的。条件变量用来自动阻塞一个线程,直 到某特殊情况发生为止。通常条件变量和互斥锁同时使用。

条件变量使我们可以睡眠等待某种条件出现。条件变量是利用线程间共享的全局变量进行同步 的一种机制,主要包括两个动作:

一个线程等待"条件变量的条件成立"而挂起;另一个线程使 "条件成立"(给出条件成立信号)。

【原理】:

条件的检测是在互斥锁的保护下进行的。线程在改变条件状态之前必须首先锁住互斥量。如果一个条件为假,一个线程自动阻塞,并释放等待状态改变的互斥锁。如果另一个线程改变了条件,它发信号给关联的条件变量,唤醒一个或多个等待它的线程,重新获得互斥锁,重新评价条件。如果两进程共享可读写的内存,条件变量 可以被用来实现这两进程间的线程同步。

【条件变量的操作流程如下】:

  1. 初始化:init()或者pthread_cond_tcond=PTHREAD_COND_INITIALIER;属性置为NULL;

  2. 等待条件成立:pthread_wait,pthread_timewait.wait()释放锁,并阻塞等待条件变量为真 timewait()设置等待时间,仍未signal,返回ETIMEOUT(加锁保证只有一个线程wait);

  3. 激活条件变量:pthread_cond_signal,pthread_cond_broadcast(激活所有等待线程)

  4. 清除条件变量:destroy;无线程等待,否则返回EBUSY清除条件变量:destroy;无线程等待,否则返回EBUSY

4、读写锁(同步)

读写锁与互斥量类似,不过读写锁允许更改的并行性,也叫共享互斥锁。互斥量要么是锁住状态,要么就是不加锁状态,而且一次只有一个线程可以对其加锁。读写锁可以有3种状态:读模式下加锁状态、写模式加锁状态、不加锁状态。

一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁(允许多个线程读但只允许一个线程写)。

【读写锁的特点】:

如果有其它线程读数据,则允许其它线程执行读操作,但不允许写操作;

如果有其它线程写数据,则其它线程都不允许读、写操作。

【读写锁的规则】:

如果某线程申请了读锁,其它线程可以再申请读锁,但不能申请写锁;

如果某线程申请了写锁,其它线程不能申请读锁,也不能申请写锁。

读写锁适合于对数据结构的读次数比写次数多得多的情况。

5、自旋锁(同步)

自旋锁与互斥量功能一样,唯一一点不同的就是互斥量阻塞后休眠让出cpu,而自旋锁阻塞后不会让出cpu,会一直忙等待,直到得到锁。

自旋锁在用户态使用的比较少,在内核使用的比较多!自旋锁的使用场景:锁的持有时间比较短,或者说小于2次上下文切换的时间。

自旋锁在用户态的函数接口和互斥量一样,把pthread_mutex_xxx()中mutex换成spin,如:pthread_spin_init()。

6、信号量(同步与互斥)

信号量广泛用于进程或线程间的同步和互斥,信号量本质上是一个非负的整数计数器,它被用来控制对公共资源的访问。

编程时可根据操作信号量值的结果判断是否对公共资源具有访问的权限,当信号量值大于 0 时,则可以访问,否则将阻塞。PV 原语是对信号量的操作,一次 P 操作使信号量减1,一次 V 操作使信号量加1。

****三、

使用加锁操作和解锁操作可以解决并发线程/进程的互斥问题。

任何想进入临界区的线程,必须先执行加锁操作。若加锁操作顺利通过,则线程可进入临界区;在完成对临界资源的访问后再执行解锁操作,以释放该临界资源。

互斥锁

互斥锁是一种「独占锁」,比如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程

对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的。当加锁失败时,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,于是就可以继续执行。如下图:

互斥锁加锁失败时,线程状态被内核修改为睡眠,等锁释放后修改为就绪,因此存在线程上下文切换(切换线程的独有数据和寄存器)的开销,如果临界区的代码执行时间很短(短于切换的开销),应该使用自旋锁。

pthread_mutex_init();

pthread_mutex_lock();

pthread_mutex_trylock(); /* 非阻塞的形式上互斥锁 */

pthread_mutex_unlock();

pthread_mutex_destory(); /* 此时锁必须为unlock状态) */

2 、自旋锁

  • 自旋锁在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。
  • 自旋锁加锁失败后,线程会忙等待,直到它拿到锁;需要注意,在单核 CPU 上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。
  • 自旋锁开销少,在多核系统下一般不会主动产生线程切换,适合异步、协程等在用户态切换请求的编程方式,但如果被锁住的代码执行时间过长,自旋的线程会长时间占用 CPU 资源。

临界区的执行之间小于CPU上下文切换开销的时候可以用自旋锁。

#include <pthread.h>

pthread_spinlock_t spinlock; /* 声明一个自旋锁 */

pthread_spin_lock(&spinlock); /* 上自旋锁 */

pthread_spin_unlock(&spinlock); /* 解自旋锁 */

3 、读写锁

读写锁的工作原理:

  • 当「写锁」没有被线程持有时,多个线程能够并发地持有读锁,这大大提高了共享资源的访问效率,因为「读锁」是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据。
  • 但是,一旦「写锁」被线程持有后,读线程的获取读锁的操作会被阻塞,而且其他写线程的获取写锁的操作也会被阻塞。

读写锁适用于能明确区分读操作和写操作的场景,在读多写少的场景,能发挥出优势。

1 **)读优先锁:**当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 仍然可以成功获取读锁,最后直到读线程 A 和 C 释放读锁后,写线程 B 才可以成功获取写锁。如下图:

2 **)写优先锁:**当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 获取读锁时会失败,于是读线程 C 将被阻塞在获取读锁的操作,这样只要读线程 A 释放读锁后,写线程 B 就可以成功获取写锁。如下图:

3 )读写公平锁:

读优先锁可能造成写饥饿;

写优先锁可能造成读饥饿;

公平读写锁比较简单的一种方式是:用队列把获取锁的线程排队,不管是写线程还是读线程都按照先进先出的原则加锁即可,这样读线程仍然可以并发,也不会出现「饥饿」的现象。

pthread_rwlock_init()

pthread_rwlock_rdlock()

pthread_rwlock_wrlock()

pthread_rwlock_unlock()

pthread_rwlock_tryrdlock() /* 非阻塞的形式上读锁 */

pthread_rwlock_trywrlock() /* 非阻塞的形式上写锁 */

pthread_rwlock_timerdlock() /* 获取不到锁会等待一段时间 */

pthread_rwlock_timewrlock()

pthread_rwlock_destroy()

4 、乐观锁与悲观锁

互斥锁、自旋锁、读写锁,都是属于悲观锁。

悲观锁认为多线程同时修改共享资源的概率比较高,很容易出现冲突,所以访问共享资源前,先要上锁。

相反的,如果多线程同时修改共享资源的概率比较低,就可以采用乐观锁。

乐观锁做假定冲突的概率很低,它的工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。

乐观锁全程并没有加锁,所以它也叫无锁编程。比如在线文档、SVN、Git等等,先修改再通过版本号判断是否有冲突。

****四、信号量

信号量是操作系统提供的一种协调共享资源访问的方法。

通常信号量表示资源的数量,对应的变量是一个整型(sem)变量。

另外,还有两个原子操作的系统调用函数来控制信号量的,分别是:

  • P 操作 :将 sem1,相减后,如果 sem < 0,则进程/线程进入阻塞等待,否则继续,表明 P 操作可能会阻塞;
  • V 操作 :将 sem1,相加后,如果 sem <= 0,唤醒一个等待中的进程/线程,表明 V 操作不会阻塞;

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

1 、使用信号量实现临界区的互斥访问

信号量初始化为1:

任何想进入临界区的线程,必先在互斥信号量上执行 P 操作,在完成对临界资源的访问后再执行 V 操作。由于互斥信号量的初始值为 1,故在第一个线程执行 P 操作后 sem 值变为 0,表示临界资源为空闲,可分配给该线程,使之进入临界区。

若此时又有第二个线程想进入临界区,也应先执行 P 操作,结果使 sem 变为负值,这就意味着临界资源已被占用,因此,第二个线程被阻塞。

并且,直到第一个线程执行 V 操作,释放临界资源而恢复 s 值为 0 后,才唤醒第二个线程,使之进入临界区,待它完成临界资源的访问后,又执行 V 操作,使 sem 恢复到初始值 1。

对于两个并发线程,互斥信号量的值仅取 1、0 和 -1 三个值,分别表示:

  • 如果互斥信号量为 1,表示没有线程进入临界区;
  • 如果互斥信号量为 0,表示有一个线程进入临界区;
  • 如果互斥信号量为 -1,表示一个线程进入临界区,另一个线程等待进入。

2 、使用信号量实现事件同步

信号量初始化为0:

生产者-消费者问题描述:

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

我们对问题分析可以得出:

  • 任何时刻只能有一个线程操作缓冲区,说明操作缓冲区是临界代码,需要互斥;
  • 缓冲区空时,消费者必须等待生产者生成数据;缓冲区满时,生产者必须等待消费者取出数据。说明生产者和消费者需要同步。

那么我们需要三个信号量,分别是:

  • 互斥信号量 mutex:用于互斥访问缓冲区,初始化值为 1;
  • 资源信号量 fullBuffers:用于消费者询问缓冲区是否有数据,有数据则读取数据,初始化值为 0(表明缓冲区一开始为空);
  • 资源信号量 emptyBuffers:用于生产者询问缓冲区是否有空位,有空位则生成数据,初始化值为 n (缓冲区大小);
相关推荐
QC班长2 小时前
如何进行接口性能优化?
java·linux·性能优化·重构·系统架构
_OP_CHEN3 小时前
【Linux网络编程】(一)初识计算机网络:从独立主机到协议世界的入门之旅
linux·服务器·网络·网络协议·计算机网络·socket·c/c++
原来是猿3 小时前
Linux-【文件系统上】
linux·服务器·数据库
寂柒6 小时前
信号量——基于环形队列的生产消费模型
linux·ubuntu
林姜泽樾10 小时前
Linux入门第十二章,创建用户、用户组、主组附加组等相关知识详解
linux·运维·服务器·centos
xiaokangzhe10 小时前
Linux系统安全
linux·运维·系统安全
feng一样的男子10 小时前
NFS 扩展属性 (xattr) 提示操作不支持解决方案
linux·go
Highcharts.js11 小时前
Highcharts React v4.2.1 正式发布:更自然的React开发体验,更清晰的数据处理
linux·运维·javascript·ubuntu·react.js·数据可视化·highcharts
c++之路12 小时前
Linux网络协议与编程基础:TCP/IP协议族全解析
linux·网络协议·tcp/ip