Javascript 是一種單線程
的編程語言,只有一個調用棧,決定了它在同一時間只能做一件事。在代碼執行的時候,通過將不同函數的執行上下文壓入執行棧中來保證代碼的有序執行。在執行同步代碼的時候,如果遇到了異步事件,js 引擎并不會一直等待其返回結果,而是會將這個事件掛起,繼續執行執行棧中的其他任務。因此JS又是一個非阻塞
、異步
、并發式
的編程語言。
進程與線程的區別和聯系
當我們啟動某個程序時,操作系統會給該程序創建一塊內存,用來存放代碼、運行中的數據和一個執行任務的主線程,這樣的運行環境就叫做進程。
而線程是依附于進程的,在進程中使用多線程并行處理能提升運算效率,進程將任務分成很多細小的任務,再創建多個線程,在里面并行分別執行
進程與進程之間完全隔離,互不干擾,由于進程之間是相互獨立的,所以一個進程崩潰不會影響其他進程,如瀏覽器每一個標簽頁就是一個獨立的進程,關閉其中一個標簽頁別的標簽頁并不會受到影響。
線程之間的數據是共享的,一個進程可以有多個線程(一個進程至少有一個線程),當一個進程有多個線程時,每個線程都有一套獨立的寄存器和堆棧信息,而代碼、數據和文件是共享的
一個進程中的任意一個線程執行出錯,會導致這個進程崩潰
當一個進程關閉之后,操作系統會回收該進程的內存空間
瀏覽器的進程與線程
以大家熟悉的Chrome的內核為例,他不僅是多線程的,而且是多進程的。
最新的Chrome瀏覽器包括:瀏覽器主進程,GPU進程,網絡進程,渲染進程,和插件進程
瀏覽器進程
: 負責控制瀏覽器除標簽頁外的界面,包括地址欄、書簽、前進后退按鈕等,以及負責與其他進程的協調工作,同時提供存儲功能
GPU進程
:負責整個瀏覽器界面的渲染
網絡進程
:負責發起和接受網絡請求
插件進程
:主要是負責插件的運行,因為插件可能崩潰,所以需要通過插件進程來隔離,以保證插件崩潰也不會對瀏覽器和頁面造成影響
渲染進程
:負責控制顯示tab標簽頁內的所有內容,核心任務是將HTML、CSS、JS轉為用戶可以與之交互的網頁,排版引擎Blink和JS引擎V8都是運行在該進程中,默認情況下Chrome會為每個Tab標簽頁創建一個渲染進程
瀏覽器打開一個頁面至少需要主進程、GPU、網絡和渲染進程,后續如果再打開新的標簽頁的話,已經創建好的瀏覽器進程,GPU進程,網絡進程是共享的,不會重新啟動,默認情況下會為每一個標簽頁配置一個渲染進程,但是也有例外,比如同一站點的頁面間跳轉就可能重用渲染進程。
我們作為前端最關心的就是渲染進程,那仔細來看一下渲染進程。
渲染進程
上面已經提到渲染進程負責控制顯示tab標簽頁內的所有內容,核心任務是將HTML、CSS、JS轉為用戶可以與之交互的網頁,排版引擎Blink和JS引擎V8都是運行在該進程中,默認情況下Chrome會為每個Tab標簽頁創建一個渲染進程,某個選項卡崩潰,其他選項卡并不會受影響。
渲染進程中的線程
GUI渲染線程
:GUI(圖形用戶界面),該線程負責渲染頁面,解析html和CSS、構建DOM樹、CSSOM樹、渲染樹(包含要顯示的節點和節點的樣式信息,即整合 DOM 和 CSSOM 信息)、布局計算(計算節點在頁面的位置和大小)、和繪制頁面(遍歷渲染樹,調用 GPU 繪制,顯示在頁面上),重繪重排(回流)也是在該線程執行,GUI更新會被保存在一個隊列中,等到JS引擎空閑時,立即被執行。
JS引擎線程:
一個tab頁中只有一個JS引擎線程(單線程),負責解析和執行JS。這個線程就是負責執行JS的主線程,"JS是單線程的"就是指的這個線程。大名鼎鼎的Chrome V8引擎就是在這個線程運行的。需要注意的是,這個線程跟GUI線程是互斥的。互斥的原因是JS也可以操作DOM,如果JS線程和GUI線程同時操作DOM,結果就混亂了,不知道到底渲染哪個結果。這帶來的后果就是如果JS長時間運行,GUI線程就不能執行,整個頁面就感覺卡死了。
計時器線程:
指setInterval和setTimeout,因為JS引擎是單線程的,所以如果處于阻塞狀態,那么計時器就會不準了,所以需要單獨的線程來負責計時器工作。
異步http請求線程
:這個線程負責處理異步的ajax請求,當請求完成后,他也會通知事件觸發線程,然后事件觸發線程將這個事件放入事件隊列給主線程執行。
事件觸發線程:
定時器線程其實只是一個計時的作用,他并不會真正執行時間到了的回調,真正執行這個回調的還是JS主線程。所以當時間到了定時器線程會將這個回調事件給到事件觸發線程,然后事件觸發線程將它加到任務隊列里面去。最終JS主線程從任務隊列取出這個回調執行。事件觸發線程管理著一個任務隊列,事件觸發線程不僅會將定時器事件放入任務隊列,其他滿足條件的事件也是他負責放進任務隊列,如鼠標點擊事件等。
setTimeout、DOM或者 HTTP請求這部分其實并不在 v8 引擎中,這些屬于webAPIs
,即瀏覽器的API,不是js引擎提供的。
所謂的事件循環,或者說js能夠實現異步非阻塞特性的基礎就是因為多線程設計的存在。
消化總結:
用戶啟動某個應用程序會建立一個或多個進程,如瀏覽器的tab標簽頁,一個進程中的任務被劃分到多個線程處理,有GUI渲染線程,JS引擎線程,網絡線程等,JS的單線程即是指瀏覽器渲染進程中的JS引擎線程
(因為只有一個JS引擎線程)。
了解了JS的單線程特性之后,我們來思考幾個問題。
javascript為什么會是單線程的語言?
Javascript的單線程,與它的用途有關。作為瀏覽器腳本語言,Javascript的主要用途是與用戶互動,以及操作DOM。
在《javascript高級程序設計》一書中有一個很好的解釋:如果JS是多線程語言,那么假如當多個線程同時操作同一個DOM的時候,瀏覽器該如何渲染?瀏覽器該聽哪個線程的指令?渲染結果是否會超出預期?基于這個特性,JS必須只能是單線程語言。
為了利用多核CPU的計算能力,HTML5提出Web Worker標準,允許Javascript腳本創建多個線程,但是子線程完全受主線程控制,且不得操作DOM。所以,這個新標準并沒有改變Javascript單線程的本質。
Javascript代碼是如何執行的?
Javascript并不是一行一行的分析并執行代碼的,所有的 JS 代碼在運行時都是在執行上下文中
進行的。執行上下文是一個抽象的概念,JS 中有三種執行上下文:
全局執行上下文
,默認的,在瀏覽器中是 window 對象,并且 this 在非嚴格模式下指向它
函數執行上下文
,JS 的函數每當被調用時會創建一個上下文
Eval 執行上下文
,eval 函數會產生自己的上下文,這里不討論
執行上下文在執行棧(調用棧)
中被以后進先出的順序執行。當引擎第一次遇到 JS 代碼時,會產生一個全局執行上下文
并壓入執行棧,每遇到一個函數調用,就會往棧中壓入一個新的函數執行上下文
。引擎執行棧頂
的函數(執行上下文),執行完畢,彈出當前執行上下文,并等待垃圾回收,全局上下文只有唯一的一個,它在瀏覽器關閉時出棧。
如何理解同步和異步?
舉個例子:你在燒水的時候還可以去洗菜切菜,因為燒水你只需要打開開關然后等水自己燒開提醒你就好了,不需要一直等著燒水什么都不做,這里的燒水就是異步任務。
為什么是異步、并發、非阻塞的?
我們在頁面中通常會發大量的請求,獲取后端的數據去渲染頁面。因為瀏覽器是單線程的,試想一下,當我們發出異步請求的時候,阻塞了,后面的代碼都不執行了,那頁面可能出現長時間白屏,極度影響用戶體驗。
所以JS采取了"異步任務回調通知"的模式,而實現這個“通知”的,正是事件循環,當遇到異步任務時,就將這個任務交給對應的線程,當這個異步任務滿足回調條件時,對應的線程又通過事件觸發線程將這個事件放入任務隊列,然后主線程從任務隊列取出事件繼續執行。
事件循環并不是Javascript首創的,它是計算機的一種運行機制。
基于JS的用途是瀏覽器腳本語言,用于操作DOM與用戶進行交互,為了避免多個線程同時操作DOM導致渲染結果超出預期,所以JS被設計為一個單線程的語言。
開發時會有很多耗時的異步任務,如果都在主線程中阻塞,那會極度影響用戶體驗,所以JS是異步、并發、非阻塞的。
Javascript代碼的執行過程中,依靠函數調用棧來搞定函數的執行順序。
說了這么多,終于輪到我們的主角了,下面有請任務隊列和事件循環登場。
任務隊列和事件循環
事件循環與任務隊列是JS中比較重要的兩個概念。這兩個概念在ES5和ES6兩個標準中有不同的實現。
ES5下的概念:
任務隊列是一個事件的隊列,所謂任務是WebAPIs返回的一個個通知,也可以理解成消息的隊列、回調隊列,里面存放異步任務的回調,各個異步線程調用webAPI執行完后通過事件觸發線程把回調函數放入任務隊列,表示相關的異步任務可以進入“執行棧”了,等待被主線程讀取。
瀏覽器包含3類事件循環:Window (用于運行網頁內容的瀏覽器級容器,包括實際的 window,一個 tab 標簽或者一個 frame。)事件循環、Worker 事件循環、Worklet 事件循環
setTimeout/Ajax/Promise/DOM事件(user interaction task source)
等都是任務源,來自同類任務源的任務我們稱它們是同源的,比如setTimeout與setInterval就是同源的。
ES5中的事件循環,如圖:

圖中有三大塊:
"任務隊列"遵循先進先出的原則,排在前面的事件,優先被主線程讀取。主線程的讀取過程基本上是自動的,只要執行棧一清空,"任務隊列"上第一位的事件就自動進入主線程執行。
主線程從"任務隊列"中讀取事件,這個過程是循環不斷的,所以整個的這種運行機制又稱為Event Loop(事件循環)。
事件循環的大體流程:
主線程開始執行script代碼,同步代碼直接執行,遇到異步任務源就將它掛起交給對應的異步線程,自己繼續執行同步任務
異步線程調用相應API處理,滿足回調條件后,將異步回調事件放入任務隊列
主線程的執行棧中的同步任務都執行完畢后,就來讀取任務隊列中的異步任務回調事件
主線程不斷循環上述流程
到了ES6 的標準,由于出現了 Promise ,ES5 時代的"同步任務"與"異步任務"已經沒有辦法解釋其中的原理,因此出現了 task 隊列與 job 隊列之分。
ES6將任務分為 宏任務(macrotask)
與 微任務(microtask)
,在新ECMAscript標準中,它們被分別稱為 task 與 jobs ;
任務隊列則為宏任務隊列
(Task Queue)和微任務隊列
(Job Queue)。
事件循環由宏任務和在執行宏任務期間產生的所有微任務組成。宏任務隊列可以有多個,微任務隊列只有一個,完成當下的宏任務后,會立刻執行所有在此期間入隊的微任務。
這種設計是為了給緊急任務一個插隊的機會,否則新入隊的任務永遠被放在隊尾。微任務使得我們能夠在重新渲染UI之前執行指定的行為,避免不必要的UI重繪。
TIPS: 其實并沒有宏任務隊列一說,人家原名就叫任務隊列(Task Queue)。首先要說明宏任務其實一開始就只是任務(task),因為ES6新引入了Promise標準,同時瀏覽器實現上多了一個microtask微任務概念,作為對照才稱宏任務,至于宏任務隊列,為了便于理解和區分大家就這么叫了。
宏任務(task)
進入執行棧等待主線程執行的主代碼塊,包括從異步隊列里加入到棧的,如setTimeout()、setInterval()的回調,其中不含異步隊列中的微任務如Promise.then回調。
宏任務大概包括:script(整塊代碼)
、setTimeout
、setInterval
、I/O
、DOM事件(UI交互事件)
、setImmediate
(node環境)、postMessage
、MessageChannel
,這些也被稱作任務源
宏任務是瀏覽器規定的(W3C)
瀏覽器為了能夠使得JS內部宏任務與DOM任務能夠有序的執行,會在一個宏任務執行結束后,在下一個宏任務執行開始前,對頁面進行重新渲染(GUI線程接管渲染,更新DOM樹,重新繪制)
異步任務可能是宏任務也可能是微任務,而宏任務可能是異步代碼也可能是同步代碼,被掛起后放到任務隊列的是異步的宏任務,同步宏任務會直接執行
宏任務隊列可以有多個,微任務隊列只有一個
Q:有很多小伙伴不理解為什么“script(整塊代碼)”是宏任務
A: MDN文檔定義中有詳細說明。
一個任務就是指計劃由標準機制來執行的任何 Javascript,如程序的初始化、事件觸發的回調等。 除了使用事件,你還可以使用 setTimeout() 或者 setInterval() 來添加任務。
由此可以得出結論,宏任務包含js主代碼塊,但是有一個爭議
存在,就是js主代碼塊是否進入宏任務隊列中
,或者說任務隊列是否只存放異步任務回調
關于這個問題,目前主要存在兩種看法,
script(整塊代碼)是宏任務(同步),首先被放入宏任務隊列中,一個事件循環從宏任務隊列開始,開始執行時宏任務隊列中只有script(整塊代碼)任務,遇到同步代碼直接入執行棧執行,異步代碼放入對應的任務隊列。
沒有把 script(整塊代碼)放入宏任務隊列,而是直接被主線程壓入執行棧執行,只有異步任務才會被掛起并放入任務隊列。
我個人其實更傾向于第二種說法,因為幾乎所有文章都指出任務隊列是消息隊列、回調隊列,我是實在沒有找到script(整塊代碼)是怎么被放入或者是以什么形式被放入任務隊列的相關說明,但其實這兩種說法在實際代碼運行表現上都是一致的,所以你怎么理解并不影響后續的事件循環流程,大家如果找到更官方更明確的說法歡迎交流,解惑。
微任務
可以理解是在當前宏任務執行結束后立即執行的任務(宏任務的小跟班),也就是說,在當前宏任務后,下一個宏任務之前,在重新渲染之前。
即宏任務->所有微任務->渲染,宏任務->所有微任務->渲染 ,...
微任務大概包括:new promise().then(回調)
、MutationObserver(html5新特性)
、Object.observe(已廢棄,proxy替代)、process.nextTick(node環境)
,這些也被稱作任務源
執行宏任務的過程中如果遇到微任務,就把微任務放到微任務隊列,這個過程由主線程維護,而非事件觸發線程
當執行到script腳本的時候,js引擎會為全局創建一個執行上下文,在該執行上下文中維護了一個微任務隊列,這個微任務隊列是給 V8 引擎 內部使用的,所以你是無法通過 Javascript 直接訪問的。
process.nextTick不在Event Loop的任何階段,他是一個特殊API,他會立即執行,然后才會繼續執行Event Loop,若同時存在promise和nextTick,則先執行nextTick
區別:
任務隊列和微任務隊列的區別很簡單,但卻很重要:
1.當執行來自任務隊列中的任務時,在每一次新的事件循環開始迭代的時候運行時都會執行隊列中的每個任務。在每次迭代開始之后加入到隊列中的任務需要在下一次迭代開始之后才會被執行.
2.每次當一個任務退出且執行上下文為空的時候,微任務隊列中的每一個微任務會依次被執行。不同的是它會等到微任務隊列為空才會停止執行——即使中途有微任務加入。換句話說,微任務可以添加新的微任務到隊列中,并在下一個任務開始執行之前且當前事件循環結束之前執行完所有的微任務。
簡單概括一下區別:
了解了宏任務和微任務的概念之后,我們來補充一下ES6事件循環的具體流程:
首先,javascript整體代碼被作為宏任務放入執行棧中執行,所有同步代碼先執行,執行過程中,當遇到任務源時,判斷是宏任務還是微任務
如果是宏任務,加入到宏任務隊列中,如果是微任務,加入到微任務隊列中
同步代碼執行完成后,執行棧空閑,檢查微任務隊列中是否有可執行任務,如果有,依次執行微任務隊列中的所有任務
渲染UI,開始下一輪循環
檢查宏任務隊列是否有可執行的宏任務,如果有,取出隊列中最前面的那個宏任務,加入到執行棧中開始執行,然后重復以上步驟,直到宏任務隊列中所有任務執行結束
定時器不準
任務隊列可以放置定時器回調事件,但是需要注意的是,setTimeout()只是將事件插入了"任務隊列",必須等到當前代碼(執行棧)執行完,主線程才會去執行它指定的回調函數。要是當前代碼耗時很長,有可能要等很久,所以并沒有辦法保證,回調函數一定會在setTimeout()指定的時間執行。
假設我們定義了一個2s的定時器,那么該定時器的執行流程如下:
主線程執行同步代碼
遇到setTimeout,將它交給定時器線程
定時器線程開始計時,2秒到了通知事件觸發線程
事件觸發線程將定時器回調放入事件隊列,異步流程到此結束
主線程如果有空,將定時器回調拿出來執行,如果沒空這個回調就一直放在隊列里。
所以,如果在定義了定時器之后,我們又進行了非常耗時的同步代碼運算,那即使到了2s,同步代碼也會阻塞定時器回調事件的執行,因此,此時回調執行的時間必然是不準確的了,所以再次強調,寫代碼時一定不要長時間占用主線程。
事件循環總結
事件循環(Event Loop) 是讓 Javascript 做到既是單線程,又絕對不會阻塞的核心機制,也是 Javascript 并發模型的基礎,是用來協調各種事件、用戶交互、腳本執行、UI 渲染、網絡請求等的一種機制,具體的管理方法由它所處的運行環境決定,目前JS的主要運行環境有兩個,瀏覽器和Node.js,這兩個環境的事件循環機制還有些區別,Node.js的事件循環我之后會另開一篇文章細說。
事件循環是讓 JS 做到既是單線程,又可以異步并發不會阻塞的核心機制。
瀏覽器是不僅是多進程而且是多線程的,如渲染進程中有GUI渲染線程、JS引擎線程、計時器線程、HTTP請求線程、事件觸發線程,事件循環就是依靠瀏覽器底層的多線程實現,所謂JS的單線程指的就是瀏覽器渲染進程中的JS引擎線程,因為只有一個JS引擎線程,所以是單線程,也被稱為主線程。
主線程執行JS代碼的過程中,依靠執行棧來管理執行任務的順序,遵循后進先出的原則,同步任務直接入棧執行,異步任務被掛起待完成后被放入任務隊列,
任務隊列有宏任務隊列和微任務隊列的區別,宏任務隊列中存放宏任務,如setTimeout、setInterval、DOM事件等,微任務隊列中存放微任務,如Promise的then回調等。
當執行棧的任務執行完成后會去讀取任務隊列中的任務,優先執行微任務隊列中的所有任務,微任務隊列清空后,重新渲染UI,開始下一輪循環,檢查宏任務隊列是否有可執行的宏任務,如果有,取出隊列中最前面的那個宏任務,加入到執行棧中開始執行,重復以上步驟就是事件循環。
參考文檔