深度解密setTimeout和setInterval——为setInterval正名!

20,503 阅读13分钟

前言

重复定时器,JS有一个方法叫做setInterval专门为此而生,但是大家diss他的理由很多,比如跳帧,比如容易内存泄漏,是个没人爱的孩子。而且setTimeout完全可以通过自身迭代实现重复定时的效果,因此setIntervval更加无人问津,而且对他退避三舍,感觉用setInterval就很low。But!setInverval真的不如setTimeout吗?请大家跟着笔者一起来一步步探索吧!

大纲

  • 重复定时器存在的问题

  • 手写一个重复定时器

    • setTimeout的问题与优化
    • setInterval的问题与优化
  • 那些年setInterval背的锅——容易造成内存泄漏

重复定时器的各类问题

无论是setTimeout还是setInterval都逃不过执行延迟,跳帧的问题。为什么呢?原因是事件环中JS Stack过于繁忙的原因,当排队轮到定时器的callback执行的时候,早已超时。还有一个原因是定时器本身的callback操作过于繁重,甚至有async的操作,以至于无法预估运行时间,从而设定时间。

setTimeout篇

setTimeout那些事

对于setTimeout通过自身迭代实现重复定时的效果这一方法的使用,笔者最早是通过自红宝书了解的。

setTimeout(function(){ 
 var div = document.getElementById("myDiv"); 
 left = parseInt(div.style.left) + 5; 
 div.style.left = left + "px"; 
 if (left < 200){ 
    setTimeout(arguments.callee, 50); 
 } 
}, 50); 

选自《JavaScript高级程序设计(第3版)》第611页

这应该是非常经典的一种写法了,但是setTimeout本身运行就需要额外的时间运行结束之后再激活下一次的运行。这样会导致一个问题就是时间不断延迟,原本是1000ms的间隔,再setTimeout无意识的延迟下也许会慢慢地跑到总时长2000ms的偏差。

修复setTimeout的局限性

说到想要修正时间偏差,大家会想到什么?没错!就是获取当前时间的操作,通过这个操作,我们就可以每次运行的时候修复间隔时间,让总时长不至于偏差太大。

/*
id:定时器id,自定义
aminTime:执行间隔时间
callback:定时执行的函数,返回callback(id,runtime),id是定时器的时间,runtime是当前运行的时间
maxTime:定时器重复执行的最大时长
afterTimeUp:定时器超时之后的回调函数,返回afterTimeUp(id,usedTime,countTimes),id是定时器的时间,usedTime是定时器执行的总时间,countTimes是当前定时器运行的回调次数
*/
function runTimer(id,aminTime,callback,maxTime,afterTimeUp){
    //....
    let startTime=0//记录开始时间
    function getTime(){//获取当前时间
        return new Date().getTime();
    }
    /*
    diffTime:需要扣除的时间
    */
    function timeout(diffTime){//主要函数,定时器本体
        //....
        let runtime=aminTime-diffTime//计算下一次的执行间隔
        //....
        timer=setTimeout(()=>{
            //....
            //计算需扣除的时间,并执行下一次的调用
            let tmp=startTime
            callback(id,runtime,countTimes);
            startTime=getTime()
            diffTime=(startTime-tmp)-aminTime
            timeout(diffTime)
        },runtime)
    }
    //...
}

启动与结束一个重复定时器

重复定时器的启动很简单,但是停止并没有这么简单。我们可以通过新建一个setTimeout结束当前的重复定时器,比如值执行20秒钟,超过20秒就结束。这个处理方案没有问题,只不过又多给了应用加了一个定时器,多一个定时器就多一个不确定因素。

因此,我们可以通过在每次执行setTimeout的是判断是否超时,如果超时则返回,并不执行下一次的回调。同理,如果想要通过执行次数来控制也可以通过这个方式。

function runTimer(id,aminTime,callback,maxTime,afterTimeUp){
    //...
    function timeout(diffTime){//主要函数,定时器本体
        //....
        if(getTime()-usedTime>=maxTime){ //超时清除定时器
            cleartimer()
            return
        }
        timer=setTimeout(()=>{
            //
            if(getTime()-usedTime>=maxTime){ //因为不知道那个时间段会超时,所以都加上判断
                cleartimer()
                return
            }
            //..
        },runtime)
    }
    function cleartimer(){//清除定时器
        //...
    }
    function starttimer(){
        //...
        timeout(0)//因为刚开始执行的时候没有时间差,所以是0
    }
    return {cleartimer,starttimer}//返回这两个方法,方便调用
}

按照次数停止,我们可以在每次的callback中判断。

let timer;
timer=runTimer("a",100,function(id,runtime,counts){
    if(counts===2){//如果已经执行两次了,则停止继续执行
       timer.cleartimer()
    }
},1000,function(id,usedTime,counts){})
timer.starttimer()

通过上方按照次数停止定时器的思路,那么我们可以做一个手动停止的方式。创建一个参数,用于监控是否需要停止,如果为true,则停止定时器。

let timer;
let stop=false
setTimeout(()=>{
    stop=true
},200)
timer=runTimer("a",100,function(id,runtime,counts){
    if(stop){
       timer.cleartimer()
    }
},1000,function(id,usedTime,counts){})
timer.starttimer()

setInterval篇

setInterval那些事

大家一定认为setTimeout高效于setInterval,不过事实啪啪啪打脸,事实胜于雄辩,setInterval反而略胜一筹。不过要将setInterval打造成高性能的重复计时器,因为他之所以这么多毛病是没有用对。经过笔者改造后的Interval可以说和setTimeout不相上下。

将setInterval封装成和上述setTimeout一样的函数,包括用法,区别在于setInterval不需要重复调用自身。只需要在回调函数中控制时间即可。

timer=setInterval(()=>{
    if(getTime()-usedTime>=maxTime){ 
        cleartimer()
        return
    }
    countTimes++
    callback(id,getTime()-startTime,countTimes);
    startTime=getTime();
},aminTime)

为了证明Interval的性能,以下是一波他们两的pk。

Nodejs中:

浏览器中:

在渲染或者计算没有什么压力的情况下,定时器的效率

在再渲染或者计算压力很大的情况下,定时器的效率

首先是毫无压力的情况下大家的性能,Interval完胜!

接下来是很有压力的情况下?。哈哈苍天饶过谁,在相同时间,相同压力的情况下,都出现了跳帧超时,不过两人的原因不一样setTimeout压根没有执行,而setInterval是因为抛弃了相同队列下相同定时器的其他callback也就是只保留了了队列中的第一个挤进来的callback,可以说两人表现旗鼓相当。

也就是说在同步的操作的情况下,这两者的性能并无多大区别,用哪个都可以。但是在异步的情况下,比如ajax轮循(websocket不在讨论范围内),我们只有一种选择就是setTimeout,原因只有一个——天晓得这次ajax要浪多久才肯回来,这种情况下只有setTimeout才能胜任。

居然setTimeout不比setInterval优秀,除了使用场景比setInterval广,从性能上来看,两者不分伯仲。那么为什么呢?在下一小节会从事件环,内存泄漏以及垃圾回收这几个方面诊断一下原因。

事件环(eventloop)

为了弄清楚为什么两者都无法精准地执行回调函数,我们要从事件环的特性开始入手。

JS是单线程的

在进入正题之前,我们先讨论下JS的特性。他和其他的编程语言区别在哪里?虽然笔者没有深入接触过其他语言,但是有一点可以肯定,JS是服务于浏览器的,浏览器可以直接读懂js。

对于JS还有一个高频词就是,单线程。那么什么是单线程呢?从字面上理解就是一次只能做一件事。比如,学习的时候无法做其他事情,只能专心看书,这就是单线程。再比如,有些妈妈很厉害,可以一边织毛衣一边看电视,这就是多线程,可以同一时间做两件事。

JS是非阻塞的

JS不仅是单线程,还是非阻塞的语言,也就是说JS并不会等待某一个异步加载完成,比如接口读取,网络资源加载如图片视频。直接掠过异步,执行下方代码。那么异步的函数岂不是永远无法执行了吗?

eventloop

因此,JS该如何处理异步的回调方法?于是eventloop出现了,通过一个无限的循环,寻找符合条件的函数,执行之。但是JS很忙的,如果一直不断的有task任务,那么JS永远无法进入下一个循环。JS说我好累,我不干活了,罢工了。

stack和queue

于是出现了stack和queue,stack是JS工作的堆,一直不断地完成工作,然后将task推出stack中。然后queue(队列)就是下一轮需要执行的task们,所有未执行而将执行的task都将推入这个队列之中。等待当前stack清空执行完毕,然后eventloop循环至queue,再将queue中的task一个个推到stack中。

正因为eventloop循环的时间按照stack的情况而定。就像公交车一样,一站一站之间的时间虽然可以预估,但是难免有意外发生,比如堵车,比如乘客太多导致上车时间过长,比如不小心每个路口都吃到了红灯等等意外情况,都会导致公交陈晚点。eventloop的stack就是一个不定因素,也许stack内的task都完成后远远超过了queue中的task推入的时间,导致每次的执行时间都有偏差。

诊断setTimeout和setInterval

那些年setInterval背的锅——容易造成内存泄漏(memory leak)

说到内存泄漏就不得不提及垃圾回收(garbage collection),这两个概念绑在一起解释比较好,可是说是一对好基友。什么是内存泄露?听上去特别牛逼的概念,其实就是我们创建的变量或者定义的对象,没有用了之后没有被系统回收,导致系统没有新的内存分配给之后需要创建的变量。简单的说就是借了没还,债台高筑。所以垃圾回收的算法就是来帮助回收这些内存的,不过有些内容应用不需要,然而开发者并没有释放他们,也就是我不需要了但是死活不放手,垃圾回收也没办法只能略过他们去收集已经被抛弃的垃圾。那么我们要怎样才能告诉垃圾回收算法,这些东西我不要了,你拿走吧?怎么样的辣鸡才能被回收给新辣鸡腾出空间呢?说到底这就是一个编程习惯的问题。

导致memory leak的最终原因只有一个,就是没有即使释放不需要的内存——也就是没有释放定义的参数,导致垃圾回收无法回收内存,导致内存泄露。

那么内存是怎么分配的呢?

比如我们定义了一个常量var a="apple",那么内存中就会分配出空间村粗apple这个字符串。大家也许会觉得不就是字符串嘛,能占多少内存。没错,字符串占不了多少内存,但是如果是一个成千上万的数组呢?那内存占的可就很多了,如果不及时释放,后续工作会很艰难。

但是内存的概念太过于抽象,该怎么才能feel到这个占了多少内存或者说内存被释放了呢?打开chrome的Memory神器,带你体验如何感觉内存。

这里我们创建一个demo用来测试内存是如何工作的:

let array=[]//创建数组
createArray()//push内容,增加内存

function createArray(){
    for(let j=0;j<100000;j++){
        array.push(j*3*5)
    }
}
function clearArray(){
    array=[]
}

let grow=document.getElementById("grow")
grow.addEventListener("click",clearArray)//点击清除数组内容,也就是清除了内存

实践是唯一获取真理的方式。通过chrome的测试工具,我们可以发现清除分配给变量的内容,可以释放内存,这也是为什么有许多代码结束之后会xxx=null,也就是为了释放内存的原因。

既然我们知道了内存是如何释放的,那么什么情况,即使我们清空了变量也无法释放的内存的情况呢?

做了一组实验,array分别为函数内定义的变量,以及全局变量

let array=[]
createArray()
function createArray(){
    for(let j=0;j<100000;j++){
        array.push(j*3*5)
    }
}
createArray()
function createArray(){
    let array=[]
    for(let j=0;j<100000;j++){
        array.push(j*3*5)
    }
}

结果惊喜不惊喜,函数运行完之后,内部的内存会自动释放,无需重置,然而全局变量却一直存在。也就是说变量的提升(hoist)而且不及时清除引用的情况下会导致内存无法释放。

还有一种情况与dom有关——创建以及删除dom。有一组很经典的情况就是游离状的dom无法被回收。以下的代码,root已经被删除了,那么root中的子元素是否可以被回收?

let root=document.getElementById("root")
for(let i=0;i<2000;i++){
    let div=document.createElement("div")
    root.appendChild(div)
}
document.body.removeChild(root)

答案是no,因为root的引用还存在着,虽然在dom中被删除了,但是引用还在,这个时候root的子元素就会以游离状态的dom存在,而且无法被回收。解决方案就是root=null,清空引用,消除有力状态的dom。

如果setInterval中存在无法回收的内容,那么这一部分内存就永远无法释放,这样就导致内存泄漏。所以还是编程习惯的问题,内存泄漏?setInterval不背这个锅。

垃圾回收(garbage collection)机制

讨论完那些原因会造成内存泄漏,垃圾回收机制。主要分为两种:reference-counting和mark sweap。

reference-counting 引用计数

这个比较容易理解,就是当前对象是否被引用,如果被引用标记。最后没有被标记的则清除。这样有个问题就是程序中两个不需要的参数互相引用,这样两个都会被标记,然后都无法被删除,也就是锁死了。为了解决这个问题,所以出现了标记清除法(mark sweap)。

mark sweap

标记清除法(mark sweap),这个方法是从这个程序的global开始,被global引用到的参数则标记。最后清除所有没有被标记的对象,这样可以解决两对象互相引用,无法释放的问题。

因为是从global开始标记的,所以函数作用域内的变量,函数完成之后就会释放内存。

通过垃圾回收机制,我们也可以发现,global中定义的内容要谨慎,因为global相当于是主函数,浏览器不会随便清除这一部分的内容。所以要注意,变量提升问题。

总结

并没有找到石锤表明setInterval是造成内存泄漏的原因。内存泄漏的原因分明是编码习惯不好,setInterval不背这个锅。