10.Semaphore信号量

目录

1)信号量模型

2)如何使用信号量

3)源码分析

3.1 类结构

3.2 acquire

3.3 release

4)快速实现一个限流器


Semaphore,现在普遍翻译为“信号量”,以前也曾被翻译成“信号灯”,因为类似现实生活里的红绿灯,车辆能不能通行,要看是不是绿灯。同样,在编程世界里,线程能不能执行,也要看信号量是不是允许。

信号量是由大名鼎鼎的计算机科学家迪杰斯特拉(Dijkstra)于1965年提出,在这之后的15年,信号量一直都是并发编程领域的终结者,直到1980年管程被提出来,我们才有了第二选择。目前几乎所有支持并发编程的语言都支持信号量机制,所以学好信号量还是很有必要的

1)信号量模型

信号量模型还是很简单的,可以简单概括为:一个计数器,一个等待队列,三个方法。在信号量模型里,计数器和等待队列对外是透明的,所以只能通过信号量模型提供的三个方法来访问它们,这三个方法分别是:init()、down()和up()。你可以结合下图来形象化地理解。

这三个方法详细的语义具体如下所示。

  • 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()。

 

2)如何使用信号量

在累加器的例子里面,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执行完临界区代码之后才获得了进入临界区执行的机会,从而保证了互斥性。

 

3)源码分析

 信号量 Semaphore 用于控制资源能够被并发访问的线程数量,以保证多个线程能够合理的使用特定资源,比如数据库连接等。

  Semaphore 在构造时设置一个许可数量,这个许可数量用 AQS.state来记录。

  •   acquire() 方法就是获取许可,只有获取到许可才可以继续执行访问共享资源,获取到许可之后 AQS.state 减1,以记录当前可用的许可数量;如果获取不到许可,线程就阻塞等待其他线程归还许可。
  •   release() 方法将许可归还,AQS.state 加1;归还之后,唤醒 AQS 队列中阻塞的线程获取许可。

3.1 类结构

  Semaphore 同样是由 AQS 实现的,用内部类 Sync 来管理锁,Sync 有两个实现,分别为 NonfairSync(非公平锁)和 FairSync(公平锁)。

  这个类结构有没有似曾相识的感觉,重入锁 ReentrantLock 也是同样的类结构,Semaphore 的源码跟 ReentrantLock 有很多相似但又比 ReentrantLock简单。

public class Semaphore implements java.io.Serializable {
    private final Sync sync;
    abstract static class Sync extends AbstractQueuedSynchronizer {}
    static final class NonfairSync extends Sync {}
    static final class FairSync extends Sync {}
}

看下构造方法,设置许可数 permits 其实就是将 AQS.state 设置为 permits:

public Semaphore(int permits) {
    sync = new NonfairSync(permits);
}

NonfairSync(int permits) {
    super(permits);
}

Sync(int permits) {
    setState(permits);
}

3.2 acquire

  acquire() 方法就是获取许可,获取到许可就可以继续执行访问共享资源,获取不到就阻塞等待其他线程归还许可。

AQS.state 用来记录可用的许可数量,每获取一个许可 state 减1。

**
 * 获取许可的方法其实就是获取锁的方法
 */
public void acquire() throws InterruptedException {
    sync.acquireSharedInterruptibly(1);
}

public final void acquireSharedInterruptibly(int arg)
        throws InterruptedException {
    // 响应打断
    if (Thread.interrupted())
        throw new InterruptedException();
    
    // 真正获取锁的方法,由Semaphore.NonfairSync实现
    if (tryAcquireShared(arg) < 0)     
    	// 获取锁失败,当前线程阻塞并进入AQS同步队列     
        doAcquireSharedInterruptibly(arg);  
}

/**
 * Semaphore.NonfairSync实现的获取锁的方法
 */
protected int tryAcquireShared(int acquires) {
    return nonfairTryAcquireShared(acquires);
}

/**
 * 每获取一个许可,将state-1,state表示剩余的许可数
 * 如果许可已经用完,返回remaining<0,表示获取不到锁/许可,线程阻塞
 * 如果还有许可,返回remaining>=0,表示获取到锁/许可,线程继续执行
 */
final int nonfairTryAcquireShared(int acquires) {
    for (;;) {
        int available = getState();
        // 每获取一个许可,将state-1,state表示剩余的许可数
        int remaining = available - acquires;
        if (remaining < 0 ||
            compareAndSetState(available, remaining))
            return remaining;
    }
}

3.3 release

  release() 方法归还许可,其实就是将 AQS.state 加1。归还成功,唤醒 AQS 队列中等锁的线程,从被阻塞的位置开始执行。

/**
 * 释放许可调用释放锁的方法
 */
public void release() {
    sync.releaseShared(1);
}

/**
 * 释放锁,完全成功,依次唤醒AQS队列中等待共享锁的线程
 */
public final boolean releaseShared(int arg) {
    if (tryReleaseShared(arg)) { // 释放锁,由Semaphore.Sync实现
        doReleaseShared();       // 释放锁成功,唤醒AQS队列中等锁的线程
        return true;
    }
    return false;
}

/**
 * 每归还一个许可将state加1
 */
protected final boolean tryReleaseShared(int releases) {
    for (;;) {
        int current = getState();
        int next = current + releases;// 每归还一个许可将state加1
        if (next < current) // overflow
            throw new Error("Maximum permit count exceeded");
        if (compareAndSetState(current, next))
            return true;
    }
}

4)快速实现一个限流器

上面的例子,我们用信号量实现了一个最简单的互斥锁功能。估计你会觉得奇怪,既然有Java SDK里面提供了Lock,为啥还要提供一个Semaphore ?其实实现一个互斥锁,仅仅是 Semaphore的部分功能,【Semaphore还有一个功能是Lock不容易实现的,那就是:Semaphore可以允许多个线程访问一个临界区】

现实中还有这种需求?有的。比较常见的需求就是我们工作中遇到的各种池化资源,例如连接池、对象池、线程池等等。其中,你可能最熟悉数据库连接池,在同一时刻,一定是允许多个线程同时使用连接池的,当然,每个连接在被释放前,是不允许其他线程使用的。

其实前不久,我在工作中也遇到了一个对象池的需求。所谓对象池呢,指的是一次性创建出N个对象,之后所有的线程重复利用这N个对象,当然对象在被释放前,也是不允许其他线程使用的。对象池,可以用List保存实例对象,这个很简单。但关键是限流器的设计,这里的限流,指的是不允许多于N个线程同时进入临界区。那如何快速实现一个这样的限流器呢?这种场景,我立刻就想到了信号量的解决方案。

信号量的计数器,在上面的例子中,我们设置成了1,这个1表示只允许一个线程进入临界区,但如果我们把计数器的值设置成对象池里对象的个数N,就能完美解决对象池的限流问题了。下面就是对象池的示例代码。

class ObjPool {
  final List pool;
  // 用信号量实现限流器
  final Semaphore sem;
  // 构造函数
  ObjPool(int size, T t){
    pool = new Vector(){};
    for(int i=0; i func) {
    T t = null;
    sem.acquire();
    try {
      t = pool.remove(0);
      return func.apply(t);
    } finally {
      pool.add(t);
      sem.release();
    }
  }
}
// 创建对象池
ObjPool pool = 
  new ObjPool(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,那么说明有线程在等待,此时会自动唤醒等待的线程。

简言之,使用信号量,我们可以轻松地实现一个限流器,使用起来还是非常简单的。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值