如何快速实现一个限流器

Semaphore,现在普遍翻译为“信号量”,以前也曾被翻译成“信号灯”,因为类似现实生活中的红绿灯,车辆能不能通行,主要看是不是绿灯。同样,在编程世界里,线程能不能执行,也要看信号量是不是允许。
信号量是由大名鼎鼎的计算机科学家狄杰斯特拉于1965年提出的。在这之后的15年,信号量一直都是并发编程领域的终结者,直到1980年管程被提出来,我们才有了第二选择。目前几乎所有支持并发程序的语言都支持信号量机制,所以学好信号量还是很有必要的。
下面我们首先介绍信号量模型,之后介绍如何使用信号量,最后我们再用信号量来实现一个限流器。

信号量模型

信号量模型还是很简单的,可以简单概括为一个计数器、一个等待队列、三个方法。在信号量模型里,计数器和等待队列对外是透明的,所以只能通过信号量模型提供的三个方法来访问他们。这三个方法分别是init()、down()、up(),你可以结合下面的图来形象化的理解。
如何快速实现一个限流器_第1张图片这三个方法详细的语义具体如下所示:
init():设置计数器的初始值。
down():计数器的值减1,如果此时计数器的值小于零,则当前线程将被阻塞,否则当前线程可以继续执行。
up():计数器的值加1,如果此时计数器的值小于或等于零,则唤醒等待队列中的一个线程,并将其中等待队列中移除。
这里提到的三个方法都是原子性的,并且这个原子性是由信号量模型的实现方法保证的。在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和semSingle来称呼他们,虽然叫法不同,但语义都是相同的。在Java SDK并发包里,down和up对应的则是acquire和release。

如何使用信号量

通过上文,你应该会发现信号量的模型还是很简单的,那具体该如何使用呢?其实你想想红绿灯就可以了。十字路口的红绿灯可以控制交通,得益于它的一个关键规则,车辆在通过路口前必须检查是否是绿灯?只有绿灯才能通行,这个规则和我们前面提到的锁规则是不是很类似?
其实信号量的使用也是类似的,这里我们还是用累加器的例子来说明信号量的使用吧。在累加器的例子中,Count+=1操作是个临界区,只允许一个线程执行。也就是说,要保证互斥,那这种情况用信号量怎么控制呢?其实很简单,就像我们用互斥锁一样,只需要在进入临界区前执行一下down操作,退出临界区之前执行一下up操作就可以了。下面是Java代码的实例acquire就是信号量里的down操作,release就是信号量里的up操作。

static int count;
//初始化信号量
static final Semaphore s = new Semaphore();
//用信号量保证互斥
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);
		} fianlly {
			pool.add(t);
			sem.relese();
		}
	}
}
//创建对象池
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在并发编程领域走的很快,重点支持的还是管程模型。管程模型理论上解决了信号量模型的一些不足,主要体现在易用性和工程化方面。

你可能感兴趣的:(并发编程学习,java,开发语言,笔记)