精讀Javascript系列(六)併發編程、 Javascript異步框架

前言

Javascript是非阻塞型單線程事件驅動的語言,故而JS和瀏覽器API(WebWorker)聯合才能實現異步,異步並不是JS核心的一部分。如果接觸過C++這類較底層的面嚮對象語言,就可知JS異步是併發編程的極大幅度簡化,JS很完美的將底層封裝起來,不需要程序員關注麻煩透頂的細節,只需要幾行代碼就能實現異步。

或許JS異步真的就是你人生的夢魘,即便是動動手就能實現,卻始終沒辦法用合適的理論去掌握它,以至於常常在一些特殊情形出現遺漏。事實上,你不真正理解異步,你就不可能真正瞭解Javascript這門語言

本文會涉及大量底層內容,對進階有些參考性,但是剛接觸JS的,選擇性的閱讀。

注意術語

  1. 本文中所用的術語(例如阻塞同步等)皆是線程級,皆是線程級,皆是線程級(重要的事情說三遍)
  2. 進程層級和線程層級兩者有本質上的不同。
  3. 換個角度說,學過系統編程的,請看看程序編程
  4. 零基礎賽高!

總之,開始吧。


併發編程

理解異步,就必須要知道什麼是異步。不過在此之前,首先要了解什麼是併發編程,下面的內容會捎帶一些底層知識,對一些概念也做了一些簡化,因此詳細請參考其他書籍。

首先,術語事件都是指任務, 兩個術語都是通用的(事實上許多術語表達的都是同一個意思)

同一時刻,要準備完成(即待處理)兩個及兩個以上的任務時,就說有多個事件正在發生, 即併發;此時,這些事件就可以說成是併發事件不支持併發的系統在同一時刻只會有一個待處理的任務。

仍然是在同一時刻,必須同時執行兩個及兩個以上的任務時, 就說有多個事件正在進行(或稱作處理)中,即並行。由於並行的前提是有多個併發事件,因此並行編程的前提是支持併發

線程執行任務最小運作單位,簡單來說就是一個線程在同一時刻負責處理一個任務。每個線程各自都有一個調用棧(對應於執行棧),並負責處理自己調用棧的任務(確切來說是執行上下文

單核心CPU處理器的計算機上,它只有一個線程,因此在某一時刻只能執行一個任務;處理多個併發事件時,

  • 線程上切割成數個時間片
  • 將這些任務再次分割爲數個獨立片段(真正的原子級別ATOM),並將它們一一分配到時間片中。
  • 採取的策略是:這個任務執行一些,其他的任務再執行一些,即在單位時間內交替執行任務。由於切換速度極快,因此根本察覺不到任務交替。
  • 不同任務的切換是需要開銷的。

畫成圖就是:
在這裏插入圖片描述
但是在多核心處理器中,有多個線程,因此可以同時執行多個任務(即並行啦), 並行可以有效節省任務切換的開銷。
即:
在這裏插入圖片描述
注意

  • 無論是併發還是並行,所說的都是同一時刻的事情;只是併發所說的事件是未處理的,因此還是停留在發生階段; 而並行則所說的事件是正在處理的, 已經進入到了執行階段
  • 一個事件要被處理,首先能夠檢查到它已經發生。一個未發生的事件是不可能得到處理的
  • 併發相較於並行側重點在於如何更快處理待處理的事件並考慮如何更快的進行事件切換並行相較於併發側重點在於如何同時處理正在執行中的事件
  • 高級編程語言中,併發並行兩個概念在併發編程中不會做嚴格區分(確切來講,併發並行硬件編程領域,哎,那是來自深淵的概念。。。);通俗來講,我們的併發就是默認並行的。

關於JS:

  • Javascript本身是單線程的,但它支持併發,但是無法並行
  • 正因爲如此,JS異步框架也只能是基於單線程的。

好了,併發就這麼簡單。


線程通信

當一個任務完成前, 可能會使用上一個任務的處理結果。這時就可以說前一個任務依賴於後一個任務。當然,如果任務之間並無依賴,那麼各自執行完畢即可。

例如:

  let taskA = (a,b)=>a+b  // 相加 
  let taskB = (a,b)=>a-b  // 相乘
  let taskC = (a,b)=>a*b  // 平方差
  let a = 100, b = 200;
  let res = taskC(taskA(a,b), taskC(a,b))
  console.log(res);  // 6000000

如上面代碼, taskC需要使用taskAtaskB的處理結果(這裏指的是返回值),就可以說taskC依賴於taskAtaskB兩個任務。但是taskAtaskB兩個任務並無依賴,因此誰先完成都可以。

當然下面的代碼也可以說成是依賴:

  var  o={}
  var f1=(a,b)=>{o.a=a, o.b=b}
  var f2=()=>{
       f1(1111,2222);
       console.log(`a=${o.a},b=${o.b}`);
   }
  
  f2();

如果在f2之前, 不執行f1處理對象 of2就無法順利完成(因爲會中途報錯),儘管不是返回值的形式,但這也是一種依賴

綜合所述,兩個線程:ThreadAThreadB, 如果ThreadA中的任務依賴於ThreadB的任務,就可以說成ThreadA依賴於ThreadB。 也就是在這個時候,併發編程變得撲朔迷離起來,即併發編程難點在於如何管理兩者依賴的資源。

一旦線程之間有了依賴, 線程之間就必須建立通信;即必須要有一個有效的手段告知對方線程在何時執行或是對於或是告知己方線程在何時處理完成。線程通信共有兩種方式: 一、共享內存, 二、消息傳遞。

備註:

  • 沒有依賴的線程,自己完成自己的任務就Okay了
  • 簡單來說,就是有事聯繫,沒事就……(看來技術也來自生活啊,哎)。

消息傳遞

現在假設有兩個線程TATB,且TA依賴於TB
消息傳遞通信機制中,,那麼 :

  1. TA 需要得到 TB 的處理結果時,這稱作請求
    • 請求操作可以是一個函數調用,或是發送一個請求消息
    • 請求時也可以提供一些附帶消息,例如函數參數(上例中的f1(1111,2222));或是消息中包含回調函數等等,例如setTimeout(....)
    • 但凡是能在兩個線程間傳遞的,都可以稱作消息
  2. TB 接收 TA 的請求後,TB便進入處理任務的過程,這稱作響應
    • TB可能會立即執行任務, 即立即響應,它可以直接返回(返回時也可以附帶一個返回值
    • TB也可能不立即執行任務,但是會將請求消息保存在自己的消息序列中,等候處理。

模型:(C是提供給Thread的交流組件)
在這裏插入圖片描述
注意

  • 細節遠比上面所說的複雜,詳細參考經典Actor模型 ; 它是真正面向對象的。
  • 消息傳遞儘管是基於異步(它本身沒有同步),但不代表它無法實現同步。
  • 請求響應沒有那麼大的必然性。請求時並不會關心對方線程如何響應,它關心的是響應的結果是否符合預期;同理,響應時也不會關心對方線程是怎麼請求的,它關心的只是到最後給予響應,至於何時纔會響應是己方線程的事情
  • 舉一個例子:在上課時,老師會讓一個學生站起來回答問題,此時老師向學生問的問題就是一個請求學生回答問題就是一個響應,至於學生是否應該站起來,與老師無關
  • 再舉一個例子:張三老婆讓張三下班回家順便買菜。這裏順便買菜就是張三老婆請求命令),張三買菜就是響應,至於是立即就買菜,還是下班買菜,與張三老婆無關。張三可以把買菜這則請求牢記於心下班再買,這就類似於把請求放到消息隊列中。

共享內存

這也是最常見的通信方式。簡而言之,即在內存中開闢出一塊公共內存空間,稱作共享內存。 線程會把所需的資源(變量)都放在共享內存中,這樣對方線程就可以通過這個變量的操作告知對方狀態。

爲防止線程噁心競爭共享內存,會使用一個來管理資源。即當一個線程使用資源時,會把資源上鎖,通過這個的狀態,其他線程就知道現在無法操作,可能會等待,也可能會繼續執行。 當線程使用完畢,便解鎖資源,此時其他線程就可以使用、上鎖、解鎖。如此反覆。

如:
在這裏插入圖片描述(這裏只是一個簡單模型,事實上也涉及了許多複雜內容。詳細請直接谷歌百度……)

但是這種通信方式極容易產生問題:

  • 當某個線程遲遲不解鎖,那麼其餘線程就一直無法訪問到資源,有的線程可能會被迫長時間陷入等待狀態。這種情形就稱作死鎖
  • 其次,多個線程對資源總會有一個競爭關係,如果沒有好的方式管理,會導致更大的災難。

對硬件的瞭解就這麼多好了。
至少,作爲一個開發者,瞭解一下這些底層有什麼關係;Javascript開發者也不應該例外


同步、異步、阻塞、非阻塞

正如之前所說的,己方線程請求後對方線程會立即響應,所以己方線程沒必要再急着繼續執行任務, 只需要稍稍等待一()點點時間就可以接收到響應然後執行;類似於這種一方請求後會等待對方立即響應的通信稱作同步,同步通信中,請求後必須等待對方響應後纔可以執行。

與之相反, 己方線程請求後根本不關心對方線程是不是立即響應,並且對方線程也真的不會立即響應;然後己方繼續完成自己的剩餘任務,類似於這種通信方式稱作異步;換言之,異步通信中,請求後是立即接收到對方響應的

同步異步是兩種截然不同的實現方式,但只要效果一致就可以。

常見線程狀態:

  1. 線程在不做任何事情時,處於一種空閒狀態,即休眠(dormant)。
  2. 當線程正在處理一個任務時併發送一個請求,在得到響應之前線程原則上是可以做任何事的,諸如線程會一直檢查通信的對方線程的完成狀態,但是這樣會耗費寶貴的CPU資源; 爲此一般會強制掛起該線程(使之暫停), 令其在得到響應前一直處於等待狀態, 這種情形稱作阻塞(Blocking)。
  3. 當然,線程發送請求後並不要求立即得到響應,這時線程在真正得到響應前仍然繼續執行任務,這就是非阻塞(Non-Blocking)

其次:

  • 同步與異步, 與線程是否阻塞並無概念的關聯。只是阻塞的確可能導致同步,相反如果同步不阻塞就導致了異步。
  • 一般而言,請求等價於發送消息和函數調用; 響應等價於另一方線程執行任務。
  • 因此,是同步還是異步,取決於響應時機,換言之,要求立即響應的,就是同步;不要求立即響應的,就是異步。還是之前的一句話,不要用阻塞非阻塞區分同步和異步。

術語統一:

  1. 一個函數調用後並且能夠立即得到響應, 那麼就可以稱作這個函數調用就可以稱作同步請求。
  2. 一個函數調用後只是發送消息,並不能立即得到響應。那麼這個函數調用就是異步請求。

現在正式認識一下JS異步吧。


JavaScript異步框架

JS異步併發處理框架:

  1. 事件循環(EventLoop),用於處理消息序列中的所有任務,擁有自己的調用棧(對應於執行棧)。
  2. 消息序列(EventBus/MessageQueue):線程要處理的所有任務鏈表。
  3. 事件循環可以有許多個消息序列。

因爲JS是單線程語言,結合常見的併發異步框架,可以得到下圖:
在這裏插入圖片描述

注意:

  • 上面的圖示是根據Vert.x進行改動的(JS的確可以用這種併發模型,詳見官方介紹)
  • 並沒有消息序列進行詳細劃分(例如延遲隊列、宏任務序列、微任務序列……)
  • 和網上流傳的模型,是同義的。

事件循環如下:

  1. 將線程從休眠狀態喚醒;檢查待處理的事件;開啓事件循環;
  2. 更新消息序列
    1. 事件發生後發出請求(沒發生就不發送請求)
    2. 那麼保存在消息序列中。
  3. 從消息序列中取出任務
    1. 按照串行順序取出任務
    2. 當所有消息序列清空後,可能會觸發視圖渲染事件(也可能不觸發);
    3. 一旦觸發,便會在消息序列中再新增一個消息。然後到4步繼續處理。
    4. 如果再也不會觸發任何事件,且消息序列爲空,轉至5
  4. 通過事件循環執行任務
    1. 在這個階段,如果有任務發送異步請求的消息,那麼將該消息放到消息序列的後面;然後繼續執行任務(不會被掛起)。
    2. 反過來說,如果有任務只是同步請求,則由JS引擎掛起當前任務並更換運行時執行上下文,並不會添加消息。
    3. 此處涉及到大量底層操作,例如執行棧的彈出和推入等等。
    4. 任務處理完畢後,回到3
  5. 停止事件循環,線程進入休眠狀態。
  6. 如果有事件發生,回到1

整理以上內容,可以得到下面的結論:

  • 事件循環一旦開啓就不會陷入阻塞(可以理解爲事件循環不會被強制暫停)
  • 阻塞的任務放到消息序列中, 不會阻塞的任務直接在事件循環中執行。
  • 每個事件循環的執行上下文的狀態都是不同的,換言之,每個事件循環不會共享狀態。

附註:

上面沒有提到microtasksmacrotasks,這只是暫時瞭解。

setTimeout是最常見的異步操作;本質上它是一個定時器。它可以將消息加入到延遲隊列中(瀏覽器提供的)。當消息到期後便會被取出放到事件循環中運行。這裏可以將延遲隊列視作一個特殊的消息隊列,在setTimeout不爲0時一定要牢記順序,畢竟雖然也視作了消息隊列,但是本身有一定特殊性的。

例如:

   console.log(1111);
   setTimeout(()=>{
       console.log('Easy Asynchronous Javascript');    
   },3000)
   // 毫秒爲單位
   console.log(2222);
   

輸出如下:

1111
2222
Easy Asynchronous Javascript  // 3s後輸出

事實上,我們提供給setTimeout的時間指的是最小延遲時間(最小是4ms),例如下面的代碼將會延長當前事件循環的執行週期:

 var start = new Date();
 console.log('開始延長3s');
 do{
     var current = new Date();
 }while(current-start <= 3000);
 console.log('3s後輸出這裏');
 setTimeout(()=>{console.log('easy js');}, 0)

下面的代碼會同時輸出。

    console.log(1111);
    var start = new Date();
    setTimeout(()=>{
        console.log('Easy Asynchronous Javascript');    
    },3000)
    do{
        var current = new Date();
    }while(current-start<=3000)

JS異步追蹤:

異步相較於同步很難理解,其難點在於不能很清楚的分析線程何時才能得到響應

下面給出一個同步代碼:

 var tmp = 0;
 var syncTask = ()=>{tmp+=1000}
 var main=()=>{
     
     syncTask();
     console.log(tmp); // 1000
 }
 main();

JS執行棧機制,保證了syncTask響應前main函數無法繼續執行(main被掛起了),換言之,main函數總能獲取syncTask的響應,因此同步代碼中,無須擔心得不到響應的問題。

但是換成這樣:

  var tmp = 0;
  var asyncTask = ()=>{tmp+=1000}
  var main=()=>{
      
      setTimeout(asyncTask)    /// (*)
      console.log(tmp); // 0 
  }
  
  main();
  setTimeout(()=>{console.log(tmp)}) // 1000  (**)

這是爲什麼?

(*)行結束後,由於main函數沒有被掛起,因此繼續執行。所以:tmp仍然是 0

此時當前消息序列中卻有了一條新消息,即:
在這裏插入圖片描述
請注意: 這則消息是被安排在了最後。

script標籤內的所有代碼完成,又處理完了其他事件,此時消息隊列就成了這樣:
在這裏插入圖片描述
最後,執行到(**)位置輸出tmp

在這裏插入圖片描述
okay,先到這裏。

Javascript完整模型:

結合我們所學,執行上下文、執行棧、瀏覽器客戶端、內存、消息序列……就可以得到下面的圖:
在這裏插入圖片描述很難懂麼????

把Javascript語言特性綜合到一起就是:

  • Javascript是單線程的 --> 所以只有一個ExecutionStack 並且沒有其他阻塞線程(不同於多線程事件循環!)。
  • Javascript的事件循環, 永不阻塞
  • Javascript是事件驅動,並且是內部是高度抽象(即底層不可見特性)。
  • handlers環境捆綁到一起, 數據既可能存放在*stack**,也可能存放在heap中。
  • handler運行時,會產生一個ExecutionContext(執行上下文),便於ExecutionStack建立關聯。

可以這麼說吧,進階第一步就是爲了理解這張破圖,深挖JS然後看看它的本質是什麼。

最後

如果你我有緣相遇,你會發現我所寫的與常見博文有極大不同之處,很簡單,我的資料來源都是無人問津的,大部分很有參考性的資料都不會主動顯於人前,都必須靠自己尋找 ;

嘛,我的態度就是我只寫好我的筆記,然後保持沉默,僅此而已

若, 你對進階抱有疑問, 你可能會懷疑這些東西的實用價值。但是事實上,在你第一次學習Javascript,就已經把所有實用的東西都學完了, 進階的目的就是挖掘這些實用代碼的底層理論(不是重頭複習……)。還是那句話,如果你不想深度解讀源代碼,不進階也可以的。總之,祝君好運。

下面是兩個參考資料:
《Vert.x線程解密》
《C++併發編程 》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章