Generator 詳解(使用場景,babel 轉譯,協程,異步,上層應用,async/await)

原創禁止私自轉載


Generator

可以隨心所欲的交出和恢複函數的執行權,yield交出執行權,next()恢復執行權

Generator 函數是一個狀態機,封裝了多個內部狀態,執行一個Generator函數會返回一個迭代器對象,可以依次遍歷 Generator 函數內部的每一個狀態

調用一個生成器函數並不會馬上執行它裏面的語句,而是返回一個這個 generator 的 迭代器 (iterator )對象。當這個迭代器的 next() 方法被首次(後續)調用時,其內的語句會執行到第一個(後續)出現yield的位置爲止,yield 後緊跟迭代器要返回的值。

或者如果用的是 yield*(多了個星號),則表示將執行權移交給另一個生成器函數(當前生成器暫停執行)。

next()方法返回一個對象,這個對象包含兩個屬性:value 和 done,value 屬性表示本次 yield 表達式的返回值,done 屬性爲布爾類型,表示生成器後續是否還有 yield 語句,即生成器函數是否已經執行完畢並返回。

典型場景

依賴 async 的上層庫和應用不勝枚舉,比如 koa

koa 等依賴其上層語法糖封裝: koa

基本使用

code

function* 這種聲明方式(function關鍵字後跟一個星號)會定義一個生成器函數 (generator function),它返回一個 Generator 對象。

更多 demo 參考: 迭代器 , 常見數列生成器
// 斐波那契豎列生成器
function* fib() {
    let [x, y]: [number, number] = [0, 1];
    while (true) {
      [x, y] = [y, x + y];
      yield x;
    }
}

const generator: Generator = fib();

// 階乘
function* factorial() {
    let x: number = 1;
    let fac: number = 1;
    while (true) {
        yield fac;
        fac = fac * ++x;
    }
}

Generator 對象

調用一個生成器函數並不會馬上執行它裏面的語句,而是返回這個生成器的 迭代器 (實現 iterator )的對象 Generator, 所以它符合可迭代協議迭代器協議。如上述代碼中 const generator: Generator = fib(); 接受 fib() 的類型即: Generator

Generator 對象

  • Generator.prototype.next()
返回一個由 yield表達式生成的值。
  • Generator.prototype.return()
返回給定的值並結束生成器。
  • Generator.prototype.throw()
向生成器拋出一個錯誤。

yield 優先級

操作符優先級彙總

yield 僅僅比 展開運算符: ..., 逗號: , 的優先級高,所以注意區分 yield fn() + 10fn() + 10 纔是 yield 表達式。

generator 中斷 的入參和返回

generator 入參返回

注意,不是要說整個 generator 的出入參,而是 yield 和 next,這個問題,其實困擾我蠻久的,原因是 generator 和傳統 js 的函數調用區別很大, 如果你很熟悉普通函數調用的出入參,在這裏往往轉不過彎。

  • 返回: next() 返回類 { done: boolean, value: any } 對象, 其中 value 則是 yield 表達式的值。

實際上返回會好理解一些,當我們執行 generator 函數之後獲得一個 Generator 對象當我們第一次調用 GeneratorObj.next() 時,函數纔會開始執行,直到第一個 yield 表達式執行完成, 並將 yield 表達式結果提供給 next 進行返回。【注意 yield 表達式此時開始執行】,然後進入中斷。

function pi(n: number): number {
    return Math.PI * n;
}

function* fn(n: number) {
    // 第一個 next 調用後 yield 表達式【pi(n) + 10, 注意優先級】執行並將結果: 13.1415... 進行包裝
    // { value: 13.14..., done: false }
    let g1 = yield pi(n) + 10;
    // 同理這裏就是: { value: 3.141592653589793, done: false }
    g1 = (yield pi(n)) + 10;

    // return 等價一最後一個 yield。
    return 100;
}

const fnGenx: Generator = fn(1);
Log(fnGenx.next());         // { value: 13.141592653589793, done: false }
Log(fnGenx.next());         // { value: 3.141592653589793, done: false }
Log(fnGenx.next());         // { value: 100, done: true }

當在生成器函數中顯式 return 時,會導致生成器立即變爲完成狀態,即調用 next() 方法返回的對象的 done 爲 true。如果 return 後面跟了一個值,那麼這個值會作爲當前調用 next() 方法返回的 value 值。

調用 next 時會立即獲得 yield 表達式的執行結果。也就是說 yield 不能單獨處理異步,因爲 yield 其實不在意其後的表達式所有代碼執行結束的時間點。因此也無法確定下次 next 的調用時間點。

  • 入參: next 方法也可以通過接受一個參數用以向生成器傳值。請注意,首次調用 next 方法時參數會被丟棄。next 入參規則如下:

調用 next()方法時,如果傳入了參數,那麼這個參數會作爲上一條執行的 yield 語句的返回值

實際上往往會誤認爲 let g1 = (yield x10(n)) + 10; 中 yield 表達式的值就會直接賦值給 g1 其實並不是這樣的,yield 表達式的值是 next 的返回值,當下次 next(100) 傳入的值會替代上一個 yield 表達式的值。也就等價於 g1 = (100) + 10

function x10(n: number): number {
    return 10 * n;
}

function* fn(n: number) {
    // yield x10(n) + 10 結果爲:30, 下次 next 時傳入的值做了 +10, 則 g1 值爲: 40
    let g1 = yield x10(n) + 10;
    Log(g1);            // 40
    // 同理: (yield x10(g1)) 結果爲: 40 * 10 = 400, 下次 next 時傳入的值: 400 + 10 = 410
    // 代入中斷的點: g1 = 410(yield x10(g1)) + 10 =  420
    g1 = (yield x10(g1)) + 10;
    Log(g1);            // 420
}

// 第一個參數由生成器提供
const fnGenx: Generator = fn(2);
let genObj = fnGenx.next(100);    // 第一次入參會被丟棄, 因爲他沒有上一個 yield

while (!genObj.done) {
    Log("outer: ", genObj.value);
    genObj = fnGenx.next(genObj.value + 10);
}

// outer:  30
// 40
// outer:  400
// 420

一個官方 demo

/** gen函數運行解析:
 * i=0 時傳入參數(0),並將參數0賦給上一句yield的返回賦值,由於沒有上一句yield語句,這步被忽略
 * 執行var val =100,然後執行yield val,此時g.next(i)返回{ value: 100, done: false }
 * 然後console.log(i,g.next(i).value),打印出0 100
 *
 * i=1 時傳入參數(1),並將參數1賦給上一句yield的返回賦值,即(val = 1)
 * 然後執行console.log(val),打印出1。
 * 接着進入第二次while循環,調用yield val,此時g.next(i)返回{ value: 1, done: false }
 * 然後console.log(i,g.next(i).value),打印出1 1
 *
 * i=2 ....(省略)
 */
function* gen() {
   var val =100;
   while(true) {
      val = yield val;
      console.log(val);
   }
}

var g = gen();
for(let i =0;i<5;i++){
   console.log(i,g.next(i).value);
}

// 返回:
//  0 100
//  1
//  1 1
//  2
//  2 2
//  3
//  3 3
//  4
//  4 4

clipboard.png

yield*

如果 yield* Generator, 可以等價認爲將 這個 Generator 的所有 yield 插入到 當前位置

function* anotherGenerator(i) {
    yield i + 1;
    yield i + 2;
    yield i + 3;
}

function* generator(i){
    yield i;
    yield* anotherGenerator(i);// 移交執行權
    yield i + 10;
}

// 等價於
function* generator(i){
    yield i;
    //   yield* anotherGenerator(i);// 移交執行權
    yield i + 1;
    yield i + 2;
    yield i + 3;
    yield i + 10;
}

注意點

  1. next 的參數會作爲上一條執行的 yield 語句的返回值: let first = yield 1; 中 first 不是直接賦值爲 yield 表達式的值, 而是 下次 next 傳入的值。
  2. 生成器函數不能當做構造器使用。
function* f() {}
var obj = new f; // throws "TypeError: f is not a constructor"
  1. yield 表達式是立即執行的,並且返回表達式值, 如果 yield 表達式是異步的,你需要在恰當的時機觸發 next 才能達到 async 的執行順序。在『重要問題 generator & 異步』中有詳細講解
  2. generator 和異步機制不同,只是配合 generator + 執行器可以 '同步化' 處理異步, Generator 函數是ES6提供的一種異步編程解決方案
  3. “中斷”是 Generator 的重要特徵 ———— Generator 能讓一段程序執行到指定的位置先中斷,啓動。

babel 轉譯

參考: demo

function *gen(p) {
  console.log(p)
  const de1 = yield fn(p);
  console.log(de1)
  const de2 = yield fn(de1);
  console.log(de2)
}

function fn(p) {
    return Math.random() * p;
}

通過 babel 編譯爲

"use strict";
var _marked = /*#__PURE__*/regeneratorRuntime.mark(gen);
function gen(p) {
  var de1, de2;
  return regeneratorRuntime.wrap(function gen$(_context) {
    while (1) {
      switch (_context.prev = _context.next) {
        case 0:
          console.log(p);
          _context.next = 3;
          return fn(p);
        case 3:
          de1 = _context.sent;
          console.log(de1);
          _context.next = 7;
          return fn(de1);
        case 7:
          de2 = _context.sent;
          console.log(de2);
        case 9:
        case "end":
          return _context.stop();
      }
    }
  }, _marked, this);
}

function fn(p) {
  return Math.random() * p;
}

可以看到 babel 使用了一個諸如的對象: regeneratorRuntime 在不支持的環境 polyfill,這個對象解析出現在 babel 的 babel-plugin-transform-runtime 插件中.

const moduleName = injectCoreJS2
    ? "@babel/runtime-corejs2"
    : "@babel/runtime";
let modulePath = moduleName;

if (node.name === "regeneratorRuntime" && useRuntimeRegenerator) {
    path.replaceWith(
    this.addDefaultImport(
        `${modulePath}/regenerator`,
        "regeneratorRuntime",
    ),
    );
    return;
}

繼續跟進到 babel-runtime-corejs2/regenerator/index.js, babel-runtime/regenerator/index.js 文件中, 兩個文件均只有一行代碼: module.exports = require("regenerator-runtime"); 都使用了 fackbook 的 regenerator

支撐思想: 協程

協程,又稱微線程,纖程. 是一種非搶佔式資源調度單元, 是一個無優先級的輕量級的用戶態線程

前期知識準備

現代操作系統是分時操作系統,資源分配的基本單位是進程,CPU調度的基本單位是線程。

簡單來說,進程(Process), 線程(Thread)的調度是由操作系統負責,線程的睡眠、等待、喚醒的時機是由操作系統控制,開發者無法精確的控制它們。使用協程,開發者可以自行控制程序切換的時機,可以在一個函數執行到一半的時候中斷執行,讓出CPU,在需要的時候再回到中斷點繼續執行。

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

協程

傳統的編程語言,早有多任務的解決方案,其中有一種叫做"協程"(coroutine),意思是多個線程互相協作,完成異步任務, 這和普通的搶佔式線程有所不同。

JS 中 generator 就類似一個語言層面實現的非搶佔式的輕量級"線程"。 線程包含於進程,而協程包含於線程

  • 所以協程具有極高的執行效率。因爲子程序切換不是線程切換,而是由程序自身控制,因此,沒有線程切換的開銷,和多線程比,線程數量越多,協程的性能優勢就越明顯。
  • 不需要多線程的鎖機制
  • 線程由系統控制切換,協程是由用戶控制切換。

從更高的層面來講,協程和多線程是兩種解決“多任務”編程的技術。多線程使得 '同一時刻貌似' 有多個線程在併發執行,不過需要在多個線程間協調資源,因爲多個線程的執行進度是“不可控”的。而協程則避免了多線程的問題,同一時刻實質上只有一個“線程”在執行,所以不會存在資源“搶佔”的問題。

不過在 JS 領域,貌似不存在技術選擇的困難,因爲 JS 目前還是“單線程”的,所以引入協程也是很自然的選擇吧。

協程 & 函數棧

大多語言都是層級調用,比如A調用B,B在執行過程中又調用了C,C執行完畢返回,B執行完畢返回,最後是A執行完畢。所以子程序調用是通過棧實現的,一個線程就是執行一個子程序。子程序調用總是一個入口,一次返回,調用順序是明確的。

而協程的調用和子程序不同。協程看上去也是子程序,但執行過程中,在子程序內部可中斷,然後轉而執行別的子程序,在適當的時候再返回來接着執行。

協程的中斷: 實際上是掛起的概念

協程發起異步操作意味着該協程將會被掛起,爲了保證喚醒時能正常運行,需要正確保存並恢復其運行時的上下文。記錄步驟爲:

  • 保存當前協程的上下文(運行棧,返回地址,寄存器狀態)
  • 設置將要喚醒的協程的入口指令地址到IP寄存器
  • 恢復將要喚醒的協程的上下文

可以參考 libco 騰訊開源的一個C++協程庫,作爲微信後臺的基礎庫,經受住了實際的檢驗: libco

JS 協程: generator

js 的生成器也是一種特殊的協程,它擁有 yield 原語,但是卻不能指定讓步的協程,只能讓步給生成器的調用者或恢復者。由於不能多個協程跳來跳去,生成器相對主執行線程來說只是一個可暫停的玩具,它甚至都不需要另開新的執行棧,只需要在讓步的時候保存一下上下文就好。因此我們認爲生成器與主控制流的關係是不對等的,也稱之爲非對稱協程(semi-coroutine)。

因此一般的協程實現都會提供兩個重要的操作 Yield 和 Resume(next)。

Generator 實現協程的問題

  • 在協程執行中不能有阻塞操作,否則整個線程被阻塞(協程是語言級別的,線程,進程屬於操作系統級別)
  • 需要特別關注全局變量、對象引用的使用
  • yield 僅能存在於 生成器內部[對比 node-fibers]

真.協程

所謂的真協程是相對 generator 而言的, node-fibers 庫提供了對應的實現,我們用一個例子部分代碼說明二者區別

import Fiber from 'fibers'
function fibersCo () {    /* 基於 fibers 的執行器 ..... */    }
fibersCo(() => {
    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();
});

通過這個代碼可以發現,在第一次中斷被恢復的時候,恢復的是一系列的執行棧!從棧頂到棧底依次爲:foo1 => getSum => fibersCo 裏的匿名函數;而使用生成器,我們就無法寫出這樣的程序,因爲 yield 原語只能在生產器內部使用, SO

無論什麼時候被恢復,都是簡單的恢復在生成器內部,所以說生成器的中斷是不開調用棧滴。

重要問題

generator & 異步

  1. generator 處理異步

generator 機制和異步有所不同, Generator 和普通函數本質區別在於 Generator 能讓一段程序執行到指定的位置,然後交出執行棧,調用下次 next 的時候又會從之前中斷的位置繼續開始執行,配合這種機制處理異步,則會產生同步化異步處理的效果。

  1. generator 的問題

但其實很快發現 generator 不能單獨處理異步問題,原因在於

  • generator 無法獲取下次 next 的時機。
  • generator 無法自執行
  1. generator 處理異步的思路 + 實踐

使用 Generator 函數來處理異步操作的基本思想就是在執行異步操作時暫停生成器函數的執行,然後在階段性異步操作完成的狀態中通過生成器對象的next方法讓Generator函數從暫停的位置恢復執行,如此往復直到生成器函數執行結束。簡單來講其實就是將異步串行化了。

也正是基於這種思想,Generator函數內部才得以將一系列異步操作寫成類似同步操作的形式,形式上更加簡潔明瞭。

而要讓Generator函數按順序自動完成內部定義好的一系列異步操作,還需要配套的執行器。與之配套的有兩種思路

其實在 async/await 之前就已經有了 co 庫使用此兩種方案實現類似 async 的機制。參考 co 源碼分析

  1. 優勢: 異常捕獲。 generator 的異常捕獲模型,優於 promise。
function* gen(x){
  try {
    var y = yield x + 2;
  } catch (e){
    console.log(e);
  }
  return y;
}

generator 的 yield 會產生調用函數棧麼?

因爲 yield 原語只能在生產器內部使用, 所以無論什麼時候被恢復,都是簡單的恢復在生成器內部。所以說生成器的中斷是不開調用棧滴。

參考上述章節

上層應用

async / await

併發通信: 多個generator函數結合在一起,讓他們獨立平行的運行,並且在它們執行的過程中來來回回得傳遞信息

參考

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