關於JavaScript併發、競態場景下的一些思考和解決方案

前言

時間是程序裏最複雜的因素javascript

編寫 Web 應用的時候,通常來講,咱們大多時候處理的都是同步的、線性的業務邏輯。可是正如開篇所說的「時間是程序裏最複雜的因素」,應用一旦複雜,每每會遭遇不少異步問題,若是代碼中涉及到到多個異步的時候,這時候就須要慎重考慮了,咱們須要的意識到的是:html

到底咱們的異步邏輯是易讀的麼?可維護的麼?哪些是併發場景,哪些是競態場景,咱們有什麼對策麼?注意提提神!如下全程須要集中精神思考 🤔前端

解決問題以前

在拋出具體的解決問題的技術方案以前。首先探討一下咱們常見的請求會遇到的問題。java

請求時序問題

通常而言,在前端而言咱們常常遇到的異步場景,是請求問題。(固然對應到後端,有多是各類 IO 操做,好比讀寫文件、操做數據庫等)。ios

那筆者爲什麼談到請求,由於大多人都會忽略此類問題。咱們每每有時候會發出多個同類型的請求(不必定符合咱們意願),可是往往以爲本身的應用十分健壯,實際上若是沒有小心控制「野獸」的話,實際上應用也會至關脆弱!git

以下圖,應用依照 A1 -> A2 -> A3 順序發起請求,咱們也指望的是 A1 -> A2 -> A3 的順序返回響應給應用。es6

但實際上呢。可是每一個請求都是十分野性的。咱們根本沒法把控它哪時候回來!請求的響應順序極大程度依賴用戶的網絡環境。好比上圖的響應順序實際上就是 A3 -> A1 -> A2,此時應用將有機率會變得一團糟!github

不過也不用擔憂,實際上,一旦當你注意問題的時候,其實就離解決問題不遠了。ajax

那麼咱們常見的作法會有什麼呢?數據庫

結束標記

經過應用中的標記狀態,在需求請求完成後,標記成功,忽略多餘請求,能夠巧妙避開請求競態的陷阱。因爲此寫法比較常見,再也不贅述。

隊列化

將請求串行!某些特殊場景下可使用。在時間線上將多個異步拍平成一條線。野獸請求們依序進入隊列(至關於咱們給請求們拉起了繮繩,劃好了奔跑的道路),以下圖:

只有當 A1 請求響應時,才進行 A2 請求,A2 響應成功時,進行 A3 請求。同理以此類推。(注意雖然請求的順序強行被修改成串行,但並不意味這發起請求的動做也是串行)。所以在從時間維度上大大簡化了場景,極大的減小了 bug 的發生機率。

缺點也很明顯,請求串行後阻塞了,某些場景下也許作了不少無用功。

取消請求 + 最新

有同窗們就會以爲,效率是否略顯低下,既然咱們前面的請求雖然依序生效了,可是最終很快都會被最新的請求結果所替換,那麼還作那麼多無用功幹嗎?是的,的確不該當這麼作!如圖:

凡有新的請求產生,取消上一個還在路上的請求(原生的 XMLHttpRequest.abort()、axios 的 cancelToken),而後只取最新的一個請求,靜靜等待它的響應。好比 redux-saga 中 takeLatest。

(可是請同窗們注意,若是須要每個請求都對服務器產生效果,好比 POST 請求等,有時候隊列也不失爲一個好的解決方式)

問題以及背景

上文其實算是一個引子,接下來我將併發競態的問題抽象簡化爲如下代碼,請看:

// 模擬了一個 ajax 請求函數,對於每個請求有一個隨機延時
function ajax(url, cb) {
  let fake_responses = {
    file1: "The first text",
    file2: "The middle text",
    file3: "The last text"
  };
  let wait = (Math.round(Math.random() * 1e4) % 8000) + 1000;

  console.log("Requesting: " + url + `, time cost: ${wait} ms`);

  setTimeout(() => {
    cb(fake_responses[url]);
  }, wait);
}

function output(text) {
  console.log(text);
}
複製代碼

那麼如何實現一個 getFile 函數,使得能夠並行請求,而後依照請求順序打印響應的值,最終異步完成後打印完成。(注意,此處考慮併發場景)

getFile("file1");
getFile("file2");
getFile("file3");
複製代碼

指望結果:

Requesting: file1, time cost: 8233 ms
Requesting: file2, time cost: 2581 ms
Requesting: file3, time cost: 7334 ms
The first text
The middle text
The last text
Complete!
get files total time: 8247.093ms
複製代碼

下文將和你們介紹從編寫實現上如何解決併發競態的問題的幾種方案!

解決方案:Thunks

什麼是 Thunk

Thunk 這個詞是起源於「思考」的幽默過去式的意思。它本質上就是一個延遲執行計算的函數。好比下述:

// 對於下述 1 + 2 計算是即時的
// x === 3
let x = 1 + 2;

// 1 + 2 的計算是延遲的
// 函數 foo 能夠稍後調用進行值的計算
// 因此函數 foo 就是一個 thunk
let foo = () => 1 + 2;
複製代碼

那麼咱們來實現一個 getFile 函數以下:

function getFile(file) {
  let resp;

  ajax(file, text => {
    if (resp) resp(text);
    else resp = text;
  });

  return function thunk(cb) {
    if (resp) cb(resp);
    else resp = cb;
  };
}
複製代碼

注意咱們如上有一個頗有趣的實現,實際上在調用 getFile 函數的時候,內部就已經發生了 ajax 請求(所以請求並無被阻塞),可是真正返回響應的邏輯放在了 thunk 中。

所以,業務邏輯以下:

let thunk1 = getFile("file1");
let thunk2 = getFile("file2");
let thunk3 = getFile("file3");

thunk1(text => {
  output(text);
  thunk2(text => {
    output(text);
    thunk3(text => {
      output(text);
      output("Complete!");
    });
  });
});
複製代碼

調用後,很好實現了咱們的需求!可是!可是同窗們也發現了,仍是不免陷入了回調地獄,寫法仍是很差維護,換而言之,仍是不夠優雅~

嗯...有什麼辦法呢?

中間件

近幾年,中間件的思想和使用十分流行,或者咱們能夠嘗試使用中間件方式實現一下?

首先咱們寫一個簡單的 compose 函數以下(固然此場景下咱們並不關注中間件的上下文,所以簡化其實現):

function compose(...mdws) {
  return () => {
    function next() {
      const mdw = mdws.shift();
      mdw && mdw(next);
    }
    mdws.shift()(next);
  };
}
複製代碼

那咱們的 getFile 函數實現也得稍微改一下,讓返回的 thunk 函數能夠交由中間件的 next 控制:

function getFileMiddleware(file, cb) {
  let resp;

  ajax(file, function(text) {
    if (!resp) resp = text;
    else resp(text);
  });

  return next => {
    const _next = args => {
      cb && cb(args);
      next(args);
    };
    if (resp) {
      _next(resp);
    } else {
      resp = _next;
    }
  };
}
複製代碼

基於上述兩個實現。咱們最終的寫法能夠修改成如下形式:

const middlewares = [
  getFileMiddleware("file1", output),
  getFileMiddleware("file2", output),
  getFileMiddleware("file3", resp => {
    output(resp);
    output("Complete!");
  })
];

compose(...middlewares)();
複製代碼

最終輸出結果仍然知足咱們對併發控制的需求!可是寫法上優雅了很多!篇幅有限,就不貼上結果了,同窗們可驗證一下~

解決方案:Promises

到目前爲止。咱們都沒有好好利用 JavaScript 送給咱們的禮物「Promise」。Promise 是一個對將來的值的容器。利用 Promise 也能很好的完成咱們的需求。

以下,實現 getFile 函數:

function getFile(file) {
  return new Promise(function(resolve) {
    ajax(file, resolve);
  });
}
複製代碼

來來來,調用一下

const p1 = getFile("file1");
const p2 = getFile("file2");
const p3 = getFile("file3");

p1.then(t1 => {
  output(t1);
  p2.then(t2 => {
    output(t2);
    p3.then(t3 => {
      output(t3);
      output("Complete!");
    });
  });
});
複製代碼

同樣知足,可是?咱們又陷入了 Promise 地獄...

對 Promise 地獄 Say NO

若是寫出了上述的 Promise 地獄,證實對 Promise 的瞭解還不夠,事實上也背離了 Promise 的設計初衷。咱們能夠改成下述寫法:

const p1 = getFile("file1");
const p2 = getFile("file2");
const p3 = getFile("file3");
const constant = v => () => v;

p1.then(output)
  .then(constant(p2))
  .then(output)
  .then(constant(p3))
  .then(output)
  .then(() => {
    output("Complete!");
  });
複製代碼

嗯哼~又更加優雅了點。Promise 地獄不見啦~

更加函數式的 Promise 方式

首先我要認可。我如今是,將來也是函數式編程的忠實擁護者。所以上述寫法雖然減小了嵌套,可是仍是以爲略顯無聊,若是有一百個文件等待請求,難道咱們還有手寫一百個 getFile,還有數不清的 then 麼?

問題來了,如何再一步改進呢?咱們好好思考一下。

首先他們是一個重複的事情,既然重複那就能夠抽象,在加上咱們函數式工具 reduce 方法,改進以下:

const urls = ["file1", "file2", "file3"];
const getFilePromises = urls.map(getFile);
const constant = v => () => v;

getFilePromises
  .concat(Promise.resolve("Complete!"), Promise.resolve())
  .reduce((chain, filePromise) => {
    return chain.then(output).then(constant(filePromise));
  });
複製代碼

問題解決,而且優雅~(同窗們可能留意到我 concat 了一個 Promise.resolve,是由於此處 reduce 中總須要下個 Promise 承接上一個的值進行執行,細節實現問題,無需介意)。

解決方案:Generators

Generator 是狀態機的一種語法形式。

ES6 中還有一個解決異步問題的新朋友 generator。同理咱們來用 generator 來實現需求。這裏咱們使用 co 來簡化 generator 的調用。

const co = require("co");

function getFile(file) {
  return new Promise(function(resolve) {
    ajax(file, resolve);
  });
}

function* loadFiles() {
  const p1 = getFile("file1");
  const p2 = getFile("file2");
  const p3 = getFile("file3");

  output(yield p1);
  output(yield p2);
  output(yield p3);

  output("Complete!");
}

co(loadFiles);
複製代碼

同樣的完成了需求,咱們又多了一種解決問題的思路對吧~ generator 其實在解決異步問題上的能量超乎想象。值得咱們花費多點時間學習!

等等,貌似咱們在硬編碼,再改進一下吧~

function loadFiles(urls) {
  const getFilePromises = urls.map(getFile);
  return function* gen() {
    do {
      output(yield getFilePromises.shift());
    } while (getFilePromises.length > 0);

    output("Complete!");
  };
}

co(loadFiles(["file1", "file2", "file3"]));
複製代碼

好啦!Perfect~

解決方案:async/await

既然寫到了這裏,咱們也用 ES7 中出現的 async/await 寫一下實現方案吧!

async function loadFiles(urls) {
  const getFilePromises = urls.map(getFile);
  do {
    const res = await getFilePromises.shift();
    output(res);
  } while (getFilePromises.length > 0);

  output("Complete!");
}

loadFiles(["file1", "file2", "file3"]);
複製代碼

固然,其實和 generator 的實現寫法上大體無什麼差別,可是在寫法上提高了可讀性~

小結

關於異步請求,是明顯的反作用,可謂名副其實的「野獸」。除了上述提到的一些方法外,咱們應該永不中止尋找更好更優雅的範式去處理這類狀況,好比響應式編程、亦或者函數式編程中的 IO functor 等。

對異步的掌控也許還須要咱們瞭解 JavaScript 事件循環、任務隊列、RxJS 等相關知識、仍是要去學習更多範式和思惟方式,與時間交朋友,而不是與之爲敵。

以上。對你們若有助益,不勝榮幸。

參考資料

相關文章
相關標籤/搜索