只是不愿随波逐流 ...|

lidongdongdong~

园龄:2年7个月粉丝:14关注:8

45、信号量

内容来自王争 Java 编程之美

信号量是并发编程中的一个重要概念,JUC 提供了 Semaphore 类来实现信号量,信号量用来限制临界区和共享资源的并发访问

  • 使用互斥锁:临界区和共享资源同时只能被 "一个线程" 访问
  • 使用信号量:临界区和共享资源同时可以被 "多个线程" 访问
    因此信号量也可以看做是一种 "共享锁",其底层也是基于 AQS 实现的

1、信号量的由来

1.1、示例

假设我们在开发一个接口服务器,对于某个接口,我们希望限制其并发执行度,也就是同一时刻只能允许 N 个线程并发执行这个接口的请求
对于这样一个接口并发限制功能,具体该如何来实现呢?

我们可以使用 AtomicInteger 原子类来实现接口并发限制功能,代码如下所示

public class Demo {
// apiX 接口同时只允许 10 个线程并发执行
private final AtomicInteger permits = new AtomicInteger(10); // permits 允许、许可证
public void apiX() {
// 可用许可为 0 ~ 9
int newPermits = permits.decrementAndGet();
if (newPermits < 0) {
permits.incrementAndGet();
return; // 拒绝执行业务逻辑, 直接返回
}
try {
// 执行业务逻辑
} finally {
permits.incrementAndGet();
}
}
}

当然我们也可以使用锁来实现接口并发限制功能,代码如下所示

public class Demo {
// apiX 接口同时只允许 10 个线程并发执行
private int permits = 10; // permits 允许、许可证
public void apiX() {
if (permits <= 0) {
return;
}
synchronized (this) {
if (permits <= 0) return; // 双重检测
permits--;
}
try {
// 执行业务逻辑
} finally {
synchronized (this) {
permits++;
}
}
}
}

1.2、问题

对以上两种实现方法,如果已经有 10 个线程在执行 apiX() 接口,那么第 11 线程在执行 apiX() 接口时,将无法获取到许可(permit),于是就拒绝执行业务逻辑并直接返回
尽管这样可以保证任何时刻最多只有 10 个线程同时在执行 apiX() 接口,但这样也会导致大量接口请求被拒绝

如何解决大量接口请求被拒绝的问题呢?
我们可以将无法获取许可的线程阻塞,等待其他持有许可的线程释放许可之后,再将阻塞线程唤醒,让其竞争获取许可并继续执行后续业务逻辑
但是这样可能会让接口请求的响应时间增大

实际上,上述处理过程就是一个标准的等待通知机制,我们可以使用上一节讲到的条件变量来实现,代码如下所示,线程在执行 apiX() 函数时

  • 如果可用 permits <= 0,那么线程会调用 awaitUniterruptibly() 阻塞
  • 当可用 permits > 0 时,线程获取许可,将 permits 减一,并执行业务逻辑
    当业务逻辑执行完成之后,在退出 apiX() 函数之前,线程将归还持有的许可,也就是将 permits 加一,并调用 singal() 函数,通知其他等待许可的线程
public class Demo {
// apiX 接口同时只允许 10 个线程并发执行
private int permits = 10;
private Lock lock = new ReentrantLock();
private Condition condition = lock.newCondition(); // 条件变量: 等待许可的线程
public void apiX() {
lock.lock();
try {
// 可用许可为 1 ~ 10
while (permits <= 0) {
condition.awaitUninterruptibly();
}
permits--;
} finally {
lock.unlock();
}
try {
// 执行业务逻辑
} finally {
lock.lock();
permits++;
condition.signal();
lock.unlock();
}
}
}

上述使用条件变量的实现方式比较复杂,实际上对于临界区的并发访问限制,使用信号量实现更加简单

2、临界区并发访问限制

2.1、Semaphore 常用方法

Semaphore 类提供的常用方法有以下几个,我们可以粗略地将以下方法分为两组

  • 前五个为一组:默认一次获取或释放的许可(permit)个数为 1
  • 后五个为一组:可以指定一次获取或释放的许可个数

对于每组方法来说,都有 4 个不同的获取许可的方法:可中断获取、不可中断获取、非阻塞获取、可超时获取
这跟 Lock 提供的各种加锁方法非常相似,也应证了前面提到的,信号量可以看作是一种共享锁

public void acquire() throws InterruptedException;
public void acquireUninterruptibly();
public boolean tryAcquire();
public boolean tryAcquire(long timeout, TimeUnit unit) throws InterruptedException;
public void release();
public void acquire(int permits) throws InterruptedException;
public void acquireUninterruptibly(int permits);
public boolean tryAcquire(int permits);
public boolean tryAcquire(int permits, long timeout, TimeUnit unit) throws InterruptedException;
public void release(int permits);

2.2、示例

关于 Semaphore 具体如何使用,我们来看以下示例代码
在以下示例代码中,我们使用 Semaphore 重新实现了接口并发限制功能,其中 acquireUninterruptibly() 用来获取许可
如果当前没有可用许可,那么调用 acquireUninterruptibly() 函数的线程将阻塞等待,直到其他线程通过调用 release() 函数释放许可,此线程才会重新竞争获取许可

public class Demo {
private final Semaphore semaphore = new Semaphore(10);
public void apiX() {
semaphore.acquireUninterruptibly();
try {
// 执行业务逻辑
} finally {
semaphore.release();
}
}
}

3、共享资源并发访问限制

从上述 Semaphore 的使用方法,我们可以发现

  • 如果信号量中的许可个数 = 1:那么同时只能有一个线程访问临界区,此时信号量就退化成了互斥锁
  • 如果信号量中的许可个数 > 1:那么信号量就可以看作是一种共享锁

信号量跟锁的不同之处在于,释放锁的线程必须持有锁,而信号量则没有这样的要求,也就是说:没有调用 acquire() 函数的线程,也可以直接调用 release() 函数
在这种应用场景下,我们把 acquire() 简单理解为减少可用许可个数,release() 简单理解为增加可用许可个数
此时,信号量不再是用来限制对临界区的并发访问,而是用来限制对共享资源的并发访问

接下来我们举个例子解释一下,如何使用信号量限制对共享资源的并发访问
上一节,我们使用条件变量实现了一个支持 "阻塞读" 的无限队列,本节我们使用信号量实现一个支持 "阻塞写" 的有限队列,代码如下所示
对于如何使用信号量实现一个支持阻塞读和阻塞写的有限队列,我们留给你作为思考题

public class QueueSemaphore {
private static final int Q_SIZE = 20;
private Semaphore semaphore = new Semaphore(Q_SIZE); // 信号量: 20 个位置许可
private List<String> list = new ArrayList<>(Q_SIZE);
private int count = 0;
// 当队列元素个数 = 20 时,put() 函数会阻塞,直到队列中有空位才唤醒
public void put(String elem) {
semaphore.acquireUninterruptibly();
synchronized (this) {
list.add(count, elem);
count++;
}
}
public String get() {
if (count == 0) return null;
synchronized (this) {
if (count == 0) return null; // 双重检测
String ret = list.remove(--count);
semaphore.release();
return ret;
}
}
}

对于上述代码,信号量表示队列中的空闲位置,其初始值为队列大小
当队列满之后,信号量中的可用许可个数变为 0,线程执行 put() 函数时会阻塞在 acquireUninterruptibly() 函数中
当其他线程调用 get() 函数时,执行 release() 函数增加许可之后,阻塞的线程才得以继续执行

4、信号量的实现原理

4.1、Semaphore 源码

跟 ReentrantLock 和 ReadWriteReentrantLock 相同,Semaphore 也是基于 AQS 来实现
Semaphore 的部分源码如下所示,其代码结构跟之前讲过的 ReentrantLock 的代码结构非常相似

public class Semaphore {
private final Sync sync;
abstract static class Sync extends AbstractQueuedSynchronizer {
Sync(int permits) {
setState(permits); // state = permits
}
protected final boolean tryReleaseShared(int releases) {
// ...
}
}
// 非公平锁
static final class NonfairSync extends Sync {
NonfairSync(int permits) {
super(permits);
}
protected int tryAcquireShared(int acquires) { ... }
}
// 公平锁
static final class FairSync extends Sync {
FairSync(int permits) {
super(permits);
}
protected int tryAcquireShared(int acquires) { ... }
}
public Semaphore(int permits) {
sync = new NonfairSync(permits); // 默认为非公平模式
}
public Semaphore(int permits, boolean fair) {
sync = fair ? new FairSync(permits) : new NonfairSync(permits); // 指定工作模式
}
// ... 暂时省略核心方法的实现 ...
}

从以上代码,我们可以发现,Semaphore 既支持公平模式,也支持非公平模式
在创建 Semaphore 对象时,我们可以指定对象的工作模式(公平模式还是非公平模式),如果不指定,则默认为非公平模式
两种工作模式底层使用 FairSync 和 NonfairSync 两个不同的 AQS 来实现,AQS 是基于模板方法模式实现的,并且 Semaphore 可以看做是一种共享锁
因此 FairSync 类和 NofairSync 类实现了 AQS 的 tryAcquireShared() 抽象方法,不过实现逻辑并不相同
对于 tryReleaseShared() 抽象方法,因为在 FairSync 和 NofairSync 中的实现逻辑相同,因此它被放置于 FairSync 和 NofairSync 的公共父类 Sync 中

Semaphore 的常用方法有很多,我们前面已经罗列过,不过它们的代码实现都相差不大,因此我们拿其中比较简单的 acquireUninterruptibly() 和 release() 来举例讲解

4.2、acquireUninterruptibly()

acquireUninterruptibly() -> acquireShared() -> tryAcquireShared() -> doAcquireShared()

acquireUninterruptibly() 函数的源码如下所示
acquireUninterruptibly() 直接调用 AQS 的 acquireShared() 函数,acquireShared() 函数的定义也罗列在了下面,其代码实现也非常简单

// 位于 Semaphore.java 中
public void acquireUninterruptibly() {
sync.acquireShared(1);
}
// 位于 AbstractQueuedSynchronizer.java 中
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0) // 竞争获取许可, 返回值 < 0 表示失败, 需要排队等待许可
doAcquireShared(arg); // 排队等待许可
}

在 acquireShared() 函数的代码实现中,tryAcquireShared() 函数为 AQS 的抽象函数,用来竞争获取许可,其代码实现位于 NofairSync 和 FairSync 中,具体如下所示
doAcquireShared() 函数用来排队等待许可,在讲解 ReentrantReadWriteLock 的底层实现原理时,我们详细讲解过 doAcquireShared() 函数,这里就不再赘述了

// NonfairSync 中 tryAcquireShared() 函数的代码实现
protected int tryAcquireShared(int acquires) {
for (; ; ) {
int available = getState(); // 许可个数存放在 state 变量中
int remaining = available - acquires;
if (remaining < 0 || compareAndSetState(available, remaining))
return remaining;
}
}
// FairSync 中 tryAcquireShared() 函数的代码实现
protected int tryAcquireShared(int acquires) {
for (; ; ) {
if (hasQueuedPredecessors()) return -1; // 比上面的代码多了这一行
int available = getState();
int remaining = available - acquires;
if (remaining < 0 || compareAndSetState(available, remaining))
return remaining;
}
}

以上两个 tryAcquireShared() 函数的代码实现基本相同,许可个数存放在 AQS 的 state 变量中,两个函数都是通过自旋 + CAS 的方式来获取许可
两个函数唯一的区别在于,对于公平模式下的 Semaphore,当线程调用 tryAcquireShared() 函数时
如果等待队列中有等待许可的线程,那么线程将直接去排队等待许可,而不是像非公平模式下的 Semaphore 那样,线程可以 "插队" 直接竞争许可

4.3、release()

release() -> releaseShared() -> tryReleaseShared() -> doReleaseShared()

release() 函数的源码如下所示,release() 直接调用 AQS 的 releaseShared() 函数,releaseShared() 函数的定义也罗列在了下面,其代码实现也非常简单

public void release() {
sync.releaseShared(1);
}
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}

在 releaseShared() 函数的代码实现中,tryReleaseShared() 函数为 AQS 的抽象函数,用来释放许可
其代码实现位于 Sync 中,具体如下所示,核心逻辑为:采用自旋 + CAS 来更新 state
doReleaseShared() 函数用来唤醒排队等待许可的其中一个线程(位于等待队列中的第一个线程)
在讲解 ReentrantReadWriteLock 的底层实现原理时,我们详细讲解过 doReleaseShared() 函数,这里就不再赘述了

protected final boolean tryReleaseShared(int releases) {
for (; ; ) {
int current = getState();
int next = current + releases;
if (next < current) // 超过了 int 整型的表示范围, 基本不会发生
throw new Error("Maximum permit count exceeded");
if (compareAndSetState(current, next)) return true;
}
}

5、课后思考题

在上一节的思考题中,我们要求你使用条件变量实现一个支持阻塞读和阻塞写的有限队列,本节我们希望你能使用信号量来实现一个支持阻塞读和阻塞写的有限队列

/**
* 信号量: 支持阻塞读和阻塞写的有限队列
*/
public class BlockingQueueSem<E> {
private final List<E> list;
private int size;
private int capacity;
private final Lock lock = new ReentrantLock();
private final Semaphore emptySemaphore; // 入队信号量: 初始为 capacity 个许可
private final Semaphore elementSemaphore; // 出队信号量: 初始为 0 个许可
public BlockingQueueSem(int capacity) {
this.capacity = capacity;
size = 0;
list = new LinkedList<>();
emptySemaphore = new Semaphore(capacity);
elementSemaphore = new Semaphore(0);
}
/**
* 入队: 队列已满时, 写入操作会被阻塞, 直到队列有空位为止
*/
public void enqueue(E e) {
emptySemaphore.acquireUninterruptibly();
lock.lock();
try {
list.add(e);
size++;
elementSemaphore.release();
} finally {
lock.unlock();
}
}
/**
* 出队: 队列为空时, 读取操作会被阻塞, 直到队列有可读的数据为止
*/
public E dequeue() {
elementSemaphore.acquireUninterruptibly();
lock.lock();
try {
E ret = list.remove(0);
size--;
emptySemaphore.release();
return ret;
} finally {
lock.unlock();
}
}
public int getSize() {
return size;
}
public int getCapacity() {
return capacity;
}
}
posted @   lidongdongdong~  阅读(36)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
展开