1. 程式人生 > 其它 >淺析JavaScript中的協程、程序如何切換執行緒的機制、執行緒如何切換協程的機制、協程的體現(生成器函式)、協程如何實現非同步和非阻塞以及為什麼要使用生成器+Promise組合

淺析JavaScript中的協程、程序如何切換執行緒的機制、執行緒如何切換協程的機制、協程的體現(生成器函式)、協程如何實現非同步和非阻塞以及為什麼要使用生成器+Promise組合

一、使用遊戲來理解協程的概念

  如果你還在想辦法理解協程是什麼,那麼就讓我們玩一玩分手廚房。分手廚房(overcooked),是一款多人烹飪遊戲,玩家需要在特定的時間內做出儘可能多的訂單。協程 (coroutine)有些人花了很多時間並不一定能理解它,而遊戲,卻很容易理解。

1、如何玩?先讓我們來看看分手廚房的玩法。

  玩家們分別控制著帶廚師帽的小人,沒有廚師帽的是一些NPC(no player character),如上圖,我們一共有四個玩家,對應著四個廚師。

  遊戲開始後,左上角會不停地出現訂單,而玩家們通過不停地完成這些訂單得分。 時間截止後,分數達到一定,即可進入下一關。

(1)每個訂單會標明需要什麼材料,比如灰色的是蘑菇,紅色的代表西紅柿,棕色的是洋蔥等

(2)每個訂單也標註著自己的製作流程,比如先把切碎西紅柿,接著放在灶臺上煮,當煮好後,將菜盛到盤子裡遞給顧客

  現在我們已經學會了怎麼玩分手廚房,但是要怎麼樣才進入下一關呢?每一關有積分要求,製作完成的訂單越多,得分越多,也就可以通關。

  那麼怎麼樣才能製作更多的訂單呢?小夥伴要分工明確,並且密切地配合,快馬加鞭地做菜。比如同學小張負責切菜,拿食材,同學小丁負責煮,上菜等等,很多時候能者還要多勞。

  實際操作就會發現簡單的規則,但是操作和配合卻很難控制。長時間不能完成的訂單就會銷燬(可能顧客等不及退單了),然後被扣分。食物煮的太久就會著火,著火就要救火。兩個人走位不對就會撞在一起,而且容易衝突起來,該拿的食材沒有拿。

  這些困難對應著協程的理解,可實際上理解協程很簡單,因為你只需要明白訂單本身就可以了。

2、協程是什麼?

  協程可以讓計算機程式在IO密集型的場景下,支援更多的請求,而且比多執行緒的方式,節省更多地資源,效能更優。

  那什麼是協程呢?維基百科是這麼定義的:

協程是計算機程式的一類元件,推廣了協作式多工的子程式,允許執行被掛起與被恢復。相對子例程而言,協程更為一般和靈活,但在實踐中使用沒有子例程那樣廣泛。

  定義裡面的關鍵詞是協作式掛起恢復。是不是有點難理解?實際上它們就真真切切地存在分手廚房裡面

(1)協作式,玩家們不停地協作製作訂單

(2)掛起,當食材切好,放在鍋裡煮著的時候,這個訂單就被“掛起”了

(3)當菜煮好的時候,你就去盛它,這個訂單就被恢復了

  但是協程比分手廚房奇妙的地方在於,站在前人的肩膀上,我們不用像遊戲裡的小人一樣忙手忙手,不知所措,只需編寫訂單,計算機就會充當遊戲裡的小人們把訂單完成得漂漂亮亮的。

  這些訂單就是我們的程式碼,但是它們又不同於常見的程式碼,它們是藉助於async、await構造的協程coroutine,看起來就像是同步的程式碼,但是計算機會用非同步的方式去執行。

3、非同步是什麼?

  類比分手廚房,訂單本身是同步編寫的:先切,後煮,上菜。而訂單的實際製作是非同步的方式:

(1)當出現一個訂單的時候,我們有空的時候,就會去拿食材,然後切菜。

(2)但是如果其他菜已經做好了,我們就會放下當前的訂單,去處理其他的訂單。

  所以訂單們不是從一開始製作,不停地烹飪,直到一個訂單完成,才去製作其他訂單,而是中間穿插了多個不同訂單的製作。這就是非同步方式地烹飪菜餚。

  所以非同步地執行是指做事情仍按照順序,但是並不要求順序在時間上相連,只要按照邏輯的順序即可。這些程式碼看起來跟同步方式執行的程式碼沒有區別,所以叫用同步編寫的程式碼。

二、程序、執行緒與協程

  協程和執行緒對比起來更容易理解,因為他倆實在太像了。

 1、執行緒(英語thread)是作業系統能夠進行運算排程的最小單位。大部分情況下,它被包含在程序之中,是程序中的實際運作單位。

  一條執行緒指的是程序中一個單一順序的控制流,一個程序中可以併發多個執行緒,每條執行緒並行執行不同的任務。

 2、那麼什麼是程序呢?

  程序是程式執行的一個例項。一個程式是靜態的二進位制檔案,是沒有靈魂的,當我們啟動程式時就會開啟一個程序,這個時候作業系統開始讀取程式的二進位制檔案,並且向系統申請一些資源。

  簡言之,程序就是一個程式執行的時候系統環境變數和用到的資源以及本身程式碼的集合,其特點是每個 CPU 核心任何時間內僅能執行一項程序,即同一時刻執行的程序數不會超過核心數,這對支援更高併發是個阻礙,並且為了解決程序阻塞的問題,作業系統遍引入了更輕量的執行緒。

  執行緒是作業系統能夠進行運算排程的最小單位。可以這麼理解:

在程序這個大圈子中,存在著各種資源:

在沒有執行緒時,這些資源全部由一個可執行程式碼調配,這有點像單執行緒程序。

當引入執行緒之後,一個程序下可以有很多執行緒,相當於一個可執行程式碼被分成了很多段,這些片段可以單獨執行,並且使用所在程序內的資源。

3、時間片輪轉

  當然,要使一個應用程式完整執行起來就必須要把這些細分的執行緒全都執行起來,於是便需要時間片輪轉

  作業系統為每一個執行緒分配 CPU 執行時間(通常為幾百毫秒),當執行這個執行緒的時間超過分配的執行時間時,系統會強制 CPU 去執行下一個等待的執行緒(補充一下,執行緒和程序都是有狀態的,這裡這個正在”等待“的執行緒應該是”中斷“狀態),如此快速的不斷切換執行緒便實現了併發。同時程式執行的時候也只會出現執行緒阻塞,而不是整個程序阻塞,如此便解決了上面的問題。

4、併發是指一段時間內執行多個程式(執行緒算是一個程序的子程式)

5、協程

  執行緒是為了解決阻塞和併發的問題,在一段時間內執行更多的程式,類似的,協程也是為了在一段時間執行更多的“程式”(應該說是函式)並且避免執行緒阻塞。有了之前的鋪墊,類比起來講協程就很容易了。

  執行緒和協程解決的併發問題不是一個問題:執行緒是為了讓作業系統併發執行程式,以達到“同時”(實際是時間片輪轉 - 交替執行)執行更多程式的目的,而協程是為了讓一個執行緒內的程式併發服務更多內容。

  這裡不太好解釋,一個直觀的例子就是一個單執行緒的伺服器程式同時服務多個使用者,如何做到服務更多使用者?想想執行緒是怎麼來的,我們只需要把這個執行緒中的程式繼續細分,然後像時間片輪轉一樣不斷的去執行這些細分的“子程式”。即使一個這樣的“子程式”執行發生阻塞,也不會導致整個執行緒阻塞,在這個“子程式”阻塞的時候切換到其他“子程式”繼續服務,既解決了阻塞的問題,也實現了併發。大概理解了吧,協程就是執行緒中可以交替執行的程式碼片段

  執行緒切換是由作業系統的時間片輪轉控制的,而協程是程式自己實現的,讓協程不斷輪流執行,所以實現協程還必須要有一個類似於時間片的結構。

  不同於執行緒的時間片切換,協程的切換不是按照時間來算的,而是按照程式碼既定分配,就是說程式碼執行到這一行才啟動協程,協程是可以由我們程式設計師自己操控的

三、es6生成器函式就是協程的體現

1、協程如何展現?

  在 JavaScript 中,協程是怎樣的呢?其實 es6 裡的生成器函式就是協程的展現。協程,就是一個生成器,生成器本身是一個函式,也就是說在 JavaScript 中協程是由一個生成器函式實現的。

2、協程如何切換?

  協程本身是個函式,協程之間的切換,本質是函式執行權的轉移。

  生成器函式的yield關鍵字可以交出函式的執行權,掛起自身,然後JS引擎去執行這個函式後面的語句。

  使用 yield next() 方法就能不斷的交出和恢復函式的執行權。

3、可以把生成器函式的執行權交給普通函式(你也可以把非協程看做是一個協程整體),也可以在一個協程中呼叫另一個協程,實現協程之間的切換

function* anotherGenerator(i) {
    yield i + 1;
    yield i + 2;
    yield i + 3;
}
function* generator(i) {
    yield i;
    yield* anotherGenerator(i); // 移交執行權
    yield i + 10;
}
var gen = generator(10);
console.log(gen.next().value); // 10
console.log(gen.next().value); // i=10,傳入anotherGenerator協程,為 11
console.log(gen.next().value); // 12
console.log(gen.next().value); // 13
console.log(gen.next().value); // 20

  第 9 行使用 yield* 將執行權交給另一個生成器函式,接下來要等到這個生成器函式anotherGenertor()執行完畢,執行權才會回到generator函式。這和普通函式表現一致,都是後進先出,符合JS事件迴圈機制(Event Loop)

四、協程如何實現非同步和非阻塞

1、非同步

  實現非同步的關鍵就是把會阻塞執行緒函式的執行權交出去,讓這個函式等待恢復執行,等待的時間內請求(或者其他非同步任務)也該執行完了,這時候再來繼續執行這個函式。

  通過前面對協程的執行方式的講解我們很容易就能想到用協程來解決這個問題,利用 yield 掛起這個阻塞執行緒函式,然後繼續執行後面的語句,等這個函式不再阻塞了,再回到這個函式繼續執行。

  那麼問題來了,應該什麼時候繼續執行這個掛起的函式呢?你可能想到大概估計一下阻塞時間,設定時間再回來執行,這個方案有點牽強。

2、Promise

  這時候 Promise 就派上用場了,Promise 本質是一個狀態機,用於表示一個非同步操作的最終完成 (或失敗)及其結果值。它有三個狀態:

  • pending: 初始狀態,既不是成功,也不是失敗狀態
  • fulfilled: 意味著操作成功完成
  • rejected: 意味著操作失敗

  最終 Promise 會有兩種狀態,一種成功,一種失敗,當 pending 變化的時候,Promise 物件會根據最終的狀態呼叫不同的處理函式

  根據 Promise 的特點,他是一個狀態機,在yield之後可以用 Promise 來表示非同步任務是否執行完畢(是否是pending狀態),並且還能夠自動判別非同步任務成功與否(fulfilled 還是 rejected)並執行處理函式。

  如此看來用協程+Promise 可以完美實現非同步,讓我們來根據上面的理論實現一下:

// 模擬阻塞2s事件
function resolveAfter2Seconds(val) {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve(val);
        }, 2000);
    });
}
// 實現生成器
function* coroutineFunc(val) {
    yield resolveAfter2Seconds(val);
}

let doIt = coroutineFunc("OK"); // 生成器建立協程
let value = doIt.next().value; // 執行
// value是Promise物件
value.then((res) => {
    console.log(res);
});
// 模擬後面被阻塞的語句
for (let i = 0; i < 10; i++) {
    console.log(i);
}

  這段程式碼的輸出順序是 0=>1=>2=>...=>9,兩秒之後輸出'OK',從輸出順序來看我們已經實現了非同步。其執行過程和之前說的一樣,掛起會阻塞執行的函式,繼續執行後面的語句,等待 Promise 改變狀態並自動執行處理函式。

3、使用 Generator、Promise 組合和直接使用 Promise 的區別

  實際上下面這段程式碼執行順序的結果和上面一模一樣:

function resolveAfter2Seconds(val) {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve(val);
        }, 2000);
    });
}
resolveAfter2Seconds("OK").then((res) => {
    console.log(res);
});

  但是我們為什麼要使用上面那種複雜的寫法呢?原因有3:

(1)為了簡化問題,便於理解,我已經簡化了程式碼,在前一個例子中,生成器函式內,yield 行後面完全可以寫更多的程式碼,這些程式碼一定是在非同步獲取到資料之後才執行的

(2)如果直接使用 Promise 需要把這些程式碼放在 then 程式碼塊裡邊才能保證在非同步獲取到值之後執行

(3)那麼當有多個非同步事件的時候問題就來了——可怕的巢狀

4、Async、Await

  ECMAscript2017 中提供了更高階的協程控制語法,其被看做是對 Generator 和 Promise 組合的封裝,使非同步函式看起來更像同步函式,減輕開發者的痛苦。上面的例子改寫:

async function f1() {
    var x = await resolveAfter2Seconds(10);
    console.log(x); // 10
}

  可以看出 Async、Await 實現了 Generator 的自動迭代,不需要手動使用next()方法來繼續執行。