「前端進階」從多執行緒角度來看 Event Loop
- 2019 年 11 月 5 日
- 筆記
幾乎在每一本JS相關的書籍中,都會說JS是 單執行緒
的,JS是通過事件隊列 (EventLoop)
的方式來實現非同步回調的。對很多初學JS的人來說,根本搞不清楚單執行緒的JS為什麼擁有 非同步
的能力,所以,我試圖從 進程
、 執行緒
的角度來解釋這個問題。
CPU

算機的核心是 CPU
,它承擔了所有的計算任務。
它就像一座工廠,時刻在運行。
進程

定工廠的電力有限,一次只能供給一個車間使用。也就是說,一個車間開工的時候,其他車間都必須停工。背後的含義就是,單個CPU一次只能運行一個任務。
進程
就好比工廠的車間,它代表CPU所能處理的單個任務。進程
之間相互獨立,任一時刻,CPU總是運行一個 進程
,其他 進程
處於非運行狀態。CPU使用時間片輪轉進度演算法來實現同時運行多個 進程
。
執行緒

個車間里,可以有很多工人,共享車間所有的資源,他們協同完成一個任務。
執行緒
就好比車間里的工人,一個 進程
可以包括多個 執行緒
,多個 執行緒
共享 進程
資源。
CPU、進程、執行緒之間的關係
從上文我們已經簡單了解了CPU、進程、執行緒,簡單匯總一下。
進程
是cpu資源分配的最小單位(是能擁有資源和獨立運行的最小單位)執行緒
是cpu調度的最小單位(執行緒是建立在進程的基礎上的一次程式運行單位,一個進程中可以有多個執行緒)- 不同
進程
之間也可以通訊,不過代價較大 單執行緒
與多執行緒
,都是指在一個進程
內的單和多
瀏覽器是多進程的
我們已經知道了 CPU
、 進程
、 執行緒
之間的關係,對於電腦來說,每一個應用程式都是一個 進程
, 而每一個應用程式都會分別有很多的功能模組,這些功能模組實際上是通過 子進程
來實現的。對於這種 子進程
的擴展方式,我們可以稱這個應用程式是 多進程
的。
而對於瀏覽器來說,瀏覽器就是多進程的,我在Chrome瀏覽器中打開了多個tab,然後打開windows控制管理器:

上圖,我們可以看到一個Chrome瀏覽器啟動了好多個進程。
總結一下:
- 瀏覽器是多進程的
- 每一個Tab頁,就是一個獨立的進程
瀏覽器包含了哪些進程
- 主進程
- 協調控制其他子進程(創建、銷毀)
- 瀏覽器介面顯示,用戶交互,前進、後退、收藏
- 將渲染進程得到的記憶體中的Bitmap,繪製到用戶介面上
- 處理不可見操作,網路請求,文件訪問等
- 第三方插件進程
- 每種類型的插件對應一個進程,僅當使用該插件時才創建
- GPU進程
- 用於3D繪製等
渲染進程
,就是我們說的瀏覽器內核
- 負責頁面渲染,腳本執行,事件處理等
- 每個tab頁一個渲染進程
那麼瀏覽器中包含了這麼多的進程,那麼對於普通的前端操作來說,最重要的是什麼呢?
答案是 渲染進程
,也就是我們常說的 瀏覽器內核
瀏覽器內核(渲染進程)
從前文我們得知,進程和執行緒是一對多的關係,也就是說一個進程包含了多條執行緒。
而對於 渲染進程
來說,它當然也是多執行緒的了,接下來我們來看一下渲染進程包含哪些執行緒。
GUI渲染執行緒
- 負責渲染頁面,布局和繪製
- 頁面需要重繪和迴流時,該執行緒就會執行
- 與js引擎執行緒互斥,防止渲染結果不可預期
JS引擎執行緒
- 負責處理解析和執行javascript腳本程式
- 只有一個JS引擎執行緒(單執行緒)
- 與GUI渲染執行緒互斥,防止渲染結果不可預期
事件觸發執行緒
- 用來控制事件循環(滑鼠點擊、setTimeout、ajax等)
- 當事件滿足觸發條件時,將事件放入到JS引擎所在的執行隊列中
定時觸發器執行緒
- setInterval與setTimeout所在的執行緒
- 定時任務並不是由JS引擎計時的,是由定時觸發執行緒來計時的
- 計時完畢後,通知事件觸發執行緒
非同步http請求執行緒
- 瀏覽器有一個單獨的執行緒用於處理AJAX請求
- 當請求完成時,若有回調函數,通知事件觸發執行緒
當我們了解了渲染進程包含的這些執行緒後,我們思考兩個問題:
- 為什麼 javascript 是單執行緒的
- 為什麼 GUI 渲染執行緒與 JS 引擎執行緒互斥
為什麼 javascript 是單執行緒的
首先是歷史原因,在創建 javascript 這門語言時,多進程多執行緒的架構並不流行,硬體支援並不好。
其次是因為多執行緒的複雜性,多執行緒操作需要加鎖,編碼的複雜性會增高。
而且,如果同時操作 DOM ,在多執行緒不加鎖的情況下,最終會導致 DOM 渲染的結果不可預期。
為什麼 GUI 渲染執行緒為什麼與 JS 引擎執行緒互斥
這是由於 JS 是可以操作 DOM 的,如果同時修改元素屬性並同時渲染介面(即 JS執行緒
和 UI執行緒
同時運行), 那麼渲染執行緒前後獲得的元素就可能不一致了。
因此,為了防止渲染出現不可預期的結果,瀏覽器設定 GUI渲染執行緒
和 JS引擎執行緒
為互斥關係, 當 JS引擎執行緒
執行時 GUI渲染執行緒
會被掛起,GUI更新則會被保存在一個隊列中等待 JS引擎執行緒
空閑時立即被執行。
從 Event Loop 看 JS 的運行機制
到了這裡,終於要進入我們的主題,什麼是 Event Loop
先理解一些概念:
- JS 分為同步任務和非同步任務
- 同步任務都在JS引擎執行緒上執行,形成一個
執行棧
- 事件觸發執行緒管理一個
任務隊列
,非同步任務觸發條件達成,將回調事件放到任務隊列
中 執行棧
中所有同步任務執行完畢,此時JS引擎執行緒空閑,系統會讀取任務隊列
,將可運行的非同步任務回調事件添加到執行棧
中,開始執行

前端開發中我們會通過 setTimeout/setInterval
來指定定時任務,會通過 XHR/fetch
發送網路請求, 接下來簡述一下 setTimeout/setInterval
和 XHR/fetch
到底做了什麼事
我們知道,不管是 setTimeout/setInterval
和 XHR/fetch
程式碼,在這些程式碼執行時, 本身是同步任務,而其中的回調函數才是非同步任務。
當程式碼執行到 setTimeout/setInterval
時,實際上是 JS引擎執行緒
通知 定時觸發器執行緒
,間隔一個時間後,會觸發一個回調事件, 而 定時觸發器執行緒
在接收到這個消息後,會在等待的時間後,將回調事件放入到由 事件觸發執行緒
所管理的 事件隊列
中。
當程式碼執行到 XHR/fetch
時,實際上是 JS引擎執行緒
通知 非同步http請求執行緒
,發送一個網路請求,並制定請求完成後的回調事件, 而 非同步http請求執行緒
在接收到這個消息後,會在請求成功後,將回調事件放入到由 事件觸發執行緒
所管理的 事件隊列
中。
當我們的同步任務執行完, JS引擎執行緒
會詢問 事件觸發執行緒
,在 事件隊列
中是否有待執行的回調函數,如果有就會加入到執行棧中交給 JS引擎執行緒
執行
用一張圖來解釋:

用程式碼來解釋一下:
let timerCallback = function() { console.log('wait one second'); }; let httpCallback = function() { console.log('get server data success'); } // 同步任務 console.log('hello'); // 同步任務 // 通知定時器執行緒 1s 後將 timerCallback 交由事件觸發執行緒處理 // 1s 後事件觸發執行緒將 timerCallback 加入到事件隊列中 setTimeout(timerCallback,1000); // 同步任務 // 通知非同步http請求執行緒發送網路請求,請求成功後將 httpCallback 交由事件觸發執行緒處理 // 請求成功後事件觸發執行緒將 httpCallback 加入到事件隊列中 $.get('www.xxxx.com',httpCallback); // 同步任務 console.log('world'); //... // 所有同步任務執行完後 // 詢問事件觸發執行緒在事件事件隊列中是否有需要執行的回調函數 // 如果沒有,一直詢問,直到有為止 // 如果有,將回調事件加入執行棧中,開始執行回調程式碼
總結一下:
- JS引擎執行緒只執行執行棧中的事件
- 執行棧中的程式碼執行完畢,就會讀取事件隊列中的事件
- 事件隊列中的回調事件,是由各自執行緒插入到事件隊列中的
- 如此循環
宏任務、微任務
當我們基本了解了什麼是執行棧,什麼是事件隊列之後,我們深入了解一下事件循環中 宏任務
、 微任務
什麼是宏任務
我們可以將每次執行棧執行的程式碼當做是一個宏任務(包括每次從事件隊列中獲取一個事件回調並放到執行棧中執行), 每一個宏任務會從頭到尾執行完畢,不會執行其他。
我們前文提到過 JS引擎執行緒
和 GUI渲染執行緒
是互斥的關係,瀏覽器為了能夠使 宏任務
和 DOM任務
有序的進行,會在一個 宏任務
執行結果後,在下一個 宏任務
執行前, GUI渲染執行緒
開始工作,對頁面進行渲染。
// 宏任務-->渲染-->宏任務-->渲染-->渲染...
主程式碼塊,setTimeout,setInterval等,都屬於宏任務
第一個例子:
document.body.style = 'background:black'; document.body.style = 'background:red'; document.body.style = 'background:blue'; document.body.style = 'background:grey';
我們可以將這段程式碼放到瀏覽器的控制台執行以下,看一下效果:

我們會看到的結果是,頁面背景會在瞬間變成白色,以上程式碼屬於同一次 宏任務
,所以全部執行完才觸發 頁面渲染
,渲染時 GUI執行緒
會將所有UI改動優化合併,所以視覺效果上,只會看到頁面變成灰色。
第二個例子:
document.body.style = 'background:blue'; setTimeout(function(){ document.body.style = 'background:black' },0)
執行一下,再看效果:

我會看到,頁面先顯示成藍色背景,然後瞬間變成了黑色背景,這是因為以上程式碼屬於兩次 宏任務
,第一次 宏任務
執行的程式碼是將背景變成藍色,然後觸發渲染,將頁面變成藍色,再觸發第二次宏任務將背景變成黑色。
什麼是微任務
我們已經知道 宏任務
結束後,會執行渲染,然後執行下一個 宏任務
, 而微任務可以理解成在當前 宏任務
執行後立即執行的任務。
也就是說,當 宏任務
執行完,會在渲染前,將執行期間所產生的所有 微任務
都執行完。
Promise,process.nextTick等,屬於 微任務
。
第一個例子:
document.body.style = 'background:blue' console.log(1); Promise.resolve().then(()=>{ console.log(2); document.body.style = 'background:black' }); console.log(3);
執行一下,再看效果:

控制台輸出 1 3 2 , 是因為 promise 對象的 then 方法的回調函數是非同步執行,所以 2 最後輸出
頁面的背景色直接變成黑色,沒有經過藍色的階段,是因為,我們在宏任務中將背景設置為藍色,但在進行渲染前執行了微任務, 在微任務中將背景變成了黑色,然後才執行的渲染
第二個例子:
setTimeout(() => { console.log(1) Promise.resolve(3).then(data => console.log(data)) }, 0) setTimeout(() => { console.log(2) }, 0) // print : 1 3 2
上面程式碼共包含兩個 setTimeout ,也就是說除主程式碼塊外,共有兩個 宏任務
, 其中第一個 宏任務
執行中,輸出 1 ,並且創建了 微任務隊列
,所以在下一個 宏任務
隊列執行前, 先執行 微任務
,在 微任務
執行中,輸出 3 ,微任務執行後,執行下一次 宏任務
,執行中輸出 2
總結
- 執行一個
宏任務
(棧中沒有就從事件隊列
中獲取) - 執行過程中如果遇到
微任務
,就將它添加到微任務
的任務隊列中 宏任務
執行完畢後,立即執行當前微任務隊列
中的所有微任務
(依次執行)- 當前
宏任務
執行完畢,開始檢查渲染,然後GUI執行緒
接管渲染 - 渲染完畢後,
JS執行緒
繼續接管,開始下一個宏任務
(從事件隊列中獲取)
