JavaScript 非同步佇列實現及擴充

sunyongjian發表於2017-09-28

引入

佇列對於任何語言來說都是重要的,io 的序列,請求的並行等等。在 JavaScript 中,又由於單執行緒的原因,非同步程式設計又是非常重要的。昨天由一道面試題的啟發,我去實現 JS 中的非同步佇列的時候,借鑑了 express 中介軟體思想,併發散到 co 實現 與 generator,以及 asyncToGenerator。

ps: 本文無圖,程式碼較多。本次用例程式碼都在此,可以 clone 下來試一下

非同步佇列

很多面試的時候會問一個問題,就是怎麼讓非同步函式可以順序執行。方法有很多,callback,promise,觀察者,generator,async/await,這些 JS 中處理非同步程式設計的,都可以做到這種序列的需求。但是很麻煩的是,處理起來是挺麻煩的,你要不停的手動在上一個任務呼叫下一個任務。比如 promise,像這樣:

a.then(() => b.then(() => c.then(...)))複製程式碼

程式碼巢狀的問題,有點嚴重。所以要是有一個佇列就好了,往佇列裡新增非同步任務,執行的時候讓佇列開始 run 就好了。先制定一下 API,我們有一個 queue,佇列都在內部維護,通過 queue.add 新增非同步任務,queue.run 執行佇列,可以先想想。

參照之前 express 中介軟體的實現,給非同步任務 async-fun 傳入一個 next 方法,只有呼叫 next,佇列才會繼續往下走。那這個 next 就至關重要了,它會控制佇列往後移一位,執行下一個 async-fun。我們需要一個佇列,來儲存 async-fun,也需要一個遊標,來控制順序。

以下是我的簡單實現:

const queue = () => {
  const list = []; // 佇列
  let index = 0;  // 遊標

  // next 方法
  const next = () => {
    if (index >= list.length - 1) return;    

    // 遊標 + 1
    const cur = list[++index];
    cur(next);
  }

  // 新增任務
  const add = (...fn) => {
    list.push(...fn);
  }

  // 執行
  const run = (...args) => {
    const cur = list[index];
    typeof cur === 'function' && cur(next);
  }

  // 返回一個物件
  return {
    add,
    run,
  }
}

// 生成非同步任務
const async = (x) => {
  return (next) => {// 傳入 next 函式
    setTimeout(() => {
      console.log(x);
      next();  // 非同步任務完成呼叫
    }, 1000);
  }
}

const q = queue();
const funs = '123456'.split('').map(x => async(x));
q.add(...funs);
q.run();// 1, 2, 3, 4, 5, 6 隔一秒一個。複製程式碼

我這裡沒去構造一個 class,而是通過閉包的特性去處理的。queue 方法返回一個包含 add,run 的物件,add 即為像佇列中新增非同步方法,run 就是開始執行。在 queue 內部,我們定義了幾個變數,list 用來儲存佇列,index 就是遊標,表示佇列現在走到哪個函式了,另外,最重要的是 next 方法,它是控制遊標向後移動的。

run 函式一旦執行,佇列即開始 run。一開始執行佇列裡的第一個 async 函式,我們把 next 函式傳給了它,然後由 async 函式決定什麼時候執行 next,即開始執行下一個任務。我們沒有並不知道非同步任務什麼時候才算完成,只能通過打成某種共識,來告知 queue 某個任務完成。就是傳給任務的 next 函式。其實 async 返回的這個函式,有一個名字,叫 Thunk,後面我們會簡單介紹。

Thunk

thunk 其實是為了解決 “傳名呼叫” 的。就是我傳給函式 A 一個表示式作引數 x + 1,但是我不確定這個 x + 1 什麼時候會用到,以及會不會用到,如果在傳入就執行,這個求值是沒有必要的。所以就出現了一個臨時函式 Thunk,來儲存這個表示式,傳入函式 A 中,待需要時再呼叫。

const thunk = () => {
  return x + 1;
};

const A = thunk => {
  return thunk() * 2;
}複製程式碼

嗯... 其實就是一個回撥函式...

暫停

其實只要某個任務,不繼續呼叫 next,佇列就已經不會繼續往下走了。比如我們 async 任務里加一個判斷(通常是非同步 io,請求的容錯處理):

// queue 函式不變,
// async 加限制條件
const async = (x) => {
  return (next) => {
    setTimeout(() => {
      if(x > 3) {
        console.log(x);
        q.run();  //重試
        return;
      }
      console.log(x);
      next();
    }, 1000);
  }
}

const q = queue();
const funs = '123456'.split('').map(x => async(x));
q.add(...funs);
q.run();
//列印結果: 1, 2, 3, 4, 4,4, 4,4 一直是 4複製程式碼

當執行到第四個任務的時候,x 是 4 的時候,不再繼續,就可以直接 return,不再呼叫 next。也有可能是出現錯誤,我們需要再重試,那就再呼叫 q.run 就可以了,因為遊標儲存的就是當前的 async 任務的索引。

另外,還有一種方式,就是新增 stop 方法。雖然感覺上面的方法就 OK 了,但是 stop 的好處在於,你可以主動的停止佇列,而不是在 async 任務里加限制條件。當然,有暫停就有繼續了,兩種方式,一個是 retry,就是重新執行上一次暫停的那個;另一個就是 goOn,不管上次最後一個如何,繼續下一個。上程式碼:

const queue = () => {
  const list = [];
  let index = 0;
  let isStop = false;

  const next = () => {
    // 加限制
    if (index >= list.length - 1 || isStop) return;    
    const cur = list[++index];
    cur(next);
  }

  const add = (...fn) => {
    list.push(...fn);
  }

  const run = (...args) => {
    const cur = list[index];
    typeof cur === 'function' && cur(next);
  }

  const stop = () => {
    isStop = true;
  }

  const retry = () => {
    isStop = false;
    run();
  }

  const goOn = () => {
    isStop = false;
    next();
  }

  return {
    add,
    run,
    stop,
    retry,
    goOn,
  }
}

const async = (x) => {
  return (next) => {
    setTimeout(() => {
      console.log(x);
      next();
    }, 1000);
  }
}

const q = queue();
const funs = '123456'.split('').map(x => async(x));
q.add(...funs);
q.run();

setTimeout(() => {
  q.stop();
}, 3000)


setTimeout(() => {
  q.goOn();
}, 5000)複製程式碼

其實還是加攔截... 只不過從 async 函式中,換到了 next 函式裡面,利用 isStop 這個變數切換 true/false,開關暫停。我加了兩個定時器,一個是 3 秒後暫停,一個是 5 秒後繼續,(請忽略定時器的誤差),按道理應該是佇列到三秒的時候,也就是第三個任務執行完暫停,然後再隔 2 秒,繼續。結果列印到 3 的時候,停住,兩秒之後繼續 4,5,6.

兩種思路,請結合場景思考問題。

併發

上面的都是在做序列,假如 run 的時候我要並行呢... 也很簡單,把佇列一次性跑完就可以了。

// 為了程式碼短一些,把 retry,goOn 先去掉了。

const queue = () => {
  const list = [];
  let index = 0;
  let isStop = false;
  let isParallel = false;

  const next = () => {
    if (index >= list.length - 1 || isStop || isParallel) return;    
    const cur = list[++index];
    cur(next);
  }

  const add = (...fn) => {
    list.push(...fn);
  }

  const run = (...args) => {
    const cur = list[index];
    typeof cur === 'function' && cur(next);
  }

  const parallelRun = () => {
    isParallel = true;
    for(const fn of list) {
      fn(next);
    }
  }

  const stop = () => {
    isStop = true;
  }

  return {
    add,
    run,
    stop,
    parallelRun,
  }
}

const async = (x) => {
  return (next) => {
    setTimeout(() => {
      console.log(x);
      next();
    }, 1000);
  }
}

const q = queue();
const funs = '123456'.split('').map(x => async(x));
q.add(...funs);
q.parallelRun();
// 一秒後全部輸出 1, 2, 3, 4, 5, 6複製程式碼

我新增了一個 parallelRun 方法,用於並行,我覺得還是不要放到 run 函式裡面了,抽象單元儘量細化還是。然後還加了一個 isParallel 的變數,預設是 false,考慮到 next 函式有可能會被呼叫,所以需要加一個攔截,保證不會處亂。

以上就是利用僅用 thunk 函式,結合 next 實現的非同步佇列控制器,queue,跟你可以把 es6 程式碼都改成 es5,保證相容,當然是足夠簡單的,不適用於負責的場景 ?,僅提供思路。

generator 與 co

為什麼要介紹 generator,首先它也是用來解決非同步回撥的,另外它的使用方式也是呼叫 next 函式,generator 才會往下執行,預設是暫停狀態。yield 就相當於上面的 q.add,往佇列中新增任務。所以我也打算一起介紹,來更好的拓寬思路。發散思維,相似的知識點做好歸納,然後某一天你就會突然有一種:原來是這麼回事,原來 xxx 是借鑑子 yyy,然後你又去研究 yyy - -。

簡介 generator

簡單介紹回顧一下,因為有同學不經常用,肯定會有遺忘。

// 一個簡單的栗子,介紹它的用法

function* gen(x) {
  const y = yield x + 1;
  console.log(y, 'here'); // 12
  return y;
}

const g = gen(1);
const value = g.next().value; // {value: 2, done: false}

console.log(value); // 2
console.log(g.next(value + 10)); // {value: 12, done: true}複製程式碼

首先生成器其實就是一個通過函式體內部定義迭代演算法,然後返回一個 iterator 物件。關於iterator,可以看我另一篇文章。
gen 執行返回一個物件 g,而不是返回結果。g 跟其他 iterator 一樣,通過呼叫 next 方法,保證遊標 + 1,並且返回一個物件,包含了 value(yield 語句的結果),和 done(迭代器是否完成)。另外,yield 語句的值,比如上面程式碼中的 y,是下一次呼叫 next 傳入的引數,也就是 value + 10,所以是 12.這樣設計是有好處的,因為這樣你就可以在 generator 內部,定義迭代演算法的時候,拿到上次的結果(或者是處理後的結果)了。

但是 generator 有一個弊端就是不會自動執行,TJ 大神寫了一個 co,來自動執行 generator,也就是自動呼叫 next。它要求 yield 後面的函式/語句,必須是 thunk 函式或者是 promise 物件,因為只有這樣才會串聯執行完,這跟我們最開始實現 queue 的思路是一樣的。co 的實現有兩種思想,一個是 thunk,一個是 promise,我們都來試一下。

Thunk 實現

還記得最開始的 queue 怎麼實現的嗎,內部定義 next 函式,來保證遊標的前進,async 函式會接收 next,去執行 next。到這裡是一樣的,我們只要在 co 函式內部定義一個同樣的 next 函式,來保證繼續執行,那麼 generator 是沒有提供索引的,不過它提供了 g.next 函式啊,所以我們只需要給 async 函式傳 g.next 不就好了,async 就是 yield 後面的語句啊,也就是 g.value。但是並不能直接傳 g.next,為什麼?因為下一次的 thunk 函式,要通過 g.next 的返回值 value 取到啊,木有 value,下一個 thunk 函式不就沒了... 所以我們還是需要定義一個 next 函式去包裝一下的。

上程式碼:

const coThunk = function(gen, ...params) {

  const g = gen(...params);

  const next = (...args) => { // args 用於接收引數
    const ret = g.next(...args);   // args 傳給 g.next,即賦值給上一個 yield 的值。
    if(!ret.done) { // 去判斷是否完成
      ret.value(next);  // ret.value 就是下一個 thunk 函式
    }
  }

  next(); // 先呼叫一波
}

// 返回 thunk 函式的 asyncFn
const asyncFn = (x) => {
  return (next) => { // 接收 next
    const data = x + 1;
    setTimeout(() => {
      next && next(data);
    }, 1000)
  }
}

const gen = function* (x) {
  const a = yield asyncFn(x);
  console.log(a);

  const b = yield asyncFn(a);
  console.log(b);

  const c = yield asyncFn(b);
  console.log(c);

  const d = yield asyncFn(c);
  console.log(d);

  console.log('done');
}

coThunk(gen, 1);
// 2, 3, 4, 5, done複製程式碼

這裡定義的 gen,功能很簡單,就是傳入引數 1,然後每個 asyncFn 非同步累加,即多個非同步操作序列,並且下一個依賴上一個的返回值。

promise 實現

其實思路都是一樣的,只不過呼叫 next,換到了 co 內部。因為 yield 後面的語句是 promise 物件的話,我們可以在 co 內部拿到了,然後在 g.next().value 的 then 語句執行 next 就好了。

// 定義 co
const coPromise = function(gen) {
// 為了執行後的結果可以繼續 then
  return new Promise((resolve, reject) => {
    const g = gen();

    const next = (data) => { // 用於傳遞,只是換個名字
      const ret = g.next(data);
      if(ret.done) { // done 後去執行 resolve,即co().then(resolve)
        resolve(data); // 最好把最後一次的結果給它
        return;
      }
      ret.value.then((data) => { // then 中的第一個引數就是 promise 物件中的 resolve,data 用於接受並傳遞。
        next(data);  //呼叫下一次 next
      })
    }

    next();
  })
}

const asyncPromise = (x) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(x + 1);
    }, 1000)
  })
}

const genP = function* () {
  const data1 = yield asyncPromise(1);
  console.log(data1);

  const data2 = yield asyncPromise(data1);
  console.log(data2);

  const data3 = yield asyncPromise(data2);
  console.log(data3);
}

coPromise(genP).then((data) => {
  setTimeout(() => {
    console.log(data + 1); // 5
  }, 1000)
});
// 一樣的 2, 3, 4, 5複製程式碼

其實 co 的原始碼就是通過這兩種思路實現的,只不過它做了更多的 catch 錯誤的處理,而且支援你 yield 一個陣列,物件,通過 promise.all 去實現。另外 yield thunk 函式的時候,它統一轉成 promise 去處理了。感興趣的可以去看一下 co,相信現在一定很明朗了。

async/await

現在 JS 中用的最常用的非同步解決方案了,不過 async 也是基於 generator 的實現,只不過是做了封裝。如果把 async/await 轉化成 generate/yield,只需要把 await 語法換成 yield,再扔到一個 generate 函式中,async 的執行換成 coPromise(gennerate) 就好了。

const asyncPromise = (x) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(x + 1);
    }, 1000)
  })
}

async function fn () {
  const data = await asyncPromise(1);
  console.log(data);
}
fn();

// 那轉化成 generator 可能就是這樣了。 coPromise 就是上面的實現
function* gen() {
  const data = yield asyncPromise(1);
  console.log(data);
}

coPromise(gen);複製程式碼

asyncToGenerator 就是這樣的原理,事實上 babel 也是這樣轉化的。

最後

我首先是通過 express 的中介軟體思想,實現了一個 JS 中需求常見的 queue (非同步佇列解決方案),然後再接著去實現一個簡單的 coThunk,最後把 thunk 換成 promise。因為非同步解決方案在 JS 中是很重要的,去使用現成的解決方案的時候,如果能去深入思考一下實現的原理,我相信是有助於我們學習進步的。

歡迎 star 個人 blog:github.com/sunyongjian… ?

相關文章