【JavaScript筆記】所以事件循環Event Loop到底是什麼?setTimeout 0 的藝術 ─ 我OK、你先請?

JavaScript-event-loop

學JavaScript少說也有2個月了,應該算是多少都略懂一些,大概是一個說深不深、說淺很淺的程度,而為了激起這表面的平靜,小精靈丟出一道題目:「你說你理解JavaScript,那你知道什麼是Event Loop嗎?」隨後小精靈更神祕地說,知道這題為何如此,你便通曉了Event Loop!


「由上而下執行,為什麼不是先打印出delay 0 sec呢?」當你如此疑惑時,小精靈留下一個很善良的線索影片,讓我們一起來攻略他。


§ 首先,我們先了解一下JavaScript的歷史背景 §

在1991年,網景(Netscape)公司,為了讓旗下的瀏覽器能夠提供更複雜的網頁互動,而請當時任職於該公司的Brendan Eich設計一個「可以提供更複雜網頁互動」的語言原型─也就是JavaScript,而當時Brendan Eich甚至只花了10天的時間就交付出了JS的第一版本。

為了讓開發者可以專注在程式開發上,JavaScript 被設計為「單線程/單執行序(single threaded runtime)」,也就是「一次只執行一段程式碼」,也因此不必煩惱「並發性議題(concurrency issues)」。

同時JavaScript 是一種原型導向的語言,具有動態與弱型別等特性,起初由於實作方式和語法都不統一,造成許多混亂的狀況,於是在 1998 年被提交到 ECMA 組織制訂成 ECMA Script 的標準,以便日後統一 JavaScript 的語法。

§ 一次只能執行一件事,JavaScript是如何執行呢? §

程式碼的執行順序,是由最上方的程式碼開始,往下逐行執行。

JavaScript是以「後進先出」( LIFO, Last In First Out)執行堆疊(call stack)。而執行堆疊(call stack)也可以理解為是「執行當下的紀錄」

當開始執行程式,會從全域(Global Scope)的主程式(main program)開始執行,當進入某一個函式,便會把這個函式推(push)至執行堆疊(stack)的最上方,以此類推往上疊加並由最上層(也就是最後進入的)開始執行,而當該函式執行結束(return),便會將此函式從原本的最上層抽離(pop off),以此類推。

想像起來很像疊漢堡的概念,或可以理解進入某一個函式像進入一座迷宮,解題過程發現需要到下一關卡尋找破解的線索,於是順著指示深入下一個房間,一直進入到需要線索的最後一層,並從最內層取得線索後,再逐步退出來,直到走玩遊戲。以下面的程式碼舉例,請從最後一行往回看:

影片解說第4:41秒,觀察執行的堆疊呈現(call stack)

如果他是一個無窮迴圈將會執行直到瀏覽器發生錯誤,例如:


影片解說第6:42秒處

§ 一次只能執行一件事,如果等待時間很長會怎麼樣?blocking !!! §

讓我們試想,如果瀏覽器「一次只執行一件事」,那麼當執行程式碼的片段需要等待回應時,全世界就必須「跟著等」,也就不能做其他行為(例如點擊等任何動作),而這樣類似畫面被「卡住」的現象,就稱做阻塞(blocking)

也就是卡在stack階段動彈不得(因為還在執行並等待回應中),這也是為什麼Ajax是以非同步執行(Asynchronous)處理,因為若Ajax Request變為同步(Synchronous)處理的話,等於每 Request 一次,就要等待函式執行完後,才能繼續往下走,也就是說等待回應的過程不能進行其他動作,講者以pseudo code 進行說明:


影片解說第8:12秒處

§ 那非同步處理拆解開來又會是怎麼樣呢? §

大概能理解同步可能會遇到的困擾後,那非同步又是如何避免這件事的發生呢?接下來影片中以 setTimeout 來模擬非同步(Asynchronous)請求的進行,以下面的程式碼為例:


由上而下,執行此程式時執行堆疊(call stack)會先打印出hi,並執行setTimeout(想像按下5秒鐘中的倒數計時),並接下去打印出JSConfEU,這時5秒鐘計時完畢,最後執行打印出there

影片解說第11:17秒處

等等!一次只執行一件事,當setTimeout被觸發時,究竟有沒有被推進執行堆疊裡面呢?

如果說被推進執行堆疊,那還沒執行完怎麼會被繞過去呢?
如果說沒有被推進執行堆疊,那是被丟去哪裡數秒呢?
要解釋這些就要談到事件循環Event Loop了!

§ 事件循環 Event Loop §
Code execution in browser (from: SessionStack)

先讓我們想想JavaScript與瀏覽器(browser)的互動關係。以表單行為為例子,當用戶在網路上填完表單後點擊提交按鈕,而觸發DOM點擊事件後透過JavaScript來執行後續相應行為。發現了嗎?這一連串行為下,JavaScript都是屬於被「被動」調用的,因為觸發才會「被」調用。

如果把瀏覽器的行為想像成一個巨大的機器,就可以發現JavaScript的觸發與事件行為,其實都是透過瀏覽器來「中轉」達成,而既然如此,那麼事件彼此的溝通就勢必不會只通過JavaScript的執行堆疊(call stack)(李組長眉頭一皺...肯定還有其他人!)

而這就可以談到,為什麼瀏覽器同時可以處理多個事情,因為瀏覽器不是只有一個JavaScript Runtime那麼Event Loop到底扮演什麼腳色?

順著以上的思路,大概能理解為什麼EcmaScript的標準定義中找不到事件循環,反而是在 HTML Living standard of Event loops 中被定義(因為Event Loop不是專屬JavaScript的機制):
To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. Each agent has an associated event loop, which is unique to that agent.

(而其實透過Eich在JavaScript 20 周年的演講回顧中也可以發現,在JavaScript設計之初,其實就沒有考慮到所謂的事件循環。)

所以事件循環Event Loop到底是什麼?

簡而言之,Event Loop本質上就是一個 user agent(此為瀏覽器)上協調各種事件的機制,而既然有「協調」就一定有執行順序的判定,也就一定伴隨著「監控行為」的概念。以圖示來理解Event Loop的運行過程:

Code execution in browser (from: SessionStack)

Call stack ─ 執行堆疊

如先前的說明,JavaScript會以後進先出(LIFO)方式執行堆疊。當開始執行程式,會從全域的主程式開始,逐一把各個函式推進執行堆疊的最上方,並由最上層(也就是最後進入的)開始執行,而當該函式結束後(return),會將此函式抽離堆疊(pop off)。

Web APIs ─ 判斷資格是否符合

瀏覽器提供許多不同的API,讓我們能夠同時(concurrently)處理多項任務。(例如:DOM操作、AJAX、setTimeout、使用行為-滑鼠、鍵盤等)

而Web APIs的隊伍並沒有所謂先來後到的排序問題,唯有當Web APIs的條件被滿足時,會將等待執行任務推進至 工作佇列(Callback Queue)等候執行。例如:setTimeout 數完5秒鐘後、點擊事件被觸發後,AJAX Request 給予回應後等。

Callback Queue ─ 排隊!先來先執行

接收從Web APIs傳來等候被執行的任務,以先進先出(FIFO)的方式,透過 Event Loop 的監控,當執行堆疊(call back)裡清空時,才傳入佇列內容中依先進先出排序的任務。

這時候讓我們來看影片12:48解釋就會非常清楚整個指派的流程:


白話來說,Event Loop的作用就是擔任執行堆疊與工作佇列間的任務分配員,當執行堆疊是空的,就將工作佇列內等候被執行的任務依序推進去。

§ setTimeout 0 的藝術 ─ 我OK、你先請 §

思考過剛剛Event Loop的概念,好像就可以知道為什麼要設定等候0秒鐘,這樣看似無意義的行為。沒錯,用意就是要 ─「等到」所有堆疊的任務都被清空後,再「立即」執行。

白話來說,當我們將setTimeout設定為0,意思就是等等!先等堆疊任務被清空了,再「立即」執行。其用意只是等候堆疊清空這個條件的確立

如範例而言,打印出來的順序會是:hi --> (先擱置there) --> JSConfEU--> (堆疊任務被清空了) --> 最後執行印出there 


§ 套用至 AJAX request 情形時 §


理解以上後,就可以理解Ajax非同步(Asynchronous )的必要性,當function cb 被放進Web APIs中等待回應時,程式碼還可以繼續往下執行(避免發生上述假設AJAX為同步時,動彈不得的窘境),等到AJAX Request 給予回應後(不論成功或失敗),將被傳至工作佇列中,等堆疊執行被清空後,才被放入堆疊中執行。

這時候讓我們來看影片16:19解釋就會非常清楚整個指派的流程:

影片的講者 Philip Roberts 有提供影片中幫助視覺化瞭解 JavaScript Runtime, call stack, event loop, task queue 的工具 Loupe ,可以直接在工具中輸入更多案例來幫助自己思考執行的過程


更多範例思考:

◆ Click Event

◆ Multiple setTimeout

◆ 以setTimeout寫出非同步執行的 forEach callback

§ 壓軸─確實理解為什麼 render 需要非同步進行 §

最後,我們再藉由瀏覽器 render 的情況,來確實理解同步與非同步的差別,而這也是我認為整部說明影片最精華之處,講者以下列程式碼來做為範例,並以 dalay() 來模擬一個耗時的程式碼:


範例說明從22:26秒開始

一般來說,瀏覽器會在每 16.6 毫秒重新渲染畫面(也就是每秒 60 個 frames)同時這也是瀏覽器理想上會進行的最快的render頻率,而渲染的優先權高於回呼函數(callback function)

而我們需要先理解,渲染畫面(render)也就是「顯示畫面」這件事,需要等到堆疊(stack)被清空後才會在畫面上真正被顯示出來。

而當講者執行// Synchronous 同步迴圈 這段程式碼時,我們可以看到在同步執行下,我們的渲染(顯示畫面)會被阻塞(block)住,就等於等候回應的過程,我們無法選擇文字、無法點擊、無法進行任何行為

若是以// Asynchronous 非同步迴圈 執行,透過影片顯示,我們可以發現所謂非同步,等於是把原本大包的任務分解成小包裝,透過從工作佇列(task queue)到堆疊(stack)的過程中,為瀏覽器「爭取」重新渲染的機會間隔

§ 不要阻塞事件循環 §

在講者的示範中明確展示了當人們說「不要阻塞事件循環」的用意,就是不要在堆疊(stack)上放慢到不行的程式,這樣瀏覽器就無法建立一個品質好且流暢的UI。因為人們都不喜歡等,尤其當你什麼都不能做的時候,你第一個想法若不是焦躁地按下F5,就是按下X。

而這也是為什麼當執行許多圖像處理或動畫時,一定要注意程式碼是如何排進佇列。

§ 補充─滑動捲軸的情況 §


從這個範例中我們可以看到,當我們滾動卷軸不斷觸發'scroll'事件,雖然避免了堆疊(stack)阻塞,但卻同時可能導致工作佇列(task queue)的阻塞...

像卷軸這一類會頻繁觸發的事件(例如:scroll、resize),可能會在很短的時間內多次連續觸發事件,將淹沒工作佇列造成效能上的打擊,而這一類情形就可以透過防抖動(debounce)和節流閥(throttle)來處理。但這是另一片海了,留待下次攻略。

§ 非同步真的就這麼完美嗎? §

透過這一連串視覺化的解構,幫助我們以「當實際觸發所會發生的call back事件」為基礎,更深地思考,當進行程式碼設計時,該如何進一步地確保程式執行的流暢程度。

例如透過以上的理解,我們可以知道 Event Loop 協助了非同步請求的實現,技巧性地將耗時較久的任務往後「有序地控制」,實現更流暢的UI體驗。但同步真的就沒有優點嗎?非同步又有什麼缺點呢?(以下簡要整理兩點,日後再來更細探討差異)

同步處理:效率較耗時,但有利於流程的控制
非同步處理:雖然執行效率高、節省時間,但也相對佔據更多的資源,相對同步流程而言,較不利於進行流程的控制。

後記:這是第一次撰寫技術型的筆記,因為害怕觀念錯誤,所以花了相當多的時間整理也看了許多不同的資料,如果有誤的地方,再請指正。技術文件深似海,每次爬文都像是掉入另一個宇宙黑洞,網路上已經有許多討論Event Loop的文件,前輩們的解析都相當仔細精彩,本來想說看過理解就好,但為了完成學習進度,還是以個人筆記的方式理解並輸出這篇文章,而從這個過程也數度停下來思考自己的理解,重複梳理概念,雖然花費相當多的時間,但這過程也更讓我體會到輸入/輸出是學習路上不可或缺的一環。

尋找資料的過程也看到 Promise 物件的建立 這邊也記錄一下,留待下回破解。

圖像範例呈現工具:Loupe

參考來源:

延伸學習:

留言

這個網誌中的熱門文章

【南投│白毫禪寺】日式禪風的建築外型,卻是一部妙法蓮華經的具體呈現

【緬甸】緬甸自助旅行,一個人旅行不負責任懶人包:行程、住宿、換匯、簽證、交通、安全小提醒