setTimeout 的实现

· 984 words · 5 min

我们知道渲染进程中所有运行在主线程上的任务首先需要先添加到任务队列,然后事件循环系统按顺序执行消息队列中的任务。 不过通过定时器设置回调函数有点特别,需要在指定时间内被调用,但是队列中的任务是按顺序执行的, 因此为了保证回调函数能在指定时间内被执行,不能直接将回调函数添加到消息队列中。

Chrome 中 出了正常的消息队列之外,还有一个消息队列,维护了需要延迟执行的任务, 包括定时器和 Chromium 内部一些需要延迟执行的任务,因此 JS 创建了一个定时器时, 渲染进程会把该定时器的回调函数添加到延迟队列中(这个所谓的“队列”其实是一个 hashmap 结构)。

void ProcessDelayTask(){
  //从delayed_incoming_queue中取出已经到期的定时器任务
  //依次执行这些任务
}

TaskQueue task_queue;
void ProcessTask();
bool keep_running = true;
void MainTherad(){
  for(;;){
    //执行消息队列中的任务
    Task task = task_queue.takeTask();
    ProcessTask(task);
    
    //执行延迟队列中的任务
    ProcessDelayTask()

    if(!keep_running) //如果设置了退出标志,那么直接退出线程循环
        break; 
  }
}

上段代码中,处理完队列中的一个任务后,就开始执行 ProcessDelayTask 函数,该函数会根据发起时间和延迟时间计算出到期任务, 然后依次执行这些到期任务,执行完后,再继续下一个循环。

定时器设置之后,JS 会返回定时器的 ID,定时器任务未被执行时是可以调用 clearTimeout 函数取消的。 浏览器的内部实现也非常简单,从 delayed_incoming_queue 延迟队列中,根据 ID 查找任务,然后从延迟队列中删掉就好了。

当前任务执行时间过久时,会影响定时器任务的执行

在使用 setTimeout 的时候,有很多因素会导致回调函数执行比设定的预期值要久, 其中一个就是当前任务执行时间过久从而导致定时器设置的任务被延后执行(执行时间很长的 for 循环)。

function bar() {
    console.log('bar')
}

function foo() {
    setTimeout(bar, 0);
    for (let i = 0; i < 5000; i++) {
        let i = 5+8+8+8
        console.log(i)
    }
}

foo()
阻塞定时器的执行

上图可以看到,foo 函数执行消耗的时间是 500ms,而 setTimeout 的回调被推迟到了 500ms 之后再去执行。

setTimeout 存在嵌套使用时,系统会设置最短时间为 4ms

定时器中嵌套使用定时器时,会延长定时器的执行时间。

function cb() { setTimeout(cb, 0) }

setTimeout(cb, 0);

用浏览器的 Performance 记录下这段代码的执行过程:

Performance

发现超过五次以上时,后面每次的调用最小间隔为 4ms。Chrome 中,定时器被嵌套 5 次以上时, 系统会判断该函数方法被阻塞了,且定时器的调用间隔小于 4ms 时,浏览器会将每次调用时间间隔强制设置为 4ms。

因此一些实时性较高的需求就不适合使用 setTimeout 了,例如用 setTimout 来实现 JS 动画就不是一个很好的主意。

未激活的页面,setTimeout 的执行最小间隔是 1000ms

浏览器未激活页面中,定时器最小时间间隔是 1000ms,目的是为了优化后台页面的加载损耗以及降低耗电量。

延迟执行时间有最大值

Chrome、Safari、Firefox 都是以 32 个 bit 来存储延时值的,32bit 最大只能存放的数字是 2147483647 毫秒。 这就意味着,如果 setTimeout 设置的延迟值大于 2147483647 毫秒(大约 24.8 天)时就会溢出, 那么相当于延时值被设置为 0 了,这导致定时器会被立即执行。

From 极客时间