浏览器事件循环;setTimeout和setInterval对比

200 阅读5分钟

事件循环

默认情况下,浏览器(以Chrome为例)的每个tab页面对应一个渲染进程,渲染进程包含主线程、合成线程、I/O线程等多个线程,主线程的工作非常繁忙,要处理Dom、计算样式、处理布局、处理事件响应、执行js代码等。事件循环流程图如下

image.png

如今有两个问题需要解决:

  1. 任务不仅来自线程内部,也可能来自外部,那么该如何调度这些任务?
  2. 在主线程的工作过程中,新任务如何参与调度

主线程如何调度执行任务

首先我们思考一个问题,我们常说的js同步代码是直接放到主线程然后开始执行吗?

答案并不是,因为渲染进程中所有运行在主线程上的任务都需要先添加到任务队,所以这些代码将会被视为一个任务先放到任务队列里(这里的任务队列就是我们常说的宏任务队列),然后主线程会通过事件循环从任务队列里开始取宏任务执行,至此才开始执行上述同步代码。后续进入队列的任务会按照先进先出的规则依次被任务循环取出执行,直到队列里没有任务为止,这就是主线程的调度方式。下面附上主线程执行的伪代码:

// 退出事件循环标志
let keepRunning = true
// 主线程
function mainThread() {
  // 循环执行任务
  while(true) {
    // 从任务队列中取出任务
    const task = taskQueue.takeTask();
    // 执行任务
    processTask(task)
    if (!keepRunning) {
      break;
    }
  }
}

微任务

由于宏任务都需要等待先进队列的宏任务执行完,所以对于某些突发情况下需要优先执行的任务是不利的。所以为了解决类似的问题,有了微任务的概念,在宏任务执行过程中可以产生微任务,保存在该宏任务执行上下文中的微任务队列中。在宏任务执行结束前,线程会遍历其微任务队列,将该宏任务执行过程中产生的微任务批量执行。

外部任务怎么处理

对于渲染进程外的任务,会通过IPC(进程间通信)发送给渲染进程的I/O线程,I/O线程再将任务发送给主线程的任务队列。其中渲染进程外的进程比如有:负责各种鼠标键盘等事件的浏览器进程、负责资源加载的网络进程等。

setTimeout和setInterval

通过定时器设置回调函数有点特别,它们需要在指定的时间间隔内被调用,但任务队列中的任务是按照顺序执行的,所以为了保证回调函数能在指定时间内执行,不能将定时器的回调函数直接添加到任务队列中。

所以浏览器根据WHATWG标准实现了延迟队列,用于存放需要被延迟执行的任务,setTimeout和setInterval就被包含其中,伪代码如下:

function mainThread() {
  // 循环执行任务
  while(true) {
    // 从任务队列中取出任务
    const task = taskQueue.takeTask();
    // 执行任务
    processTask(task)
    // 执行延迟队列中的任务
    processDelayTask()
    if (!keepRunning) {
      break;
    }
  }
}

当本轮的宏任务执行完成后(执行完processTask),浏览器就会检查是否有延迟任务过期,如果有则执行precessDelayTask方法。

对于上述执行过程你可能会发现一个问题,即如果有延迟任务已经过期了,但是processTask依然没有执行完,那么此时就会导致延迟任务无法按期执行,这就是我们常说的定时器的指定执行时间并不是一定是精准的。如下例子

function sayHello() { console.log('hello') }
function test() {
  setTimeout(sayHello, 0)
  for(let i = 0; i < 1000000; i++) { console.log(i) }
}
test()

sayHello的延迟执行时间是0,但是需要等待for循环执行完成才能执行。对此,各宿主环境在实现时设置了最小延迟时间,比如chromium,最小延迟时间为4ms,所以sayHello的最终执行延迟时间是大于设定时间的。

周期性调度setTimeout和setInterval哪个更好

这个问题是一个大家经常讨论的问题,我们首先给出使用setTimeout如何实现周期性调度

function tick() {
  console.log('tick');
  timerId = setTimeout(tick, 2000);
}
​
let timeoutTimerId = setTimeout(tick, 2000);

我们先来分析使用setInterval来执行调度到底是怎样的

let i = 1;
setInterval(function() {
  func(i++);
}, 100);

上述代码每100秒都会把func放到延迟队列等待执行,执行结果大致如下图所示

image-20230107133030162

我们可以看到func函数的执行是会占用一部分时间的,如果func的执行时间大于100ms,那么func的执行就会出现一个结束后面一个就立马执行的结果。

现在我们再来看setTimeout:

let i = 1;
setTimeout(function run() {
  func(i++);
  setTimeout(run, 100);
}, 100);

我们分析一下他的执行情况,首先过了100毫秒,主线程没有被占用的情况下会来执行func函数,当func执行完后,run函数被放到延迟队列,100ms后run变为过期任务被执行,func被执行。后续一直照着相同逻辑进行循环。可以看到,两个func函数之间的执行间隔是100ms。不会存在func本身执行的时间占用100ms的情况发生。

结论: 由上我们其实可以看出,使用setTimeout和setInterval并没有绝对性的定论说谁好谁坏,这取决于我们的使用场景。如果我们希望函数的执行间隔是固定的ms数,那么就选择setTimeout,如果想要固定时间内执行一次函数,那么就选择setInterval(这里需要注意函数的执行时长是否大于设置的固定时间,以免造成不想出现的执行情况)

以上,不合理的地方请大家指正。