前言
本文是专栏《AsyncTool框架原理源码解析》系列的第四篇文章 《AsyncTool框架的一些思考》,针对AsyncTool框架
学习过程当中的一些思考,包括但不局限于框架本身。线程池、分布式系统调用链、缓存时钟类、扩展等,学习本身就是一个分散性思考的东西,学习一个东西,肯定也会涉及到不同的知识,思考的过程是很有价值的!
专栏《AsyncTool框架原理源码解析》共有 5篇 文章,由浅入深,从实战使用再到源码、原理分析,包括但不仅局限于AsyncTool框架
思考和总结,最后分享下我为AsyncTool框架
开源项目贡献代码。以下是《专栏目录》:
1、关于线程池的思考?
作者在issue上推荐一个tomcat只使用一个不定长线程池。
AsyncTool关于多线程并发执行的问题。如果不指定线程池,默认使用不定长线程池。
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}
注意,newCachedThreadPool()
它的核心线程数是0,最大线程数是Integer.MAX_VALUE。可以说它的线程数是可以无限扩大的线程池。
他使用的队列是没有存储空间的,只要请求过来了,如果没有空闲线程,就会创建一个新的线程。
这种不定长线程池,适合处理执行时间比较短的任务。在高并发下,如果在耗时长的RPC\IO操作中使用该线程池,势必会造成系统 线程爆炸。
AsyncTool作者在京东使用的场景多数为低耗时(10ms)高并发,瞬间冲击的场景。这种高并发,且任务耗时较少的,适合使用不定长线程池。
但是这种低耗时的场景也不多,对于耗时较长的场景,推荐使用自定义线程池,可以避免那些耗时长的任务长时间占用线程,造成线程 ”爆炸 “,容错率更高。
2、AsyncTool框架执行的任务如何监控?
1、背景:
大促备战前,一般都会对系统进行压测。使用AsyncTool框架的接口,压测结果不达标,通过监控系统的调用链追踪,发现trace调用链出现”断点“了,使用AsyncTool框架执行的任务调用链丢了,这是什么情况?
2、trace调用链:
-
trace:又名调用链,指对一次单一业务请求调用过程的跟踪
-
traceId:每一个trace都会被分配一个全局唯一的ID
3、为什么AsyncTool框架执行的任务trace丢了呢?
业务开发中,一般都会使用ThreadLocal保存一些上下文信息,但是在线程池中执行对应逻辑时,由于是不同线程所以无法获取之前线程的上下文信息。
JDK
的InheritableThreadLocal
类可以完成父线程到子线程的值传递。但对于使用线程池等会池化复用线程的情况,线程由线程池创建好,并且线程是池化起来反复使用的;这时父子线程关系的ThreadLocal
值传递已经没有意义,应用需要的实际上是把 任务提交给线程池时的ThreadLocal
值传递到 任务执行时。
4、如何解决?
京东的Pfinder
和阿里李鼎大神开源的TransmittableThreadLocal
(TTL
)框架都可以解决分布式系统trace的传递问题。TTL
对JDK
的InheritableThreadLocal
类进行了增强,在使用线程池等会池化复用线程的执行组件情况下,提供ThreadLocal
值的传递功能,解决异步执行时上下文传递的问题。
他们的使用也很简单,对线程池包装一下即可。
//Pfinder
PfinderContext.executorServiceWrapper(new BaseThreadPoolExecutor(1000, 1000, 2, TimeUnit.MINUTES,
new LinkedBlockingQueue<>(1000), new ThreadFactoryBuilder().setNameFormat("skuCardWorkerCommonPool-pool-%d").build(),
new PoolPolicy("商品卡片WORKER版线程池")));
//TTL
ExecutorService executorService = TtlExecutors.getTtlExecutorService(executorService);
这里没有做深入研究,感兴趣的小伙伴可以自行了解。
3、关于缓存时钟类的思考:SystemClock
AsyncTool框架任务的超时控制,是通过缓存时钟类 SystemClock
控制的,获取当前时间使用SystemClock.now()
,为什么不直接使用System.currentTimeMillis()
?
先看一下 SystemClock
时钟类的设计。
(1)使用单例模式创建了一个时钟类SystemClock
(2)ScheduledExecutorService
是 ExecutorService
的子类,它基于 ExecutorService
功能实现周期执行的任务。
(3)创建了一个守护线程,每1ms对 AtomicLong now
进行更新 System.currentTimeMillis()
,因为守护线程的执行周期是每1ms执行一次,这里是有1ms的延迟。
/**
* 用于解决高并发下System.currentTimeMillis卡顿
* @author lry
*/
public class SystemClock {
private final int period;
private final AtomicLong now;
/**
* 单例模式
*/
private static class InstanceHolder {
private static final SystemClock INSTANCE = new SystemClock(1);
}
private SystemClock(int period) {
this.period = period;
this.now = new AtomicLong(System.currentTimeMillis());
scheduleClockUpdating();
}
private static SystemClock instance() {
return InstanceHolder.INSTANCE;
}
/**
* 守护线程
* 每1ms写一次now系统当前时间。
*/
private void scheduleClockUpdating() {
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(runnable, "System Clock");
//守护线程,必须在线程启动前设置
thread.setDaemon(true);
return thread;
});
//定时周期执行任务
scheduler.scheduleAtFixedRate(() -> now.set(System.currentTimeMillis()), period, period, TimeUnit.MILLISECONDS);
}
private long currentTimeMillis() {
return now.get();
}
/**
* 用来替换原来的System.currentTimeMillis()
*/
public static long now() {
return instance().currentTimeMillis();
}
}
通过jstack
可以看到后台始终运行着一个守护线程;
System.currentTimeMillis()
真的有性能问题吗?
说有性能问题的,有以下几种观点:
- System.currentTimeMillis要访问系统时钟,这属于临界区资源,并发情况下必然导致多线程的争用。
- System.currentTimeMillis()之所以慢是因为去跟系统打了一次交道,System.currentTimeMillis 比 new一个普通对象耗时还要高100倍左右
要知道,Linux和Windows系统的时钟实现不同,windows系统的时钟访问,比linux要快很多(据说是Windows的黑科技)。
Linux系统有2中常用的时钟源,TSC、HPET,这些时钟源都属于硬件;
根据权威测试,Linux操作系统下,TSC时钟源比HPET时钟源快很多倍。也就是说,在Linux操作系统下,如果服务器使用HPET时钟源,可能会有性能问题,可能。
有一位网友曾经遇见过HPET时钟源问题,并作了如下解释:
这个问题根源上是要切换时钟策略,本身代码角度来说应该是没有啥优化的必要。我就遇到过这个问题,场景就是高并发和大数据量,调用这个方法,现场部署的时候,相同程序个别机器性能奇差,一启动CPU 就打满了,机器都变的很卡,最终通过切换TSC时钟策略解决的。切换不了的机器,直接让服务器厂商把主板换了,然后就好了
具体可阅读如下文章:
4、为什么第一次请求耗时长?
1、背景:
项目中有一个接口,使用Asynctool框架并行编排多线程任务(RPC)。接口的核心逻辑是并行调用十几个rpc接口,汇总数据。
线程池配置是:1000核心,1000最大,1024队列。
2、现象:
发现了一个奇怪的现象。服务启动后,每次第一次请求接口时,就会有一个耗时较高的跳点,后续再调用就正常了,因为这个问题,接口压测的时候,都会先预热一下接口。
3、分析:
Rpc接口监控正常,没有跳点。感觉是CompletableFuture的问题,但又不知道问题出在哪里。(JDK版本是8u60)
经咨询AsyncTool作者,他们也有这种问题。
作者表示:CompletableFuture就第一次初始化时长。他初始化线程池那一下耗时500+,之后就不需要了
针对作者的回答,我在本地环境测试没有复现。可能在docker中的tomcat进程中运行会不一样,这个问题我觉得还需要使用arthas等工具,从头到尾的排查一下,奈何生产环境有较多限制,一直没进一步分析问题原因。
todo:待后面分析
5、全组任务超时?还是单步任务超时?
如果能够对单步任务执行超时,是不是每个执行任务的时间更可控了?
在gitee上,作者也对这个问题进行了讨论。
作者给出的解释是:如果要支持单步任务超时,每个任务的超时监控都需要一个额外的线程。会造成线程池翻倍。
退而求其次,使用了全组任务超时设置。实际上,全组任务超时,已经能满足绝大部分的场景了。
另外,在timewheel分支,实现了 一个线程监控所有任务的超时时间。原理是单线程时间轮对所有注册的wrapper进行检查。
没有深入研究,感兴趣的自行了解。
6、扩展
编排框架有哪些:
3、parseq
🎉 如果喜欢这篇文章,请不要吝啬你的赞👍👍👍哦,创作不易,感谢!