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

小美娜娜發表於2019-02-01

前言

重複定時器,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中:

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

瀏覽器中:

在渲染或者計算沒有什麼壓力的情況下,定時器的效率

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

在再渲染或者計算壓力很大的情況下,定時器的效率

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

首先是毫無壓力的情況下大家的效能,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)//點選清除陣列內容,也就是清除了記憶體
複製程式碼

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

實踐是唯一獲取真理的方式。通過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)
    }
}
複製程式碼

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

結果驚喜不驚喜,函式執行完之後,內部的記憶體會自動釋放,無需重置,然而全域性變數卻一直存在。也就是說變數的提升(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。

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

如果setInterval中存在無法回收的內容,那麼這一部分記憶體就永遠無法釋放,這樣就導致記憶體洩漏。所以還是程式設計習慣的問題,記憶體洩漏?setInterval不背這個鍋。

垃圾回收(garbage collection)機制

討論完那些原因會造成記憶體洩漏,垃圾回收機制。主要分為兩種:reference-counting和mark sweap。

reference-counting 引用計數

這個比較容易理解,就是當前物件是否被引用,如果被引用標記。最後沒有被標記的則清除。這樣有個問題就是程式中兩個不需要的引數互相引用,這樣兩個都會被標記,然後都無法被刪除,也就是鎖死了。為了解決這個問題,所以出現了標記清除法(mark sweap)。

mark sweap

標記清除法(mark sweap),這個方法是從這個程式的global開始,被global引用到的引數則標記。最後清除所有沒有被標記的物件,這樣可以解決兩物件互相引用,無法釋放的問題。

因為是從global開始標記的,所以函式作用域內的變數,函式完成之後就會釋放記憶體。

通過垃圾回收機制,我們也可以發現,global中定義的內容要謹慎,因為global相當於是主函式,瀏覽器不會隨便清除這一部分的內容。所以要注意,變數提升問題。

總結

並沒有找到石錘表明setInterval是造成記憶體洩漏的原因。記憶體洩漏的原因分明是編碼習慣不好,setInterval不背這個鍋。

相關文章