協程與事件迴圈

於明昊發表於2015-11-26

最近研究了一下 es6 的生成器函式,以及傳說中的 co。雖然網上關於協程、co 原始碼分析的文章數不勝數,但是將其與先前非同步實現的事件佇列結合起來說明的文章卻很難尋覓。之前只知道協程是實現非同步的一種方式,那其和之前的各種非同步實現究竟有什麼本質區別呢?本文將根據協程機制簡要探討一下引入協程之後的新的事件迴圈模型。由於筆者基礎知識不夠紮實,所以會先講述一大堆協程產生的背景和原理,再進行模型變化的講解。

協程實現機制

程式與執行緒

說起協程就不得不提到面試寶典必備題目之一:啥是程式啥是執行緒他倆有啥區別? 之前阮一峰老師寫過一篇博文進行科普,但是文中將電力比作 CPU 以及用廁所比喻鎖都不合適。反而是評論裡有位叫 viho_he 的哥們說的不錯,在這裡簡單的把他的思路整理一下:

  1. 單核 CPU 無法被平行使用。為了創造『共享』CPU 的假象,搞出了一個叫做時間片的概念,給任務分配時間片進行排程的排程器後來成為作業系統的核心元件;
  2. 在排程的時候,如果不對記憶體進行管理,那麼切換時間片的時候會造成程式上下文的互相汙染。但是手工管理實體地址實在是太蛋疼了,因此引入了『虛擬地址』的概念,共包含三個部分:
    • CPU 增加了記憶體管理單元模組,來進行虛擬地址和實體地址的轉換;
    • 作業系統加入了記憶體管理模組,負責管理實體記憶體和虛擬記憶體;
    • 發明了一個概念叫做程式。程式的虛擬地址一樣,經過作業系統和 MMU 對映到不同的實體地址上。
  3. 深入的談一談程式。程式是由一大堆元素組成的一個實體,其中最基本的兩個元素就是程式碼和能夠被程式碼控制的資源(包括記憶體、I/O、檔案等);一個程式從產生到消亡,可以被作業系統排程。掌控資源和能夠被排程是程式的兩大基本特點。但是程式作為一個基本的排程單位有點不人性:假如我想一邊迴圈輸出 hello world,一邊接收使用者輸入計算加減法,就得起倆程式,那隨便寫個程式碼都像 chrome 一樣變成記憶體殺手了。
  4. 因此誕生了執行緒的概念,執行緒在程式內部,處理併發的邏輯,擁有獨立的棧,卻共享執行緒的資源。使用執行緒作為 CPU 的基本排程單位顯得更加有效率,但也引發各種搶佔資源的問題,使得筆試又多了一個考題

最後總結一下就是:程式掌握著獨立資源,執行緒享受著基本排程。一個程式裡跑多個執行緒處理併發,6 的飛起。但純粹的核心態執行緒有一個問題就是效能消耗:執行緒切換的時候,程式需要為了管理而切換到核心態,狀態轉換的消耗有點嚴重。為此又產生了一個概念,喚做使用者態執行緒。使用者態執行緒吼啊,程式自己控制狀態切換,程式不用陷入核心態,會玩兒的開發者可以按照程式的特性來選擇更適合的排程演算法,程式碼的效率飛了起來。

協程、子例程與生成器

那協程是幹啥的咧?實際上,協程的概念產生的非常早,Melvin Conway 早在 1963 年就針對編譯器的設計提出一種將『語法分析』和『詞法分析』分離的方案,把 token 作為貨物,將其轉換為經典的『生產者-消費者問題』。編譯器的控制流在詞法和語法解析之間來回切換:當詞法模組讀入足夠多的 token 時,控制流交給語法分析;當語法分析消化完所有 token 後,控制流交給詞法分析。從這一概念提出的環境我們可以看出,協程的核心思想在於:控制流的主動讓出和恢復

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

說到這裡可能有讀者會產生疑問:『我大協程這麼吊,為何提出了這麼多年一直不火啊?』這就要說到當年指令式程式設計與函數語言程式設計的『劍氣之爭』,當年指令式程式設計圍繞著自頂向下的開發理念,將子例程呼叫作為唯一的控制結構。實際上,子例程就是沒用使用 yield 的協程,大宗師 Donald E. Knuth 也曾經曰過:

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

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

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

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

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

現有協程的實現

自 es6 嘗試引入生成器以來,大量的協程實現嘗試開始興起,協程一時間成為風靡前端界的新名詞。但這些實現中有的僅僅是實現了一個看上去很像協程的語法糖,有的卻 hack 了底層程式碼,實現了真正的協程。這裡以 TJ 大神的 conode-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 丟到另一個協程裡去了!等他搞定之後就又回到主執行緒上!程式碼可讀性 6666 啊!但事實真的是這樣的麼?我們來看一下 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 物件求取終值,之後恢復給生成器——這都沒有多個執行棧,並沒有什麼協程麼!但是有觀眾會指出:這不是用了生成器麼,生成器就是非對稱協程,所以它就是協程!好的,我們再來捋一捋:

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

好的,因此 TJ 大神叫它 co,也還是有一定道理的,儘管可能產生一些奇怪的誤導……我們來看一下,引入了 co 之後,js 原有的事件迴圈產生了什麼改變呢?

【回頭補個圖】

好吧,實際上並沒有什麼改變。因為 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 會將當前棧儲存,然後用對應協程的棧來填充主的執行棧。只有所有協程都被掛起或執行結束,才能繼續執行非同步程式碼

因此,真·協程的引入對事件迴圈還是造成了一定的影響,可憐的非同步程式碼要等的更久了。

相關文章