《Node.js設計模式》高階非同步準則

counterxing發表於2019-02-16

本系列文章為《Node.js Design Patterns Second Edition》的原文翻譯和讀書筆記,在GitHub連載更新,同步翻譯版連結

歡迎關注我的專欄,之後的博文將在專欄同步:

Advanced Asynchronous Recipes

幾乎所有我們迄今為止看到的設計模式都可以被認為是通用的,並且適用於應用程式的許多不同的領域。但是,有一套更具體的模式,專注於解決明確的問題。我們可以呼叫這些模式。就像現實生活中的烹飪一樣,我們有一套明確的步驟來實現預期的結果。當然,這並不意味著我們不能用一些創意來定製設計模式,以配合我們的客人的口味,對於書寫Node.js程式來說是必要的。在本章中,我們將提供一些常見的解決方案來解決我們在日常Node.js開發中遇到的一些具體問題。這些模式包括以下內容:

  • 非同步引入模組並初始化
  • 在高併發的應用程式中使用批處理和快取非同步操作的效能優化
  • 執行與Node.js處理併發請求的能力相悖的阻塞事件迴圈的同步CPU繫結操作

非同步引入模組並初始化

Chapter2-Node.js Essential Patterns中,當我們討論Node.js模組系統的基本屬性時,我們提到了require()是同步的,並且module.exports也不能非同步設定。

這是在核心模組和許多npm包中存在同步API的主要原因之一,是否同步載入會被作為一個option引數被提供,主要用於初始化任務,而不是替代非同步API

不幸的是,這並不總是可能的。同步API可能並不總是可用的,特別是對於在初始化階段使用網路的元件,例如執行三次握手協議或在網路中檢索配置引數。 許多資料庫驅動程式和訊息佇列等中介軟體系統的客戶端都是如此。

廣泛適用的解決方案

我們舉一個例子:一個名為db的模組,它將會連線到遠端資料庫。 只有在連線和與伺服器的握手完成之後,db模組才能夠接受請求。在這種情況下,我們通常有兩種選擇:

  • 在開始使用之前確保模組已經初始化,否則則等待其初始化。每當我們想要在非同步模組上呼叫一個操作時,都必須完成這個過程:
const db = require('aDb'); //The async module
module.exports = function findAll(type, callback) {
  if (db.connected) { //is it initialized?
    runFind();
  } else {
    db.once('connected', runFind);
  }

  function runFind() {
    db.findAll(type, callback);
  };
};
複製程式碼
  • 使用依賴注入(Dependency Injection)而不是直接引入非同步模組。通過這樣做,我們可以延遲一些模組的初始化,直到它們的非同步依賴被完全初始化。 這種技術將管理模組初始化的複雜性轉移到另一個元件,通常是它的父模組。 在下面的例子中,這個元件是app.js
// 模組app.js
const db = require('aDb'); // aDb是一個非同步模組
const findAllFactory = require('./findAll');
db.on('connected', function() {
  const findAll = findAllFactory(db);
  // 之後再執行非同步操作
});


// 模組findAll.js
module.exports = db => {
  //db 在這裡被初始化
  return function findAll(type, callback) {
    db.findAll(type, callback);
  }
}
複製程式碼

我們可以看出,如果所涉及的非同步依賴的數量過多,第一種方案便不太適用了。

另外,使用DI有時也是不理想的,正如我們在Chapter7-Wiring Modules中看到的那樣。在大型專案中,它可能很快變得過於複雜,尤其對於手動完成並使用非同步初始化模組的情況下。如果我們使用一個設計用於支援非同步初始化模組的DI容器,這些問題將會得到緩解。

但是,我們將會看到,還有第三種方案可以讓我們輕鬆地將模組從其依賴關係的初始化狀態中分離出來。

預初始化佇列

將模組與依賴項的初始化狀態分離的簡單模式涉及到使用佇列和命令模式。這個想法是儲存一個模組在尚未初始化的時候接收到的所有操作,然後在所有初始化步驟完成後立即執行這些操作。

實現一個非同步初始化的模組

為了演示這個簡單而有效的技術,我們來構建一個應用程式。首先建立一個名為asyncModule.js的非同步初始化模組:

const asyncModule = module.exports;

asyncModule.initialized = false;
asyncModule.initialize = callback => {
  setTimeout(() => {
    asyncModule.initialized = true;
    callback();
  }, 10000);
};

asyncModule.tellMeSomething = callback => {
  process.nextTick(() => {
    if(!asyncModule.initialized) {
      return callback(
        new Error('I don\'t have anything to say right now')
      );
    }
    callback(null, 'Current time is: ' + new Date());
  });
};
複製程式碼

在上面的程式碼中,asyncModule展現了一個非同步初始化模組的設計模式。 它有一個initialize()方法,在10秒的延遲後,將初始化的flag變數設定為true,並通知它的回撥呼叫(10秒對於真實應用程式來說是很長的一段時間了,但是對於具有互斥條件的應用來說可能會顯得力不從心)。

另一個方法tellMeSomething()返回當前的時間,但是如果模組還沒有初始化,它丟擲產生一個異常。 下一步是根據我們剛剛建立的服務建立另一個模組。 我們設計一個簡單的HTTP請求處理程式,在一個名為routes.js的檔案中實現:

const asyncModule = require('./asyncModule');

module.exports.say = (req, res) => {
  asyncModule.tellMeSomething((err, something) => {
    if(err) {
      res.writeHead(500);
      return res.end('Error:' + err.message);
    }
    res.writeHead(200);
    res.end('I say: ' + something);
  });
};
複製程式碼

handler中呼叫asyncModuletellMeSomething()方法,然後將其結果寫入HTTP響應中。 正如我們所看到的那樣,我們沒有對asyncModule的初始化狀態進行任何檢查,這可能會導致問題。

現在,建立app.js模組,使用核心http模組建立一個非常基本的HTTP伺服器:

const http = require('http');
const routes = require('./routes');
const asyncModule = require('./asyncModule');

asyncModule.initialize(() => {
  console.log('Async module initialized');
});

http.createServer((req, res) => {
  if (req.method === 'GET' && req.url === '/say') {
    return routes.say(req, res);
  }
  res.writeHead(404);
  res.end('Not found');
}).listen(8000, () => console.log('Started'));
複製程式碼

上述模組是我們應用程式的入口點,它所做的只是觸發asyncModule的初始化並建立一個HTTP伺服器,它使用我們以前建立的handlerroutes.say())來對網路請求作出相應。

我們現在可以像往常一樣通過執行app.js模組來嘗試啟動我們的伺服器。

在伺服器啟動後,我們可以嘗試使用瀏覽器訪問URLhttp://localhost:8000/並檢視從asyncModule返回的內容。 和預期的一樣,如果我們在伺服器啟動後立即傳送請求,結果將是一個錯誤,如下所示:

Error:I don't have anything to say right now
複製程式碼

《Node.js設計模式》高階非同步準則

顯然,在非同步模組載入好了之後:

《Node.js設計模式》高階非同步準則

這意味著asyncModule尚未初始化,但我們仍嘗試使用它,則會丟擲一個錯誤。

根據非同步初始化模組的實現細節,幸運的情況是我們可能會收到一個錯誤,乃至丟失重要的資訊,崩潰整個應用程式。 總的來說,我們剛剛描述的情況總是必須要避免的。

大多數時候,可能並不會出現上述問題,畢竟初始化一般來說很快,以至於在實踐中,它永遠不會發生。 然而,對於設計用於自動調節的高負載應用和雲伺服器,情況就完全不同了。

用預初始化佇列包裝模組

為了維護伺服器的健壯性,我們現在要通過使用我們在本節開頭描述的模式來進行非同步模組載入。我們將在asyncModule尚未初始化的這段時間內對所有呼叫的操作推入一個預初始化佇列,然後在非同步模組載入好後處理它們時立即重新整理佇列。這就是狀態模式的一個很好的應用!我們將需要兩個狀態,一個在模組尚未初始化的時候將所有操作排隊,另一個在初始化完成時將每個方法簡單地委託給原始的asyncModule模組。

通常,我們沒有機會修改非同步模組的程式碼;所以,為了新增我們的排隊層,我們需要圍繞原始的asyncModule模組建立一個代理。

接下來建立一個名為asyncModuleWrapper.js的新檔案,讓我們依照每個步驟逐個構建它。我們需要做的第一件事是建立一個代理,並將原始非同步模組的操作委託給這個代理:

const asyncModule = require('./asyncModule');
const asyncModuleWrapper = module.exports;
asyncModuleWrapper.initialized = false;
asyncModuleWrapper.initialize = () => {
  activeState.initialize.apply(activeState, arguments);
};
asyncModuleWrapper.tellMeSomething = () => {
  activeState.tellMeSomething.apply(activeState, arguments);
};
複製程式碼

在前面的程式碼中,asyncModuleWrapper將其每個方法簡單地委託給activeState。 讓我們來看看這兩個狀態是什麼樣子

notInitializedState開始,notInitializedState是指還沒初始化的狀態:

// 當模組沒有被初始化時的狀態
let pending = [];
let notInitializedState = {

  initialize: function(callback) {
    asyncModule.initialize(function() {
      asyncModuleWrapper.initalized = true;
      activeState = initializedState;
      
      pending.forEach(function(req) {
        asyncModule[req.method].apply(null, req.args);
      });
      pending = [];
      
      callback();
    });
  },
  
  tellMeSomething: function(callback) {
    return pending.push({
      method: 'tellMeSomething',
      args: arguments
    });
  }
  
};
複製程式碼

initialize()方法被呼叫時,我們觸發初始化asyncModule模組,提供一個回撥函式作為引數。 這使我們的asyncModuleWrapper知道什麼時候原始模組被初始化,在初始化後執行預初始化佇列的操作,之後清空預初始化佇列,再呼叫作為引數的回撥函式,以下為具體步驟:

  1. initializedState賦值給activeState,表示預初始化已經完成了。
  2. 執行先前儲存在待處理佇列中的所有命令。
  3. 呼叫原始回撥。

由於此時的模組尚未初始化,此狀態的tellMeSomething()方法僅建立一個新的Command物件,並將其新增到預初始化佇列中。

此時,當原始的asyncModule模組尚未初始化時,代理應該已經清楚,我們的代理將簡單地把所有接收到的請求防到預初始化佇列中。 然後,當我們被通知初始化完成時,我們執行所有預初始化佇列的操作,然後將內部狀態切換到initializedState。來看這個代理模組最後的定義:

let initializedState = asyncModule;
複製程式碼

不出意外,initializedState物件只是對原始的asyncModule的引用!事實上,初始化完成後,我們可以安全地將任何請求直接傳送到原始模組。

最後,設定非同步模組還沒載入好的的狀態,即notInitializedState

let activeState = notInitializedState;
複製程式碼

我們現在可以嘗試再次啟動我們的測試伺服器,但首先,我們不要忘記用我們新的asyncModuleWrapper物件替換原始的asyncModule模組的引用; 這必須在app.jsroutes.js模組中完成。

這樣做之後,如果我們試圖再次向伺服器傳送一個請求,我們會看到在asyncModule模組尚未初始化的時候,請求不會失敗; 相反,他們會掛起,直到初始化完成,然後才會被實際執行。我們當然可以肯定,比起之前,容錯率變得更高了。

可以看到,在剛剛初始化非同步模組的時候,伺服器會等待請求的響應:

《Node.js設計模式》高階非同步準則

在非同步模組載入完成後,伺服器才會返回響應的資訊:

《Node.js設計模式》高階非同步準則

模式:如果模組是需要非同步初始化的,則對每個操作進行排隊,直到模組完全初始化釋放佇列。

現在,我們的伺服器可以在啟動後立即開始接受請求,並保證這些請求都不會由於其模組的初始化狀態而失敗。我們能夠在不使用DI的情況下獲得這個結果,也不需要冗長且容易出錯的檢查來驗證非同步模組的狀態。

其它場景的應用

我們剛剛介紹的模式被許多資料庫驅動程式和ORM庫所使用。 最值得注意的是Mongoose,它是MongoDBORM。使用Mongoose,不必等待資料庫連線開啟,以便能夠傳送查詢,因為每個操作都排隊,稍後與資料庫的連線完全建立時執行。 這顯然提高了其API的可用性。

看一下Mongoose的原始碼,它的每個方法是如何通過代理新增預初始化佇列。 可以看看實現這中模式的程式碼片段:https://github.com/Automattic/mongoose/blob/21f16c62e2f3230fe616745a40f22b4385a11b11/lib/drivers/node-mongodb-native/collection.js#L103-138

for (var i in Collection.prototype) {
  (function(i){
    NativeCollection.prototype[i] = function () {
      if (this.buffer) {
        // mongoose中,在緩衝區不為空時,只是簡單地把這個操作加入緩衝區內
        this.addQueue(i, arguments);
        return;
      }

      var collection = this.collection
        , args = arguments
        , self = this
        , debug = self.conn.base.options.debug;

      if (debug) {
        if ('function' === typeof debug) {
          debug.apply(debug
            , [self.name, i].concat(utils.args(args, 0, args.length-1)));
        } else {
          console.error('\x1B[0;36mMongoose:\x1B[0m %s.%s(%s) %s %s %s'
            , self.name
            , i
            , print(args[0])
            , print(args[1])
            , print(args[2])
            , print(args[3]))
        }
      }

      return collection[i].apply(collection, args);
    };
  })(i);
}

複製程式碼

非同步批處理和快取

在高負載的應用程式中,快取起著至關重要的作用,幾乎在網路中的任何地方,從網頁,影象和樣式表等靜態資源到純資料(如資料庫查詢的結果)都會使用快取。 在本節中,我們將學習如何將快取應用於非同步操作,以及如何充分利用快取解決高請求吞吐量的問題。

實現沒有快取或批處理的伺服器

在這之前,我們來實現一個小型的伺服器,以便用它來衡量快取和批處理等技術在解決高負載應用程式的優勢。

讓我們考慮一個管理電子商務公司銷售的web伺服器,特別是對於查詢我們的伺服器所有特定型別的商品交易的總和的情況。 為此,考慮到LevelUP的簡單性和靈活性,我們將再次使用LevelUP。我們要使用的資料模型是儲存在sales這一個sublevel中的簡單事務列表,它是以下的形式:

transactionId {amount, item}
複製程式碼

keytransactionId表示,value則是一個JSON物件,它包含amount,表示銷售金額和item,表示專案型別。 要處理的資料是非常基本的,所以讓我們立即在名為的totalSales.js檔案中實現API,將如下所示:

const level = require('level');
const sublevel = require('level-sublevel');

const db = sublevel(level('example-db', {valueEncoding: 'json'}));
const salesDb = db.sublevel('sales');

module.exports = function totalSales(item, callback) {
  console.log('totalSales() invoked');
  let sum = 0;
  salesDb.createValueStream()  // [1]
    .on('data', data => {
      if(!item || data.item === item) {  // [2]
        sum += data.amount;
      }
    })
    .on('end', () => {
      callback(null, sum);  // [3]
    });
};
複製程式碼

該模組的核心是totalSales函式,它也是唯一exportsAPI;它進行如下工作:

  1. 我們從包含交易資訊的salesDbsublevel建立一個StreamStream將從資料庫中提取所有條目。
  2. 監聽data事件,這個事件觸發時,將從資料庫Stream中提取出每一項,如果這一項的item引數正是我們需要的item,就去累加它的amount到總的sum裡面。
  3. 最後,end事件觸發時,我們最終呼叫callback()方法。

上述查詢方式可能在效能方面並不好。理想情況下,在實際的應用程式中,我們可以使用索引,甚至使用增量對映來縮短實時計算的時間;但是,由於我們需要體現快取的優勢,對於上述例子來說,慢速的查詢實際上更好,因為它會突出顯示我們要分析的模式的優點。

為了完成總銷售應用程式,我們只需要從HTTP伺服器公開totalSalesAPI;所以,下一步是構建一個(app.js檔案):

const http = require('http');
const url = require('url');
const totalSales = require('./totalSales');

http.createServer((req, res) => {
  const query = url.parse(req.url, true).query;
  totalSales(query.item, (err, sum) => {
    res.writeHead(200);
    res.end(`Total sales for item ${query.item} is ${sum}`);
  });
}).listen(8000, () => console.log('Started'));
複製程式碼

我們建立的伺服器是非常簡單的;我們只需要它暴露totalSales API。 在我們第一次啟動伺服器之前,我們需要用一些示例資料填充資料庫;我們可以使用專用於本節的程式碼示例中的populate_db.js指令碼來執行此操作。該指令碼將在資料庫中建立100K個隨機銷售交易。 好的! 現在,一切都準備好了。 像往常一樣,啟動伺服器,我們執行以下命令:

node app
複製程式碼

請求這個HTTP介面,訪問至以下URL

http://localhost:8000/?item=book
複製程式碼

但是,為了更好地瞭解伺服器的效能,我們需要連續傳送多個請求;所以,我們建立一個名為loadTest.js的指令碼,它以200 ms的間隔傳送請求。它已經被配置為連線到伺服器的URL,因此,要執行它,執行以下命令:

node loadTest
複製程式碼

我們會看到這20個請求需要一段時間才能完成。注意測試的總執行時間,因為我們現在開始我們的服務,並測量我們可以節省多少時間。

批量非同步請求

在處理非同步操作時,最基本的快取級別可以通過將一組呼叫集中到同一個API來實現。這非常簡單:如果我們在呼叫非同步函式的同時在佇列中還有另一個尚未處理的回撥,我們可以將回撥附加到已經執行的操作上,而不是建立一個全新的請求。看下圖的情況:

《Node.js設計模式》高階非同步準則

前面的影象顯示了兩個客戶端(它們可以是兩臺不同的機器,或兩個不同的Web請求),使用完全相同的輸入呼叫相同的非同步操作。 當然,描述這種情況的自然方式是由兩個客戶開始兩個單獨的操作,這兩個操作將在兩個不同的時刻完成,如前圖所示。現在考慮下一個場景,如下圖所示:

《Node.js設計模式》高階非同步準則

上圖向我們展示瞭如何對API的兩個請求進行批處理,或者換句話說,對兩個請求執行到相同的操作。通過這樣做,當操作完成時,兩個客戶端將同時被通知。這代表了一種簡單而又非常強大的方式來降低應用程式的負載,而不必處理更復雜的快取機制,這通常需要適當的記憶體管理和快取失效策略。

在電子商務銷售的Web伺服器中使用批處理

現在讓我們在totalSales API上新增一個批處理層。我們要使用的模式非常簡單:如果在API被呼叫時已經有另一個相同的請求掛起,我們將把這個回撥新增到一個佇列中。當非同步操作完成時,其佇列中的所有回撥立即被呼叫。

現在,讓我們來改變之前的程式碼:建立一個名為totalSalesBatch.js的新模組。在這裡,我們將在原始的totalSales API之上實現一個批處理層:

const totalSales = require('./totalSales');

const queues = {};
module.exports = function totalSalesBatch(item, callback) {
  if(queues[item]) {  // [1]
    console.log('Batching operation');
    return queues[item].push(callback);
  }
  
  queues[item] = [callback];  // [2]
  totalSales(item, (err, res) => {
    const queue = queues[item];  // [3]
    queues[item] = null;
    queue.forEach(cb => cb(err, res));
  });
};
複製程式碼

totalSalesBatch()函式是原始的totalSales() API的代理,它的工作原理如下:

  1. 如果請求的item已經存在佇列中,則意味著該特定item的請求已經在伺服器任務佇列中。在這種情況下,我們所要做的只是將回撥push到現有佇列,並立即從呼叫中返回。不進行後續操作。
  2. 如果請求的item沒有在佇列中,這意味著我們必須建立一個新的請求。為此,我們為該特定item的請求建立一個新佇列,並使用當前回撥函式對其進行初始化。 接下來,我們呼叫原始的totalSales() API
  3. 當原始的totalSales()請求完成時,則執行我們的回撥函式,我們遍歷佇列中為該特定請求的item新增的所有回撥,並分別呼叫這些回撥函式。

totalSalesBatch()函式的行為與原始的totalSales() API的行為相同,不同之處在於,現在對於相同內容的請求API進行批處理,從而節省時間和資源。

想知道相比於totalSales() API原始的非批處理版本,在效能方面的優勢是什麼?然後,讓我們將HTTP伺服器使用的totalSales模組替換為我們剛剛建立的模組,修改app.js檔案如下:

//const totalSales = require('./totalSales');
const totalSales = require('./totalSalesBatch');
http.createServer(function(req, res) {
// ...
});
複製程式碼

如果我們現在嘗試再次啟動伺服器並進行負載測試,我們首先看到的是請求被批量返回。

除此之外,我們觀察到請求的總時間大大減少;它應該至少比對原始totalSales() API執行的原始測試快四倍!

這是一個驚人的結果,證明了只需應用一個簡單的批處理層即可獲得巨大的效能提升,比起快取機制,也沒有顯得太複雜,因為,無需考慮快取淘汰策略。

批處理模式在高負載應用程式和執行較為緩慢的API中發揮巨大作用,正是由於這種模式的運用,可以批量處理大量的請求。

非同步請求快取策略

非同步批處理模式的問題之一是對於API的答覆越快,我們對於批處理來說,其意義就越小。有人可能會爭辯說,如果一個API已經很快了,那麼試圖優化它就沒有意義了。然而,它仍然是一個佔用應用程式的資源負載的因素,總結起來,仍然可以有解決方案。另外,如果API呼叫的結果不會經常改變;因此,這時候批處理將並不會有較好的效能提升。在這種情況下,減少應用程式負載並提高響應速度的最佳方案肯定是更好的快取模式。

快取模式很簡單:一旦請求完成,我們將其結果儲存在快取中,該快取可以是變數,資料庫中的條目,也可以是專門的快取伺服器。因此,下一次呼叫API時,可以立即從快取中檢索結果,而不是產生另一個請求。

對於一個有經驗的開發人員來說,快取不應該是多麼新的技術,但是非同步程式設計中這種模式的不同之處在於它應該與批處理結合在一起,以達到最佳效果。原因是因為多個請求可能併發執行,而沒有設定快取,並且當這些請求完成時,快取將會被設定多次,這樣做則會造成快取資源的浪費。

基於這些假設,非同步請求快取模式的最終結構如下圖所示:

《Node.js設計模式》高階非同步準則

上圖給出了非同步快取演算法的兩個步驟:

  1. 與批處理模式完全相同,與在未設定快取記憶體時接收到的任何請求將一起批處理。這些請求完成時,快取將會被設定一次。
  2. 當快取最終被設定時,任何後續的請求都將直接從快取中提供。

另外我們需要考慮Zalgo的反作用(我們已經在Chapter 2-Node.js Essential Patterns中看到了它的實際應用)。在處理非同步API時,我們必須確保始終以非同步方式返回快取的值,即使訪問快取只涉及同步操作。

在電子商務銷售的Web伺服器中使用非同步快取請求

實踐非同步快取模式的優點,現在讓我們將我們學到的東西應用到totalSales() API

與非同步批處理示例程式一樣,我們建立一個代理,其作用是新增快取層。

然後建立一個名為totalSalesCache.js的新模組,程式碼如下:

const totalSales = require('./totalSales');

const queues = {};
const cache = {};

module.exports = function totalSalesBatch(item, callback) {
  const cached = cache[item];
  if (cached) {
    console.log('Cache hit');
    return process.nextTick(callback.bind(null, null, cached));
  }
  
  if (queues[item]) {
    console.log('Batching operation');
    return queues[item].push(callback);
  }
  
  queues[item] = [callback];
  totalSales(item, (err, res) => {
    if (!err) {
      cache[item] = res;
      setTimeout(() => {
        delete cache[item];
      }, 30 * 1000); //30 seconds expiry
    }
    
    const queue = queues[item];
    queues[item] = null;
    queue.forEach(cb => cb(err, res));
  });
};
複製程式碼

我們可以看到前面的程式碼與我們非同步批處理的很多地方基本相同。 其實唯一的區別是以下幾點:

  • 我們需要做的第一件事就是檢查快取是否被設定,如果是這種情況,我們將立即使用callback()返回快取的值,這裡必須要使用process.nextTick(),因為快取可能是非同步設定的,需要等到下一次事件輪詢時才能夠保證快取已經被設定。

  • 繼續非同步批處理模式,但是這次,當原始API成功完成時,我們將結果儲存到快取中。此外,我們還設定了一個快取淘汰機制,在30秒後使快取失效。 一個簡單而有效的技術!

現在,我們準備嘗試我們剛建立的totalSales模組。 先更改app.js模組,如下所示:

// const totalSales = require('./totalSales');
// const totalSales = require('./totalSalesBatch');
const totalSales = require('./totalSalesCache');
   http.createServer(function(req, res) {
     // ...
});
複製程式碼

現在,重新啟動伺服器,並使用loadTest.js指令碼進行配置,就像我們在前面的例子中所做的那樣。使用預設的測試引數,與簡單的非同步批處理模式相比,很明顯地有了更好的效能提升。 當然,這很大程度上取決於很多因素;例如收到的請求數量,以及一個請求和另一個請求之間的延遲等。當請求數量較高且跨越較長時間時,使用快取記憶體批處理的優勢將更為顯著。

Memoization被稱做快取函式呼叫的結果的演算法。 在npm中,你可以找到許多包來實現非同步的memoization,其中最著名的之一之一是memoizee

有關實現快取機制的說明

我們必須記住,在實際應用中,我們可能想要使用更先進的失效技術和儲存機制。 這可能是必要的,原因如下:

  • 大量的快取值可能會消耗大量記憶體。 在這種情況下,可以應用最近最少使用(LRU)演算法來保持恆定的儲存器利用率。
  • 當應用程式分佈在多個程式中時,對快取使用簡單變數可能會導致每個伺服器例項返回不同的結果。如果這對於我們正在實現的特定應用程式來說是不希望的,那麼解決方案就是使用共享儲存來儲存快取。 常用的解決方案是RedisMemcached
  • 與定時淘汰快取相比,手動淘汰快取記憶體可使得快取記憶體使用壽命更長,同時提供更新的資料,但當然,管理起快取來要複雜得多。

使用Promise進行批處理和快取

Chapter4-Asynchronous Control Flow Patterns with ES2015 and Beyond中,我們看到了Promise如何極大地簡化我們的非同步程式碼,但是在處理批處理和快取時,它則可以提供更大的幫助。

利用Promise進行非同步批處理和快取策略,有如下兩個優點:

  • 多個then()監聽器可以附加到相同的Promise例項。
  • then()監聽器最多保證被呼叫一次,即使在Promise已經被resolve了之後,then()也能正常工作。 此外,then()總是會被保證其是非同步呼叫的。

簡而言之,第一個優點正是批處理請求所需要的,而第二個優點則在Promise已經是解析值的快取時,也會提供同樣的的非同步返回快取值的機制。

下面開始看程式碼,我們可以嘗試使用PromisestotalSales()建立一個模組,在其中新增批處理和快取功能。建立一個名為totalSalesPromises.js的新模組:

const pify = require('pify');  // [1]
const totalSales = pify(require('./totalSales'));

const cache = {};
module.exports = function totalSalesPromises(item) {
  if (cache[item]) {  // [2]
    return cache[item];
  }

  cache[item] = totalSales(item)  // [3]
    .then(res => {  // [4]
      setTimeout(() => {delete cache[item]}, 30 * 1000); //30 seconds expiry
      return res;
    })
    .catch(err => {  // [5]
      delete cache[item];
      throw err;
    });
  return cache[item];  // [6]
};
複製程式碼

Promise確實很好,下面是上述函式的功能描述:

  1. 首先,我們需要一個名為pify的模組,它允許我們對totalSales()模組進行promisification。這樣做之後,totalSales()將返回一個符合ES2015標準的Promise例項,而不是接受一個回撥函式作為引數。
  2. 當呼叫totalSalesPromises()時,我們檢查給定的專案型別是否已經在快取中有相應的Promise。如果我們已經有了這樣的Promise,我們直接返回這個Promise例項。
  3. 如果我們在快取中沒有針對給定專案型別的Promise,我們繼續通過呼叫原始(promisified)的totalSales()來建立一個Promise例項。
  4. Promise正常resolve了,我們設定了一個清除快取的時間(假設為30秒),我們返回res將操作的結果返回給應用程式。
  5. 如果Promise被異常reject了,我們立即重置快取,並再次丟擲錯誤,將其傳播到Promise chain中,所以任何附加到相同Promise的其他應用程式也將收到這一異常。
  6. 最後,我們返回我們剛才建立或者快取的Promise例項。

非常簡單直觀,更重要的是,我們使用Promise也能夠實現批處理和快取。 如果我們現在要嘗試使用totalSalesPromise()函式,稍微調整app.js模組,因為現在使用Promise而不是回撥函式。 讓我們通過建立一個名為appPromises.js的app模組來實現:

const http = require('http');
const url = require('url');
const totalSales = require('./totalSalesPromises');

http.createServer(function(req, res) {
  const query = url.parse(req.url, true).query;
  totalSales(query.item).then(function(sum) {
    res.writeHead(200);
    res.end(`Total sales for item ${query.item} is ${sum}`);
  });
}).listen(8000, function() {console.log('Started')});
複製程式碼

它的實現與原始應用程式模組幾乎完全相同,不同的是現在我們使用的是基於Promise的批處理/快取封裝版本; 因此,我們呼叫它的方式也略有不同。

執行以下命令開啟這個新版本的伺服器:

node appPromises
複製程式碼

執行與CPU-bound的任務

雖然上面的totalSales()在系統資源上面消耗較大,但是其也不會影響伺服器處理併發的能力。 我們在Chapter1-Welcome to the Node.js Platform中瞭解到有關事件迴圈的內容,應該為此行為提供解釋:呼叫非同步操作會導致堆疊退回到事件迴圈,從而使其免於處理其他請求。

但是,當我們執行一個長時間的同步任務時,會發生什麼情況,從不會將控制權交還給事件迴圈?

這種任務也被稱為CPU-bound,因為它的主要特點是CPU利用率較高,而不是I/O操作繁重。 讓我們立即舉一個例子上看看這些型別的任務在Node.js中的具體行為。

解決子集總和問題

現在讓我們做一個CPU佔用比較高的高計算量的實驗。下面來看的是子集總和問題,我們計算一個陣列中是否具有一個子陣列,其總和為0。例如,如果我們有陣列[1, 2, -4, 5, -3]作為輸入,則滿足問題的子陣列是[1, 2, -3][2, -4, 5, -3]

最簡單的演算法是把每一個陣列元素做遍歷然後依次計算,時間複雜度為O(2^n),或者換句話說,它隨著輸入的陣列長度成指數增長。這意味著一組20個整數則會有多達1, 048, 576中情況,顯然不能夠通過窮舉來做到。當然,這個問題的解決方案可能並不算複雜。為了使事情變得更加困難,我們將考慮陣列和問題的以下變化:給定一組整數,我們要計算所有可能的組合,其總和等於給定的任意整數。

const EventEmitter = require('events').EventEmitter;
class SubsetSum extends EventEmitter {
  constructor(sum, set) {
      super();
      this.sum = sum;
      this.set = set;
      this.totalSubsets = 0;
    } //...
}
複製程式碼

SubsetSum類是EventEmitter類的子類;這使得我們每次找到一個匹配收到的總和作為輸入的新子集時都會發出一個事件。 我們將會看到,這會給我們很大的靈活性。

接下來,讓我們看看我們如何能夠生成所有可能的子集組合:

開始構建一個這樣的演算法。建立一個名為subsetSum.js的新模組。在其中宣告一個SubsetSum類:

_combine(set, subset) {
  for(let i = 0; i < set.length; i++) {
    let newSubset = subset.concat(set[i]);
    this._combine(set.slice(i + 1), newSubset);
    this._processSubset(newSubset);
  }
}
複製程式碼

不管演算法其中到底是什麼內容,但有兩點要注意:

  • _combine()方法是完全同步的;它遞迴地生成每一個可能的子集,而不把CPU控制權交還給事件迴圈。如果我們考慮一下,這對於不需要任何I/O的演算法來說是非常正常的。
  • 每當生成一個新的組合時,我們都會將這個組合提供給_processSubset()方法以供進一步處理。

_processSubset()方法負責驗證給定子集的元素總和是否等於我們要查詢的數字:

_processSubset(subset) {
  console.log('Subset', ++this.totalSubsets, subset);
  const res = subset.reduce((prev, item) => (prev + item), 0);
  if (res == this.sum) {
    this.emit('match', subset);
  }
}
複製程式碼

簡單地說,_processSubset()方法將reduce操作應用於子集,以便計算其元素的總和。然後,當結果總和等於給定的sum引數時,會發出一個match事件。

最後,呼叫start()方法開始執行演算法:

start() {
  this._combine(this.set, []);
  this.emit('end');
}
複製程式碼

通過呼叫_combine()觸發演算法,最後觸發一個end事件,表明所有的組合都被檢查過,並且任何可能的匹配都已經被計算出來。 這是可能的,因為_combine()是同步的; 因此,只要前面的函式返回,end事件就會觸發,這意味著所有的組合都被計算出來了。

接下來,我們在網路上公開剛剛建立的演算法。可以使用一個簡單的HTTP伺服器對響應的任務作出響應。 特別是,我們希望以/subsetSum?data=<Array>&sum=<Integer>這樣的請求格式進行響應,傳入給定的陣列和sum,使用SubsetSum演算法進行匹配。

在一個名為app.js的模組中實現這個簡單的伺服器:

const http = require('http');
const SubsetSum = require('./subsetSum');

http.createServer((req, res) => {
  const url = require('url').parse(req.url, true);
  if(url.pathname === '/subsetSum') {
    const data = JSON.parse(url.query.data);
    res.writeHead(200);
    const subsetSum = new SubsetSum(url.query.sum, data);
    subsetSum.on('match', match => {
      res.write('Match: ' + JSON.stringify(match) + '\n');
    });
    subsetSum.on('end', () => res.end());
    subsetSum.start();
  } else {
    res.writeHead(200);
    res.end('I\m alive!\n');
  }
}).listen(8000, () => console.log('Started'));
複製程式碼

由於SubsetSum例項使用事件返回結果,所以我們可以在演算法生成後立即對匹配的結果使用Stream進行處理。另一個需要注意的細節是,每次我們的伺服器都會返回I'm alive!,這樣我們每次傳送一個不同於/subsetSum的請求的時候。可以用來檢查我們伺服器是否掛掉了,這在稍後將會看到。

開始執行:

node app
複製程式碼

一旦伺服器啟動,我們準備傳送我們的第一個請求;讓我們嘗試傳送一組17個隨機數,這將導致產生131,071個組合,那麼伺服器將會處理一段時間:

curl -G http://localhost:8000/subsetSum --data-urlencode "data=[116,119,101,101,-116,109,101,-105,-102,117,-115,-97,119,-116,-104,-105,115]"--data-urlencode "sum=0"
複製程式碼

這是如果我們在第一個請求仍在執行的時候在另一個終端中嘗試輸入以下命令,我們將發現一個巨大的問題:

curl -G http://localhost:8000
複製程式碼

《Node.js設計模式》高階非同步準則

我們會看到直到第一個請求結束之前,最後一個請求一直處於掛起的狀態。伺服器沒有返回響應!這正如我們所想的那樣。Node.js事件迴圈執行在一個單獨的執行緒中,如果這個執行緒被一個長的同步計算阻塞,它將不能再執行一個迴圈來響應I'm alive!, 我們必須知道,這種程式碼顯然不能夠用於同時接收到多個請求的應用程式。

但是不要對Node.js中絕望,我們可以通過幾種方式來解決這種情況。我們來分析一下最常見的兩種方案:

使用setImmediate

通常,CPU-bound演算法是建立在一定規則之上的。它可以是一組遞迴呼叫,一個迴圈,或者基於這些的任何變化/組合。 所以,對於我們的問題,一個簡單的解決方案就是在這些步驟完成後(或者在一定數量的步驟之後),將控制權交還給事件迴圈。這樣,任何待處理的I / O仍然可以在事件迴圈在長時間執行的演算法產生CPU的時間間隔中處理。對於這個問題而言,解決這一問題的方式是把演算法的下一步在任何可能導致掛起的I/O請求之後執行。這聽起來像是setImmediate()方法的完美用例(我們已經在Chapter2-Node.js Essential Patterns中介紹過這一API)。

模式:使用setImmediate()交錯執行長時間執行的同步任務。

使用setImmediate進行子集求和演算法的步驟

現在我們來看看這個模式如何應用於子集求和演算法。 我們所要做的只是稍微修改一下subsetSum.js模組。 為方便起見,我們將建立一個名為subsetSumDefer.js的新模組,將原始的subsetSum類的程式碼作為起點。 我們要做的第一個改變是新增一個名為_combineInterleaved()的新方法,它是我們正在實現的模式的核心:

_combineInterleaved(set, subset) {
  this.runningCombine++;
  setImmediate(() => {
    this._combine(set, subset);
    if(--this.runningCombine === 0) {
      this.emit('end');
    }
  });
}
複製程式碼

正如我們所看到的,我們所要做的只是使用setImmediate()呼叫原始的同步的_combine()方法。然而,現在的問題是因為該演算法不再是同步的,我們更難以知道何時已經完成了所有的組合的計算。

為了解決這個問題,我們必須使用非常類似於我們在Chapter3-Asynchronous Control Flow Patterns with Callbacks看到的非同步並行執行的模式來追溯_combine()方法的所有正在執行的例項。 當_combine()方法的所有例項都已經完成執行時,觸發end事件,通知任何監聽器,程式需要做的所有動作都已經完成。

對於最終子集求和演算法的重構版本。首先,我們需要將_combine()方法中的遞迴步驟替換為非同步:

_combine(set, subset) {
  for(let i = 0; i < set.length; i++) {
    let newSubset = subset.concat(set[i]);
    this._combineInterleaved(set.slice(i + 1), newSubset);
    this._processSubset(newSubset);
  }
}
複製程式碼

通過上面的更改,我們確保演算法的每個步驟都將使用setImmediate()在事件迴圈中排隊,在事件迴圈佇列中I / O請求之後執行,而不是同步執行造成阻塞。

另一個小調整是對於start()方法:

start() {
  this.runningCombine = 0;
  this._combineInterleaved(this.set, []);
}
複製程式碼

在前面的程式碼中,我們將_combine()方法的執行例項的數量初始化為0.我們還通過呼叫_combineInterleaved()來將呼叫替換為_combine(),並移除了end的觸發,因為現在_combineInterleaved()是非同步處理的。 通過這個最後的改變,我們的子集求和演算法現在應該能夠通過事件迴圈可以執行的時間間隔交替地執行其可能大量佔用CPU的程式碼,並且不會再造成阻塞。

最後更新app.js模組,以便它可以使用新版本的SubsetSum

const http = require('http');
// const SubsetSum = require('./subsetSum');
const SubsetSum = require('./subsetSumDefer');
http.createServer(function(req, res) {
  // ...
})
複製程式碼

和之前一樣的方式開始執行,結果如下:

《Node.js設計模式》高階非同步準則

此時,使用非同步的方式執行,不再會阻塞CPU了。

interleaving模式

正如我們所看到的,在保持應用程式的響應性的同時執行一個CPU-bound的任務並不複雜,只需要使用setImmediate()把同步執行的程式碼變為非同步執行即可。但是,這不是效率最好的模式;實際上,延遲執行一個任務會額外帶來一個小的開銷,在這樣的演算法中,積少成多,則會產生重大的影響。這通常是我們在執行CPU限制任務時所需要的最後一件事情,特別是如果我們必須將結果直接返回給使用者,這應該在合理的時間內進行響應。 緩解這個問題的一個可能的解決方案是隻有在一定數量的步驟之後使用setImmediate(),而不是在每一步中使用它。但是這仍然不能解決問題的根源。

記住,這並不是說一旦我們想要通過非同步的模式來執行CPU-bound的任務,我們就應該不惜一切代價來避免這樣的額外開銷,事實上,從更廣闊的角度來看,同步任務並不一定非常漫長和複雜,以至於造成麻煩。在繁忙的伺服器中,即使是阻塞事件迴圈200毫秒的任務也會產生不希望的延遲。 在那些併發量並不高的伺服器來說,即使產生一定短時的阻塞,也不會影響效能,使用交錯執行setImmediate()可能是避免阻塞事件迴圈的最簡單也是最有效的方法。

process.nextTick()不能用於交錯長時間執行的任務。正如我們在Chapter1-Welcome to the Node.js Platform中看到的,nextTick()會在任何未返回的I / O之前排程,並且在重複呼叫process.nextTick()最終會導致I / O飢餓。 你可以通過在前面的例子中用process.nextTick()替換setImmediate()來驗證。

使用多個程式

使用interleaving模式並不是我們用來執行CPU-bound任務的唯一方法;防止事件迴圈阻塞的另一種模式是使用子程式。我們已經知道Node.js在執行I / O密集型應用程式(如Web伺服器)的時候是最好的,因為Node.js可以使得我們可以通過非同步來優化資源利用率。

所以,我們必須保持應用程式響應的最好方法是不要在主應用程式的上下文中執行昂貴的CPU-bound任務,而是使用單獨的程式。這有三個主要的優點:

  • 同步任務可以全速執行,而不需要交錯執行的步驟
  • Node.js中處理程式很簡單,可能比修改一個使用setImmediate()的演算法更容易,並且多程式允許我們輕鬆使用多個處理器,而無需擴充套件主應用程式本身。
  • 如果我們真的需要超高的效能,可以使用低階語言,如效能良好的C

Node.js有一個充足的API庫帶來與外部程式互動。 我們可以在child_process模組中找到我們需要的所有東西。 而且,當外部程式只是另一個Node.js程式時,將它連線到主應用程式是非常容易的,我們甚至不覺得我們在本地應用程式外部執行任何東西。這得益於child_process.fork()函式,該函式建立一個新的子Node.js程式,並自動建立一個通訊管道,使我們能夠使用與EventEmitter非常相似的介面交換資訊。來看如何用這個特性來重構我們的子集求和演算法。

將子集求和任務委託給其他程式

重構SubsetSum任務的目標是建立一個單獨的子程式,負責處理CPU-bound的任務,使伺服器的事件迴圈專注於處理來自網路的請求:

  1. 我們將建立一個名為processPool.js的新模組,它將允許我們建立一個正在執行的程式池。建立一個新的程式代價昂貴,需要時間,因此我們需要保持它們不斷執行,儘量不要產生中斷,時刻準備好處理請求,使我們可以節省時間和CPU。此外,程式池需要幫助我們限制同時執行的程式數量,以避免將使我們的應用程式受到拒絕服務(DoS)攻擊。
  2. 接下來,我們將建立一個名為subsetSumFork.js的模組,負責抽象子程式中執行的SubsetSum任務。 它的角色將與子程式進行通訊,並將任務的結果展示為來自當前應用程式。
  3. 最後,我們需要一個worker(我們的子程式),一個新的Node.js程式,執行子集求和演算法並將其結果轉發給父程式。

DoS攻擊是企圖使其計劃使用者無法使用機器或網路資源,例如臨時或無限中斷或暫停連線到Internet的主機的服務。

實現一個程式池

先從構建processPool.js模組開始:

const fork = require('child_process').fork;
class ProcessPool {
  constructor(file, poolMax) {
      this.file = file;
      this.poolMax = poolMax;
      this.pool = [];
      this.active = [];
      this.waiting = [];
    } //...
}
複製程式碼

在模組的第一部分,引入我們將用來建立新程式的child_process.fork()函式。 然後,我們定義ProcessPool的建構函式,該建構函式接受表示要執行的Node.js程式的檔案引數以及池中執行的最大例項數poolMax作為引數。然後我們定義三個例項變數:

  • pool表示的是準備執行的程式
  • active表示的是當前正在執行的程式列表
  • waiting包含所有這些請求的任務佇列,儲存由於缺少可用的資源而無法立即實現的任務

ProcessPool類的acquire()方法,它負責取出一個準備好被使用的程式:

acquire(callback) {
  let worker;
  if(this.pool.length > 0) {  // [1]
    worker = this.pool.pop();
    this.active.push(worker);
    return process.nextTick(callback.bind(null, null, worker));
  }

  if(this.active.length >= this.poolMax) {  // [2]
    return this.waiting.push(callback);
  }

  worker = fork(this.file);  // [3]
  this.active.push(worker);
  process.nextTick(callback.bind(null, null, worker));
}
複製程式碼

函式邏輯如下:

  1. 如果在程式池中有一個準備好被使用的程式,我們只需將其移動到active陣列中,然後通過非同步的方式呼叫其回撥函式。
  2. 如果池中沒有可用的程式,或者已經達到執行程式的最大數量,必須等待。通過把當前回撥放入waiting陣列。
  3. 如果我們還沒有達到執行程式的最大數量,我們將使用child_process.fork()建立一個新的程式,將其新增到active列表中,然後呼叫其回撥。

ProcessPool類的最後一個方法是release(),其目的是將一個程式放回程式池中:

release(worker) {
  if(this.waiting.length > 0) {  // [1]
    const waitingCallback = this.waiting.shift();
    waitingCallback(null, worker);
  }
  this.active = this.active.filter(w => worker !==  w);  // [2]
  this.pool.push(worker);
}
複製程式碼

前面的程式碼也很簡單,其解釋如下:

  • 如果在waiting任務佇列裡面有任務需要被執行,我們只需為這個任務分配一個程式worker執行。
  • 否則,如果在waiting任務佇列中都沒有需要被執行的任務,我們則把active的程式列表中的程式放回程式池中。

正如我們所看到的,程式從來沒有中斷,只在為其不斷地重新分配任務,使我們可以通過在每個請求不重新啟動一個程式達到節省時間和空間的目的。然而,重要的是要注意,這可能並不總是最好的選擇,這很大程度上取決於我們的應用程式的要求。為減少程式池長期佔用記憶體,可能的調整如下:

  • 在一個程式空閒一段時間後,終止程式,釋放記憶體空間。
  • 新增一個機制來終止或重啟沒有響應的或者崩潰了的程式。
父子程式通訊

現在我們的ProcessPool類已經準備就緒,我們可以使用它來實現SubsetSumFork模組,SubsetSumFork的作用是與子程式進行通訊得到子集求和的結果。前面曾說到,用child_process.fork()啟動一個程式也給了我們建立了一個簡單的基於訊息的管道,通過實現subsetSumFork.js模組來看看它是如何工作的:

const EventEmitter = require('events').EventEmitter;
const ProcessPool = require('./processPool');
const workers = new ProcessPool(__dirname + '/subsetSumWorker.js', 2);

class SubsetSumFork extends EventEmitter {
  constructor(sum, set) {
    super();
    this.sum = sum;
    this.set = set;
  }

  start() {
    workers.acquire((err, worker) => {  // [1]
      worker.send({sum: this.sum, set: this.set});

      const onMessage = msg => {
        if (msg.event === 'end') {  // [3]
          worker.removeListener('message', onMessage);
          workers.release(worker);
        }

        this.emit(msg.event, msg.data);  // [4]
      };

      worker.on('message', onMessage);  // [2]
    });
  }
}

module.exports = SubsetSumFork;
複製程式碼

首先注意,我們在subsetSumWorker.js呼叫ProcessPool的建構函式建立ProcessPool例項。 我們還將程式池的最大容量設定為2

另外,我們試圖維持原來的SubsetSum類相同的公共API。實際上,SubsetSumForkEventEmitter的子類,它的建構函式接受sumset,而start()方法則觸發演算法的執行,而這個SubsetSumFork例項執行在一個單獨的程式上。呼叫start()方法時會發生的情況:

  1. 我們試圖從程式池中獲得一個新的子程式。在建立程式成功之後,我們嘗試向子程式傳送一條訊息,包含sumsetsend()方法是Node.js自動提供給child_process.fork()建立的所有程式,這實際上與父子程式之間的通訊管道有關。
  2. 然後我們開始監聽子程式返回的任何訊息,我們使用on()方法附加一個新的事件監聽器(這也是所有以child_process.fork()建立的程式提供的通訊通道的一部分)。
  3. 在事件監聽器中,我們首先檢查是否收到一個end事件,這意味著SubsetSum所有任務已經完成,在這種情況下,我們刪除onMessage監聽器並釋放worker,並將其放回程式池中,不再讓其佔用記憶體資源和CPU資源。
  4. worker{event,data}格式生成訊息,使得任何時候一旦子程式處理完畢任務,我們在外部都能接收到這一訊息。

這就是SubsetSumFork模組現在我們來實現這個worker應用程式。

與父程式進行通訊

現在我們來建立subsetSumWorker.js模組,我們的應用程式,這個模組的全部內容將在一個單獨的程式中執行:

const SubsetSum = require('./subsetSum');

process.on('message', msg => {  // [1]
  const subsetSum = new SubsetSum(msg.sum, msg.set);
  
  subsetSum.on('match', data => {  // [2]
    process.send({event: 'match', data: data});
  });
  
  subsetSum.on('end', data => {
    process.send({event: 'end', data: data});
  });
  
  subsetSum.start();
});
複製程式碼

由於我們的handler處於一個單獨的程式中,我們不必擔心這類CPU-bound任務阻塞事件迴圈,所有的HTTP請求將繼續由主應用程式的事件迴圈處理,而不會中斷。

當子程式開始啟動時,父程式:

  1. 子程式立即開始監聽來自父程式的訊息。這可以通過process.on()函式輕鬆實現。我們期望從父程式中唯一的訊息是為新的SubsetSum任務提供輸入的訊息。只要收到這樣的訊息,我們建立一個SubsetSum類的新例項,並註冊matchend事件監聽器。最後,我們用subsetSum.start()開始計算。
  2. 每次子集求和演算法收到事件時,把結果它封裝在格式為{event,data}的物件中,並將其傳送給父程式。這些訊息然後在subsetSumFork.js模組中處理,就像我們在前面的章節中看到的那樣。

注意:當子程式不是Node.js程式時,則上述的通訊管道就不可用了。在這種情況下,我們仍然可以通過在暴露於父程式的標準輸入流和標準輸出流之上實現我們自己的協議來建立父子程式通訊的介面。

多程式模式

嘗試新版本的子集求和演算法,我們只需要替換HTTP伺服器使用的模組(檔案app.js):

執行結果如下:

《Node.js設計模式》高階非同步準則

更有趣的是,我們也可以嘗試同時啟動兩個subsetSum任務,我們可以充分看到多核CPU的作用。 相反,如果我們嘗試同時執行三個subsetSum任務,結果應該是最後一個啟動將掛起。這不是因為主程式的事件迴圈被阻塞,而是因為我們為subsetSum任務設定了兩個程式的併發限制。

正如我們所看到的,多程式模式比interleaving模式更加強大和靈活;然而,由於單個機器提供的CPU和記憶體資源量仍然是一個硬性限制,所以它仍然不可擴充套件。在這種情況下,將負載分配到多臺機器上,則是更優秀的解決辦法。

值得一提的是,在執行CPU-bound任務時,多執行緒可以成為多程式的替代方案。目前,有幾個npm包公開了一個用於處理使用者級模組的執行緒的API;其中最流行的是webworker-threads。但是,即使執行緒更輕量級,完整的程式也可以提供更大的靈活性,並具備更高更可靠的容錯處理。

總結

本章講述以下三點:

  • 非同步初始化模組
  • 批處理和快取在Node.js非同步中的運用
  • 使用非同步或者多程式來處理CPU-bound的任務

相關文章