Semaphore
:信号量,之前也被翻译为信号灯,因为其性质类似现实生活中的红绿灯。车辆是否通行要看是不是绿灯,在编程中线程是否可以执行,也要看信号量是否允许。
信号量由 迪杰斯特拉(Dijkstra)
在 1965 年提出,在这之后的15年信号量一直都是并发编程领域的终结者。直到 1980 年管程
提出之后才有了第二个选择。
目前几乎所有并发编程语言都支持信号量机制,所以我们需要学好它,下面的内容包括:
- 什么是信号量模型
- 如何使用信号量
- 使用信号量实现一个限流器
信号量模型
信号量模型可以简单概括为:
- 一个
计数器
- 一个
等待队列
- 三个
方法
在信号量模型中,计数器和等待队列对外是透明的,所以只能通过信号量模型提供的三个方法来访问它们,这三个方法分别是 init()
、 down()
、up()
。
这三个方法的具体语义如下:
init()
:设置计数器的初始值
down()
:计数器的值减1,如果此时计数器的值小于0
,则当前线程将被阻塞
,否则当前线程可以继续执行up()
:计数器的值加1,如果此时计数器的值小于等于0
,则唤醒
等待队列中的一个线程,并将其从等待队列中移除
。
这三个方法都是原子性
的,原子性由型号了模型的实现方保证。在 JDK中,**信号量模型
**对应的类是:java.util.concurrent.Semaphore
,这个类保证了这三个方法都是原子操作。
下面是一个信号量模型
的伪代码
说明,为了方便理解:
public class Semaphore {
// 计数器
int count;
// 等待队列
Queue queue;
// 计数器初始化操作
Semaphore(int c) {
this.count = c;
}
void down() {
this.count--;
if (this.count < 0) {
// 将当前线程插入等待队列
// 阻塞当前线程
}
}
void up() {
this.count++;
if (this.count <= 0) {
// 唤醒等待队列中的某个线程
// 唤醒线程T
}
}
}
信号量模型中的 down()
和 up()
这两个操作历史上最早被称为 P 操作
和 V 操作
,所以信号量模型也被称为 PV 原语
。也有些人喜欢用 semWait()
和 semSignal()
来称呼它们,虽然叫法不一样,但是语义相同。
在 JDK 并发包中 down()
和 up()
对应的是 acquire()
和 release()
如何使用信号量
之前将信号量类比为了信号灯,车辆在通过路口时根据红绿灯指示通过,只有绿灯才能通行。这个规则跟之前的内置锁
规则很相似。
信号量
的使用也是类似:还是使用累加器
的例子来进行说明。在累加器中, count += 1
操作是一个临界区
,需要保证这个操作的原子性,只允许一个线程执行,怎样用信号量
来保证呢?
线程进入临界区之前执行
down
操作,退出之前执行up
操作,就可以保证同时只有一个线程进入临界区。
下面是 Java
代码的示例:
static int count;
//初始化信号量
static final Semaphore s
= new Semaphore(1);
//用信号量保证互斥
static void addOne() {
s.acquire();
try {
count+=1;
} finally {
s.release();
}
}
信号量
保证互斥的原理说明:
假设有两个线程 T1
、T2
同时访问 addOne
方法,当它们同时
调用 acquire
时,该方法是一个原子操作
,所以只能有一个线程
将信号量中的计数器减为0
,另一个线程将计数器减为-1
。 对于线程 T1
来说,信号量里的计数器是0
,该结果大于等于0
,所以线程T1
可以继续执行
。而线程T2
中的信号量计数器是 -1
,根据信号量模型中 down
操作的描述,线程T2
被阻塞
,所以此时只有 T1
进入临界区
执行 count += 1
。
当线程 T1 执行完毕后,调用 release
方法,也就是 对应的 up
操作,信号量此时的计数器值是 -1
, 加1后值变为0
,此时等待队列
中的 T2
将被唤醒,于是 T2
在 T1
执行完临界区
代码之后才获得进入临界区的机会,保证了互斥性
。
使用信号量快速实现一个限流器
JDK 中提供的互斥锁除了有内置的 synchronized
,还有Lock
类,这里又有一个 Semaphore
提供互斥功能,是否重复了呢?
Semaphore
具有一个 Lock
不容易实现的功能:允许多个线程访问同一个临界区。
这个功能的实际意义在于工作中池化资源
的使用:例如连接池
,对象池
,线程池
等。 比如数据库连接池,在同一时刻,一定是允许多个线程同时使用连接池的。 但是每个连接在释放前是不允许其他线程使用的。
比如一个对象池的需求,所谓对象池,指的是:一次性创建出B个对象之后,所有的线程都重复利用这些对象。当然对象在被释放之前是不允许其他线程使用的。
对象池可以用 List 保存对象实例,关键在于限流器的设计,这里的限流,指的是不允许多于 N 个线程同时进入临界区。
如何快速实现一个这样的限流器呢? 信号量就可以解决这个问题
信号量的计数器就是可同时进入临界区的值,上个例子中是1,所以只有一个线程可以进入临界区,在这里我们将计数器的值设置为对象池中的个数N,就可以同时允许 N 个线程进入对象池,这样就解决了限流的问题。
下面是对象池的示例代码:
public 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) {
T t = null;
sem.acquire();
try {
t = pool.remove(0);
return func.apply(t);
}finally {
pool.add(t);
sem.release();
}
}
public static void main(String[] args) {
// 创建对象池
ObjPool<Long, String> pool = new ObjPool(10, 2);
// 通过对象池获取t,之后调用方法
pool.exec(t -> {
System.out.println(t);
return t.toString();
});
}
}
这个例子中使用了 List
来保存对象实例
,使用 Semaphore
实现限流器
。 关键是 ObjPool
里的 exec
方法,这个方法中实现了限流功能。
在 exec
中首先调用 acquire
方法,对应的是在 finally
中调用 release
方法。 假设对象池的大小是 10
,信号量的计数器初始化
为 10
,前10个调用 acquire
方法的线程都可以继续执行,其他线程则会阻塞
在 acquire
方法上。
对于通过信号灯的线程,我们会为每个线程分配一个对象 t
(这个分配工作由 pool.remove(0)
实现),分配完成之后执行一个回调函数 func
,函数的参数是前面分配的对象 t
;
执行完回调函数之后,则对象被释放( pool.add(t)
),同时调用 release
方法更新信号量计数器,如果此时信号量计数器的值 小于等于0
,则说明有线程正在等待,此时自动唤醒
等待的线程。
使用信号量我们可以轻松实现一个限流器
,使用起来很简单。
总结
信号量在 Java 中名气不大,但是在其他语言中很有知名度。Java 主要还是支持了管程
模型,管程模型理论上解决了信号量模型的一些不足,主要体现在易用性
和工程化
方面,比如使用信号量解决之前提过的阻塞队列问题就必管程麻烦很多。
Q.E.D.
Comments | 0 条评论