Node.js中執行緒的完整指南 – LogRocket

banq發表於2019-03-25

很多人想知道單執行緒Node.js如何與多執行緒後端競爭。因此,考慮到其所謂的單執行緒特性,許多大公司選擇Node作為其後端似乎違反直覺。 當我們說Node是單執行緒時,我們必須理解我們的真正含義。
通常支援多執行緒的後端語言具有各種機制,用於線上程和其他面向執行緒的功能之間同步值。要向JavaScript新增對此類內容的支援,需要更改整個語言,這不是Dahl的目標。對於支援多執行緒的純JavaScript,他必須建立一個變通方法。讓我們來探索......

Node.js如何真正起作用
Node.js使用兩種執行緒:由事件迴圈處理的主執行緒和工作池中的幾個輔助執行緒。
事件迴圈是一種機制,它採用回撥(函式)並將它們註冊為將來的某個時刻執行。它與正確的JavaScript程式碼在同一個執行緒中執行。當JavaScript操作阻塞執行緒時,事件迴圈也會被阻止。
工作池是一種執行模型,它生成並處理單獨的執行緒,然後同步執行任務並將結果返回到事件迴圈。然後,事件迴圈使用所述結果執行提供的回撥。
簡而言之,它負責非同步I / O操作 - 主要是與系統磁碟和網路的互動。它主要用於fs(I / O-heavy)或crypto(CPU-heavy)等模組。工作池是在libuv中實現的,每當Node需要在JavaScript和C ++之間進行內部通訊時,這會導致輕微的延遲,但這幾乎不可察覺。
使用這兩種機制,我們可以編寫如下程式碼:

fs.readFile(path.join(__dirname, './package.json'), (err, content) => {
 if (err) {
   return null;
 }

 console.log(content.toString());
});

上述fs模組告訴工作池使用其中一個執行緒來讀取檔案的內容,並在完成後通知事件迴圈。然後事件迴圈獲取提供的回撥函式並使用檔案的內容執行這個回撥函式。
以上是非阻塞程式碼的示例; 因此,我們不必同步等待某事發生。我們告訴工作池讀取檔案並使用結果呼叫提供的函式。由於工作池有自己的執行緒,因此事件迴圈可以在讀取檔案時繼續正常執行。
在需要同步執行某些複雜操作之前,這一切都很好:任何執行時間太長的函式都會阻塞執行緒。如果應用程式具有許多此類功能,則可能會顯著降低伺服器的吞吐量或完全凍結它。在這種情況下,無法將工作委派給工作池。
需要複雜計算的欄位(例如AI,機器學習或大資料)無法真正有效地使用Node.js,因為操作阻塞了主(且唯一)執行緒,使伺服器無響應。在Node.js v10.5.0釋出之前就是這種情況,這增加了對多執行緒的支援。

介紹:worker_threads
worker_threads模組是一個包,允許我們建立功能齊全的多執行緒Node.js應用程式。
執行緒工作者worker是在單獨的執行緒中生成的一段程式碼。
請注意,術語執行緒工作者,工作者和執行緒通常可以互換使用; 他們都指的是同一件事。
要開始使用執行緒工作者,我們必須匯入worker_threads模組。讓我們首先建立一個函式來幫助我們生成這些執行緒工作者,然後我們將討論它們的屬性。

type WorkerCallback = (err: any, result?: any) => any;

export function runWorker(path: string, cb: WorkerCallback, workerData: object | null = null) {
 const worker = new Worker(path, { workerData });

 worker.on('message', cb.bind(null, null));
 worker.on('error', cb);

 worker.on('exit', (exitCode) => {
   if (exitCode === 0) {
     return null;
   }

   return cb(new Error(`Worker has stopped with code ${exitCode}`));
 });

 return worker;
}


要建立一個worker,我們必須建立一個Worker類的例項。在第一個引數中,我們提供了包含worker的程式碼的檔案的路徑; 在第二個中,我們提供一個包含一個名為的屬性的物件workerData。這是我們希望執行緒在開始執行時可以訪問的資料。
請注意,無論您是使用JavaScript本身還是使用轉換為JavaScript的內容(例如,TypeScript),路徑都應始終引用帶有副檔名.js或  .mjs副檔名的檔案  。
我還想指出為什麼我們使用回撥方法而不是在message事件被觸發時返回promise 。這是因為工人worker可以派遣許多message活動,而不僅僅是一個。
正如您在上面的示例中所看到的,執行緒之間的通訊是基於事件的,這意味著我們正在設定在工作者傳送給定事件後呼叫的偵聽器。
以下是最常見的事件:
worker.on('error', (error) => {});

error表示:只要工作者中有未捕獲的異常,就會發出該事件。然後終止worker,並且錯誤可用作提供的回撥中的第一個引數。

worker.on('exit',(exitCode)=> {});

exit表示:每當工人退出時就會發出。如果process.exit()在worker內部呼叫,exitCode則會提供給回撥。如果工作人員被終止worker.terminate(),則程式碼為1。

worker.on('online',()=> {});
online表示:每當工作程式停止解析JavaScript程式碼並開始執行時就會發出。它不經常使用,但在特定情況下可以提供資訊。

worker.on('message',(data)=> {});
message :每當工作人員向父執行緒傳送資料時都會發出。
現在讓我們來看看如何線上程之間共享資料。

線上程之間交換資料
要將資料傳送到其他執行緒,我們使用該port.postMessage()方法。它有以下簽名:

port.postMessage(data[, transferList])

埠物件可以是一個parentPort或一個例項MessagePort

資料引數
第一個引數 - 這裡稱為data - 是一個複製到另一個執行緒的物件。它可以包含複製演算法支援的任何內容。
資料由結構化克隆演算法複製

它透過遞迴輸入物件來構建克隆,同時保持先前訪問過的引用的對映,以避免無限遍歷迴圈。

演算法不復制函式、錯誤、屬性描述符或原型鏈。還應該注意,以這種方式複製物件與使用JSON不同,因為它可以包含迴圈引用和型別化陣列,例如,而JSON不能。
透過支援複製型別化陣列,該演算法可以線上程之間共享記憶體。

線上程之間共享記憶體
很久以前,人們可能認為模組喜歡cluster或啟用了執行緒。
cluster模組可以建立多個節點例項,其中一個主程式在它們之間路由傳入請求。叢集應用程式使我們能夠有效地增加伺服器的吞吐量; 但是,我們不能用cluster模組生成單獨的執行緒。
child_process無論是否是JavaScript,該模組都可以生成任何可執行檔案。它非常相似,但它缺少幾個重要的功能worker_threads。
具體來說,執行緒工作者更輕量級並且與其父執行緒共享相同的程式ID。它們還可以與父執行緒共享記憶體,這樣可以避免序列化大的資料負載,從而更有效地來回傳送資料。
現在讓我們看一下如何線上程之間共享記憶體的示例。為了使儲存器被共享,例項ArrayBuffer或SharedArrayBuffer必須被髮送到其它執行緒作為資料引數或資料引數的內部。
這是一個與其父執行緒共享記憶體的worker:

import { parentPort } from 'worker_threads';

parentPort.on('message', () => {
 const numberOfElements = 100;
 const sharedBuffer = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * numberOfElements);
 const arr = new Int32Array(sharedBuffer);

 for (let i = 0; i < numberOfElements; i += 1) {
   arr[i] = Math.round(Math.random() * 30);
 }

 parentPort.postMessage({ arr });
});


首先,我們建立一個SharedArrayBuffer包含100個32位整數所需的記憶體。接下來,我們建立一個例項Int32Array,它將使用緩衝區來儲存其結構,然後我們只用一些隨機數填充陣列並將其傳送到父執行緒。

在父執行緒中:

import { runWorker } from '../run-worker';

const worker = runWorker(path.join(__dirname, 'worker.js'), (err, { arr }) => {
 if (err) {
   return null;
 }

 arr[0] = 5;
});

worker.postMessage({});


透過更改arr[0]為5,我們實際上在兩個執行緒中更改它。
當然,透過共享記憶體,我們冒險在一個執行緒中更改一個值並在另一個執行緒中更改它。但是我們在此過程中也獲得了一個非常好的功能:該值不需要序列化以便在另一個執行緒中可用,這極大地提高了效率。只需記住正確管理資料引用,以便在完成資料處理後對其進行垃圾回收。
共享一個整數陣列很好,但我們真正感興趣的是共享物件 - 儲存資訊的預設方式。不幸的是,沒有SharedObjectBuffer 或類似,但我們可以自己建立一個類似的結構

transferList引數
transferList只能包含ArrayBuffer和MessagePort。一旦它們被轉移到另一個執行緒,它們就不能再用於傳送執行緒; 記憶體被移動到另一個執行緒,因此在傳送一個執行緒中不可用。
目前,我們不能透過將網路套接字包含在transferList(我們可以使用child_process模組)中來傳輸網路套接字。

建立通訊通道
執行緒之間的通訊是透過埠進行的,埠是MessagePort類的例項並啟用基於事件的通訊。
有兩種方法可以使用埠線上程之間進行通訊。第一個是預設值,兩個更容易。在worker的程式碼中,我們匯入一個parentPort從worker_threads模組呼叫的物件,並使用該物件的  .postMessage()方法將訊息傳送到父執行緒。這是一個例子:

import { parentPort } from 'worker_threads';
const data = {
 // ...
};

parentPort.postMessage(data);


parentPort是MessagePort在幕後為我們建立的Node.js 的例項,用於啟用與父執行緒的通訊。這樣,我們可以透過使用parentPort和worker物件線上程之間進行通訊。
執行緒之間通訊的第二種方式是實際建立一個MessageChannel我們自己的並將其傳送給worker。以下是我們如何建立一個新的MessagePort並與我們的工作者分享它:

import path from 'path';
import { Worker, MessageChannel } from 'worker_threads';

const worker = new Worker(path.join(__dirname, 'worker.js'));

const { port1, port2 } = new MessageChannel();

port1.on('message', (message) => {
 console.log('message from worker:', message);
});

worker.postMessage({ port: port2 }, [port2]);


建立port1和port2後,我們就成立了事件偵聽器port1和傳送port2給工人。我們必須把它包含在transferList它轉移到工人方面。
現在,在工人內部:

import { parentPort, MessagePort } from 'worker_threads';

parentPort.on('message', (data) => {
 const { port }: { port: MessagePort } = data;

 port.postMessage('heres your message!');
});


這樣,我們使用父執行緒傳送的埠。
使用parentPort不一定是錯誤的方法,但最好MessagePort使用例項建立一個新例項,MessageChannel然後與生成的工作者共享它(閱讀:關注點分離)。
請注意,在下面的示例中,我parentPort用來保持簡單。

更多點選標題見原文。

相關文章