背景
由于网络需求需要通过发心跳来维持连接的建立,所以客户端需要通过计时器,每间隔一定事件发一次心跳请求到服务器,以此达到连接保活。我用了Timer来进行定时任务后,服务端童鞋找我说为啥同一秒会有重复的心跳请求发到服务器上呢?这就延伸出我们今天文章所要讲的内容了。
问题
业务场景是每隔10秒上报一次ping心跳,当09:50:33时候Timer执行了一次ping的上报任务后,下一次的上报的时间却是在09:50:54进行ping上报了(此次ping上报出现重复上报问题),中间间隔20几秒,在排查并非代码逻辑问题,把目光投向了定时器自身问题。
分析问题
结合自身日志和Timer的源码阅读,可以知道此问题是由于使用Timer进行定时任务上报,当你的app的cpu资源竞争非常激烈时候,你的Timer里面的Thread没有办法准时获取cpu资源来执行开发者需要做的定时任务,当获取到cpu资源时,Timer就会为了弥补之前漏执行的定时任务,会在同一时刻进行1-n次的定时任务。
前置知识
刚入门面试的我们,多多少少都会被面试官问到sleep和wait的区别,当初的我们涉世尚浅,并不是太多关注这两个的区别,以为并没有什么用处,但看完我这篇文章你就明白当初面试官为什么问你这个问题了。这里先大概讲下,wait是让当前线程让出系统资源,释放锁,处于线程队列中进行等待;sleep是不让出系统资源,当前线程挂起一定时间,不释放锁。Timer里面源码的实现就是用了wait实现。
源码解析
- 首先是从Timer的schedule函数开始看起来,大家对于这三个参数应该都有一定的认识,我这里就不展开细讲了。主要看的是scheduleAtFixedRate函数里的sched调用。注意sched第二个参数是当前系统时间+开发者所需的delay时间。
Timer().scheduleAtFixedRate(object : TimerTask() {
override fun run() {
……
}
}, delayMills, periodMills)
public void scheduleAtFixedRate(TimerTask task, long delay, long period) {
……
sched(task, System.currentTimeMillis()+delay, period);
}
- sched方法主要是把Timer的启动时间和间隔存储到Task对象里,再把Task对象加到队列里,看完了Timer的构造,我们下面看下Timer是如何运行。
private void sched(TimerTask task, long time, long period) {
……
synchronized(queue) {
……
synchronized(task.lock) {
if (task.state != TimerTask.VIRGIN)
throw new IllegalStateException(
"Task already scheduled or cancelled");
task.nextExecutionTime = time;
task.period = period;
task.state = TimerTask.SCHEDULED;
}
queue.add(task);
if (queue.getMin() == task)
queue.notify();
}
}
- Timer内部有个TimerThread线程,Run内部实现为一个死循环,通过wait/wait(time)/notify 实现挂起/唤醒操作。在mainLoop里面有个逻辑缺陷就是,每次当前线程获取cpu资源时候,就会判断队列头部的Task是否到时间执行。如果未到时间,则wait剩余时间;如果到时间执行,则更新Task的下一次执行的时间(nextExecutionTime)。
注意:那么问题就出现了,假如你的定时器任务执行完后,wait了下一次间隔时间,但是那个时间段cpu资源竞争很激烈,TimerThread根本抢不到cpu资源去执行,当到达下下一次间隔时间获取到cpu的资源时候,你的死循环就因为currentTime - executionTime >= 2倍的间隔时间,所以会同一时刻执行两个Runnable的回调,自然你Runnable回调也会在同一时刻做出重复的行为。
class TimerThread extends Thread {
public void run() {
……
mainLoop();
……
}
}
private void mainLoop() {
while (true) {
try {
TimerTask task;
boolean taskFired;
synchronized(queue) {
// 当Task队列为空时候,挂起系统资源,等待notify的唤醒
while (queue.isEmpty() && newTasksMayBeScheduled)
queue.wait();
……
// 从队列中取出头部Task
task = queue.getMin();
synchronized(task.lock) {
……
currentTime = System.currentTimeMillis();
//Task的执行sched函数时的系统时间
executionTime = task.nextExecutionTime;
//taskFired:true 执行时间到了,false 执行时间未到
if (taskFired = (executionTime<=currentTime)) {
if (task.period == 0) { // Non-repeating, remove
queue.removeMin();
task.state = TimerTask.EXECUTED;
} else { // Repeating task, reschedule
//更新头部Task的nextExecutionTime时间
queue.rescheduleMin(
task.period<0 ? currentTime - task.period
: executionTime + task.period);
}
}
}
if (!taskFired) // 任务还没有到时执行,挂起剩余的时间
queue.wait(executionTime - currentTime);
}
if (taskFired) // 任务到时执行,回调Runnable
task.run();
} catch(InterruptedException e) {
}
}
}
总结
Timer的设计者也考虑到多报的情况,所以设计了如果你传进来的period为负数,就用当前系统时间+你的period间隔时间,从而选择漏报而不是多报一次,但是好像还有bug,所以外面的schedulexxx只要period为负数就会抛异常。
所有跑线程的任务都会有资源竞争的问题,如果想要解决此类问题,应该规划线程优先级,业务的优先级最多到哪个等级,上报、crash等线程优先级比业务等级高。只有明确线程等级,才能保证你的线程能按时获取cpu资源执行任务。
一起努力搬砖