锁—信号量与管程
1.基本概念
-
互斥
只有一个线程能访问临界区。 -
临界资源
多个线程可以共享系统中的资源,但是临界资源只允许一个线程在某一时刻访问。如某些变量、硬件资源。 -
临界区
访问临界资源的代码即临界区。
2.信号量与管程
管程(Monitors)和信号量(Semaphores)是操作系统中用于实现并发编程的两种重要技术。
2.1 管程(Monitor)
定义
管程是一种高级的同步工具,是一种包含共享变量和对该变量进行操作的一组过程的抽象。它提供了一种结构化的方法来管理共享资源的访问。
特点
管程通常包含多个过程(也称为方法或函数),这些过程可以操作共享变量,并且管程内部有一个锁,用于确保同一时间只有一个过程在执行。
进程间通信
管程主要用于进程(或线程)之间的协作和通信,它提供了一种封装共享资源和同步操作的方式。
条件变量
管程通常包含条件变量,用于在不满足某些条件时使线程等待,并在条件满足时通知其他线程。
2.2 信号量(Semaphore)
定义
信号量是一种低级的同步工具,是一个计数器,用于控制对共享资源的访问。信号量的值表示可用的资源数量。
特点
信号量通常用于管理有限数量的资源,通过增减信号量的值来控制对资源的访问。它可以用于实现互斥访问和线程之间的同步。
进程间通信
信号量主要用于进程间通信,也可以用于线程之间的同步。
操作
常见的信号量操作包括 P 操作(申请资源)和 V 操作(释放资源)。当信号量的值为正时,P 操作会使其值减一;当信号量的值为负时,P 操作会使线程阻塞。V 操作会使信号量的值加一,并唤醒等待的线程。
区别:
抽象级别: 管程是一种更高级的抽象,它提供了对共享资源的结构化访问和操作。信号量是一种更底层的同步机制,主要用于管理资源的可用数量。
操作方式: 管程提供了一组过程,线程通过调用这些过程来访问共享资源。信号量主要通过 P 和 V 操作来控制资源的访问。
应用场景: 管程更适用于需要封装共享资源和操作的场景,而信号量更适用于简单的资源控制和同步场景及多个线程同时进入临界区的场景。
2.1 信号量
2.1.1 定义
操作系统提供的一种协调共享资源访问的方法,优先级高于进程,可以保证原子性。 信号量能容易实现,管程不容器实现的,是信号量可以放多个资源同时进入临界区。
信号中包括一个整形变量,和两个原子操作 P 和 V。其原子性由操作系统保证,这个整形变量只能通过 P 操作和 V 操作改变。
-
P(Prolaag,荷兰语尝试减少):信号量值减 1,如果信号量值小于 0,则说明资源不够用的,把进程加入等待队列。
-
V (Verhoog,荷兰语增加):信号量值加 1,如果信号量值小于等于 0,则说明等待队列里有进程,那么唤醒一个等待进程。
共享变量S只能由 PV 操作,PV的原子性由操作系统保证。 P相当获取锁,可能会阻塞线程;V相当于释放锁,不会阻塞线程。 根据同步队列中唤醒线程的先后顺序,可以分为公平和非公平两种。
信号量分类:
- 二进制信号量:资源数目为 0 或 1。
- 资源信号量:资源数目为任何非负值
2.1.2 例子
实现生产者消费者针对同一队列进行通信,任一时刻只有一个线程在操作队列。
假设只有一个生产者,n个消费者,那么需要三个信号量:
notFull: 表示队列不满,可以推送消息
notEmpty: 表示队列不空,可以消费消息
mutex: 表示互斥,限制同时只有一个线程访问
class Queue { private Semaphore notFull = new Semaphore(1); private Semaphore notEmpty = new Semaphore(1); private Semaphore mutex = new Semaphore(1); public void produce() throws InterruptedException { notFull.acquire(); //队列不满才能推送,否则等待 mutex.acquire(); try { //生产消息加入队列 } finally { mutex.release(); notEmpty.release(); //唤醒等待消费线程 } } public void consume() throws InterruptedException { notEmpty.acquire(); //队列不空才能消费,否则等待 mutex.acquire(); try { // 消费消息 } finally { mutex.release(); notFull.release(); //唤醒等待生产者线程 } } }
多个线程进入临界区例子
class ObjPool<T, R> { final List<T> pool; // 用信号量实现限流器 final Semaphore sem; // 构造函数 ObjPool(int size, T t) { pool = new Vector<T>() { }; for (int i = 0; i < size; i++) { pool.add(t); } sem = new Semaphore(size); } // 利用对象池的对象,调用func R exec(Function<T, R> func) throws InterruptedException { T t = null; sem.acquire(); try { t = pool.remove(0); return func.apply(t); } finally { pool.add(t); sem.release(); } } }
2.2 管程
2.2.1 定义
管程是为了解决信号量在临界区的PV操作上的配对的麻烦而提出的并发编程方法,使用条件变量等同步机制来实现线程之间的协作。
条件变量: 线程中的一种同步机制,它允许线程等待某个条件成立,与信号量不同。 当条件不满足时,线程将自己加入等待队列,同时释放持有的互斥锁; 当一个线程唤醒一个或多个等待线程时,此时条件不一定为真(虚假唤醒)。
先后出现过三种不同的管程模型,分别是:Hasen 模型、Hoare 模型和 MESA 模型,当前广泛使用的是MESA模型。
共享变量(M),同步(S),事件(E)和协程(A)
MESA模型图示
2.2.2 MESA模型编程范式
while (条件不满足) { wait(); }
MESA模型使用while判断的原因是,wait()是进入了条件变量的等待队列。若被notify()或者notifyAll()唤醒,也只是从条件变量等待队列到了入口等待队列。入口等待队列才是竞争锁的地方,等到此线程实际执行的时候,条件可能又不满足了,需要再次wait()。
Hasen模型是当前线程执行结束,唤醒下一次线程执行。 Hoare模型是当前线程中断自己,唤醒其他线程执行结束后再继续执行。 MESA模型是仅将其他线程从条件变量等待队列唤醒到入口等待队列,被唤醒的线程还需要在入口等待队列中竞争到锁后才能执行。
2.3.3 例子
实现一个自定的阻塞队列,通过Lock类保证互斥条件,notFull和notEmpty分别表示队列非满及非空的条件变量。
public class BlockedQueue<T>{ final Lock lock = new ReentrantLock(); // 条件变量:队列不满 final Condition notFull = lock.newCondition(); // 条件变量:队列不空 final Condition notEmpty = lock.newCondition(); // 入队 void enq(T x) { lock.lock(); try { while (队列已满){ // 等待队列不满 notFull.await(); } // 省略入队操作... //入队后,通知可出队 notEmpty.signal(); }finally { lock.unlock(); } } // 出队 void deq(){ lock.lock(); try { while (队列已空){ // 等待队列不空 notEmpty.await(); } // 省略出队操作... //出队后,通知可入队 notFull.signal(); }finally { lock.unlock(); } } }
2.3.4 唤醒对比
除非必须(实际也没什么必须的),尽量使用notifyAll()及signalAll(),可以避免意想不到的bug。
比如线程A等待资源a,线程B等待资源B,此时两个线程都被synchronize锁对象的锁住,线程C此时释放资源a执行notify(),却只唤醒了线程B,那么就会无限阻塞下去。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?