前言
剛學前端的時候一直聽別人說 JS 是單線程缕允、單線程旁趟、單線程的,其實完整的應該是在瀏覽器環(huán)境下 JS 執(zhí)行引擎是單線程的限煞。
那么什么是線程抹恳?為什么JS是單線程的?
1. 進程和線程
進程和線程的主要差別在于它們是不同的操作系統(tǒng)資源管理方式署驻。進程有獨立的地址空間奋献,一個進程崩潰后,在保護模式下不會對其它進程產生影響旺上,而線程只是一個進程中的不同執(zhí)行路徑秽荞。
我的理解,一個程序運行抚官,至少有一個進程扬跋,一個進程至少有一個線程,進程是操作系統(tǒng)分配內存資源的最小單位凌节,線程是 cpu 調度的最小單位钦听。
打個比方,進程好比一個工廠倍奢,線程就是里面的工人朴上,工廠內有多個工人,里面的工人可以共享里面的資源卒煞,多個工人可以一起協(xié)調工作痪宰,類似于多線程并發(fā)執(zhí)行。
2. 瀏覽器是多進程的
打開 windows 任務管理器,可以看到瀏覽器開了很多個進程衣撬,每一個 tab 頁都是單獨的一個進程乖订,所以一個頁面崩潰以后并不會影響其他頁面
瀏覽器包含下面幾個進程:
- Browser 進程:瀏覽器的主進程(負責協(xié)調、主控)具练,只有一個
- 第三方插件進程:每種類型的插件對應一個進程乍构,僅當使用該插件時才創(chuàng)建
- GPU 進程:最多一個,用于 3D 繪制等
- 瀏覽器渲染進程(瀏覽器內核)(Renderer 進程扛点,內部是多線程的):默認每個 Tab 頁面一個進程哥遮,互不影響
3. 瀏覽器渲染進程
瀏覽器渲染進程是多線程的,也是一個前端人最關注的陵究,它包括下面幾個線程:
- GUI 渲染線程
- 負責渲染瀏覽器界面眠饮,解析 HTML,CSS铜邮,構建 DOM 樹和 RenderObject 樹仪召,布局和繪制等。
- 當界面需要重繪(Repaint)或由于某種操作引發(fā)回流(reflow)時牲距,該線程就會執(zhí)行
- GUI 渲染線程與 JS 引擎線程是互斥的返咱,當 JS 引擎執(zhí)行時 GUI 線程會被掛起(相當于被凍結了)钥庇,GUI 更新會被保存在一個隊列中等到 JS 引擎空閑時立即被執(zhí)行牍鞠。
- JS 引擎線程
- 也稱為 JS 內核,負責處理 Javascript 腳本程序评姨。(例如 V8 引擎)
- JS 引擎線程負責解析 Javascript 腳本难述,運行代碼。
- JS 引擎一直等待著任務隊列中任務的到來吐句,然后加以處理胁后,一個 Tab 頁(renderer 進程)中無論什么時候都只有一個 JS 線程在運行 JS 程序
- 同樣注意,GUI 渲染線程與 JS 引擎線程是互斥的嗦枢,所以如果 JS 執(zhí)行的時間過長攀芯,這樣就會造成頁面的渲染不連貫,導致頁面渲染加載阻塞文虏。
- 事件觸發(fā)線程
- 歸屬于瀏覽器而不是 JS 引擎侣诺,用來控制事件循環(huán)(可以理解,JS 引擎自己都忙不過來氧秘,需要瀏覽器另開線程協(xié)助)
- 當 JS 引擎執(zhí)行代碼塊如 setTimeOut 時(也可來自瀏覽器內核的其他線程, 如鼠標點擊年鸳、AJAX 異步請求等),會將對應任務添加到事件線程中
- 當對應的事件符合觸發(fā)條件被觸發(fā)時丸相,該線程會把事件添加到待處理隊列的隊尾搔确,等待 JS 引擎的處理
- 注意,由于 JS 的單線程關系,所以這些待處理隊列中的事件都得排隊等待 JS 引擎處理(當 JS 引擎空閑時才會去執(zhí)行)
- 定時觸發(fā)器線程
- 傳說中的 setInterval 與 setTimeout 所在線程
- 瀏覽器定時計數器并不是由 JavaScript 引擎計數的, (因為 JavaScript 引擎是單線程的, 如果處于阻塞線程狀態(tài)就會影響記計時的準確)
- 因此通過單獨線程來計時并觸發(fā)定時(計時完畢后膳算,添加到事件隊列中座硕,等待 JS 引擎空閑后執(zhí)行)
- 注意,W3C 在 HTML 標準中規(guī)定畦幢,規(guī)定要求 setTimeout 中低于 4ms 的時間間隔算為 4ms坎吻。
- 異步 http 請求線程
- 在 XMLHttpRequest 在連接后是通過瀏覽器新開一個線程請求
- 將檢測到狀態(tài)變更時,如果設置有回調函數宇葱,異步線程就產生狀態(tài)變更事件瘦真,將這個回調再放入事件隊列中。再由 JavaScript 引擎執(zhí)行黍瞧。
4. JS 引擎是單線程的
為什么 js 引擎是單線程的诸尽,一個原因是多線程復雜度會更高,另一個問題是結果可能是不可預期的:假設 JS 引擎是多線程的印颤,有一個 div您机,A 線程獲取到該節(jié)點設置了屬性,B 線程又刪除了該節(jié)點年局,so what际看?多線程并發(fā)執(zhí)行下該怎么操作呢?
或許這就是為什么 JS 引擎是單線程的矢否,代碼從上而下順序的預期執(zhí)行仲闽,雖然降低了編程成本,但也有其他問題僵朗,如果某個操作很耗時間赖欣,比如,某個計算操作 for 循環(huán)遍歷 10000 萬次验庙,就會阻塞后面的代碼造成頁面卡頓... ...
GUI 渲染線程與 JS 引擎線程互斥的顶吮,是為了防止渲染出現(xiàn)不可預期的結果,因為 JS 是可以獲取 dom 的粪薛,如果修改這些元素屬性同時渲染界面(即 JS 線程和 UI 線程同時運行)悴了,那么渲染線程前后獲得的元素數據就可能不一致了。所以 JS 線程執(zhí)行的時候违寿,渲染線程會被掛起湃交;渲染線程執(zhí)行的時候,JS 線程會掛起陨界,所以 JS 會阻塞頁面加載巡揍,這也是為什么 JS 代碼要放在 body標簽之后,所有html內容之前菌瘪;為了防止阻塞頁面渲造成白屏腮敌。
5. WebWorker
上面說了阱当,JS 是單線程的,也就是說糜工,所有任務只能在一個線程上完成弊添,一次只能做一件事。前面的任務沒做完捌木,后面的任務只能等著油坝。隨著電腦計算能力的增強,尤其是多核 CPU 的出現(xiàn)刨裆,單線程帶來很大的不便澈圈,無法充分發(fā)揮計算機的計算能力。
Web Worker帆啃,是為 JavaScript 創(chuàng)造多線程環(huán)境瞬女,允許主線程創(chuàng)建 Worker 線程,將一些任務分配給后者運行努潘。在主線程運行的同時诽偷,Worker 線程在后臺運行,兩者互不干擾疯坤。等到 Worker 線程完成計算任務报慕,再把結果返回給主線程。這樣的好處是压怠,一些計算密集型或高延遲的任務眠冈,被 Worker 線程負擔了,主線程(通常負責 UI 交互)就會很流暢刑峡,不會被阻塞或拖慢洋闽。
Web Worker 有幾個特點:
- 同源限制:分配給 Worker 線程運行的腳本文件玄柠,必須與主線程的腳本文件同源突梦。
- DOM 限制:不能操作 DOM
- 通信聯(lián)系:Worker 線程和主線程不在同一個上下文環(huán)境,它們不能直接通信羽利,必須通過消息完成宫患。
- 腳本限制:不能執(zhí)行 alert()方法和 confirm()方法
- 文件限制:無法讀取本地文件
6. 瀏覽器渲染流程
下面是瀏覽器渲染頁面的簡單過程,詳細講又可以開一篇文章了~. ~:《從輸入 URL 到頁面渲染完成發(fā)生了什么》
- 用戶輸入 url 这弧,DNS 解析成請求 IP 地址
- 瀏覽器與服務器建立連接(tcp 協(xié)議娃闲、三次握手),服務端處理返回html代碼塊
- 瀏覽器接受處理匾浪,解析 html 成 dom 樹皇帮、解析 css 成 cssobj
- dom 樹、cssobj 結合成 render 樹
- JS 根據 render 樹進行計算蛋辈、布局属拾、重繪
- GPU 合成将谊,輸出到屏幕
JS 事件循環(huán)
上面扯皮了一大堆,下面開始進入正題
1. 同步任務和異步任務
JS 有兩種任務:
- 同步任務
- 異步任務
同步任務渐白,顧名思義就是代碼是同步執(zhí)行的尊浓,異步代碼就是代碼是異步執(zhí)行的,為什么 JS 要這么分呢纯衍?
我們假設 JS 全部代碼都是同步執(zhí)行的栋齿,一個打包過后的 JS 有 10000 行代碼,如果開始就遇到 setTimeout, 那么就需要等 100 秒才能執(zhí)行后面的代碼... ... 如果中間還有一些 io 操作和異步請求等襟诸,想想都令人崩潰
setTimeout(()=>{
// todo
},100000)
// 下面省略10000行代碼
因為同步執(zhí)行異步任務比較耗時間瓦堵,而且代碼中絕大部分都是同步代碼,所以我們可以先執(zhí)行同步代碼歌亲,把這些異步任務交給其他線程去執(zhí)行谷丸,如定時觸發(fā)器線程、異步 http 請求線程等应结,然后等這些異步任務完成了再去執(zhí)行他們刨疼。這種調度同步、異步任務的策略鹅龄,就是JS 事件循環(huán):
- 執(zhí)行整體代碼揩慕,如果是同步任務,就直接在主線程上執(zhí)行扮休,形成一個執(zhí)行棧
- 當遇到異步任務的時候如網絡請求等迎卤,就交給其他線程執(zhí)行, 當異步任務執(zhí)行完了,就往事件隊列里面塞一個回調函數
- 一旦執(zhí)行棧中的所有同步任務執(zhí)行完畢(即執(zhí)行楃枳梗空)蜗搔,就會讀取事件隊列,取一個任務塞到執(zhí)行棧中八堡,開始執(zhí)行
- 一直重復步驟 3
這就是事件循環(huán)了樟凄,確保了同步和異步任務有條不絮的執(zhí)行,只有當前所有同步任務執(zhí)行完了兄渺,主線程才會去讀取事件隊列,看看有沒有任務(異步任務執(zhí)行完的第回調)要執(zhí)行缝龄,每次取一個來執(zhí)行。
老生長談的 setTimeout
setTimeout(() => {
console.log('異步任務');
}, 0);
console.log('同步任務');
相信你狠容易就能理解下面的執(zhí)行結果挂谍,主線程掃描整體代碼:
- 發(fā)現(xiàn)有個異步任務setTimeout叔壤,就掛起交由定時器觸發(fā)線程(定時器會在等待了指定的時間后將結果以回調形式放入到事件隊列中等待讀取到主線程執(zhí)行),
- 發(fā)現(xiàn)同步任務 console口叙,直接塞入執(zhí)行棧執(zhí)行
- 從上到下執(zhí)行完了一遍
- 執(zhí)行棧處于空閑狀態(tài)炼绘,檢查事件隊列是否有任務(此時定時器執(zhí)行完了),取出一個任務塞到執(zhí)行戰(zhàn)中執(zhí)行
- 事件隊列清空
2. 宏任務(macro-task)妄田、微任務(micro-task)
1. 宏任務俺亮、微任務
除了廣義的同步任務和異步任務仗哨,JavaScript 單線程中的任務可以細分為宏任務和微任務:
- macro-task:script(整體代碼), setTimeout, setInterval, setImmediate, I/O, UI rendering
- process. nextTick, Promises, Object. observe, MutationObserver
2. 事件循環(huán)與宏任務、微任務
每次執(zhí)行棧執(zhí)行的代碼就是一個宏任務(包括每次從事件隊列中獲取一個事件回調并放到執(zhí)行棧中執(zhí)行)
再檢測本次循環(huán)中是否尋在微任務铅辞,存在的話就依次從微任務的任務隊列中讀取執(zhí)行完所有的微任務厌漂,再讀取宏任務的任務隊列中的任務執(zhí)行,再執(zhí)行所有的微任務斟珊,如此循環(huán)苇倡。JS 的執(zhí)行順序就是每次事件循環(huán)中的宏任務-微任務。
- 第一次事件循環(huán)囤踩,整段代碼作為宏任務進入主線程執(zhí)行
- 同步代碼被直接推到執(zhí)行棧執(zhí)行旨椒,遇到異步代碼就掛起交由其他線程執(zhí)行(執(zhí)行完會往事件隊列塞回調)
- 同步代碼執(zhí)行完,讀取微任務隊列堵漱,若有執(zhí)行所有微任務综慎,微任務清空
- 頁面渲染
- 從事件隊列面里取一個宏任務塞入執(zhí)行棧執(zhí)行
- 如此反復
用代碼翻譯一下就是
# 宏任務
for (let macrotask of macrotask_list) {
# 執(zhí)行一個宏任務
macrotask();
# 執(zhí)行所有微任務
for (let microtask of microtask_list) {
microtask();
}
# UI渲染
ui_render();
}
3. 事件循環(huán)與頁面渲染
在 ECMAScript 中锌历,microtask(微任務) 稱為 jobs兔簇,macrotask(宏任務) 可稱為 task。
瀏覽器為了能夠使得 JS 內部 task 與 DOM 任務能夠有序的執(zhí)行辞色,會在一個 task 執(zhí)行結束后愉镰,在下一個 task 執(zhí)行開始前米罚,對頁面進行重新渲染:
(task -> 渲染 -> task ->... )
讓我們看一下例子,我們有一個id為app的 div
<div id="app">宏任務丈探、微任務</div>
執(zhí)行下面的代碼會發(fā)生什么录择?
document. querySelector('#app').style.color = 'yellow';
Promise. resolve(). then(() => {
document. querySelector('#app').style.color = 'red';
});
setTimeout(() => {
document.querySelector('#app').style.color = 'blue';
Promise.resolve(). then(() => {
for (let i = 0; i < 99999; i++) {
console.log(i);
}
});
}, 17);
我們直接看一下運行結果:
文字會先變紅,然后過一段時間后會變藍碗降;我們分析一下程序是如何運行的:
- 第一輪事件循環(huán)隘竭,遇到第一個同步任務塞進執(zhí)行棧執(zhí)行,dom操作使文字變黃, 遇到第二個是Promise微任務塞到微任務隊列讼渊,繼續(xù)往下动看,遇到宏任務setTimeout交由定時器觸發(fā)線程
- 第一輪宏任務執(zhí)行完了,檢查微任務隊列發(fā)現(xiàn)有任務精偿,執(zhí)行并清空隊列弧圆,dom操作使文字變紅赋兵,此時setTimeout還沒執(zhí)行完
- GUI 渲染線程進行渲染笔咽,使文字變紅
- 第二輪循環(huán),執(zhí)行棧為空霹期,檢查微任務隊列為空叶组,繼續(xù)檢測事件隊列,發(fā)現(xiàn)已經有結果了历造,塞入執(zhí)行棧中執(zhí)行
- 執(zhí)行 setTimeout 里的回調甩十,執(zhí)行第一個同步任務船庇,dom操作使文字變藍,第二個是微任務塞入微任務隊列侣监,同步任務執(zhí)行完了鸭轮,發(fā)現(xiàn)微任務中有任務執(zhí)行并清空隊列,微任務里console是同步任務橄霉,此時JS線程一直在執(zhí)行窃爷,GUI 渲染線程被掛起,一直等到里面的同步任務執(zhí)行完
- GUI 渲染線程進行渲染姓蜂,使文字變藍
- 事件循環(huán)結束
HTML5標準規(guī)定了setTimeout()的第二個參數的最小值(最短間隔)按厘,不得低于4毫秒,如果低于這個值钱慢,就會自動增加逮京。
其中有一個問題是,谷歌下經測試并不玩全遵循兩個宏任務之間執(zhí)行ui渲染(谷歌的優(yōu)化策略束莫?)懒棉,把 setTimeout 事件設置為0,發(fā)現(xiàn)文字不會由黑>紅>藍览绿,而是直接黑>藍漓藕,為了模擬效果所以我把時間間隔設置為了17ms(我的屏幕是60HZ也就是16. 67ms刷新一次)
4. Vue. $nextTick
使用vue的小伙伴們可能工作中可能會經常用到這個api,Vue的官方介紹:
將回調延遲到下次 DOM 更新循環(huán)之后執(zhí)行挟裂。在修改數據之后立即使用它享钞,然后等待 DOM 更新。
其內部實現(xiàn)就是利用了 microtask(微任務)诀蓉,來延時執(zhí)行一段代碼(獲取dom節(jié)點的值), 即當前所有同步代碼執(zhí)行完后執(zhí)行 microtask(微任務)栗竖,可參照之前的文章:
參考
文章中的所有圖片均來自網絡
源碼
END