JavaScript 異步編程指南 — 關于協程的一些思考
本文轉載自微信公眾號「五月君」,作者五月君。轉載本文請聯系五月君公眾號。
從 Callback 到 Promise 的 .then().then()... 也是在不斷嘗試去解決異步編程帶來的回調嵌套、錯誤管理等問題,Promise 進一步解決了這些問題,但是當異步鏈多了之后你會發現代碼會變成這樣 .then().then()... 由原來的橫向變成了縱向的模式,仍就存在冗余的代碼,基于我們大腦對事物的思考,我們更傾向于一種近乎 “同步” 的寫法來表達我們的異步代碼,在 ES6 規范中為我們提供了 Generator 函數進一步改善我們的代碼編寫方式。
Generator 中文翻譯過來我們可以稱呼它為 “生成器”,它擁有函數的執行權,知道什么時候暫停、什么時候執行,這里還有一個概念協程,有些地方也看到過一些提問:“JavaScript 中有協程嗎?” “Node.js 中有協程嗎?” 這些問題正是本文討論的,本節著重從概念上讓大家做一些了解,認識到協程在 JavaScript 是怎么樣的存在。
進程 VS 線程 VS 協程?
在了解協程之前,先看進程、線程分別是什么,分享一個筆者之前寫的 Node.js 進階之進程與線程 文中結合 Node.js 列舉了一些示例,也是從一些基礎的層面來理解。
進程
進程(Process)是計算機中的程序關于某數據集合上的一次運行活動,是系統進行資源分配和調度的基本單位,是操作系統結構的基礎,進程是線程的容器(來自百科)。
我們啟動一個服務、運行一個實例,就是開一個服務進程,例如 Java 里的 JVM 本身就是一個進程,Node.js 里通過 node app.js 開啟一個服務進程,多進程就是進程的復制(fork),fork 出來的每個進程都擁有自己的獨立空間地址、數據棧,一個進程無法訪問另外一個進程里定義的變量、數據結構,只有建立了 IPC 通信,進程之間才可數據共享。
Mac 系統自帶的監控工具 “活動監視器” 也可看到效果。
Node.js 中我們通過 Cluster 模塊創建多進程時為什么要根據 CPU 核心數?創建更多不好嗎?在一個 CPU 核心的任何時間內只能執行一個進程。因此,當你 CPU 核心數有限時,創建過多的進程,CPU 也是忙不過來的。
Node.js 通過單線程 + 事件循環解決了并發問題。而我們使用 Node.js 利用 Cluster 模塊根據 CPU 核心數創建多進程解決的是并行問題,假設我有 4 CPU 每個 CPU 分別對應一個線程并行處理 A、B、C、D 不同的任務,線程之間互不搶占資源。
一句話總結:進程之間數據完全隔離、由操作系統調度,自動切換上下文信息,屬系統層級的構造。
線程
線程是操作系統能夠進行運算調度的最小單位,首先我們要清楚線程是隸屬于進程的,被包含于進程之中。一個線程只能隸屬于一個進程,但是一個進程是可以擁有多個線程的。
同一塊代碼,可以根據系統 CPU 核心數啟動多個進程,每個進程都有屬于自己的獨立運行空間,進程之間是不相互影響的。同一進程中的多條線程將共享該進程中的全部系統資源,如虛擬地址空間,文件描述符和信號處理等。但同一進程中的多個線程有各自的調用棧(call stack),自己的寄存器環境(register context),自己的線程本地存儲(thread-local storage),線程又有單線程和多線程之分,具有代表性的 JavaScript、Java 語言。
線程共享進程的資源,可以由系統調度運行,可以自動完成線程切換,也許你會聽到多線程編程、并發問題,首先,并發指的某個時間點多個任務隊列對應到同一個 CPU 上運行,在任一時間點內也只會有一個任務隊列在 CPU 上執行,這時就產生排隊了。
為了解決這個問題,CPU 運行時間片會被分成多個 CPU 時間段,每個時間段給各個任務隊列執行(對應多個線程),這樣解決了一個任務如果造成阻塞,不會影響到其它的任務運行,同樣線程是會自動切換的。
Node.js 是怎么解決的并發問題?Node.js 主線程是單線程的,核心通過事件循環,每次循環時取出任務隊列中的可執行任務運行,沒有多線程上下文切換,資源搶占問題,達到高并發成就。
一句話總結:線程之間大多數共享數據(各自的調用棧這些信息除外),由操作系統調用,自動切換上下文,系統層級的構造。
協程
協程又稱為微線程、纖程,英文 Coroutine。協程類似于線程,但是協程是協作式多任務的,而線程是搶占式多任務的。協程之間的調用不需要涉及任何系統調用,是語言層級的構造,可看作一種形式的控制流,有時候我們也會稱它為用戶態的輕量級線程。
協程一個特點是通過關鍵字 yield 調用其它協程,接下來每次協程被調用時,從協程上次 yield 返回的位置接著執行,這種通過 yield 協作轉移執行權的操作,彼此沒有調用者和被調用者的關系,是彼此平等對稱的一種關系。
協程與線程兩者的差異,可以看出 “同一時間如果有多個線程,但它們會都處于運行狀態,線程是搶占式的,而協程同一時間運行的只有一個,其它的協程處于暫停狀態,執行權由協程自己分配”。
協程也不是萬能的,它需要配合異步 I/O 才能發揮最好的效果,對于操作系統而言是不知道協程的存在的,它只知道線程。需要注意,如果一個協程遇到了阻塞的 I/O 調用,這時會導致操作系統讓線程阻塞,那么在這個線程上的其它協程也都會陷入阻塞。
一句話總結:協程共享數據,由程序控制完成上下文切換,語言層級的構造。
JavaScript 有協程嗎
之前知乎上有個問題 “Node.js 真的有協程嗎?” 協程在很多語言中都支持,只是每個實現略有差異,下圖來自維基百科展示了支持協程的編程語言,可以看到 JavaScript 在 ECMAScript 6 支持,ECMAScript 7 之后通過 await 支持,Node.js 做為 JavaScript 在服務端的運行時,只要你的 Node.js 版本對應支持,就是可以的。
協程在 JavaScript 中的實現
生成器與協程
生成器(Generator)是協程的子集,也稱為 “半協程”。差異在于,生成器只能把控制權交給它的調用者,完全協程有能力控制在它讓位之后哪個協程立即接續它執行。在 JavaScript 里我們說的 Generator 函數就是 ES6 對協程的實現。
JavaScript 是一個單線程的語言,只能保持一個調用棧。在異步操作的回調函數里,一旦出錯原始的調用棧早已結束,引入協程之后每個任務可以保持自己的調用棧,這樣解決的一大問題是出錯誤時可以找到原始的調用棧。
看下生成器函數與普通函數有什么區別?首先普通函數通過棧實現的,舉個例子,調用時是 A() -> B() -> C() 入棧,最后是 C() -> B() -> A() 這樣一個順序最后進入的先出棧執行。
生成器函數看似和普通函數相似,其實內部執行機制是完全不同的,生成器函數在內部執行遇到 yield 會交出函數的執行權給其它協程(此處類似 CPU 中斷),轉而去執行別的任務,在將來一段時間后等到執行權返回(生成器還會把控制權交給它的調用者),程序再從暫停的地方繼續執行。
無堆棧協程
自 ES6 開始,通過 “Generator” 和 “yield” 表達式提供了無堆棧協程功能。
“無棧協程的秘密在于它們只能從頂級函數中掛起自己。對于其他所有函數,它們的數據都分配在被調用者堆棧上,因此從協程調用的所有函數必須在掛起協程之前完成。協程保留其狀態所需的所有數據都在堆上動態分配。這通常需要幾個局部變量和參數,其大小遠小于預先分配的整個堆棧”。參考 coroutines-introduction
棧是一塊連續的內存,能夠從子函數產生的協程稱為棧式,它們可以記住整個調用棧,這種也稱為棧式協程。在 JavaScript 中我們只能從生成器函數內部暫停、恢復執行生成器函數。
下面示例 test1() 是生成器函數,但是 forEach 里面的匿名函數是一個普通的函數,就無法在內部使用 yield 關鍵字,運行時會拋出錯誤 “SyntaxError: Unexpected identifier”
- function *test1() {
- console.log('execution start');
- ['A', 'B'].forEach(function(item) {
- yield item;
- })
- }
生成器函數示例
例如,現在有兩個生成器函數 test1()、test2(),還有 co 這個工具可以幫助我們自動的執行生成器函數。
- const co = require('co');
- function *test1() {
- console.log('execution 1');
- console.log(yield Promise.resolve(1));
- console.log('execution 2');
- console.log(yield Promise.resolve(2));
- }
- function *test2() {
- console.log('execution a');
- console.log(yield Promise.resolve('a'));
- console.log('execution b');
- console.log(yield Promise.resolve('b'));
- }
- co(test1);
- co(test2);
看下運行結果:
- 第一次程序執行 test1() 函數,先輸出 'execution 1' 遇到 yield 語句程序的控制權轉移。
- 現在執行權轉移到了 test2() 函數,執行代碼輸出 'execution a' 當遇到 yield 語句后交出程序的控制權。
- 此時 test1() 函數收回執行權,恢復執行輸出 '1' 繼續往下執行輸出 'execution 2' 當遇到 yield 語句再次交出執行權,依次類推。
- execution 1
- execution a
- 1
- execution 2
- a
- execution b
- 2
- b
總結
“JavaScript 有協程嗎?” JavaScript 中是在 ES6 后基于生成器函數(Generator)實現的,生成器只能把程序的執行權還給它的調用者,這種方式我們稱為 “半協程”,而完全的協程是任何函數都可讓暫停的協程執行。
基于生成器函數這種寫法,如果去掉 yield 關鍵字,與我們普通的函數是相似的,以一種同步的方式來表達,解決了回調嵌套的問題,另外我們還可以通過 try...catch 做錯誤捕獲,只不過我們還需要借助 CO 這樣的模塊,讓生成器函數自動執行,這個問題在 ES7 中已經得到了更好地解決,我們可以通過 async/await 輕松的實現。
Reference
https://en.wikipedia.org/wiki/Coroutine#Implementations_in_JavaScript
https://zhuanlan.zhihu.com/p/70256971
http://zhangchen915.com/index.php/archives/719/
https://es6.ruanyifeng.com/#docs/generator