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();
  }
}

信号量保证互斥的原理说明:

假设有两个线程 T1T2 同时访问 addOne 方法,当它们同时调用 acquire 时,该方法是一个原子操作,所以只能有一个线程将信号量中的计数器减为0,另一个线程将计数器减为-1。 对于线程 T1来说,信号量里的计数器是0,该结果大于等于0,所以线程T1可以继续执行。而线程T2 中的信号量计数器是 -1 ,根据信号量模型中 down 操作的描述,线程T2阻塞,所以此时只有 T1 进入临界区执行 count += 1

当线程 T1 执行完毕后,调用 release 方法,也就是 对应的 up操作,信号量此时的计数器值是 -1加1后值变为0,此时等待队列中的 T2 将被唤醒,于是 T2T1 执行完临界区代码之后才获得进入临界区的机会,保证了互斥性

使用信号量快速实现一个限流器

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.

知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议

最是人间留不住,曾是惊鸿照影来。