Android Timer(定时器)踩坑记

背景

由于网络需求需要通过发心跳来维持连接的建立,所以客户端需要通过计时器,每间隔一定事件发一次心跳请求到服务器,以此达到连接保活。我用了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实现。

源码解析
  1. 首先是从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);
 }
  1. 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();
        }
    }
  1. 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) {
            }
        }
    }

总结

  1. Timer的设计者也考虑到多报的情况,所以设计了如果你传进来的period为负数,就用当前系统时间+你的period间隔时间,从而选择漏报而不是多报一次,但是好像还有bug,所以外面的schedulexxx只要period为负数就会抛异常。

  2. 所有跑线程的任务都会有资源竞争的问题,如果想要解决此类问题,应该规划线程优先级,业务的优先级最多到哪个等级,上报、crash等线程优先级比业务等级高。只有明确线程等级,才能保证你的线程能按时获取cpu资源执行任务。

  3. 一起努力搬砖

你可能感兴趣的:(Android Timer(定时器)踩坑记)