You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

147 lines
9.2 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 16 | Semaphore如何快速实现一个限流器
Semaphore现在普遍翻译为“信号量”以前也曾被翻译成“信号灯”因为类似现实生活里的红绿灯车辆能不能通行要看是不是绿灯。同样在编程世界里线程能不能执行也要看信号量是不是允许。
信号量是由大名鼎鼎的计算机科学家迪杰斯特拉Dijkstra于1965年提出在这之后的15年信号量一直都是并发编程领域的终结者直到1980年管程被提出来我们才有了第二选择。目前几乎所有支持并发编程的语言都支持信号量机制所以学好信号量还是很有必要的。
下面我们首先介绍信号量模型,之后介绍如何使用信号量,最后我们再用信号量来实现一个限流器。
## 信号量模型
信号量模型还是很简单的,可以简单概括为:**一个计数器,一个等待队列,三个方法**。在信号量模型里计数器和等待队列对外是透明的所以只能通过信号量模型提供的三个方法来访问它们这三个方法分别是init()、down()和up()。你可以结合下图来形象化地理解。
![](https://static001.geekbang.org/resource/image/6d/5c/6dfeeb9180ff3e038478f2a7dccc9b5c.png)
信号量模型图
这三个方法详细的语义具体如下所示。
* init():设置计数器的初始值。
* down()计数器的值减1如果此时计数器的值小于0则当前线程将被阻塞否则当前线程可以继续执行。
* up()计数器的值加1如果此时计数器的值小于或者等于0则唤醒等待队列中的一个线程并将其从等待队列中移除。
这里提到的init()、down()和up()三个方法都是原子性的并且这个原子性是由信号量模型的实现方保证的。在Java SDK里面信号量模型是由java.util.concurrent.Semaphore实现的Semaphore这个类能够保证这三个方法都是原子操作。
如果你觉得上面的描述有点绕的话,可以参考下面这个代码化的信号量模型。
```
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
//唤醒线程T
}
}
}
```
这里再插一句信号量模型里面down()、up()这两个操作历史上最早称为P操作和V操作所以信号量模型也被称为PV原语。另外还有些人喜欢用semWait()和semSignal()来称呼它们虽然叫法不同但是语义都是相同的。在Java SDK并发包里down()和up()对应的则是acquire()和release()。
## 如何使用信号量
通过上文,你应该会发现信号量的模型还是很简单的,那具体该如何使用呢?其实你想想红绿灯就可以了。十字路口的红绿灯可以控制交通,得益于它的一个关键规则:车辆在通过路口前必须先检查是否是绿灯,只有绿灯才能通行。这个规则和我们前面提到的锁规则是不是很类似?
其实信号量的使用也是类似的。这里我们还是用累加器的例子来说明信号量的使用吧。在累加器的例子里面count+=1操作是个临界区只允许一个线程执行也就是说要保证互斥。那这种情况用信号量怎么控制呢
其实很简单就像我们用互斥锁一样只需要在进入临界区之前执行一下down()操作退出临界区之前执行一下up()操作就可以了。下面是Java代码的示例acquire()就是信号量里的down()操作release()就是信号量里的up()操作。
```
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()的时候由于acquire()是一个原子操作所以只能有一个线程假设T1把信号量里的计数器减为0另外一个线程T2则是将计数器减为-1。对于线程T1信号量里面的计数器的值是0大于等于0所以线程T1会继续执行对于线程T2信号量里面的计数器的值是-1小于0按照信号量模型里对down()操作的描述线程T2将被阻塞。所以此时只有线程T1会进入临界区执行`count+=1`。
当线程T1执行release()操作也就是up()操作的时候,信号量里计数器的值是-1加1之后的值是0小于等于0按照信号量模型里对up()操作的描述此时等待队列中的T2将会被唤醒。于是T2在T1执行完临界区代码之后才获得了进入临界区执行的机会从而保证了互斥性。
## 快速实现一个限流器
上面的例子我们用信号量实现了一个最简单的互斥锁功能。估计你会觉得奇怪既然有Java SDK里面提供了Lock为啥还要提供一个Semaphore ?其实实现一个互斥锁,仅仅是 Semaphore的部分功能Semaphore还有一个功能是Lock不容易实现的那就是**Semaphore可以允许多个线程访问一个临界区**。
现实中还有这种需求?有的。比较常见的需求就是我们工作中遇到的各种池化资源,例如连接池、对象池、线程池等等。其中,你可能最熟悉数据库连接池,在同一时刻,一定是允许多个线程同时使用连接池的,当然,每个连接在被释放前,是不允许其他线程使用的。
其实前不久我在工作中也遇到了一个对象池的需求。所谓对象池呢指的是一次性创建出N个对象之后所有的线程重复利用这N个对象当然对象在被释放前也是不允许其他线程使用的。对象池可以用List保存实例对象这个很简单。但关键是限流器的设计这里的限流指的是不允许多于N个线程同时进入临界区。那如何快速实现一个这样的限流器呢这种场景我立刻就想到了信号量的解决方案。
信号量的计数器在上面的例子中我们设置成了1这个1表示只允许一个线程进入临界区但如果我们把计数器的值设置成对象池里对象的个数N就能完美解决对象池的限流问题了。下面就是对象池的示例代码。
```
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();
}
}
}
// 创建对象池
ObjPool<Long, String> pool =
new ObjPool<Long, String>(10, 2);
// 通过对象池获取t之后执行
pool.exec(t -> {
System.out.println(t);
return t.toString();
});
```
我们用一个List来保存对象实例用Semaphore实现限流器。关键的代码是ObjPool里面的exec()方法这个方法里面实现了限流的功能。在这个方法里面我们首先调用acquire()方法与之匹配的是在finally里面调用release()方法假设对象池的大小是10信号量的计数器初始化为10那么前10个线程调用acquire()方法都能继续执行相当于通过了信号灯而其他线程则会阻塞在acquire()方法上。对于通过信号灯的线程,我们为每个线程分配了一个对象 t这个分配工作是通过pool.remove(0)实现的分配完之后会执行一个回调函数func而函数的参数正是前面分配的对象 t 执行完回调函数之后它们就会释放对象这个释放工作是通过pool.add(t)实现的同时调用release()方法来更新信号量的计数器。如果此时信号量里计数器的值小于等于0那么说明有线程在等待此时会自动唤醒等待的线程。
简言之,使用信号量,我们可以轻松地实现一个限流器,使用起来还是非常简单的。
## 总结
信号量在Java语言里面名气并不算大但是在其他语言里却是很有知名度的。Java在并发编程领域走的很快重点支持的还是管程模型。 管程模型理论上解决了信号量模型的一些不足,主要体现在易用性和工程化方面,例如用信号量解决我们曾经提到过的阻塞队列问题,就比管程模型麻烦很多,你如果感兴趣,可以课下了解和尝试一下。
## 课后思考
在上面对象池的例子中对象保存在了Vector中Vector是Java提供的线程安全的容器如果我们把Vector换成ArrayList是否可以呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。