Java并发编程 | 信号量

2,974 阅读3分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第4天,点击查看活动详情

本系列专栏 Java并发编程专栏 - 元浩875的专栏 - 掘金 (juejin.cn)

前言

在管程发明之前,解决并发编程的原子性问题都是使用信号量。信号量即Semaphore,也有被翻译为信号灯,因为类似现实生活里的红绿灯,车辆能不能通行,要看是不是绿灯;在编程世界中,线程能不能执行,要看信号量是不是允许的。

信号量是由大名鼎鼎的计算机科学家Dijkstra发明的,因为比管程模型更早,所以目前几乎所有语言都支持信号量机制,所以很有必要学习一下这个信号量机制。

正文

和前面管程模型一样,学习信号量也是从其模型学起。

信号量模型

信号量模型非常简单,用一句话简单概括就是:一个计数器,一个等待队列,三个方法。信号量模型如下图:

image.png

在这个模型中,计数器和等待队列对外是透明的,所以只能通过信号量提供的三个方法来访问它们,分别是init()、down()和up(),所以理解这3个方法什么意思非常重要:

  1. init(),设置计数器的初始值。
  2. down(),计数器的值减1;如果此时的计数器的值小于0,则当前线程将被阻塞,否则当前线程可以继续执行。
  3. up(),计数器的值加1;如果此时计数器的值小于或者等于0,则唤醒等待队列中的一个线程,并将其从等待队列中移除。

首先这里的3个方法必须是原子性的,因为这3个方法如果不是原子性的就没有用它来解决并发问题的必要了,而这个原子性是由信号量的实现方保证的。

其次就是这里的加减计数器的值,会对应线程的操作,这里其实有点绕,我们可以代码来表示,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()这2个操作在历史上最早被称为P操作和V操作,所以信号量模型也被称为PV原语。另外,也有人喜欢用semWait()和semSignal(),虽然叫法不同,但是语义都是一样的。

如何使用信号量

在Java并发包中信号量的实现类就是Semaphore,我们就来想一下如何使用信号量。

其实想想红绿灯就可以了,在十字路口的红绿灯可以控制交通,得益于一个关键规则:车辆通过路口必须检查是否绿灯,只有绿灯才能通行。而线程执行到判断信号量时,根据其保存的值来决定线程是否需要等待。

那如何使用呢 其实就是和使用互斥锁是一样的,只需要在进入临界区执行一下down()操作,退出临界区执行一下up()操作即可。下面是Java代码示例,其中acquire()就是信号量的down()操作,release()就是信号量的up()操作。

private var count = 0

private val semaphore = Semaphore(1)

fun addOne(){
    semaphore.acquire()
    try {
        count += 1
    }finally {
        semaphore.release()
    }
}

在这里,我们还是以让共享变量count + 1 这个例子来说,我们来分析一下信号量是如何实现互斥和同步的。

假设有俩个线程T1和T2同时访问addOne()方法,当他们同时调用acquire(),由于acquire()是一个原子操作,所以只能有一个线程(假设T1)把信号量值减为0,另一个线程(T2)则将计数器减为-1。对于T1来说,根据前面信号量模型规则来说,计数器值大于等于0,所以线程T1会继续执行。对于线程T2来说信号量里计数器的值为-1,小于0,根据信号量模型规则,线程T2将被阻塞,所以此时只有线程T1会进入临界区来执行count+=1的操作。

当线程T1执行完release()操作时,也就是up()操作,信号量里计数器的值就是-1再加1,即为0,根据前面信号量模型中,当小于等于0时,等待队列中的T2将会被唤醒,这样也就保存了互斥性。

所以关于信号量的使用,必须要对前面所说的信号量模型理解透彻,尤其是down()和up()后,根据信号量里的计数器来进行的不同操作。

实现限流器

这里或许就有点奇怪,上面的的代码可以使用Java SDK中的Lock可以快速解决,但是为什么又要提供一个Semaphore? 其实除了实现互斥锁,信号量还有一个功能是Lock不容易实现的,那就是Semaphore可以允许多个线程访问一个临界区。

这就很有意思了,在我们之前所说的互斥就是为了临界区在同一时刻只有一个线程访问,那这个需求是什么场景呢

答案就是各种池化资源,例如连接池、对象池等等,假如现在有个需求是关于对象池的,所谓的对象池,指的就是一次性创建出N个对象,之后所有的线程重复利用这N个对象,但是当对象在释放之前,其他线程是不允许使用的。所以我们可以使用List保存N个对象,关键就是限流,何为限流,即不允许多于N个线程同时进入临界区。

这个对象池的需求用信号量就很好处理,在前面计数器的Java代码中,我们把信号量初始值设置为1,就代表着只允许一个线程进入临界区,所以我们直接把计数器值设置为N就解决了需求,大致代码如下:

class ObjPool<T, R>(size: Int, t: T) {

    //对象池的对象
    private var pool: MutableList<T> = Vector()

    //信号量用来实现限流器
    private var semaphore: Semaphore

    init {
        for (i in 0 until size){
            pool.add(t)
        }
        semaphore = Semaphore(size)
    }
    
    fun exec(func: (T) -> R): R{
        semaphore.acquire()
        try {
            val t = pool.removeAt(0)
            return func.invoke(t)
        }finally {
            semaphore.release()
        }
    }
}

这里ObjPool定义了俩个泛型, 分别代表数据类型,和返回类型,然后成员变量pool用来存放对象,初始化信号量值为10。然后在exec方法中,先执行acquire()方法即down操作,然后在finally中保证执行release(),即up()操作,然后使用如下:

    val pool = ObjPool<Long,String>(10,2)
    pool.exec { t ->
         t.toString()
    }

假设有N个线程同时调用exec方法,前10个都会进入临界区,当第11个线程准备进入时,在执行down操作后,发现会小于0,所以会进入等待队列。而前面有执行完的线程,会执行up操作,会发现up操作小于等于0,会唤醒一个等待队列中的线程。

所以可以看出,这里的信号量的初始值就是同时进入临界区的线程,同时保存对象池的集和使用线程安全的Vector。

总结

本篇介绍了一个在计算机系统中早于管程的模型:信号量,而且使用信号量实现了一个管程模型不好实现的地方,就是可以让多个进入临界区的特殊情况。