如何用一个定时器做延时队列

本文最后更新于:几秒前

如何用一个定时器做延时队列

起因

起因是团队内小伙伴遇到了一个有意思的题,大概内容是这样:如何实现类似 Message 组件(全局通知提示,几秒钟自动消失的那种)的调度功能。这个题这样子看很简单,大概是这样

const showMessage = msg =>
  new Promise(resolve => {
    const message = Message.show(msg);
    setTimeout(() => {
      message.close();
      resolve();
    }, delay);
  });

大致意思就是如此,每次显示就开启定时器并在定时器结束后关闭,虽然没很完整具体实现,但是很容易理解这题目的意思

后来就被问到:能否用一个定时器实现

小伙伴对此比较,所以我在这里也试着实现一下

为什么感兴趣

实际上这种场景在业务中也是比较多,除了上面说的 Message 组件,还有类似以下的场景:

  • 用户下单后超过 30 分钟不支付则自动取消订单并归还库存
  • 会议室被预定了会在开始前 15 分钟通知用户

这些延迟时间已知,但是随时可能被创建的场景,延时队列就是其中一种方案,说是队列但是并不遵从先进先出,应该是按时间进行的结构。而中心化调度可以把过程控制在一个地方,也较好维护(其实真实业务直接创定时器就可以了,主要是小伙伴搞不定)

思考过程

首先要知道这题目的是看是否能中心化调度,而且每个任务都是延迟的,而且还有可能后加入的任务延迟时间比已有的定时器更早达到,这种情况就需要不断地调整定时器。

而我们知道定时器 api:setTimeout是无法修改时间的,只能手动清除;那么第二个想到的就是类似requestAnimationFrame的方案,在每次回调中检查时间并查询任务队列中的延时任务是否已经到达然后执行,但是这种方案其实性能更差也更多的无意义判断和调用

后面我就只能往同时只存在一个定时器的实现情况,也就是说动态化定时器

那既然有了想法就开始想思路,其实很简单

  1. 创建单例定时器,并且维护一个任务队列
  2. 插入任务并且开始启动定时器
  3. 在等待时如果插入了别的任务,则取消定时器,并计算相对时间重新开启
  4. 定时器通知中心调度并取出达到的任务,并执行后出列
  5. 重复上述步骤直到队列被清空

有了思路之后,开始看看是否有问题,首先是不断的重启定时器感觉好像有点浪费性能,然后还有小伙伴会觉得需要做个排序(甚至默写了一个插排),其实这里用相对时间做缓存记录即可,但是要注意一个点就是相对时间

假设我们有三个任务分别创建,任务一延时 2 秒,任务二延时 2 秒在 1 秒后插入,任务三延时 1 秒在 1.5 秒后插入,如下图

时间轴示例

可以看到,在任务一时我们创建了一个 2 秒的定时器,在 1 秒后,插入了任务二,其实他会在任务一之后才完成,那其实我们完全可以不重启定时器,因为在任务一完成后我们再计算即可。但是要注意的是,任务二也是 2 秒任务,但是我们期望是在时间轴的第 3 秒执行,如果我们直接存起来,那就会变成第 4 秒才执行了(2 秒后再等 2 秒执行任务二),所以其实我们可以把任务二补足时间,以当前定时器的启动时期间为基准,因为如果任务较多的话,基准多就容易出问题。

时间轴补丁示例

然后看任务三,任务三是后面插入的任务但是他会比任务一更早完成,那这个时候就无法利用已有的任务一定时器,这种情况才必要重启定时器,并且要修改任务 1 的相对基准时间为 1.5 秒后,如图所示:

时间轴补丁示例2

到这里为止其实思路都完整了,我们可以画成一个流程图更好帮助我们编码

编码流程图

开始编码

首先我们可以先做好拆分,因为延时任务队列其实跟定时器无关的,那定时器我们可以稍微封装一下,因为需要记录创建时间和一个获取已经过了的时间:

class Timer {
  // 是否启动
  active = false;
  // 本次延时
  #delay = 0;
  // 创建时间
  #timestamp = 0;
  // 定时器实例
  #id = null;
  // 任务内容
  #task = null;
  // 获取已过去的时间
  get passedTime() {
    return this.active ? Date.now() - this.#timestamp : 0;
  }
  // 获取剩余时间
  get restTime() {
    return this.active ? this.#delay - this.passedTime : 0;
  }

  set(task, delay) {
    if (this.#id) {
      this.stop();
    }

    this.#timestamp = Date.now();
    this.#delay = delay;
    this.#task = task;
    this.active = true;
    this.#id = setTimeout(() => {
      this.#task.call(null);
      this.active = false;
    }, this.#delay);
  }

  stop() {
    clearTimeout(this.#id);
    this.active = false;
    this.#delay = 0;
    this.#id = null;
  }
}

ok,到此我们有个自己的定时器,定时器在启动时会清除存在的定时器,这样子就能保证我们同时只有一个定时器在运行

截下来我们要开始写延时队列,首先得有定时器实例,也要有一个集合存储,这里我们用 Set,因为其实我们不需要获取某个下标的内容,更需要承担一个增删压力

class DelayQueue {
  #timer = new Timer();

  #queue = new Set();
}

这个队列的核心 api 就是添加任务了,顺便可以定一个简单的任务实例结构

interface Task {
  delay: number;
  run(): unknown;
}

然后可以添加任务,添加任务时记得要获取定时器的已过时间并抹平,并且如果新增的任务延时不超过现在剩余时间,那就是说我们当前定时器还可以用,就不需要重新开启

class DelayQueue {
  add(run, delay = 0) {
    this.#queue.add({
      run,
      delay: delay + this.#timer.passedTime,
    });
    if (this.#timer.restTime > delay || !this.active) {
      this.#setSchedule();
    }
  }
}

开启定时任务的方法,我们需要绑定回调的同时,需要有一个抹平当前时间的 api,并且能返回当前任务队列中最近的任务延时

而为什么需要抹平,上面也已经说过了,当一个回调完成后,当前队列中任务还是以上一个为基准的,那我们需要把基准时间修改,并同时作用于所有集合内的任务

class DelayQueue {
  #refreshRestTime() {
    let minDelay = null;
    const passedTime = this.#timer.passedTime;
    this.#queue.forEach(item => {
      item.delay -= passedTime;
      if (minDelay === null || minDelay > item.delay) {
        minDelay = item.delay;
      }
    });
    return minDelay;
  }

  #setSchedule() {
    const delay = this.#refreshRestTime();
    Promise.resolve().then(() => {
      this.#timer.set(() => {
        this.#resolveTask();
      }, delay);
    });
  }
}

ok,到这里回调就很简单了,无非就是找到并且调用,然后出列,最后再重新启动即可

class DelayQueue {
  #getClosestTasks() {
    let results = [];
    let minDelay = null;

    this.#queue.forEach(item => {
      if (minDelay === null || minDelay > item.delay) {
        minDelay = item.delay;
        results = [item];
      } else if (minDelay === item.delay) {
        results.push(item);
      }
    });

    return [results, minDelay];
  }

  #resolveTask() {
    const [results] = this.#getClosestTasks();
    results.forEach(task => {
      this.#queue.delete(task);
      task.run();
    });
    if (this.#queue.size) {
      this.#setSchedule();
    }
  }
}

最后写个简单的测试用例,包含先进后跑,后进先跑,先进先跑,后进后跑的场景

const $$log = (...msg) => console.log(new Date().toTimeString(), ...msg);
const queue = new DelayQueue();

const start = Date.now();
$$log(1);
setTimeout(() => {
  queue.add(() => $$log('1000ms', Date.now() - start), 1000);
}, 0);
setTimeout(() => {
  queue.add(() => $$log('1500ms', Date.now() - start), 1000);
}, 500);
setTimeout(() => {
  queue.add(() => $$log('1500ms', Date.now() - start), 500);
}, 1000);
setTimeout(() => {
  queue.add(() => $$log('800ms', Date.now() - start), 500);
}, 300);
setTimeout(() => {
  queue.add(() => $$log('3100ms', Date.now() - start), 100);
}, 3000);
setTimeout(() => {
  queue.add(() => $$log('2600ms', Date.now() - start), 2500);
}, 100);
setTimeout(() => {
  queue.add(() => $$log('1000ms', Date.now() - start), 500);
}, 500);

最后附上 完整代码

总结一下就是,其实写多个 setTimeout 会更简单,只需要存储创建任务对象即可,但是多学习总归没有错的。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!