信号量与管程
前言
我们知道,在并发领域内,需要关注分工、同步与互斥,针对分工问题,就是将任务拆解,分配给多个线程执行,而在多线程执行的过程中,需要解决线程之间的协作与互斥问题进而保证并发安全。那么解决这类问题的方案是什么呢?没错就是信号量和管程。
信号量
简介
信号量的概念是由荷兰计算机科学家Edsger W. Dijkstra在1960年引入的。Dijkstra引入了P(Proberen
,荷兰语中的"try")和V(Verhogen
,荷兰语中的"increment")这两个操作,并使用它们来解决各种同步问题,如著名的哲学家进餐问题。
Dijkstra最初引入信号量的目的是为了管理稀缺的计算机资源,如打印机或磁带驱动器。但随着时间的推移,信号量被广泛应用于各种场景中,成为并发编程中的基石。
信号量有两种常见类型:
- 二进制信号量:其值只能为0或1,类似于互斥锁,常用于资源的互斥访问。
- 计数信号量:其值可以为任何非负整数,常用于管理有限的资源集,如线程池中的线程数量或数据库连接池中的连接数量。
实现原理
信号量模型比较简单,它由一个计数器、一个等待队列和三个方法组成,即如下图所示:
信号量模型维护一个计数器来决定进入临界区的线程数,init方法则是初始化计数器大小,P操作则是将计数器-1,V操作则是把计数器+1,信号量的运转流程如下图所示:
demo演示
package com.markus.concurrent;
import java.util.concurrent.Semaphore;
/**
* @author: markus
* @date: 2023/8/19 2:21 PM
* @Description: 信号量demo
* @Blog: https://markuszhang.com
* It's my honor to share what I've learned with you!
*/
public class SemaphoreDemo {
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(1);
int count = 0;
ShareObject shareObject = new ShareObject(semaphore, count);
Thread threadA = new Thread(() -> {
for (int i = 0; i < 100_000; i++) {
// 线程安全
shareObject.increment();
}
});
Thread threadB = new Thread(() -> {
for (int i = 0; i < 100_000; i++) {
shareObject.increment();
}
});
threadA.start();
threadB.start();
threadA.join();
threadB.join();
System.out.println(shareObject.getCount());
}
}
class ShareObject {
private Semaphore semaphore;
private int count;
public ShareObject(Semaphore semaphore, int count) {
this.semaphore = semaphore;
this.count = count;
}
public void increment() {
try {
semaphore.acquire();
// 临界区 非原子性操作,如果不做同步互斥控制,会造成并发不安全的情况
count += 1;
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release();
}
}
public void unsafeIncrement() {
count++;
}
public int getCount() {
return this.count;
}
}
管程
简介
管程的概念是在1970s由Edsger W. Dijkstra、C.A.R. Hoare和Per Brinch Hansen等计算机科学家独立提出的。其中,C.A.R. Hoare的论文“Monitors: An Operating System Structuring Concept”特别影响深远,他详细描述了管程的结构和特性,并提出了条件变量的概念。
管程的提出旨在简化并发编程中复杂的同步问题,提供一个更高级和更结构化的同步方法。与信号量相比,管程通常更易于理解和使用,因为它将同步机制与数据结构紧密集成,并自动处理互斥。
许多现代编程语言和操作系统都提供了原生或类似管程的支持。例如,Java的synchronized
关键字和内置的对象锁提供了管程的基本功能(互斥),而Object
类的wait()
, notify()
, 和notifyAll()
方法则实现了条件变量的功能。
上面提到两个关键组件:
- 互斥访问:管程确保其方法在任何时候都只能被一个线程执行。
- 条件变量:允许线程等待特定条件成立或通知其他等待的线程条件已经改变。这是管程中的核心部分,常常用于线程间同步。
实现原理
与信号量不同,管程是将共享变量、同步队列封装了起来,并在此基础上增加了条件变量及其等待队列,管程模型如下图所示:
需要一提的是:上图是管程MESA模型的实现,还有另外一种模型可以实现管程:Hoare
模型,MESA模型和Hoare模型的核心区别就在于:
- 在Hoare模型中,当一个线程在条件变量上执行
signal
操作来唤醒另一个线程时,控制权会立即被传递给被唤醒的线程。这意味着,唤醒的线程立刻获得管程的锁并开始执行,执行signal
操作的线程将被暂停,直到被唤醒的线程释放锁或进入等待状态。 - 在MESA模型中,当线程被唤醒时,它并不立即重新获得管程的锁。相反,它被放入一个就绪队列,等待重新获得锁。这种行为可能会导致所谓的“叫醒后等待”(wakeup-wait)的情况,即一个被唤醒的线程可能在获得锁之前需要等待其他线程。
MESA模型的优势在于它通常更容易实现,并且可以减少上下文切换的数量。
Java选择MESA模型来实现其内置的管程(Monitor)机制主要基于以下几个原因:
- 实现简便性:MESA模型简化了唤醒和调度的过程。在Hoare模型中,当一个线程执行
signal
操作时,它必须将锁传递给被唤醒的线程,这可能导致额外的上下文切换和调度复杂性。而在MESA模型中,执行signal
操作的线程可以继续执行,直到它自然地释放锁。 - 减少上下文切换:如前所述,MESA模型可以减少不必要的上下文切换,因为执行
signal
的线程不必立即放弃执行权。 - 预测性:在多处理器系统上,MESA模型可以提供更好的性能和预测性。由于线程不需要立即传递控制权,这有助于在多处理器环境中实现更有效的锁缓存和减少锁迁移。
- 假唤醒的处理:MESA模型天然地支持处理假唤醒(spurious wakeups)。线程在被唤醒后会重新检查等待的条件,这样可以确保即使因为假唤醒而被唤醒,线程也不会执行不应该执行的代码。
尽管MESA模型引入了所谓的"叫醒后等待"(wakeup-wait)的现象,但由于上述优点,Java开发者认为它是一个更好的选择。这也是为什么Java的Object.wait()
和Object.notify()/notifyAll()
方法的行为与MESA模型相吻合。
Java 内置的管程方案(synchronized)使用简单,synchronized 关键字修饰的代码块,在编译期会自动生成相关加锁和解锁的代码,但是仅支持一个条件变量;而 Java SDK 并发包实现的管程支持多个条件变量,不过并发包里的锁,需要开发人员自己进行加锁和解锁操作。
demo演示
package com.markus.concurrent;
/**
* @author: markus
* @date: 2023/8/19 3:12 PM
* @Description:
* @Blog: https://markuszhang.com
* It's my honor to share what I've learned with you!
*/
public class Synchronized4MonitorDemo {
public static void main(String[] args) throws InterruptedException {
ShareInteger shareInteger = new ShareInteger(0);
Thread threadA = new Thread(() -> {
for (int i = 0; i < 100_000; i++) {
try {
shareInteger.increment();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
Thread threadB = new Thread(() -> {
for (int i = 0; i < 100_000; i++) {
try {
shareInteger.increment();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
threadA.start();
threadB.start();
Thread.sleep(2000);
System.out.println("主线程将count设置为501");
shareInteger.setCount(501);
// 等待两个线程执行完
threadA.join();
threadB.join();
// 打印最终的加和结果
System.out.println(shareInteger.getCount());
}
}
class ShareInteger {
private int count;
public ShareInteger(int count) {
this.count = count;
}
public void increment() throws InterruptedException {
synchronized (this) {
while (count <= 500) {
System.out.println(Thread.currentThread().getName() + " 被阻塞");
this.wait();
System.out.println(Thread.currentThread().getName() + " 被唤醒");
}
count += 1;
}
}
public void setCount(int count) {
synchronized (this) {
this.count = count;
if (count > 500) {
// 唤醒所有等待count>500条件的线程
this.notifyAll();
}
}
}
public int getCount() {
return count;
}
}
信号量与管程的对比
管程和信号量都是用于处理并发问题的同步原语,但它们具有不同的特点和使用方法。下面是管程和信号量的优劣对比以及它们的使用场景:
- 管程:
- 优点:
- 封装性:管程提供了良好的封装性,因为它将数据和对数据的操作包含在一个单一的结构或对象中。这使得管程在面向对象的环境中特别有用。
- 简介性:管程自动处理锁的获取和释放,使得代码更简洁且易于理解。
- 条件变量支持:管程内部的条件变量提供了一种强大的机制,允许线程在特定条件下等待或被唤醒。
- 缺点:
- 灵活性:相对于信号量,管程可能在某些特定场景下不那么灵活。
- 使用场景:
- 需要结构化并发控制的情境,尤其是在面向对象的设计中。
- 当需要使用条件变量来处理复杂的同步条件时。
- 优点:
- 信号量:
- 优点:
- 灵活性:信号量为并发控制提供了极大的灵活性。它可以用于实现互斥、同步,以及各种资源计数场景
- 广泛性:信号量是很多操作系统和并发库的基石,它有着广泛的应用
- 缺点:
- 易出错:由于信号量的灵活性,使用它的代码可能更容易出错。例如,忘记释放信号量或不正确的信号量使用可能导致死锁
- 缺乏封装:信号量不提供封装数据和操作的机制,可能导致数据不一致或竞争条件。
- 使用场景:
- 用于实现互斥锁和其他锁类型。
- 管理有限资源的数量,如线程池中的线程或数据库连接。
- 实现复杂的同步场景,如生产者-消费者问题。(这里并没有管程实现简单,并且使用不当还会出错)
- 优点:
管程与信号量在Java中都有相应的实现,基于不同的场景应用不同的模型,并不是说谁好谁不好,只能说在某种场景下,谁比谁更合适,例如实现一个限流器,信号量就优于管程;实现一个阻塞队列,管程就优于信号量
其他同步工具
下面罗列下其他同步工具,做一些简要介绍,后续会单拉出几篇文章做详细解释。
CAS
原子操作,它检查当前值是否与预期值匹配,如果匹配,则使用新值更新它。
读拷贝更新
一种同步机制,允许读取操作无锁并发地执行,而更新操作通过延迟回收机制避免与读取操作冲突。
读写锁
一种锁机制,允许多个读者并发访问,但在写入时保证独占访问。
障碍同步
一种同步原语,使一组线程在继续执行之前等待所有线程都到达某个点。
总结
总结起来,管程和信号量都是并发控制的核心工具,各自带有其独特的特点和使用方法。管程,通过其结构化和封装的特性,为复杂的同步问题提供了简单、直观的解决方案,尤其适用于面向对象的环境中。它们强调了数据和对数据的操作之间的紧密结合,确保数据的完整性和安全性。而信号量,作为一种更基础且灵活的同步原语,能够用于广泛的场景,从基本的互斥到复杂的协调任务。虽然信号量提供了更大的灵活性,但这种灵活性也可能带来更高的错误风险。因此,在选择适当的并发工具时,开发者需要根据特定的问题和需求来权衡。不管如何,了解这两个工具的工作方式及其优劣势是任何希望深入并发编程的开发者的基础任务。