系统掌握并发编程系列(二)详解Thread类的主要属性和方法
系统掌握并发编程系列(四)详细分析传统并发协同方式(synchronized与wait() notify())
系统掌握并发编程系列(五)讲透传统并发协同方式伪唤醒与加锁失效问题
系统掌握并发编程系列(六)详细讲解并发协同利器CountDownLatch
系统掌握并发编程系列(七)详细讲解并发协同利器CyclicBarrier
系统掌握并发编程系列(八)详细讲解并发协同利器Semaphore
上一篇讲解并发协同利器Semaphore的常用方法,本文来对信号量的具体应用进行分析,信号量的典型应用场景包括:资源池管理(如数据库连接池)、限流控制、生产者-消费者模型等,我们选取限流这个场景来分析信号量的使用。
信号量隔离
首先什么是信号量隔离?隔离的是什么?信号量的许可证也可以理解成一个计数器:通过维护一个计数器,当计数器大于零时,线程可以访问资源(资源可以是某个服务、某个变量等),并将计数器减一;当计数器为零时,请求访问资源的线程需要等待,直到其他线程释放资源并增加计数器;计数器的数量决定了可以同时访问资源的线程数量。所以信号量隔离就是采用信号量许可证隔离线程对资源的访问,隔离的是对资源的并发访问。
了解了信号量隔离的概念之后,我们对前文“多个线程同时调用接口”的例子做下改造,用信号量来控制并发调用接口的线程数量,代码如下:
public class SemaphoreDemo {
public static void main(String[] args) throws InterruptedException {
int threads = 5;
// 开始信号
CyclicBarrier startSignal = new CyclicBarrier(threads, () -> System.out.println("所有线程准备完成..."));
System.out.println("第一轮压测开始...");
doConcurrentTest(threads, startSignal);
}
private static void doConcurrentTest(int threads, CyclicBarrier startSignal) throws InterruptedException {
// 准备完成信号
CountDownLatch readySignal = new CountDownLatch(threads);
// 调用完成信号
CountDownLatch doneSignal = new CountDownLatch(threads);
//并发控制信号量
Semaphore semaphore = new Semaphore(3);
for (int i = 0; i < threads; i++) {
new Thread(() -> {
try {
//准备完成
readySignal.countDown();
System.out.println(Thread.currentThread().getName() + "," + " 已准备就绪,等待开始信号...");
// 等待主线程发出开始信号
startSignal.await();
//尝试获取调用许可证
if(semaphore.tryAcquire()) {
try {
doPost();
}finally {
// 模拟调用耗时
Thread.sleep(3000);
//释放许可证
semaphore.release();
}
}else {
System.out.println("限流控制,"+Thread.currentThread().getName()+"被限制调用doPost()方法");
}
doneSignal.countDown();
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
//等待所有子线程准备完成
System.out.println("主线程等待所有子线程准备完成...");
readySignal.await();
System.out.println("主线程发出开始信号...");
// 等待所有子线程完成任务
doneSignal.await();
System.out.println("所有子线程已完成接口调用...");
}
private static void doPost() {
System.out.println("时间戳" + System.currentTimeMillis() + "," + Thread.currentThread().getName() + " 开始调用接口...");
}
}
上面的例子中,通过创建许可证数量为3的信号量对象,每次调用doPost()方法前都需要线程先获取许可证,只有获得了许可证的线程才能调用。运行结果如下:
第一轮压测开始...
主线程等待所有子线程准备完成...
Thread-0, 已准备就绪,等待开始信号...
Thread-1, 已准备就绪,等待开始信号...
Thread-2, 已准备就绪,等待开始信号...
Thread-3, 已准备就绪,等待开始信号...
Thread-4, 已准备就绪,等待开始信号...
主线程发出开始信号...
所有线程准备完成...
时间戳1748056738532,Thread-4 开始调用接口...
时间戳1748056738532,Thread-0 开始调用接口...
时间戳1748056738532,Thread-1 开始调用接口...
限流控制,Thread-2被限制调用doPost()方法
限流控制,Thread-3被限制调用doPost()方法
所有子线程已完成接口调用...
从运行结果可以看出,由于许可证数量只有3个,所以只有3个线程能调用doPost()方法,另外2个线程被隔离在外。为了加深对信号的理解,我们进一步分析信号量在开源框架中的实际运用。
信号量在Hystrix的运用
Hystrix是一个开源的一款容错库,主要目的是解决分布式系统中因服务依赖导致的延迟和故障蔓延问题,通过熔断、隔离、降级等机制,提升系统的弹性和容错能力,防止雪崩效应。在早期的springcloud全家桶中,通常通过集成Hystrix组件实现限流、熔断、隔离、降级等功能。尽管Hystrix 已停止更新(新项目推荐Sentinel 等替代方案),其熔断、隔离等设计思想仍广泛使用,值得学习研究。
HystrixCommand与信号量隔离
在hystrix的源码文件hystrix-core/src/main/java/com/netflix/hystrix/HystrixCommandProperties.java中,定义了线程池隔离(THREAD)和信号量隔离(SEMAPHORE)两种隔离策略,如下:
/**
* Isolation strategy to use when executing a {@link HystrixCommand}.
* <p>
* <ul>
* <li>THREAD: Execute the {@link HystrixCommand#run()} method on a separate thread and restrict concurrent executions using the thread-pool size.</li>
* <li>SEMAPHORE: Execute the {@link HystrixCommand#run()} method on the calling thread and restrict concurrent executions using the semaphore permit count.</li>
* </ul>
*/
public static enum ExecutionIsolationStrategy {
THREAD, SEMAPHORE
}
线程池隔离策略中,采用一个独立的线程执行HystrixCommand的run()方法,并通过线程池的大小来控制并发的线程数;在信号量隔离策略中,直接使用请求线程执行HystrixCommand的run()方法,并通过信号量的许可证数量来控制并发的线程数。从两种隔离策略的实现方式中可以看出,线程池隔离是比较消耗性能的,这也是hystrix被弃用的主要原因。下面简单了解下HystrixCommand这类。
/**
* Used to wrap code that will execute potentially risky functionality (typically meaning a service call over the network)
* with fault and latency tolerance, statistics and performance metrics capture, circuit breaker and bulkhead functionality.
* This command is essentially a blocking command but provides an Observable facade if used with observe()
*
* @param <R>
* the return type
*
* @ThreadSafe
*/
public abstract class HystrixCommand<R> extends AbstractCommand<R> implements HystrixExecutable<R>, HystrixInvokableInfo<R>, HystrixObservable<R> {
...
/**
* Implement this method with code to be executed when {@link #execute()} or {@link #queue()} are invoked.
*
* @return R response type
* @throws Exception
* if command execution fails
*/
protected abstract R run() throws Exception;
...
}
从官方的注释可以看出,HystrixCommand 这个类主要用于封装远程服务调用为独立的命令对象,具有容错、延迟、统计、捕获性能指标、断路、隔离功能。其中的run()方法是Hystrix框架中用于执行被包装的请求的核心方法,通过创建并配置HystrixCommand对象,调用run()方法来执行被包装的请求;run()方法内部执行实际的业务逻辑,如果执行成功,则返回结果;如果执行失败(如超时、抛出异常等),则会触发回退逻辑(fallback)。
看到这里可能会问,Hystrix内部是如何建信号量对象并指定许可证的数量的?既然HystrixCommand对象封装了远程调用请求, 那么猜测其构造函数中应该有信号量相关的参数。
/**
* Allow constructing a {@link HystrixCommand} with injection of most aspects of its functionality.
* <p>
* Some of these never have a legitimate reason for injection except in unit testing.
* <p>
* Most of the args will revert to a valid default if 'null' is passed in.
*/
/* package for testing */
HystrixCommand(HystrixCommandGroupKey group, HystrixCommandKey key, HystrixThreadPoolKey threadPoolKey, HystrixCircuitBreaker circuitBreaker, HystrixThreadPool threadPool,
HystrixCommandProperties.Setter commandPropertiesDefaults, HystrixThreadPoolProperties.Setter threadPoolPropertiesDefaults,
HystrixCommandMetrics metrics, TryableSemaphore fallbackSemaphore, TryableSemaphore executionSemaphore,
HystrixPropertiesStrategy propertiesStrategy, HystrixCommandExecutionHook executionHook) {
super(group, key, threadPoolKey, circuitBreaker, threadPool, commandPropertiesDefaults, threadPoolPropertiesDefaults, metrics, fallbackSemaphore, executionSemaphore, propertiesStrategy, executionHook);
}
构造函数中指定了TryableSemaphore类型的执行信号量和回退信号量,但不是我们期望的java.util.concurrent.Semaphore类的信号量。那么TryableSemaphore又是什么?
AbstractCommand与信号量的定义方式
顺着TryableSemaphore这个名称,在hystrix-core/src/main/java/com/netflix/hystrix/AbstractCommand.java文件中找到了其定义的地方。
abstract class AbstractCommand<R> implements HystrixInvokableInfo<R>, HystrixObservable<R> {
...
static interface TryableSemaphore {
/**
* Use like this:
* <p>
*
* <pre>
* if (s.tryAcquire()) {
* try {
* // do work that is protected by 's'
* } finally {
* s.release();
* }
* }
* </pre>
*
* @return boolean
*/
public abstract boolean tryAcquire();
/**
* ONLY call release if tryAcquire returned true.
* <p>
*
* <pre>
* if (s.tryAcquire()) {
* try {
* // do work that is protected by 's'
* } finally {
* s.release();
* }
* }
* </pre>
*/
public abstract void release();
public abstract int getNumberOfPermitsUsed();
}
...
}
AbstractCommand 是 HystrixCommand 的抽象父类,它实现了绝大部分的执行逻辑以及熔断器控制等功能。TryableSemaphore是抽象类AbstractCommand中的一个内部静态接口,声明了获取许可证 tryAcquire(),释放许可证release()等方法,这只是一个接口,那么应该有具体的实现类指定许可证的数量并实现具体的获取许可证,释放许可证的逻辑。顺着这个思路找到了具体的实现类TryableSemaphoreActual,也是在抽象类AbstractCommand中。
/**
* Semaphore that only supports tryAcquire and never blocks and that supports a dynamic permit count.
* <p>
* Using AtomicInteger increment/decrement instead of java.util.concurrent.Semaphore since we don't need blocking and need a custom implementation to get the dynamic permit count and since
* AtomicInteger achieves the same behavior and performance without the more complex implementation of the actual Semaphore class using AbstractQueueSynchronizer.
*/
static class TryableSemaphoreActual implements TryableSemaphore {
protected final HystrixProperty<Integer> numberOfPermits;
private final AtomicInteger count = new AtomicInteger(0);
public TryableSemaphoreActual(HystrixProperty<Integer> numberOfPermits) {
this.numberOfPermits = numberOfPermits;
}
@Override
public boolean tryAcquire() {
int currentCount = count.incrementAndGet();
if (currentCount > numberOfPermits.get()) {
count.decrementAndGet();
return false;
} else {
return true;
}
}
@Override
public void release() {
count.decrementAndGet();
}
@Override
public int getNumberOfPermitsUsed() {
return count.get();
}
}
这是一个仅支持tryAcquire尝试获取且从不阻塞的信号量,支持动态的许可证计数量。从源码可以看出,采用了原子整型类AtomicInteger实现许可证数量的增加和减少而不是用java.util.concurrent.Semaphore,主要原因是不需要阻塞,需要一个可定制的动态的许可证数量,且原子整型类AtomicInteger实现了相同的效果,无需像Semaphore那样基于复杂的抽象排队同步器实现。
Hystrix中信号量的创建与使用
了解了Hystrix中信号量的定义方式之后,我们来看Hystrix中信号量的创建与使用,同样在AbstractCommand这个类中。
/**
* Get the TryableSemaphore this HystrixCommand should use for execution if not running in a separate thread.
*
* @return TryableSemaphore
*/
protected TryableSemaphore getExecutionSemaphore() {
if (properties.executionIsolationStrategy().get() == ExecutionIsolationStrategy.SEMAPHORE) {
if (executionSemaphoreOverride == null) {
TryableSemaphore _s = executionSemaphorePerCircuit.get(commandKey.name());
if (_s == null) {
// 创建信号量对象
executionSemaphorePerCircuit.putIfAbsent(commandKey.name(), new TryableSemaphoreActual(properties.executionIsolationSemaphoreMaxConcurrentRequests()));
// assign whatever got set (this or another thread)
return executionSemaphorePerCircuit.get(commandKey.name());
} else {
return _s;
}
} else {
return executionSemaphoreOverride;
}
} else {
// return NoOp implementation since we're not using SEMAPHORE isolation
return TryableSemaphoreNoOp.DEFAULT;
}
}
从源码中可以看出,如果是信号量隔离策略,则创建TryableSemaphoreActual信号量对象,并通过properties.executionIsolationSemaphoreMaxConcurrentRequests()指定最大的并发请求数。
private Observable<R> applyHystrixSemantics(final AbstractCommand<R> _cmd) {
// mark that we're starting execution on the ExecutionHook
// if this hook throws an exception, then a fast-fail occurs with no fallback. No state is left inconsistent
executionHook.onStart(_cmd);
/* determine if we're allowed to execute */
if (circuitBreaker.attemptExecution()) {
//1.获取信号量对象
final TryableSemaphore executionSemaphore = getExecutionSemaphore();
final AtomicBoolean semaphoreHasBeenReleased = new AtomicBoolean(false);
final Action0 singleSemaphoreRelease = new Action0() {
@Override
public void call() {
if (semaphoreHasBeenReleased.compareAndSet(false, true)) {
//4.释放信号量
executionSemaphore.release();
}
}
};
final Action1<Throwable> markExceptionThrown = new Action1<Throwable>() {
@Override
public void call(Throwable t) {
eventNotifier.markEvent(HystrixEventType.EXCEPTION_THROWN, commandKey);
}
};
//2.尝试获取信号量
if (executionSemaphore.tryAcquire()) {
try {
/* used to track userThreadExecutionTime */
executionResult = executionResult.setInvocationStartTime(System.currentTimeMillis());
//3.执行命令
return executeCommandAndObserve(_cmd)
.doOnError(markExceptionThrown)
.doOnTerminate(singleSemaphoreRelease)
.doOnUnsubscribe(singleSemaphoreRelease);
} catch (RuntimeException e) {
return Observable.error(e);
}
} else {
return handleSemaphoreRejectionViaFallback();
}
} else {
return handleShortCircuitViaFallback();
}
}
从源码中可以看出,执行封装了远程服务调用的命令之前先尝试获取许可证,只有获得许可成功才执行调用命令。到此,我们了解信号量在Hystrix的使用的主要目的已经实现,尽管这其中还有很多复杂的逻辑。
总结
本文讲解了信号量隔离的概念,并用一个简单的例子说明用信号量来控制并发调用接口的线程数量的实现过程,然后分析了信号量在Hystrix的运用,讲解了Hystrix中的隔离策略,详细分析了Hystrix中信号量的定义方式、信号量的创建和使用。从Hystrix中信号量的定义方式可以看出,信号量是一个广义上的概念,其实现方式是有多种的,并非局限于java并发包(java.util.concurrent)中Semaphore。
如果文章对您有帮助,不妨“帧栈”一下,关注“帧栈”公众号,第一时间获取推送文章,您的肯定将是我写作的动力!