天天看點

JavaScript 運作機制詳解:再談Event Loop

JavaScript 運作機制詳解:再談Event Loop

javascript語言的一大特點就是單線程,也就是說,同一個時間隻能做一件事。那麼,為什麼javascript不能有多個線程呢?這樣能提高效率啊。

javascript的單線程,與它的用途有關。作為浏覽器腳本語言,javascript的主要用途是與使用者互動,以及操作dom。這決定了它隻能是單線程,否則會帶來很複雜的同步問題。比如,假定javascript同時有兩個線程,一個線程在某個dom節點上添加内容,另一個線程删除了這個節點,這時浏覽器應該以哪個線程為準?

是以,為了避免複雜性,從一誕生,javascript就是單線程,這已經成了這門語言的核心特征,将來也不會改變。

為了利用多核cpu的計算能力,html5提出web worker标準,允許javascript腳本建立多個線程,但是子線程完全受主線程控制,且不得操作dom。是以,這個新标準并沒有改變javascript單線程的本質。

單線程就意味着,所有任務需要排隊,前一個任務結束,才會執行後一個任務。如果前一個任務耗時很長,後一個任務就不得不一直等着。

如果排隊是因為計算量大,cpu忙不過來,倒也算了,但是很多時候cpu是閑着的,因為io裝置(輸入輸出裝置)很慢(比如ajax操作從網絡讀取資料),不得不等着結果出來,再往下執行。

javascript語言的設計者意識到,這時主線程完全可以不管io裝置,挂起處于等待中的任務,先運作排在後面的任務。等到io裝置傳回了結果,再回過頭,把挂起的任務繼續執行下去。

于是,所有任務可以分成兩種,一種是同步任務(synchronous),另一種是異步任務(asynchronous)。同步任務指的是,在主線程上排隊執行的任務,隻有前一個任務執行完畢,才能執行後一個任務;異步任務指的是,不進入主線程、而進入"任務隊列"(task queue)的任務,隻有"任務隊列"通知主線程,某個異步任務可以執行了,該任務才會進入主線程執行。

具體來說,異步執行的運作機制如下。(同步執行也是如此,因為它可以被視為沒有異步任務的異步執行。)

(2)主線程之外,還存在一個"任務隊列"(task queue)。隻要異步任務有了運作結果,就在"任務隊列"之中放置一個事件。 (3)一旦"執行棧"中的所有同步任務執行完畢,系統就會讀取"任務隊列",看看裡面有哪些事件。那些對應的異步任務,于是結束等待狀态,進入執行棧,開始執行。 (4)主線程不斷重複上面的第三步。

下圖就是主線程和任務隊列的示意圖。

JavaScript 運作機制詳解:再談Event Loop

隻要主線程空了,就會去讀取"任務隊列",這就是javascript的運作機制。這個過程會不斷重複。

"任務隊列"是一個事件的隊列(也可以了解成消息的隊列),io裝置完成一項任務,就在"任務隊列"中添加一個事件,表示相關的異步任務可以進入"執行棧"了。主線程讀取"任務隊列",就是讀取裡面有哪些事件。

"任務隊列"中的事件,除了io裝置的事件以外,還包括一些使用者産生的事件(比如滑鼠點選、頁面滾動等等)。隻要指定過回調函數,這些事件發生時就會進入"任務隊列",等待主線程讀取。

所謂"回調函數"(callback),就是那些會被主線程挂起來的代碼。異步任務必須指定回調函數,當主線程開始執行異步任務,就是執行對應的回調函數。

"任務隊列"是一個先進先出的資料結構,排在前面的事件,優先被主線程讀取。主線程的讀取過程基本上是自動的,隻要執行棧一清空,"任務隊列"上第一位的事件就自動進入主線程。但是,由于存在後文提到的"定時器"功能,主線程首先要檢查一下執行時間,某些事件隻有到了規定的時間,才能傳回主線程。

主線程從"任務隊列"中讀取事件,這個過程是循環不斷的,是以整個的這種運作機制又稱為event loop(事件循環)。

JavaScript 運作機制詳解:再談Event Loop

上圖中,主線程運作的時候,産生堆(heap)和棧(stack),棧中的代碼調用各種外部api,它們在"任務隊列"中加入各種事件(click,load,done)。隻要棧中的代碼執行完畢,主線程就會去讀取"任務隊列",依次執行那些事件所對應的回調函數。

執行棧中的代碼(同步任務),總是在讀取"任務隊列"(異步任務)之前執行。請看下面這個例子。

上面代碼中的req.send方法是ajax操作向伺服器發送資料,它是一個異步任務,意味着隻有目前腳本的所有代碼執行完,系統才會去讀取"任務隊列"。是以,它與下面的寫法等價。

也就是說,指定回調函數的部分(onload和onerror),在send()方法的前面或後面無關緊要,因為它們屬于執行棧的一部分,系統總是執行完它們,才會去讀取"任務隊列"。

除了放置異步任務的事件,"任務隊列"還可以放置定時事件,即指定某些代碼在多少時間之後執行。這叫做"定時器"(timer)功能,也就是定時執行的代碼。

定時器功能主要由settimeout()和setinterval()這兩個函數來完成,它們的内部運作機制完全一樣,差別在于前者指定的代碼是一次性執行,後者則為反複執行。以下主要讨論settimeout()。

settimeout()接受兩個參數,第一個是回調函數,第二個是推遲執行的毫秒數。

上面代碼的執行結果是1,3,2,因為settimeout()将第二行推遲到1000毫秒之後執行。

如果将settimeout()的第二個參數設為0,就表示目前代碼執行完(執行棧清空)以後,立即執行(0毫秒間隔)指定的回調函數。

上面代碼的執行結果總是2,1,因為隻有在執行完第二行以後,系統才會去執行"任務隊列"中的回調函數。

總之,settimeout(fn,0)的含義是,指定某個任務在主線程最早可得的空閑時間執行,也就是說,盡可能早得執行。它在"任務隊列"的尾部添加一個事件,是以要等到同步任務和"任務隊列"現有的事件都處理完,才會得到執行。

html5标準規定了settimeout()的第二個參數的最小值(最短間隔),不得低于4毫秒,如果低于這個值,就會自動增加。在此之前,老版本的浏覽器都将最短間隔設為10毫秒。另外,對于那些dom的變動(尤其是涉及頁面重新渲染的部分),通常不會立即執行,而是每16毫秒執行一次。這時使用requestanimationframe()的效果要好于settimeout()。

需要注意的是,settimeout()隻是将事件插入了"任務隊列",必須等到目前代碼(執行棧)執行完,主線程才會去執行它指定的回調函數。要是目前代碼耗時很長,有可能要等很久,是以并沒有辦法保證,回調函數一定會在settimeout()指定的時間執行。

node.js也是單線程的event loop,但是它的運作機制不同于浏覽器環境。

JavaScript 運作機制詳解:再談Event Loop

根據上圖,node.js的運作機制如下。

(1)v8引擎解析javascript腳本。 (2)解析後的代碼,調用node api。 (4)v8引擎再将結果傳回給使用者。

上面代碼中,由于process.nexttick方法指定的回調函數,總是在目前"執行棧"的尾部觸發,是以不僅函數a比settimeout指定的回調函數timeout先執行,而且函數b也比timeout先執行。這說明,如果有多個process.nexttick語句(不管它們是否嵌套),将全部在目前"執行棧"執行。

現在,再看setimmediate。

上面代碼中,setimmediate與settimeout(fn,0)各自添加了一個回調函數a和timeout,都是在下一次event loop觸發。那麼,哪個回調函數先執行呢?答案是不确定。運作結果可能是1--timeout fired--2,也可能是timeout fired--1--2。

令人困惑的是,node.js文檔中稱,setimmediate指定的回調函數,總是排在settimeout前面。實際上,這種情況隻發生在遞歸調用的時候。

上面代碼中,setimmediate和settimeout被封裝在一個setimmediate裡面,它的運作結果總是1--timeout fired--2,這時函數a一定在timeout前面觸發。至于2排在timeout fired的後面(即函數b在timeout後面觸發),是因為setimmediate總是将事件注冊到下一輪event loop,是以函數a和timeout是在同一輪loop執行,而函數b在下一輪loop執行。

我們由此得到了process.nexttick和setimmediate的一個重要差別:多個process.nexttick語句總是在目前"執行棧"一次執行完,多個setimmediate可能則需要多次loop才能執行完。事實上,這正是node.js 10.0版添加setimmediate方法的原因,否則像下面這樣的遞歸調用process.nexttick,将會沒完沒了,主線程根本不會去讀取"事件隊列"!

事實上,現在要是你寫出遞歸的process.nexttick,node.js會抛出一個警告,要求你改成setimmediate。

另外,由于process.nexttick指定的回調函數是在本次"事件循環"觸發,而setimmediate指定的是在下次"事件循環"觸發,是以很顯然,前者總是比後者發生得早,而且執行效率也高(因為不用檢查"任務隊列")。

(完)

繼續閱讀