JS學習筆記之由定時器引發的深入思考

weixin_33797791發表於2018-04-12

前言

感覺知識就像網貸,是個無底洞啊,本來只是在犀牛書上看到定時器的內容,只有一頁而已,然而我卻花了幾周的時間來整理它,不過真的是學無止境,還有很多細節無法深入,大家一起學習進步吖~

簡單的栗子

例1:

setTimeout(() => {
    console.log('hello world')
}, 0)
function printStr(str) {
    console.log(str)
}
printStr('hello Melody')

例2:

let startTime = Date.now();
setTimeout(() => {
    console.log(Date.now()-startTime)
}, 100)

for (let i = 0; i < 1000000000; i++) {}

這兩個問題絕大部分人都能答的上來,不過答案的擴充套件性極強,我想面試官問你這個問題也只是以此為引子想看看你的基礎是否夠紮實。
最最基本也要知道以上兩個問題的答案,由此延伸其內部原理,包括但不限於:

  • js引擎為什麼是單執行緒的?
  • 什麼叫阻塞?非同步是如何解決阻塞問題的?
  • 執行緒和程式的區別
  • 瀏覽器程式
  • 什麼是任務佇列?什麼是執行棧?
  • 事件循壞(Event Loop)
  • Microtasks 和 Macrotasks
  • HTML5的Web Worker?
    ......

js引擎為什麼是單執行緒的?

其實關於 js引擎為什麼是單執行緒的 這個問題我覺得就像 地球為什麼是圓的花兒為什麼這樣紅 一樣無聊,但是我還是問了這個無聊的問題,(我就是這麼無聊(* ̄︶ ̄))js誕生之初本就沒指望他幹什麼大事,它不走C和Java的“高階”路線,一開始就找準定位--指令碼語言,作為指令碼語言,它不需要很快的速度,很強的效能,所以本著簡單易學的原則,js被設計成單執行緒語言。

還有一個說法是,js會操作dom,而dom的修改會觸發瀏覽器的渲染程式去渲染介面,如果js是多執行緒的,當它同時對同一個節點做了增加和刪除操作,渲染程式就不知道該怎麼渲染這個節點了。關於這個原因其實我覺得比較牽強,Python不就是多執行緒的,你們咋不說它也會造成這個問題哪?

所以單執行緒就單執行緒,它也不見得就比多執行緒差多少啊。

什麼叫阻塞?非同步是如何解決阻塞問題的?

js裡面的阻塞就是成群結隊的函式啊網路請求啊排著隊等js執行,執行就得花時間,某個任務花的時間長了,佔用js的時間多了,就導致後面的任務等待執行的時間很長,比較壞的情況是,js引擎執行緒和GUI渲染執行緒是互斥的,如果js久久執行不完,就會導致視窗一直白屏,甚至瀏覽器認為該視窗失去響應,會詢問使用者是否要關閉該視窗。

阻塞不是單執行緒的專利,事實上無論是單執行緒or多執行緒,當併發量過高時都會造成阻塞,只是單執行緒更容易阻塞,不需要高併發量,只需要來一個耗時的I/O讀取操作就可以讓執行緒無法繼續往下處理,只能乾等著。所以對於這類耗時的操作,沒必要等待他們執行完成,只需要告訴他們:我還有事要先去忙沒空等你,你結果返回了再來通知我,我自會回去處理,這就是非同步和回撥。

因為非同步的用處實在太多,一不小心就是非同步接非同步,回撥套回撥,然後就陷入了“回撥地獄”,不過近幾年js的回撥已經可以處理得非常優雅了,包括有:

執行緒和程式

我們說:xxx語言是單(多)執行緒的,瀏覽器是多程式
我們說:電腦好卡啊,開啟工作管理員看看哪些程式佔用CPU過多

所以:程式和執行緒到底是個啥?

官方解釋程式是cpu資源分配的最小單位執行緒是cpu排程的最小單位

不懂,有沒有通俗一點的解釋?

CPU是計算機的“大腦”,“身體”的各個機能運轉都依靠於“大腦”處理,我們的大腦永遠在工作,除非我們掛掉了。CPU也會一直運作,除非切斷了電源。但是不管CPU有多忙,它一次只能執行一個任務,或者說:它在一個時間段內只會執行一個程式

比如說這一時刻我開啟了一個瀏覽器視窗,並且正在用鍵盤輸入文字,那麼CPU要管理的程式就有瀏覽器和鍵盤,當然除此之外顯示卡啊RAM啊等各種資源的程式也都是一直在執行的。總之不管有多少個程式(任務)要執行,都只能排著隊等待CPU的“臨幸”,還有一點是,CPU“臨幸”某個程式的時間並不是根據這個程式執行完需要多久來確定的,而是由CPU自己分配,如果CPU分配的時間用完了,那麼CPU就會儲存有關這個程式的執行環境,我們稱之為“執行上下文”,等CPU下一次回來繼續執行該程式時,實際要做的:

  1. 載入執行上下文
  2. 執行程式
  3. 如果CPU分配時間用完,跳到4,如果程式結束,跳到5
  4. 儲存程式的執行上下文,等待下一次CPU處理
  5. 回收該程式佔用資源,包括其執行上下文。

好了,現在再來理解程式是cpu資源分配的最小單位是不是容易多了?計算機運轉的過程實際就是CPU在操控各個程式運轉的過程,CPU資源有限,能容納的程式數量有限,CPU能力有限,它一次始終只能執行一個程式。

而程式還很“大”,為了將程式細分,就引入了執行緒的概念,這就像一段程式程式碼由函式和全域性變數組成,程式也是由很多個執行緒組成的,這些執行緒共享程式的資源,也就是cpu為該程式分配的上下文環境。執行緒是cpu排程的最小單位這句話的通俗解釋是cpu將自己的資源給程式,但真正使用這些資源的是執行緒。

瀏覽器程式

瀏覽器是多程式的,它包括:

  • 瀏覽器程式(主程式):管理瀏覽器的前進後退、與使用者互動,同時負責處理所有和磁碟、網路的通訊,不分析和渲染任何網頁內容。
  • 渲染程式(瀏覽器核心):渲染程式是多執行緒的,它負責解析HTML,CSS構建DOM樹,負責解析js和事件觸發等等。要注意的是它對磁碟和網路等都沒有訪問許可權,這些都是通過瀏覽器程式去訪問的。
  • 外掛程式:專為Flash, Adobe reader這類外掛建立的程式。
  • GPU程式:目前絕大部分瀏覽器都有GPU程式(GPU就是我們通常說的顯示卡的晶片)GPU程式主要負責硬體加速,即提高瀏覽器對視訊和影像的渲染體驗。

以此我們可以看到瀏覽器做的事情其實非常多,它的各個程式相互配合,可以實現瀏覽網頁、播放視訊、檢視pdf, excel等各類文件(只要安裝了對應外掛)、發起網路請求、讀取計算機磁碟等等功能。不過由於瀏覽器核心是由不同廠商開發,所以瀏覽器之間也有差別,雖然它們都被要求遵守W3C(全球資訊網聯盟)的規範,但也僅僅是遵守了部分規範而已。

渲染程式(瀏覽器核心)

渲染程式是我們最關注的程式,它有多個執行緒,主要包括:

  • GUI渲染執行緒:負責渲染介面,即解析HTML和CSS構建DOM樹
  • JS引擎執行緒: 負責解析JavaScript指令碼,處理自己內部的任務(執行棧),如果沒有任務就去執行佇列的棧頂取任務執行。
  • 計時器執行緒:等待延時時間到達就將計時器裡的事件放到執行佇列中。
  • http請求執行緒:等待網路請求返回結果就將其回撥函式放到執行佇列中。
  • 事件觸發執行緒:等待使用者做點選、按下滑鼠等操作時將該事件放到執行佇列中。另外,該執行緒還負責控制事件迴圈。
    ......
    現在讓我們從這段簡單的話裡理出幾條重要的資訊。
  1. GUI渲染執行緒和JS引擎執行緒是互斥的
    因為js指令碼可以操作DOM樹,所以為了避免GUI渲染和js執行在操作DOM時發生衝突,它們並不會一起發生,當GUI渲染過程遇到<script>標籤時就會停下來等待這段js程式碼執行完再繼續渲染。

  2. js引擎是由js當前所在環境提供的
    js可以在瀏覽器裡面執行是因為瀏覽器提供瞭解析js語法的引擎,Node讓js可以執行在服務端是也是因為Node給js提供了引擎,並且,各瀏覽器之間、Node和瀏覽器之間實現的js引擎都是有差異的。

  3. 很多事情是瀏覽器做的而不是js引擎做的
    我以前一直沒深究過單執行緒的js是如何調配事件,如何監聽非同步事件的回撥函式的,現在才知道這些都是瀏覽器在處理,或者說是瀏覽器的渲染程式在處理,而js引擎要做的就是依次處理執行棧中的任務,當執行棧為空就去執行佇列取出第一個任務接著處理。

  4. 瀏覽器會給非同步任務開闢另外的執行緒
    這裡另外的執行緒就是指計時器執行緒、http請求執行緒和事件觸發執行緒等。而js會在執行棧(同步任務)為空時才去處理任務佇列(非同步任務)的任務。

執行棧和任務佇列

執行棧又叫主執行緒,任務佇列又叫訊息佇列

上面我們已經提到了這兩個概念了,現在讓我們結合例子和圖來加深理解。
例3:

    setTimeout(() => {
        console.log('setTimeout延時到了')
    }, 500)
    function excute(a, b) {
        let addRes = add(a, b);
        console.log(`add result: ${addRes}`)
    }
    function add(a, b) {
        return a+b
    }
    excute(2,3)

程式碼開始遇到了setTimeout,由計時器執行緒處理,計時器執行緒會負責計時,當到達setTimeout的延時時間即這裡的500ms時會將其加入到任務佇列中等待js執行。


5807862-547951375b7ee173.jpg
計時器執行緒

接著執行excute()函式,excute()是同步函式,所以進入執行棧(主執行緒)中:

5807862-07cbbe5846f0188f.jpg
執行棧

在執行excute()過程中發現它內部呼叫了add()函式,於是將add()函式也加入執行棧中:
5807862-a4ee96890f8bdd01.jpg
執行棧

add()函式執行完以後將結果返回並從執行棧中彈出:
5807862-135e3b052d520f49.jpg
執行棧

excute()函式列印結果,並從執行棧中彈出,此時執行棧中為空,js開始去處理任務佇列中的任務,假如現在前面的定時器任務已經加入到任務佇列中了:
5807862-a04ebaf74a9793d9.jpg
執行佇列

js會去任務佇列詢問是否有待處理事件,如果有就取第一條執行,此時列印出console.log('setTimeout延時到了')

本篇文章開頭的第一個例子setTimeout的延時是0,不是延時0ms執行,而是延時0ms加入到任務佇列中,所以它會在所有的同步任務執行完以後再執行。而在第二個例子中,for (let i = 0; i < 1000000000; i++) {}是一個比較耗時的同步任務,所以setTimeout列印出的時間差是大於100ms的。(當然,即使是一個耗時很短的同步任務也會導致setTimeout列印出的值大於100,這裡只是為了放大同步任務對其的影響而已)

事件迴圈(Event Loop)

經過前面的分析可以得出以下結論:

js先順序執行執行棧中的任務,當執行棧為空時再去詢問任務佇列是否有任務,而任務佇列是一個先進先出的機構,js引擎始終從任務佇列的頂部取任務執行,js引擎從任務佇列取事件的過程是迴圈不斷的,所以這個過程又被稱為“事件迴圈(Event Loop)”

整個過程大致是這樣:


5807862-c91746afa8c048eb.jpg
2018-03-28_161542.jpg

但是!但是!不僅僅是這麼簡單,如果僅僅是同步任務和非同步任務這種區分方式,那麼看下面這個例子:
例4:

    setTimeout(() => {
        console.log('定時器1開始了~')
    }, 0)
    Promise.resolve().then(() => {
        console.log('promise1 開始了~')
    })
    setTimeout(() => {
        console.log('定時器2開始了~')
        Promise.resolve().then(() => {
            console.log('promise2 開始了~')
        })
    }, 0)
    console.log("---end---");

這段程式碼的輸出結果是什麼?js引擎是如何處理不同型別的非同步任務的?
答案是Microtasks 和 Macrotasks。

Microtasks 和 Macrotasks

Macrotasks也稱Tasks,後文我就直接寫Tasks,也方便大家區分。

我在學習這裡的時候就被誤導過,當時以為Tasks和Microtasks是針對非同步任務而言的,而其實不是,應該說這才是區分任務最準確的方式。

  • Tasks:所有的同步任務(執行棧)、setTimeout、setInterval等
  • Microtasks:Promise、process.nextTick等

一個簡單的結論是:先執行Tasks,Tasks執行完以後再執行Microtasks
當我看到這個結論時,心中已經有了答案,我覺得例4程式碼的列印結果是:

---end---(因為這是在執行棧中的任務,會最先執行)
定時器1開始了~ (setTimeout屬於Tasks,先於Promise執行)
定時器2開始了~
promise1 開始了~ (Promise屬於Microtasks,會在Tasks執行完以後才開始執行)
promise2 開始了~

然而正確的結果是:

---end---
promise1 開始了~
定時器1開始了~
定時器2開始了~
promise2 開始了~

咦?說好的Tasks先於Microtasks執行呢?怎麼反倒是Promise先執行了?然後我又仔細讀了這段話:

js開始執行Tasks,執行過程中如果遇到Microtasks就將其加入任務佇列中,當Tasks執行完畢以後就去執行Microtasks。然後觸發GUI渲染執行緒重新渲染介面,當GUI渲染完成以後再繼續下一輪Tasks,如果下一輪又遇到了Microtasks則等這一輪Tasks執行完畢以後又繼續執行Microtasks......

所以,準確的事件迴圈應該是:Tasks -> Microtasks -> GUI渲染 -> Tasks....

前面的結論其實也沒有問題,確實是先執行Tasks,Tasks執行完以後再執行Microtasks,只是這句話有歧義,先執行Tasks 的意思是先執行當前這一個Tasks,所以!!並不是說Tasks會先於所有的Microtasks執行,而是在每一次的事件迴圈過程中,當前的Tasks一定會先於當前的Microtasks執行

如果還不明白,再看例4的程式碼(一部分):

    setTimeout(() => {
        console.log('定時器1開始了~')
    }, 0)
    Promise.resolve().then(() => {
        console.log('promise1 開始了~')
    })
    console.log("---end---");

setTimeoutconsole.log("---end---")是兩個Tasks,Promise是Microtasks,而setTimeoutPromise是非同步任務會加入到任務佇列中等待執行,console.log("---end---")會直接進入主執行緒(執行棧)執行,現在重新畫一個流程圖就應該是這樣的:

5807862-0de49954aebfaa3a.png
事件迴圈.png

(畫圖畫到吐血啊~)

執行過程已經非常清楚了,每一輪事件迴圈只會執行一個Tasks和多個Microtasks,而所有的同步任務一開始就在執行棧中了,它們的執行優先順序最高,所以setTimeout或者setInterval這類Tasks會在第二輪以後才被執行。

現在再來看例4的全部程式碼:

//程式碼塊1
    setTimeout(() => {
        console.log('定時器1開始了~')
    }, 0)
//程式碼塊2
    Promise.resolve().then(() => {
        console.log('promise1 開始了~')
    })
//程式碼塊3
    setTimeout(() => {
        console.log('定時器2開始了~')
//程式碼塊3-1
        Promise.resolve().then(() => {
            console.log('promise2 開始了~')
        })
    }, 0)
//程式碼塊4
    console.log("---end---");

根據前面的分析,第一輪事件迴圈包括:

  • 主執行緒裡的程式碼,屬於Tasks的程式碼塊4
  • 任務佇列裡的程式碼,屬於Microtasks的程式碼塊2

第二輪事件迴圈包括:

  • 任務佇列裡面的程式碼,屬於Tasks的程式碼塊1

第三輪事件迴圈包括:

  • 任務佇列裡面的程式碼,屬於Tasks的程式碼塊3
  • 任務佇列裡的程式碼,屬於Microtasks的程式碼塊3-1

注意:不同的瀏覽器結果不一樣,但根據規範,這確實才是正確的結果。

HTML5的Web Worker

Web Worker是讓js可以模擬多執行緒工作的技術,即Web Worker裡面的任務不會阻塞主執行緒執行和GUI渲染,但是,由於我們前面提到的原因,Web Worker是不能處理與DOM相關的任務的,具體來說,在Web Worker裡可以操作的物件有:

  • navigator物件
  • location物件(只讀)
  • XMLHttpRequest物件
  • setTimeout和setInterval方法
  • 應用快取

不可操作的物件有:

  • DOM物件
  • Window物件
  • document物件

因為我也還沒用到過這個技術,所以就不再展開它的詳細用法了,建議大家閱讀MDN上的使用 Web Workers,講的非常詳細。

總之呢,Web Worker並沒有讓js由單執行緒變成多執行緒,它只是讓js有了多執行緒的能力,一般來說,會放在Web Worker裡的任務都是耗時或計算量很大的,而大部分時候我們都不需要js來做計算量很大的工作,所以目前用到它的地方還不多,不過這也只是我的看法~

寫在最後

感覺寫在最後的話被我寫在前言裡面了,所以好像也沒啥好總結的了,只是感覺自己很拖沓,這篇文章前前後後拖了大半個月,真的是很懶很拖延了~

相關文章