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

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