協程(Coroutine)-ES中關於Generator/async/await的學習思考

協程--輕量級的用戶態線程

協程(Coroutine)是一種輕量級的用戶態線程。簡單來說,進程(Process), 線程(Thread)的調度是由操作系統負責,線程的睡眠、等待、喚醒的時機是由操作系統控制,開發者無法精確的控制它們。使用協程,開發者可以自行控制程序切換的時機,可以在一個函數執行到一半的時候中斷執行,讓出CPU,在需要的時候再回到中斷點繼續執行。因爲切換的時機是由開發者來決定的,就可以結合業務的需求來實現一些高級的特性。

先來抽象的理解一下進程和線程

術語簡介

上下文: 指的是程序在執行中的一個狀態。通常我們會用調用棧來表示這個狀態——棧記載了每個調用層級執行到哪裏,還有執行時的環境情況等所有有關的信息。   
調度: 指的是決定哪個上下文可以獲得接下去的CPU時間的方法。
進程: 是一種古老而典型的上下文系統,每個進程有獨立的地址空間,資源句柄,他們互相之間不發生干擾。
線程: 是一種輕量進程,實際上在linux內核中,兩者幾乎沒有差別,除了線程並不產生新的地址空間和資源描述符表,而是複用父進程的。 但是無論如何,線程的調度和進程一樣,必須陷入內核態。

詳細的知識介紹請參考《深入理解計算機操作系統》一書,這裏只是簡單的說一些概念;

  1. 早期時候,CPU都是單核的,由於單核 CPU 無法被平行使用(多個程序同時運行在一個CPU上)。爲了創造共享CPU的假象,設計人員就搞出了一個叫做時間片的概念,將時間分割成爲連續的時間片段,讓多個程序在這些連續的時間片中交叉獲得CPU使用權限,這樣看起來就好像多個程序在同時運行一樣。後來,給任務分配時間片並進行調度的調度器成爲了操作系統的核心組件;

  2. 時間被分割爲連續的時間片段後,在調度不同程序運行的時候,如果不對內存進行管理(上下文切換),那麼切換時間片的時候會造成程序上下文的互相污染(A程序在運行時,突然發現自己內存中多了好多變量,一臉萌比。其實這些變量全是B程序產生的,如果兩個程序存在同名變量,那更是兩臉萌比了)。但是手工管理物理地址難度巨大,因此設計大牛們又引入了虛擬地址的概念,共包含三個部分:

    • CPU 增加了內存管理單元模塊,來進行虛擬地址和物理地址的轉換;
    • 操作系統加入了內存管理模塊,負責管理物理內存和虛擬內存;
    • 發明了一個概念叫做進程。進程的虛擬地址一樣,經過操作系統和 MMU 映射到不同的物理地址上。
  3. 經過前面兩部的演變,進程就產生了。進程是由一大堆元素組成的一個實體,其中最基本的兩個元素就是代碼和能夠被代碼控制的資源(包括內存、I/O、文件等);一個進程從產生到消亡,可以被操作系統調度。掌控資源和能夠被調度是進程的兩大基本特點。但是進程作爲一個基本的調度單位有點不人性:假如我想一邊循環輸出 hello world,一邊接收用戶輸入計算加減法,就得起倆進程,那隨便寫個代碼都像 chrome 一樣變成內存殺手了。這個時候,設計大牛們又想着能不能有一種輕量級的進程呢,這種‘輕量級的進程’不需要自己的獨立的內存,IO等資源,而是共享已有的資源,緊接着誕生了線程的概念,線程在進程內部,處理併發的邏輯,擁有獨立的棧,卻共享線程的資源。使用線程作爲 CPU 的基本調度單位顯得更加有效率,但也引發各種搶佔資源的問題,活生生變成了一把雙刃劍.

通過上面的介紹,我們現在在來說一下協程產生的背景。上面的介紹中,我們知道進程掌握着獨立資源,線程享受着基本調度。一個進程裏可以跑多個線程處理併發。但純粹的內核態線程有一個問題就是性能消耗:線程切換的時候,進程需要爲了管理而切換到內核態,狀態轉換的消耗有點嚴重。爲此又產生了一個概念,喚做用戶態線程(協程)。用戶態線程就是程序自己控制狀態切換,進程不用陷入內核態,開發者可以按照程序的特性來選擇更適合的調度算法,協程屬於語言級別的調度算法實現。

協程、子例程與生成器的區別

協程的概念產生的非常早,Melvin Conway 早在 1963 年就針對編譯器的設計提出一種將”語法分析”和”詞法分析”分離的方案,把 token 作爲貨物,將其轉換爲經典的生產者-消費者問題。編譯器的控制流在詞法和語法解析之間來回切換:當詞法模塊讀入足夠多的 token 時,控制流交給語法分析;當語法分析消化完所有 token 後,控制流交給詞法分析。(有興趣的童鞋可以去看看編譯原理,推薦龍書)從這一概念提出的環境我們可以看出,協程的核心思想在於:

控制流的主動讓出和恢復。

這一點和文章開始提到的用戶態線程有幾分相似,但是用戶態線程多在語言層面實現,對於使用者還是不夠開放,無法提供顯示的調度方式。但是協程做到了這一點,用戶可以在編碼階段通過類似 yieldto 原語對控制流進行調度。

子例程和協程的區別。

子例程和協程產生,需要我們先明確命令式編程與函數式編程這兩種不同的編程範式對邏輯控制方式的差異。剛開始產生程序編碼這一行業時,使用的是命令式編程,命令式編程圍繞着自頂向下的開發理念,將子例程調用作爲唯一的控制結構。而函數式編程則產生在命令式編程之後,屬於前人痛定思痛的一種產物(具體的細節感興趣的話可以WIKI一下)。介於篇幅問題,這裏直接拋出概念。實際上,子例程就是沒用使用 yield 的協程, 程序設計大師 Donald E. Knuth 也曾經將子例程定義爲:

子例程是協程的一種特例。

但不進行讓步和恢復的協程(子例程),終究失掉了協程的靈魂內核,不能稱之爲協程。直到後來出現了一個叫做迭代器(Iterator)的神奇的東西。迭代器的出現主要還是因爲數據結構日趨複雜,以前用 for 循環就可以遍歷的結構需要抽象出一個獨立的迭代器來支持遍歷,用 js 爲例。迭代器的遍歷會搞成下面這個樣子:

for (let key of Object.keys(obj)) {
    console.log(`${key} : ${obj[key]}`);
}

實際上,要實現這種迭代器的語法糖,就必須引入協程的思想:主執行棧在進入循環後先讓步給迭代器,迭代器取出下一個迭代元素之後再恢復主執行棧的控制流。這種迭代器的實現就是因爲內置了生成器(generator)。生成器也是一種特殊的協程,它擁有 yield 原語,但是卻不能指定讓步的協程,只能讓步給生成器的調用者或恢復者。由於不能多個協程跳來跳去,生成器相對主執行線程來說只是一個可暫停的玩具,它甚至都不需要另開新的執行棧,只需要在讓步的時候保存一下上下文就好。因此我們認爲生成器與主控制流的關係是不對等的,也稱之爲非對稱協程(semi-coroutine)。

由此我們也知道了,爲啥 es6 一下引起了這麼一大坨特性啊,因爲引入迭代器,就必須引入生成器,這倆就是這種不可分割的基友關係。

協程的專題簡介

協程的語義:

一般的協程實現都會提供兩個重要的操作 Yield 和 Resume 。其中:
- Yield:是讓出cpu的意思,它會中斷當前的執行,回到上一次Resume的地方
- Resume:繼續協程的運行。執行Resume後,回到上一次協程Yield的地方。(ES中對應的是next操作)

協程與線程的關係

首先我們可以明確,協程不能調度其他進程中的上下文。每個協程要獲得CPU,都必須在線程中執行。因此,協程所能利用的CPU數量,和用於處理協程的線程數量直接相關。作爲推論,在單個線程中執行的協程,可以視爲單線程應用。這些協程,在未執行到特定位置(基本就是阻塞操作)前,是不會被搶佔,也不會和其他CPU上的上下文發生同步問題的。因此,一段協程代碼,中間沒有可能導致阻塞的調用,執行在單個線程中。那麼這段內容可以被視爲同步的。(記住這一點方便理解後面的內容,畢竟JS是單(進)線程模型)

協程的優點:

  1. 簡化了編碼形式,提升代碼可讀性。
    協程可以將異步的邏輯代碼用同步的方式進行編寫,將多個異步操作集中到一個函數中完成,不需要維護過多的session數據或狀態機,同時兼備異步的高效。如果一個業務邏輯中涉及到多個異步請求,使用傳統的異步回調方式,會使代碼變得很凌亂,邏輯被不同的函數分割的支離破碎,非常不便於代碼閱讀,調試(很容易就造成回調地獄)。如果使用協程,可以將一個完整的業務邏輯集中在一個函數中完成,一眼瞭然。

  2. 編程簡單。單線程模式,沒有線程中普遍存在的資源競爭問題,不需要加鎖操作,降低編碼難度,分離關注點。

  3. 降低資源開銷。協程是用戶態線程,切換時不需要考慮線程,進程切換時存在的時間和資源開銷。
    舉例:
    一個事務需要AB兩個步驟,每一步需要請求不同的服務器:
    可以看到:如果使用協程的話,可以在一個函數中看到參數,每個步驟的請求和應答內容,就不需要用戶再去維護一個session和狀態機。並且,看起來就像同步一樣的代碼邏輯。
    001

協程的缺點:

  1. 在協程執行中不能有阻塞操作,否則整個線程被阻塞(協程是語言級別的,線程,進程屬於操作系統級別)
  2. 需要特別關注全局變量、對象引用的使用

NodeJS中部分現有的協程實現窺探

自 es6 嘗試引入生成器以來,大量的協程實現嘗試開始興起,協程一時間成爲風靡前端界的新名詞。但這些實現中有的僅僅是實現了一個看上去很像協程的語法糖,有的卻 hack 了底層代碼,實現了真正的協程。這裏以 TJ 大神的 co 和 node-fibers 爲例,淺析這兩種協程實現方式上的差異。

CO

co 實際上是一個語法糖,它可以包裹一個生成器,然後生成器裏可以使用同步的方式來編寫異步代碼,效果如下:

var fs = require('fs');

var readFile = function (fileName){
    return new Promise(function (resolve, reject){
        fs.readFile(fileName, function(error, data){
            if (error) reject(error);
            resolve(data);
        });
    });
};

co(function* (){
    let f1 = yield readFile('/etc/fstab');
    let f2 = yield readFile('/etc/shells');
    let sum = f1.toString().length + f2.toString().length;
    console.log(sum);
});

在 es7 中的 async/await 更甜的語法糖,實現效果如下:

async function (){
    let f1 = await readFile('/etc/fstab');
    let f2 = await readFile('/etc/shells');
    let sum = f1.toString().length + f2.toString().length;
    console.log(sum);
};

這段代碼彷彿是在說明:我們把 readFile 丟到另一個協程裏去了!等他搞定之後就又回到主線程上!代碼可讀性一下子就提升了!但事實真的是這樣的麼?我們來看一下 co 的不考慮異常處理的精簡版本實現:

function co(gen){
    let def = Promise.defer();
    let iter = gen();

    function resolve(data) {
        // 恢復迭代器並帶入promise的終值
        step(iter.next(data));
    }

    function step(it) {
        it.done ?
            // 迭代結束則解決co返回的promise
            def.resolve(it.value) :
            // 否則繼續用解決程序解決下一個讓步出來的promise
            it.value.then(resolve);
    }

    resolve();
    return def.promise;
}

從 co 的代碼實現可以看出,實際上 co 只是進行了對生成器讓步、恢復的控制,把讓步出來的 promise 對象求取終值,之後恢復給生成器——這都沒有多個執行棧,並沒有什麼協程麼!但是有觀衆會指出:這不是用了生成器麼,生成器就是非對稱協程,所以它就是協程!好的,我們再來捋一捋:

協程在誕生之時,只有一個 Ghost,叫做主動讓步和恢復控制流,協程因之而生;
後來在實現上,發現可以採用可控用戶態線程的方式來實現,因此這種線程成爲了協程的一個 shell。
後來又發現,生成器也可以實現一部分主動讓步和恢復的功能,但是弱了一點,我們也稱生成器爲協程的一個弱弱的 shell。
所以我們說起協程,實際上說的是它的 Ghost,只要能主動讓步和恢復,就可以叫做協程;但協程的實現方式有多種,有的有獨立棧,有的沒有獨立棧,他們都只是協程的殼,不要在意這些細節,嗯。

好吧,實際上並沒有什麼改變。因爲 promise 本身的實現機制還是回調,所以在 then 的時候就把回調扔給 webAPI 了,等到合適的時機又扔回給事件隊列。事件隊列中的代碼需要等到主棧清空的時候再運行,這時候執行了 iter.next 來恢復生成器——而生成器是沒有獨立棧的,只有一份保存的上下文;因此只是把生產器的上下文再次加載到棧頂,然後沿着恢復的點繼續執行而已。引入生成器之後,事件循環的一切都木有改變!

Node-fibers

看完了生成器的實現,我們再來看下真·協程的效果。這裏以 hack 了 node.js 線程的 node-fibers 爲例,看一下真·協程與生產器的區別在何處。

首先,node-fibers 本身僅僅是實現了創造協程的功能以及一些原語,本身並沒有類似 co 的異步轉同步的語法糖,我們採用相似的方式來包裹一個,爲了區別,就叫它 ceo 吧(什麼鬼):

let Fiber = require('fibers');

function ceo(cb){
    let def = Promise.defer();
    // 注意這裏傳入的是回調函數
    let fiber = new Fiber(cb);

    function resolve(data) {
        // 恢復迭代器並帶入promise的終值
        step(fiber.run(data));
    }

    function step(it) {
        !fiber.started ?
            // 迭代結束則解決co返回的promise
            def.resolve(it.value) :
            // 否則繼續用解決程序解決下一個讓步出來的promise
            it.then(resolve);
    }

    resolve();
    return def.promise;
}

ceo(() => {
    let f1 = Fiber.yield(readFile('/etc/fstab'));
    let f2 = Fiber.yield(readFile('/etc/shells'));
    let sum = f1.toString().length + f2.toString().length;
    console.log(sum);
});

上面的代碼看起來和前面的好像最大的區別就是生成器變成了回調函數,只是少了一個 * 嘛。但是注意啦,關鍵點就在這裏:沒有了生成器,我們可以在任意一層函數裏進行讓步,這裏使用 ceo 包裹的這個回調,是一個真正獨立的執行棧。在真·協程裏,我們可以搞出這樣的代碼:

ceo(() => {
    let foo1 = a => {
        console.log('read from file1');
        let ret = Fiber.yield(a);
        return ret;
    };
    let foo2 = b => {
        console.log('read from file2');
        let ret = Fiber.yield(b);
        return ret;
    };

    let getSum = () => {
        let f1 = foo1(readFile('/etc/fstab'));
        let f2 = foo2(readFile('/etc/shells'));
        return f1.toString().length + f2.toString().length;
    };

    let sum = getSum();

    console.log(sum);
});

通過這個代碼可以發現,在第一次讓步被恢復的時候,恢復的是一系列的執行棧!從棧頂到棧底依次爲:foo1 => getSum => ceo 裏的匿名函數;而使用生成器,我們就無法寫出這樣的程序,因爲 yield 原語只能在生產器內部使用——無論什麼時候被恢復,都是簡單的恢復在生成器內部,所以說生成器是不用開新棧滴。

那麼問題就來了,使用了真·協程之後,原先的事件循環模型是否會發生改變呢?是不是主執行棧調用協程的時候,協程就會在自己的棧裏跑,而主棧就排空了可以執行異步代碼呢?我們來看下面這個例子:

"use strict";
var Fiber = require('fibers');

let syncTask = () => {
    var now = +new Date;
    while (new Date - now < 1000) {}
    console.log('SyncTask Loaded!');
};

let asyncTask = () => {
    setTimeout(() => {
        console.log('AsyncTask Loaded!');
    });
};

var fiber = Fiber(() => {
    syncTask();
    Fiber.yield();
});

function mainThread() {
    asyncTask();
    fiber.run();
}

mainThread();

// 輸出:
// SyncTask Loaded!
// AsyncTask Loaded!

我們在主線程執行的時候拋出了一個異步方法,之後在協程裏用冗長的同步代碼阻塞它,這裏我們可以清楚的看到:阻塞任何一個執行中的協程都會阻塞掉主線程!也就是說,即使加入了協程,js 還是可以被認爲是單線程的,因爲同一時刻勢必只有一個協程在運行,在協程運行和恢復期間,js 會將當前棧保存,然後用對應協程的棧來填充主的執行棧。只有所有協程都被掛起或運行結束,才能繼續運行異步代碼

因此,真·協程的引入對事件循環還是造成了一定的影響,可憐的異步代碼要等的更久了。

簡單的總結一下

協程的使用:

考慮服務端協程的使用(可以延伸到客戶端中具體使用方式),服務端程序中協程創建和啓動的時機非常重要。一般來說,會在收到一個請求包時認爲是一個事務的開始,此時創建並啓動協程,協程執行完即認爲事務結束。期間,如果遇到異步請求事件,當發送完請求後,在協程內主動Yield,當收到應答後再Resume回來。其過程可以用下圖表示:
002

-- 關於Node部分的代碼參考了網絡博文,具體引用地址由於時間問題已經不可找回,如能提供不勝感激

發佈了106 篇原創文章 · 獲贊 79 · 訪問量 46萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章