身為一個從其他程式語言(C#/Java)轉到Javascript的開發人員,在學習Javascript過程中,setTimeout()方法的運作原理是我遇到的一個不太好理解的部分,本文嘗試結合其他程式語言的實現,從setTimeout說事件循環模型
setTimeout()方法不是ecmascript規範定義的內容,而是屬於BOM提供的功能。查看w3school對setTimeout()方法的定義,setTimeout() 方法用於在指定的毫秒數後呼叫函數或計算表達式。
語法setTimeout(fn,millisec),其中fn表示要執行的程式碼,可以是一個包含javascript程式碼的字串,也可以是一個函數。第二個參數millisec是以毫秒錶示的時間,表示fn需延後多久執行。
呼叫setTimeout()方法之後,該方法傳回一個數字,這個數字是計劃執行程式碼的唯一標識符,可以透過它來取消逾時呼叫。
起初我對 setTimeout()的使用比較簡單,對其運行機理也沒有深入的理解,直到看到下面程式碼
var start = new Date; setTimeout(function(){ var end = new Date; console.log('Time elapsed:', end - start, 'ms'); }, 500); while (new Date - start < 1000) {};
在我最初對setTimeout()的認識中,延遲設定為500ms,所以輸出應該為Time elapsed: 500 ms。因為在直覺的理解中,Javascript執行引擎,在執行上述程式碼過程中,應當是一個由上往下的順序執行過程,setTimeout函數是先於while語句執行的。可是實際上,上述程式碼運行多次後,輸出至少延遲了1000ms。
聯想到以往學習Java的經驗,上述Javascript的setTimeout()讓我困惑。 Java對setTimeout的實作有多種API實現,這裡我們以java.util.Timer套件為例。使用Timer在Java中實現上述邏輯,運行多次,輸出都是Time elapsed: 501 ms。
import java.util.Date; import java.util.Timer; import java.util.TimerTask; public class TimerTest { public static void main(String[] args) { // TODO Auto-generated method stub long start = System.currentTimeMillis(); Timer timer = new Timer(); timer.schedule(new MyTask(start), 500); while (System.currentTimeMillis() - start < 1000) {}; } } class MyTask extends TimerTask { private long t; public MyTask(long start) { // TODO Auto-generated constructor stub t=start; } @Override public void run() { // TODO Auto-generated method stub long end = System.currentTimeMillis(); System.out.println("Time elapsed:"+(end - this.t)+ "ms"); } }
這裡深究setTimeout()為什麼出現這一差異之前,先說說java.util.Timer的實作原理。
上述程式碼幾個關鍵要素為Timer、TimerTask類別以及Timer類別的schedule方法,透過閱讀相關原始碼,可以了解其實作。
Timer:一個Task任務的調度類,和TimerTask任務一樣,是供使用者使用的API類,透過schedule方法安排Task的執行計畫。該類別透過TaskQueue任務隊列和TimerThread類別完成Task的調度。
TimerTask:實作Runnable接口,表示每個任務均為一個獨立的線程,透過run()方法提供使用者自訂自己任務。
TimerThread:繼承於Thread,是真正執行Task的類別。
TaskQueue:儲存Task任務的資料結構,內部由一個最小堆實現,堆的每個成員為TimeTask,每個任務依靠TimerTask的nextExecutionTime屬性值進行排序,nextExecutionTime最小的任務在隊列的最前端,從而能夠現實最早執行。
# 看過了Java.util.Timer對類似setTimeout()的實作方案,繼續回到前文Javascript的setTimeout()方法中,再來看看之前的輸出為何與預期不符。
var start = new Date; setTimeout(function(){ var end = new Date; console.log('Time elapsed:', end - start, 'ms'); }, 500); while (new Date - start < 1000) {};
透過閱讀程式碼不難看出,setTimeout()方法執行在while()循環之前,它聲明了「希望」在500ms之後執行一次匿名函數,這一聲明,也即對匿名函數的註冊,在setTimeout()方法執行後立即生效。程式碼最後一行的while迴圈會持續運行1000ms,透過setTimeout()方法註冊的匿名函數輸出的延遲時間總是大於1000ms,表示對此匿名函數的實際呼叫被while()循環阻塞了,實際的呼叫在while()循環阻塞結束後才真正執行。
而在Java.util.Timer中,對於定時任務的解決方案是透過多線程手段實現的,任務物件儲存在任務佇列,由專門的調度線程,在新的子執行緒中完成任務的執行。透過schedule()方法註冊一個非同步任務時,調度執行緒在子執行緒立即開始工作,主執行緒不會阻塞任務的運行。
這就是Javascript與Java/C#之類語言的一大差異,也就是Javascript的單執行緒機制。在現有瀏覽器環境中,Javascript執行引擎是單執行緒的,主執行緒的語句和方法,會阻塞定時任務的運行,執行引擎只有在執行完主執行緒的語句後,定時任務才會實際執行,這期間的時間,可能大於註冊任務時設定的延遲時間。在這一點上,Javascript與Java/C#的機制很不同。
# 在單線程的Javascript引擎中,setTimeout()是如何運作的呢,這裡就要提到瀏覽器核心中的事件循環模型了。簡單的講,在Javascript執行引擎之外,有一個任務隊列,當在程式碼中呼叫setTimeout()方法時,註冊的延時方法會交由瀏覽器內核其他模組(以webkit為例,是webcore模組)處理,當延時方法到達觸發條件,即到達設定的延時時間時,此延時方法被加入到任務佇列。這個過程由瀏覽器內核其他模組處理,與執行引擎主執行緒獨立,執行引擎在主執行緒方法執行完畢,到達空閒狀態時,會從任務佇列中順序獲取任務來執行,這一過程是一個不斷循環的過程,稱為事件循環模型。
參考一個演講中的資料,上述事件循環模型可以用下圖描述。
# Javascript執行引擎的主執行緒運行的時候,產生堆疊(heap)和堆疊(stack)。程式中程式碼依序進入堆疊中等待執行,當呼叫setTimeout()方法時,即圖中右側WebAPIs方法時,瀏覽器核心對應模組開始延時方法的處理,當延時方法到達觸發條件時,方法被加入到用於回呼的任務佇列,只要執行引擎堆疊中的程式碼執行完畢,主執行緒就會去讀取任務佇列,依序執行那些滿足觸發條件的回呼函數。
以演講中的範例進一步說明
#
# 以圖中程式碼為例,執行引擎開始執行上述程式碼時,相當於先講一個main()方法加入執行棧。繼續往下開始console.log('Hi')時,log('Hi')方法入棧,console.log方法是一個webkit核心支援的普通方法,而不是前面圖中WebAPIs涉及的方法,所以這裡log ('Hi')方法立即出棧由引擎執行。
#
# console.log('Hi')語句執行完成後,log()方法出棧執行,輸出了Hi。引擎繼續往下,將setTimeout(callback,5000)加入執行堆疊。 setTimeout()方法屬於事件循環模型中WebAPIs中的方法,引擎在將setTimeout()方法出棧執行時,將延時執行的函數交給了對應模組,即圖右方的timer模組來處理。
# 執行引擎將setTimeout出棧執行時,將延時處理方法交由了webkit timer模組處理,然後立即繼續往下處理後面程式碼,於是將log('SJS')加入執行棧,接下來log('SJS' )出棧執行,輸出SJS。而執行引擎在執行萬console.log('SJS')後,程式處理完畢,main()方法也出棧。
#
#
# 這時在setTimeout方法執行5秒後,timer模組偵測到延時處理方法到達觸發條件,於是將延時處理方法加入任務佇列。而此時執行引擎的執行棧為空,所以引擎開始輪詢檢查任務佇列是否有任務需要執行,就檢查到已經到達執行條件的延時方法,於是將延時方法加入執行棧。引擎發現延時方法呼叫了log()方法,於是又將log()方法入棧。然後對執行堆疊依序出棧執行,輸出there,清空執行堆疊。
清空執行堆疊後,執行引擎會繼續去輪詢任務佇列,檢查是否還有任務可執行。
到这里已经可以彻底理解下面代码的执行流程,执行引擎先将setTimeout()方法入栈被执行,执行时将延时方法交给内核相应模块处理。引擎继续处理后面代码,while语句将引擎阻塞了1秒,而在这过程中,内核timer模块在0.5秒时已将延时方法添加到任务队列,在引擎执行栈清空后,引擎将延时方法入栈并处理,最终输出的时间超过预期设置的时间。
var start = new Date; setTimeout(function(){ var end = new Date; console.log('Time elapsed:', end - start, 'ms'); }, 500); while (new Date - start < 1000) {};
前面事件循环模型图中提到的WebAPIs部分,提到了DOM事件,AJAX调用和setTimeout方法,图中简单的把它们总结为WebAPIs,而且他们同样都把回调函数添加到任务队列等待引擎执行。这是一个简化的描述,实际上浏览器内核对DOM事件、AJAX调用和setTimeout方法都有相应的模块来处理,webkit内核在Javasctipt执行引擎之外,有一个重要的模块是webcore模块,html的解析,css样式的计算等都由webcore实现。对于图中WebAPIs提到的三种API,webcore分别提供了DOM Binding、network、timer模块来处理底层实现,这里还是继续以setTimeout为例,看下timer模块的实现。
Timer类是webkit 内核的一个必需的基础组件,通过阅读源码可以全面理解其原理,本文对其简化,分析其执行流程。
通过setTimeout()方法注册的延时方法,被传递给webcore组件timer模块处理。timer中关键类为TheadTimers类,其包含两个重要成员,TimerHeap任务队列和SharedTimer方法调度类。延时方法被封装为timer对象,存储在TimerHeap中。和Java.util.Timer任务队列一样,TimerHeap同样采用最小堆的数据结构,以nextFireTime作为关键字排序。SharedTimer作为TimerHeap调度类,在timer对象到达触发条件时,通过浏览器平台相关的接口,将延时方法添加到事件循环模型中提到的任务队列中。
TimerHeap采用最小堆的数据结构,预期延时时间最小的任务最先被执行,同时,预期延时时间相同的两个任务,其执行顺序是按照注册的先后顺序执行。
var start = new Date; setTimeout(function(){ console.log('fn1'); }, 20); setTimeout(function(){ console.log('fn2'); }, 30); setTimeout(function(){ console.log('another fn2'); }, 30); setTimeout(function(){ console.log('fn3'); }, 10); console.log('start while'); while (new Date - start < 1000) {}; console.log('end while');
上述代码输出依次为
start while end while fn3 fn1 fn2 another fn2
1.《Javascript异步编程》
2.JavaScript 运行机制详解:再谈Event Loophttp://www.ruanyifeng.com/blog/2014/10/event-loop.html
3.Philip Roberts: Help, I'm stuck in an event-loop.https://vimeo.com/96425312
4.How JavaScript Timers Work.http://ejohn.org/blog/how-javascript-timers-work/
5.How WebKit’s event model works.http://brrian.tumblr.com/post/13951629341/how-webkits-event-model-works
6.Timer实现.http://blog.csdn.net/shunzi__1984/article/details/6193023
以上是從setTimeout來談談說事件循環模型的詳細內容。更多資訊請關注PHP中文網其他相關文章!