工作中:Timers in JavaScript
這篇文章屬於工作中系列,主要用來記錄我在工作上實際面對到的問題
問題
最近在寫Node時要做一個每秒定期會做20至30次的工作,但是用了內建的timer之後發現並不是很精準,比如說,當有某個片段的程式碼如下,我期望的會是每秒會印30上下次log,但實際執行之後發現在十秒過後印出的Count數跟預期的有些落差
let count = 0;
let someJob = setInterval(() => {
console.log(++count);
}, 33)
setTimeout(() => {
clearInterval(someJob);
}, 10000)
原因
單執行緒以及Call Stack
在JavaScript當中使用Timer(setTimeout, setInterval
),尤其是在時間間距較小的情況下,時常會發現他並不完全準確,其原因來自於JavaScript單執行緒的特性。
所謂的單執行緒指的是JavaScript一次只能執行一個Execution Context(一個紀錄目前Function執行環境的物件),而為了記錄整個程式執行的狀況,在JavaScript Interpreter當中會有一個Stack(常被稱作Execution Context Stack或Call Stack)的資料結構專門用來儲存這些Execution Context。
新的Execution Context會隨著Function呼叫被推至Stack的頂端,而那個在頂端的Execution Context就是所謂的Active Execution Context,也就是當前正在執行,也是唯一被執行的Context,Non-blocking的程式碼如timer在被推至Stack上之後即會交由另外的機制去處理、計算Timer上的時間,同時被pop掉,不會阻擋其他Execution Context的執行。
Message Queue和Event Loop
非同步程式碼在Call Stack上被pop之後仍然會有機制去持續觀察他的觸發條件,這裡的timer在預設的時間到達之後其處理函式(callback)會被放到一個叫做Message Queue (Message Queue、Task Queue名字很多…)的資料結構(First in, First Out)當中。
JavaScript會有一個叫做Event Loop的機制持續去觀察Call Stack與Queue,當他觀察到Call Stack已經沒有任何正在執行的Execution Context,就會從Message Queue當中取出處理函式(在這邊就是timer的callback function),把它放到Call Stack上去處理。
這就是會什麼timer在JavaScript當中未必會準確的原因,當我們定義的時間到了之後,其處理函式只是被放進Message Queue當中而非直接執行,如果這時Call Stack仍然還有執行的Execution Context或Message Queue當中在timer的處理函式之前還有其他還沒被執行的Callback function時,這個timer的處理函式能做的事就只有等,等到Call Stack空了、Message Queue中他排到第一,他才能被Event Loop放到Call Stack去執行。
這也是為什麼setTimeout(() => console.log('我未必會立即執行'), 0)
未必會立即執行的原因,而也有人運用這樣的特性讓JavaScript也有類似其他程式語言中wait()的功能。
setTimeout與setInterval
根據上面的理解就可以了解以下程式碼的差異
setTimeout(function repeat() {
setTimeout(repeat, 10)
}, 10)
setInterval(function() {/* Some Code */}, 10)
第一個setTimeout的意思是在10ms之後幫我把我的Callback Function放到Message Queue上等待執行,所以實際執行Callback的時間一定是大於等於10ms,而在Callback當中又註冊另外一個Timeout,這個Timeout的Callback執行時間又會是10ms後+在Message Queue當中的等待時間,意思就是前後Timer會互相牽連,時間有可能一直不斷延遲下去。
第二個setInterval的意思則是不管前面的Timer有沒有執行、何時執行,他所做的就是每10ms將Callback Function放到Message Queue當中,前後Timer互不影響。
解法
所以在真的需要精準的Timer時,除了用一些別人寫好的現成模組外,我在Reddit上找到了以下這段比較好理解的解決方式,他基本上就是利用「截長補短」的特性,假如間隔的設定是30ms,但是第一個間隔實際卻花了32ms,他就動態調整下一次的間隔為28ms,以此類推不斷下去。
function customSetInterval(func, time){
var lastTime = Date.now(); // 設定interval時的時間
var lastDelay = time; // 一開始設定的interval
var outp = {};
function tick(){
func();
var now = Date.now(); // 實際執行callback時的時間
var dTime = now - lastTime; // 計算從上一次執行callback到現在實際花了多少時間
lastTime = now;
// 下一次設定的間隔是:一開始設定的間隔 + (上一次本來該花多少時間 - 實際花了多少時間)
// = 一開始設定的間隔 + 應該調整的誤差
lastDelay = time + lastDelay - dTime;
outp.id = setTimeout(tick, lastDelay);
}
outp.id = setTimeout(tick, time);
return outp;
}
var x = customSetInterval(...);
clearTimeout(x.id)
參考
- What is the Execution Context & Stack in JavaScript?
- (影片) Philip Roberts: What the heck is the event loop anyway?
- (書) JavaScript Ninja Ch.8: Taming threads and timers