定时器与系统时间

时间:2022-09-08 20:33:21

问题:
--------------------------------------------------------------------------------
用户反馈一些定时活动提前开启或者延后开启
1) 登录服务器,查看时间确实慢了或者快了。总之是有几台服务器时间不准确了。
2) 查看代码是使用的ScheduledExecutorService.scheduleAtFixedRate,Java的API,不至于这里存在Bug
3) 查看log4j日志输出发现:
    12点的定时活动,之前的[活动运行时间]就是12点整;后面有几天的[活动运行时间]是12点零几分,而且分秒都一致
    确认了一下,变化之间同步了一下服务器时间,但是没有重启jvm
4) 初步怀疑是ScheduledExecutorService内部执行使用的是相对时间,不是每次采样服务器系统时间

 

问题确认-测试:
--------------------------------------------------------------------------------
5) 测试

ScheduledExecutorService service = Executors.newScheduledThreadPool(2);
service.scheduleAtFixedRate(new Runnable() { // Runnable-1
    @Override
    public void run() {
        System.out.println( String.format("\n#### %s ####", 
                new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.S").format(new Date())));
    }
}, 0, 10, TimeUnit.SECONDS); // 10秒执行一次
service.scheduleAtFixedRate(new Runnable() { // Runnable-2
    int i = 0;
    @Override
    public void run() {
        System.out.print( (i++) + "," );
    }
}, 0, 1, TimeUnit.SECONDS);    // 1秒执行一次

 

输出-1:
    0,
    #### 2014-11-28 16:51:48.118 ####
    1,2,3,4,5,6,7,8,9,
    #### 2014-11-28 16:51:58.93 ####
    10,11,12,13,14,15,16,17,18,19,20,
    #### 2014-11-28 16:52:08.94 ####
    21,22,23,24,25,26,27,28,29,
    #### 2014-11-28 16:52:18.93 ####
    30,31,32,33,34,35,36,37,38,39,
    #### 2014-11-28 16:52:28.93 ####
    40,41,42,43,44,45,46,47,48,49,
    #### 2014-11-28 16:58:36.480 #### // 调整时间
    50,51,52,53,54,55,56,57,58,59,
    #### 2014-11-28 16:58:46.480 ####
    60,61,62,63,64,65,66,67,68,69,
    #### 2014-11-28 16:58:56.480 ####

在 16:52:28.93 时调整时间为16:58:36.480(向后跳), Runnable-2依然进行了10次输出,然后Runnable-1输出1次

 

输出-2:
    0,
    #### 2014-11-28 17:12:40.971 ####
    1,2,3,4,5,6,7,8,9,
    #### 2014-11-28 17:12:50.943 ####
    10,11,12,13,14,15,16,17,18,19,
    #### 2014-11-28 17:13:00.943 #### // 调整时间
    20,21,22,23,24,25,26,27,28,29,
    #### 2014-11-28 17:05:09.69 ####
    30,31,32,33,34,35,36,37,38,39,
    #### 2014-11-28 17:05:19.68 ####

在 17:13:00.943 时调整时间为17:05:09.69(向前跳), Runnable-2依然进行了10次输出,然后Runnable-1输出1次

测试结论: 时间的跳跃不影响 Runnable-1 10个秒单位输出一次, ScheduledExecutorService 没有使用系统时间

 

 

问题确认-JDK源码:
--------------------------------------------------------------------------------
初始化

ScheduledExecutorService service = Executors.newScheduledThreadPool(2);
    new ScheduledThreadPoolExecutor(corePoolSize)
        super(corePoolSize, Integer.MAX_VALUE, 0, TimeUnit.NANOSECONDS, new DelayedWorkQueue())

注册定时任务

service.scheduleAtFixedRate(new Runnable() {...})
    RunnableScheduledFuture<?> t = decorateTask(command, new ScheduledFutureTask<Object>(command, null, 
            triggerTime(initialDelay, unit), unit.toNanos(period)));    // ScheduledThreadPoolExecutor.ScheduledFutureTask
    delayedExecute(t);
        prestartCoreThread
            addIfUnderCorePoolSize
                addThread
                    Worker w = new Worker(firstTask);   // ThreadPoolExecutor.Worker
                    Thread t = threadFactory.newThread(w);
                    workers.add(w);
        super.getQueue().add(command);      // DelayedWorkQueue

执行

ThreadPoolExecutor.Worker.run
    task = getTask()
        r = workQueue.take();       // DelayedWorkQueue

ScheduledThreadPoolExecutor.DelayedWorkQueue.take
    dq.take();         // DelayQueue<RunnableScheduledFuture>
DelayQueue<E extends Delayed>
Delayed 元素的一个*阻塞队列,只有在延迟期满时才能从中提取元素。 该队列的头部 是延迟期满后保存时间最长的 Delayed 元素。 如果延迟都还没有期满,则队列没有头部,并且 poll 将返回
null。 当一个元素的 getDelay(TimeUnit.NANOSECONDS) 方法返回一个小于等于 0 的值时,将发生到期。 即使无法使用 take 或 poll 移除未到期的元素,也不会将这些元素作为正常元素对待。 例如,size 方法同时返回到期和未到期元素的计数。此队列不允许使用 null 元素。 take() long delay = first.getDelay(TimeUnit.NANOSECONDS); if (delay > 0) { long tl = available.awaitNanos(delay); }
ScheduledThreadPoolExecutor.ScheduledFutureTask.getDelay  
public
long getDelay(TimeUnit unit) { return unit.convert(time - now(), TimeUnit.NANOSECONDS); } final long now() { /** * public static long nanoTime() * 返回最准确的可用系统计时器的当前值,以毫微秒为单位。 * 此方法只能用于测量已过的时间,与系统或钟表时间的其他任何时间概念无关。 * 返回值表示从某一固定但任意的时间算起的毫微秒数(或许从以后算起,所以该值可能为负)。 */ return System.nanoTime() - NANO_ORIGIN; }

 

问题确认-nanoTime测试:
--------------------------------------------------------------------------------

new Thread(){
    public void run () {
        long lastNanos = System.nanoTime();
        long lastMilis = System.currentTimeMillis();
        for (int i = 0; i < 100; i++) {
            try { Thread.sleep(10000L); } catch (InterruptedException e) { } // 10秒钟输出一次
            
            long nanos = System.nanoTime();
            long millis = System.currentTimeMillis();
            
            System.out.println( String.format("%-25s %-10s %s-%s = %s [%s]",
                    new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.S").format(new Date(millis)), 
                    millis - lastMilis,
                    nanos, lastNanos,
                    nanos - lastNanos, (nanos - lastNanos) / 1000 / 1000
            ));
            
            lastNanos = nanos;
            lastMilis = millis;
        }
    }
}.start();

输出:
    2014-12-05 10:10:45.950   10001        107273041687944-107263041775958 = 9999911986 [9999]
    2014-12-05 10:10:55.997   10047        107283088127723-107273041687944 = 10046439779 [10046]
    2014-12-05 10:20:04.198   548201      107293088578057-107283088127723 = 10000450334 [10000]
    2014-12-05 10:00:12.399   -1191799   107303089035855-107293088578057 = 10000457798 [10000]
    2014-12-05 10:00:22.399   10000        107313089495208-107303089035855 = 10000459353 [10000]
    在 10:10:55.997 时调整时间为10:20:04.198(向后跳), 在 10:20:04.198 时调整时间为10:00:12.399(向前跳),
nanos - lastNanos总是维持在 10s左右, millis - lastMilis 确实期望中的时间

 

想法与目标:
--------------------------------------------------------------------------------
7) 我想做一个定时器,依赖于当前系统时间的定时器;顺便解决当前问题
    a) 它不是定时任务,不是一个任务系统。
    b) 它只做一件事情,到时间了提醒我做某个任务。
    比如,12点了,提醒我该吃叫花鸡了;20点了,该打帮会战了。

 

初步设计:
--------------------------------------------------------------------------------

8) 初步设计
    a) 首先创建一个任务线程池,用于执行定时器叫醒的任务。
        Executors.newScheduledThreadPool(workServicePoolSize)
    b) 创建一个定时器线程, 每隔1秒执行一次handleFunc(心跳步长1秒)
        Executors.newSingleThreadScheduledExecutor
    c) handleFunc根据当前系统时间查找到时的任务,把任务放置到任务线程池,由任务线程池执行
        execute(new Runnable(){});
    可以初步解决任务依赖系统时间来执行问题

 

详细设计:
--------------------------------------------------------------------------------

9) Linux系统时间会变快或者变慢,比如23点战力排行榜截止并在30分钟后开始领取奖励
    a) 如果快的太多;大家当前都是22:55,但是服务器已经23:00,想等着最后冲榜的兄弟立马就哭了
    b) 如果慢的太多;大家当前都是23:00,但是服务器才是22:55,我都休息了,准备开始领取奖励,你还能冲击战力榜

    a) handleFunc每次执行后,记录一下当前执行时间为 lastExecuteTime
    b) handleFunc下次执行的时候,拿 executeTime(当前时间) 和 lastExecuteTime比较一下
        如果 executeTime == lastExecuteTime + 1: (心跳步长1秒)
            正常时间,正常执行
        如果 executeTime > lastExecuteTime + 1:
            时间快了(通常是服务器时间慢了;校正服务器时间,服务器时间会快进), 需要处理一下:
                [lastExecuteTime + 1, executeTime - 1]的任务 根据业务决定是否需要立马补执行
                [executeTime]的任务 是当前时间正常任务,需要正常执行
        如果 executeTime <= lastExecuteTime:
            时间慢了(通常是服务器时间快了;校正服务器时间,服务器时间会回退):
                [executeTime, lastExecuteTime] 都执行过了,一般不需要再执行了

    注1:当前时间和lastExecuteTime都是抹去毫秒的,日常定时服务基于秒来计算足够了
    注2: 服务器可以每隔1个小时同步一次时间,比方 NN:38,通常要避开整点、半点、整十分
    注3: 每小时同步一次最多误差几秒而已,对于普通业务而言:
        时间快了的情况下,立马补执行一下就可以了,比较重要的奖励提前或者延迟5秒发没有多少差别
        时间慢了的情况下,可以忽视掉[executeTime, lastExecuteTime]间的任务,不需要再执行一次了
    注4: 执行时间粒度比较小的,比方说1秒执行一次的,可以无视时间跳跃的问题

 

详细设计-定时任务:
--------------------------------------------------------------------------------

10) 这样用定时器的方案,可以解决时间跳跃的问题;但是日常开发通常是定时周期任务
    比如, 12点吃叫花鸡,12点定时器通知吃叫花鸡;但是吃叫花鸡是每天12点都吃,这就是个定时周期任务,需要每天12点都
    通知一下吃。处理方案可以如下:
    a) 修改“handleFunc根据当前系统时间查找到时的任务”
    b) 查找的任务仓库分为两类仓库:
        一次性任务仓库:
            到时间就执行任务,并移除; 任务仓库的存储的key是任务执行时间戳
        每日的任务仓库:
            任务仓库的存储的key是,任务相对于凌晨00:00:00的秒数
            handleFunc执行时候,查看一下,今天过去了多少秒(这个是使用系统时间),找到对应任务,然后执行。这个不需要移除任务

    这样依然是定时器的概念,“12点到时间了,我叫你去吃叫花鸡;明天12点到时间了,我再叫你去吃叫花鸡”,
    而不是“12点了,我叫你去吃叫花鸡;24小时后我再叫你去吃鸡”。

    注1: 同理可以增加每周、每小时、每分钟的任务仓库
    注2: 通常不需要每月、每年、每十年等周期任务,如果需要加也很简单
    注3: 每秒的,不需要这么多少事情, handleFunc 过了就直接执行(每次时间间隔是利用nano计算出来的1秒,所以应该执行)

 

接口设计:
--------------------------------------------------------------------------------

11) 对外接口:
    a) 启动
    b) 关闭
    c) 注册一次性任务
    d) 注册每周任务
    e) 注册每日任务
    f) 注册每时任务:每个小时的第几分、第几秒执行的什么任务
    g) 注册每分任务
    h) 注册每秒任务

 

其它说明:
--------------------------------------------------------------------------------

12) 其它:
    a) handleFunc 只是找到任务把它扔进工作线程池执行,不怎么占用CPU,不会造成任务选取的堵塞
    b) handleFunc 每次都会去 分钟的任务仓库查找合适的任务并执行;同一任务上一分钟没执行玩,当前任务也会继续执行,不会延迟,会同时执行
        既然是每分钟任务,任务不应该超过1分钟;如果偶尔会超过1分钟,可以在注册的任务里面自行加锁
    c) 服务器时间慢了;校正服务器时间,服务器时间会快进,补执行任务只补处理一定时间(比如30分钟)
        系统时间是1小时同步一次,误差最大不过几秒;如果再大,就应该升级内核或者换服务器了
        补执行的时间段过程,可能会影响正常服务(正常服务进程、系统资源占用等等)
    d) 任务扔到线程池里面时候,会额外catch住,防止挂掉当前线程