深入解析NodeJS中的程序管理

2022-07-14 22:01:35

熟悉 js 的朋友都知道,js 是單執行緒的,在 Node 中,採用的是 多程序單執行緒 的模型。由於javascript單執行緒的限制,在多核伺服器上,我們往往需要啟動多個程序才能最大化伺服器效能。

Node.js 程序叢集可用於執行多個 Node.js 範例,這些範例可以在其應用程式執行緒之間分配工作負載。 當不需要程序隔離時,請改用 worker_threads 模組,它允許在單個 Node.js 範例中執行多個應用程式執行緒。

零、NodeJS多程序

  • 程序總數,其中一個主程序,cpu 個數 x cpu 核數 個 子程序
  • 無論 child_process 還是 cluster,都不是多執行緒模型,而是多程序模型
  • 應對單執行緒問題,通常使用多程序的方式來模擬多執行緒

一、核心模組cluster叢集

Node 在 V0.8 版本之後引入了 cluster模組,通過一個主程序 (master) 管理多個子程序 (worker) 的方式實現叢集

叢集模組可以輕鬆建立共用伺服器埠的子程序。

cluster 底層是 child_process 模組,除了可以傳送普通訊息,還可以傳送底層物件 TCPUDP 等, cluster 模組是 child_process 模組和 net 模組的組合應用。 cluster 啟動時,內部會啟動 TCP 伺服器,將這個 TCP 伺服器端 socket 的檔案描述符發給工作程序。

cluster 模組應用中,一個主程序只能管理一組工作程序,其運作模式沒有 child_process 模組那麼靈活,但是更加穩定:

1.png

1.cluster設定詳情

1.1 引入cluster

const cluster = require('cluster')復

1.2 cluster常用屬性

  • .isMaster 標識主程序, Node<16
  • .isPrimary 標識主程序, Node>16
  • .isWorker 標識子程序
  • .worker 對當前工作程序物件的參照【子程序中】
  • .workers 儲存活動工作程序物件的雜湊,以 id 欄位為鍵。 這樣可以很容易地遍歷所有工作程序。 它僅在主程序中可用。cluster.wokers[id] === worker【主程序中】
  • .settings 唯讀, cluster設定項。在呼叫 .setupPrimary()或.fork()方法之後,此設定物件將包含設定,包括預設值。之前為空物件。此物件不應手動更改或設定。

cluster.settings設定項詳情:

- `execArgv` <string[]>傳給 Node.js 可執行檔案的字串參數列。 **預設值:**  `process.execArgv`。
- `exec` <string> 工作程序檔案的檔案路徑。 **預設值:** `process.argv[1]`。
- `args` <string[]> 傳給工作程序的字串引數。 **預設值:**`process.argv.slice(2)`。
- `cwd` <string>工作程序的當前工作目錄。 **預設值:**  `undefined` (從父程序繼承)。
- `serialization` <string>指定用於在程序之間傳送訊息的序列化型別。 可能的值為 `'json'` 和 `'advanced'`。  **預設值:**  `false`。
- `silent` <boolean>是否將輸出傳送到父程序的標準輸入輸出。 **預設值:**  `false`。
- `stdio` <Array>設定衍生程序的標準輸入輸出。 由於叢集模組依賴 IPC 來執行,因此此設定必須包含 `'ipc'` 條目。 提供此選項時,它會覆蓋 `silent`。
- `uid` <number>設定程序的使用者標識。 
- `gid` <number>設定程序的群組標識。
- `inspectPort` <number> | <Function> 設定工作程序的檢查器埠。 這可以是數位,也可以是不帶引數並返回數位的函數。 預設情況下,每個工作程序都有自己的埠,從主程序的 `process.debugPort` 開始遞增。
- `windowsHide` <boolean> 隱藏通常在 Windows 系統上建立的衍生程序控制檯視窗。 **預設值:**  `false`。

1.3 cluster常用方法

  • .fork([env]) 衍生新的工作程序【主程序中】
  • .setupPrimary([settings]) Node>16
  • .setupMaster([settings]) 用於更改預設的 'fork' 行為,用後設定將出現在 cluster.settings 中。任何設定更改只會影響未來對 .fork()的呼叫,而不會影響已經執行的工作程序。上述預設值僅適用於第一次呼叫。Node 小於 16【主程序中】
  • .disconnect([callback]) 當所有工作程序斷開連線並關閉控制程式碼時呼叫【主程序中】

1.4 cluster常用事件

為了讓叢集更加穩定和健壯,cluster 模組也暴露了許多事件:

  • 'message' 事件, 當叢集主程序接收到來自任何工作程序的訊息時觸發。
  • 'exit' 事件, 當任何工作程序死亡時,則叢集模組將觸發 'exit' 事件。
cluster.on('exit', (worker, code, signal) => {
  console.log('worker %d died (%s). restarting...',
              worker.process.pid, signal || code);
  cluster.fork();
});
  • 'listening'事件,從工作程序呼叫 listen() 後,當伺服器上觸發 'listening' 事件時,則主程序中的 cluster 也將觸發 'listening' 事件。
cluster.on('listening', (worker, address) => {
  console.log(
    `A worker is now connected to ${address.address}:${address.port}`);
});
  • 'fork' 事件,當新的工作程序被衍生時,則叢集模組將觸發 'fork' 事件。
cluster.on('fork', (worker) => {
  timeouts[worker.id] = setTimeout(errorMsg, 2000);
});
  • 'setup' 事件,每次呼叫 .setupPrimary()時觸發。
  • disconnect事件,在工作程序 IPC 通道斷開連線後觸發。 當工作程序正常退出、被殺死、或手動斷開連線時
cluster.on('disconnect', (worker) => {
  console.log(`The worker #${worker.id} has disconnected`);
});

1.5 Worker類

Worker 物件包含了工作程序的所有公共的資訊和方法。 在主程序中,可以使用 cluster.workers 來獲取它。 在工作程序中,可以使用 cluster.worker 來獲取它。

1.5.1 worker常用屬性

  • .id 工作程序標識,每個新的工作程序都被賦予了自己唯一的 id,此 id 儲存在 id。當工作程序存活時,這是在 cluster.workers 中索引它的鍵。
  • .process 所有工作程序都是使用 child_process.fork() 建立,此函數返回的物件儲存為 .process。 在工作程序中,儲存了全域性的 process

1.5.2 worker常用方法

  • .send(message[, sendHandle[, options]][, callback]) 向工作程序或主程序傳送訊息,可選擇使用控制程式碼。在主程序中,這會向特定的工作程序傳送訊息。 它與 ChildProcess.send()相同。在工作程序中,這會向主程序傳送訊息。 它與 process.send() 相同。
  • .destroy()
  • .kill([signal])此函數會殺死工作程序。kill() 函數在不等待正常斷開連線的情況下殺死工作程序,它與 worker.process.kill() 具有相同的行為。為了向後相容,此方法別名為 worker.destroy()
  • .disconnect([callback])傳送給工作程序,使其呼叫自身的 .disconnect()將關閉所有伺服器,等待那些伺服器上的 'close' 事件,然後斷開 IPC 通道。
  • .isConnect() 如果工作程序通過其 IPC 通道連線到其主程序,則此函數返回 true,否則返回 false。 工作程序在建立後連線到其主程序。
  • .isDead()如果工作程序已終止(由於退出或收到訊號),則此函數返回 true。 否則,它返回 false

1.5.3 worker常用事件

為了讓叢集更加穩定和健壯,cluster 模組也暴露了許多事件:

  • 'message' 事件, 在工作程序中。
cluster.workers[id].on('message', messageHandler);
  • 'exit' 事件, 當任何工作程序死亡時,則當前worker工作程序物件將觸發 'exit' 事件。
if (cluster.isPrimary) {
  const worker = cluster.fork();
  worker.on('exit', (code, signal) => {
    if (signal) {
      console.log(`worker was killed by signal: ${signal}`);
    } else if (code !== 0) {
      console.log(`worker exited with error code: ${code}`);
    } else {
      console.log('worker success!');
    }
  });
}
  • 'listening'事件,從工作程序呼叫 listen() ,對當前工作程序進行監聽。
cluster.fork().on('listening', (address) => {
  // 工作程序正在監聽
});
  • disconnect事件,在工作程序 IPC 通道斷開連線後觸發。 當工作程序正常退出、被殺死、或手動斷開連線時
cluster.fork().on('disconnect', () => {
  //限定於當前worker物件觸發
});

2. 程序通訊

Node中主程序和子程序之間通過程序間通訊 (IPC) 實現程序間的通訊,程序間通過 .send()(a.send表示向a傳送)方法傳送訊息,監聽 message 事件收取資訊,這是 cluster模組 通過整合 EventEmitter 實現的。還是一個簡單的官網的程序間通訊例子

  • 子程序:process.on('message')process.send()
  • 父程序:child.on('message')child.send()
# cluster.isMaster
# cluster.fork()
# cluster.workers
# cluster.workers[id].on('message', messageHandler);
# cluster.workers[id].send();
# process.on('message', messageHandler); 
# process.send();


const cluster = require('cluster');
const http = require('http');

# 主程序
if (cluster.isMaster) {
  // Keep track of http requests
  console.log(`Primary ${process.pid} is running`);
  let numReqs = 0;
  
  // Count requests
  function messageHandler(msg) {
    if (msg.cmd && msg.cmd === 'notifyRequest') {
      numReqs += 1;
    }
  }

  // Start workers and listen for messages containing notifyRequest
  // 開啟多程序(cpu核心數)
  // 衍生工作程序。
  const numCPUs = require('os').cpus().length;
  for (let i = 0; i < numCPUs; i++) {
    console.log(i)
    cluster.fork();
  }

// cluster worker 主程序與子程序通訊
  for (const id in cluster.workers) {
    // ***監聽來自子程序的事件
    cluster.workers[id].on('message', messageHandler); 
    
    // ***向子程序傳送
    cluster.workers[id].send({                         
        type: 'masterToWorker',
        from: 'master',
        data: {
            number: Math.floor(Math.random() * 50)
        }
    });
  }
  cluster.on('exit', (worker, code, signal) => {
    console.log(`worker ${worker.process.pid} died`);
  });

} else {

  # 子程序

  // 工作程序可以共用任何 TCP 連線
  // 在本範例中,其是 HTTP 伺服器
  // Worker processes have a http server.
  http.Server((req, res) => {
    res.writeHead(200);
    res.end('hello world\n');

    //****** !!!!Notify master about the request !!!!!!*******
    //****** 向process傳送
    process.send({ cmd: 'notifyRequest' }); 
    
    //****** 監聽從process來的
    process.on('message', function(message) { 
        // xxxxxxx
    })
  }).listen(8000);
  console.log(`Worker ${process.pid} started`);
}

2.png

2.1 控制程式碼傳送與還原

NodeJS 程序之間通訊只有訊息傳遞,不會真正的傳遞物件。

send() 方法在傳送訊息前,會將訊息組裝成 handle 和 message,這個 message 會經過 JSON.stringify 序列化,也就是說,傳遞控制程式碼的時候,不會將整個物件傳遞過去,在 IPC 通道傳輸的都是字串,傳輸後通過 JSON.parse 還原成物件。

2.2 監聽共同埠

程式碼裡有 app.listen(port) 在進行 fork 時,為什麼多個程序可以監聽同一個埠呢?

原因是主程序通過 send() 方法向多個子程序傳送屬於該主程序的一個服務物件的控制程式碼,所以對於每一個子程序而言,它們在還原控制程式碼之後,得到的服務物件是一樣的,當網路請求向伺服器端發起時,程序服務是搶佔式的,所以監聽相同埠時不會引起異常。

  • 看下埠被佔用的情況:
# master.js

const fork = require('child_process').fork;
const cpus = require('os').cpus();

for (let i=0; i<cpus.length; i++) {
    const worker = fork('worker.js');
    console.log('worker process created, pid: %s ppid: %s', worker.pid, process.pid);
}
# worker.js

const http = require('http');
http.createServer((req, res) => {
	res.end('I am worker, pid: ' + process.pid + ', ppid: ' + process.ppid);
}).listen(3000);

以上程式碼範例,控制檯執行 node master.js 只有一個 worker 可以監聽到 3000 埠,其餘將會丟擲 Error: listen EADDRINUSE :::3000 錯誤。

  • 那麼多程序模式下怎麼實現多程序埠監聽呢?答案還是有的,通過控制程式碼傳遞 Node.js v0.5.9 版本之後支援程序間可傳送控制程式碼功能
/**
 * http://nodejs.cn/api/child_process.html#child_process_subprocess_send_message_sendhandle_options_callback
 * message
 * sendHandle
 */
subprocess.send(message, sendHandle)

當父子程序之間建立 IPC 通道之後,通過子程序物件的 send 方法傳送訊息,第二個引數 sendHandle 就是控制程式碼,可以是 TCP通訊端、TCP伺服器、UDP通訊端等,為了解決上面多程序埠佔用問題,我們將主程序的 socket 傳遞到子程序。

# master.js

const fork = require('child_process').fork;
const cpus = require('os').cpus();
const server = require('net').createServer();
server.listen(3000);
process.title = 'node-master'

for (let i=0; i<cpus.length; i++) {
    const worker = fork('worker.js');
    
    # 控制程式碼傳遞
    worker.send('server', server);
    console.log('worker process created, pid: %s ppid: %s', worker.pid, process.pid);
}
// worker.js
let worker;
process.title = 'node-worker'
process.on('message', function (message, sendHandle) {
  if (message === 'server') {
    worker = sendHandle;
    worker.on('connection', function (socket) {
      console.log('I am worker, pid: ' + process.pid + ', ppid: ' + process.ppid)
    });
  }
});

驗證一番,控制檯執行 node master.js

3.png

4.png

5.png

2.3 程序負載均衡

瞭解 cluster 的話會知道,子程序是通過 cluster.fork() 建立的。在 linux 中,系統原生提供了 fork 方法,那麼為什麼 Node 選擇自己實現 cluster模組 ,而不是直接使用系統原生的方法?主要的原因是以下兩點:

  • fork的程序監聽同一埠會導致埠佔用錯誤

  • fork的程序之間沒有負載均衡,容易導致驚群現象

cluster模組 中,針對第一個問題,通過判斷當前程序是否為 master程序,若是,則監聽埠,若不是則表示為 fork 的 worker程序,不監聽埠。

針對第二個問題,cluster模組 內建了負載均衡功能, master程序 負責監聽埠接收請求,然後通過排程演演算法(預設為 Round-Robin,可以通過環境變數 NODE_CLUSTER_SCHED_POLICY 修改排程演演算法)分配給對應的 worker程序

3. 異常捕獲

3.1 未捕獲異常

當程式碼丟擲了異常沒有被捕獲到時,程序將會退出,此時 Node.js 提供了 process.on('uncaughtException', handler) 介面來捕獲它,但是當一個 Worker 程序遇到未捕獲的異常時,它已經處於一個不確定狀態,此時我們應該讓這個程序優雅退出:

  • 關閉異常 Worker 程序所有的 TCP Server(將已有的連線快速斷開,且不再接收新的連線),斷開和 Master 的 IPC 通道,不再接受新的使用者請求。
  • Master 立刻 fork 一個新的 Worker 程序,保證線上的『工人』總數不變。
  • 異常 Worker 等待一段時間,處理完已經接受的請求後退出。
+---------+                 +---------+
|  Worker |                 |  Master |
+---------+                 +----+----+
     | uncaughtException         |
     +------------+              |
     |            |              |                   +---------+
     | <----------+              |                   |  Worker |
     |                           |                   +----+----+
     |        disconnect         |   fork a new worker    |
     +-------------------------> + ---------------------> |
     |         wait...           |                        |
     |          exit             |                        |
     +-------------------------> |                        |
     |                           |                        |
    die                          |                        |
                                 |                        |
                                 |                        |

3.2 OOM、系統異常

當一個程序出現異常導致 crash 或者 OOM 被系統殺死時,不像未捕獲異常發生時我們還有機會讓程序繼續執行,只能夠讓當前程序直接退出,Master 立刻 fork 一個新的 Worker。


二、子程序

1. child_process模組

child_process 模組提供了衍生子程序的能力, 簡單來說就是執行cmd命令的能力。 預設情況下, stdin、 stdout 和 stderr 的管道會在父 Node.js 程序和衍生的子程序之間建立。 這些管道具有有限的(且平臺特定的)容量。 如果子程序寫入 stdout 時超出該限制且沒有捕獲輸出,則子程序會阻塞並等待管道緩衝區接受更多的資料。 這與 shell 中的管道的行為相同。 如果不消費輸出,則使用 { stdio: 'ignore' } 選項。

1.1 引入child_process

const cp = require('child_process');

1.2 基本概念

通過 API 建立出來的子程序和父程序沒有任何必然聯絡

  • 4個非同步方法,建立子程序:fork、exec、execFile、spawn

    • Node

      • fork(modulePath, args):想將一個 Node 程序作為一個獨立的程序來執行的時候使用,使得計算處理和檔案描述器脫離 Node 主程序(複製一個子程序)
    • 非 Node

      • spawn(command, args):處理一些會有很多子程序 I/O 時、程序會有大量輸出時使用
      • execFile(file, args[, callback]):只需執行一個外部程式的時候使用,執行速度快,處理使用者輸入相對安全
      • exec(command, options):想直接存取執行緒的 shell 命令時使用,一定要注意使用者輸入
  • 3個同步方法:execSyncexecFileSyncspawnSync

6.png

其他三種方法都是 spawn() 的延伸。

1.2.1 fork(modulePath, args)函數, 複製程序

  • fork 方法會開放一個 IPC 通道,不同的 Node 程序進行訊息傳送
  • 一個子程序消耗 30ms 啟動時間和 10MB 記憶體

記住,衍生的 Node.js 子程序獨立於父程序,但兩者之間建立的 IPC 通訊通道除外。 每個程序都有自己的記憶體,帶有自己的 V8 範例

舉個?

在一個目錄下新建 worker.js 和 master.js 兩個檔案:

# child.js

const t = JSON.parse(process.argv[2]);
console.error(`子程序 t=${JSON.stringify(t)}`);
process.send({hello:`兒子pid=${process.pid} 給爸爸程序pid=${process.ppid} 請安`});
process.on('message', (msg)=>{
    console.error(`子程序 msg=${JSON.stringify(msg)}`);
});
# parent.js

const {fork} = require('child_process');
for(let i = 0; i < 3; i++){
    const p = fork('./child.js', [JSON.stringify({id:1,name:1})]);
    p.on('message', (msg) => {
        console.log(`messsgae from child msg=${JSON.stringify(msg)}`, );
    });
    p.send({hello:`來自爸爸${process.pid} 程序id=${i}的問候`});
}

7.png

通過 node parent.js 啟動 parent.js,然後通過 ps aux | grep worker.js 檢視程序的數量,我們可以發現,理想狀況下,程序的數量等於 CPU 的核心數,每個程序各自利用一個 CPU 核心。

這是經典的 Master-Worker 模式(主從模式)

8.png

實際上,fork 程序是昂貴的,複製程序的目的是充分利用 CPU 資源,所以 NodeJS 在單執行緒上使用了事件驅動的方式來解決高並行的問題。

適用場景
一般用於比較耗時的場景,並且用node去實現的,比如下載檔案;
fork可以實現多執行緒下載:將檔案分成多塊,然後每個程序下載一部分,最後拼起來;

1.2.2 execFile(file, args[, callback])

  • 會把輸出結果快取好,通過回撥返回最後結果或者異常資訊
const cp = require('child_process');
// 第一個引數,要執行的可執行檔案的名稱或路徑。這裡是echo
cp.execFile('echo', ['hello', 'world'], (err, stdout, stderr) => {
  if (err) { console.error(err); }
  console.log('stdout: ', stdout);
  console.log('stderr: ', stderr);
});

適用場景
比較適合開銷小的任務,更關注結果,比如ls等;

1.2.3 exec(command, options)

主要用來執行一個shell方法,其內部還是呼叫了spawn ,不過他有最大快取限制。

  • 只有一個字串命令
  • 和 shell 一模一樣
const cp = require('child_process');

cp.exec(`cat ${__dirname}/messy.txt | sort | uniq`, (err, stdout, stderr) => {
  console.log(stdout);
});

適用場景
比較適合開銷小的任務,更關注結果,比如ls等;

1.2.4 spawn(command, args)

  • 通過流可以使用有大量資料輸出的外部應用,節約記憶體
  • 使用流提高資料響應效率
  • spawn 方法返回一個 I/O 的流介面

單一任務

const cp = require('child_process');

const child = cp.spawn('echo', ['hello', 'world']);
child.on('error', console.error);

# 輸出是流,輸出到主程序stdout,控制檯
child.stdout.pipe(process.stdout);
child.stderr.pipe(process.stderr);

多工串聯

const cp = require('child_process');
const path = require('path');

const cat = cp.spawn('cat', [path.resolve(__dirname, 'messy.txt')]);
const sort = cp.spawn('sort');
const uniq = cp.spawn('uniq');

# 輸出是流
cat.stdout.pipe(sort.stdin);
sort.stdout.pipe(uniq.stdin);
uniq.stdout.pipe(process.stdout);

適用場景
spawn是流式的,所以適合耗時任務,比如執行npm install,列印install的過程

1.3 各種事件

1.3.1 close

在程序已結束並且子程序的標準輸入輸出流(sdtio)已關閉之後,則觸發 'close' 事件。這個事件跟exit不同,因為多個程序可以共用同個stdio流。

引數:

  • code(退出碼,如果子程序是自己退出的話)
  • signal(結束子程序的訊號)

問題:code一定是有的嗎?
(從對code的註解來看好像不是)比如用kill殺死子程序,那麼,code是?

1.3.2 exit

引數:
code、signal,如果子程序是自己退出的,那麼code就是退出碼,否則為null;
如果子程序是通過訊號結束的,那麼,signal就是結束程序的訊號,否則為null。
這兩者中,一者肯定不為null。

注意事項
exit事件觸發時,子程序的stdio stream可能還開啟著。(場景?)此外,nodejs監聽了SIGINT和SIGTERM訊號,也就是說,nodejs收到這兩個訊號時,不會立刻退出,而是先做一些清理的工作,然後重新丟擲這兩個訊號。(目測此時js可以做清理工作了,比如關閉資料庫等。)

SIGINT:interrupt,程式終止訊號,通常在使用者按下CTRL+C時發出,用來通知前臺程序終止程序。
SIGTERM:terminate,程式結束訊號,該訊號可以被阻塞和處理,通常用來要求程式自己正常退出。shell命令kill預設產生這個訊號。如果訊號終止不了,我們才會嘗試SIGKILL(強制終止)。

1.3.3 error

當發生下列事情時,error就會被觸發。當error觸發時,exit可能觸發,也可能不觸發。(內心是崩潰的)

  • 無法衍生該程序。
  • 程序無法kill。
  • 向子程序傳送訊息失敗。

1.3.4 message

當採用process.send()來傳送訊息時觸發。

引數
message,為json物件,或者primitive value;sendHandle,net.Socket物件,或者net.Server物件(熟悉cluster的同學應該對這個不陌生)

1.4 方法

.connected:當呼叫.disconnected()時,設為false。代表是否能夠從子程序接收訊息,或者對子程序傳送訊息。

.disconnect() :關閉父程序、子程序之間的IPC通道。當這個方法被呼叫時,disconnect事件就會觸發。如果子程序是node範例(通過child_process.fork()建立),那麼在子程序內部也可以主動呼叫process.disconnect()來終止IPC通道。


三、NodeJS多執行緒

應對單執行緒問題,通常使用多程序的方式來模擬多執行緒

1. 單執行緒問題

  • 對 cpu 利用不足
  • 某個未捕獲的異常可能會導致整個程式的退出

2. Node 執行緒

  • Node 程序佔用了 7 個執行緒

  • Node 中最核心的是 v8 引擎,在 Node 啟動後,會建立 v8 的範例,這個範例是多執行緒的

    • 主執行緒:編譯、執行程式碼
    • 編譯/優化執行緒:在主執行緒執行的時候,可以優化程式碼
    • 分析器執行緒:記錄分析程式碼執行時間,為 Crankshaft 優化程式碼執行提供依據
    • 垃圾回收的幾個執行緒
  • JavaScript 的執行是單執行緒的,但 Javascript 的宿主環境,無論是 Node 還是瀏覽器都是多執行緒的。

Javascript 為什麼是單執行緒?
這個問題需要從瀏覽器說起,在瀏覽器環境中對於 DOM 的操作,試想如果多個執行緒來對同一個 DOM 操作是不是就亂了呢,那也就意味著對於DOM的操作只能是單執行緒,避免 DOM 渲染衝突。在瀏覽器環境中 UI 渲染執行緒和 JS 執行引擎是互斥的,一方在執行時都會導致另一方被掛起,這是由 JS 引擎所決定的。

3. 非同步 IO

  • Node 中有一些 IO 操作(DNS,FS)和一些 CPU 密集計算(Zlib,Crypto)會啟用 Node 的執行緒池
  • 執行緒池預設大小為 4,可以手動更改執行緒池預設大小
process.env.UV_THREADPOOL_SIZE = 64

4. 真 Node 多執行緒

4.1 worker_threads核心模組

  • Node 10.5.0 的釋出,給出了一個實驗性質的模組 worker_threads 給 Node 提供真正的多執行緒能力
  • worker_thread 模組中有 4 個物件和 2 個類
    • isMainThread: 是否是主執行緒,原始碼中是通過 threadId === 0 進行判斷的。
    • MessagePort: 用於執行緒之間的通訊,繼承自 EventEmitter。
    • MessageChannel: 用於建立非同步、雙向通訊的通道範例。
    • threadId: 執行緒 ID。
    • Worker: 用於在主執行緒中建立子執行緒。第一個引數為 filename,表示子執行緒執行的入口。
    • parentPort: 在 worker 執行緒裡是表示父程序的 MessagePort 型別的物件,在主執行緒裡為 null
    • workerData: 用於在主程序中向子程序傳遞資料(data 副本)
const {
  isMainThread,
  parentPort,
  workerData,
  threadId,
  MessageChannel,
  MessagePort,
  Worker
} = require('worker_threads');

function mainThread() {
  for (let i = 0; i < 5; i++) {
    const worker = new Worker(__filename, { workerData: i });
    worker.on('exit', code => { console.log(`main: worker stopped with exit code ${code}`); });
    worker.on('message', msg => {
      console.log(`main: receive ${msg}`);
      worker.postMessage(msg + 1);
    });
  }
}

function workerThread() {
  console.log(`worker: workerDate ${workerData}`);
  parentPort.on('message', msg => {
    console.log(`worker: receive ${msg}`);
  }),
  parentPort.postMessage(workerData);
}

if (isMainThread) {
  mainThread();
} else {
  workerThread();
}

4.2 執行緒通訊

const assert = require('assert');
const {
  Worker,
  MessageChannel,
  MessagePort,
  isMainThread,
  parentPort
} = require('worker_threads');
if (isMainThread) {
  const worker = new Worker(__filename);
  const subChannel = new MessageChannel();
  worker.postMessage({ hereIsYourPort: subChannel.port1 }, [subChannel.port1]);
  subChannel.port2.on('message', (value) => {
    console.log('received:', value);
  });
} else {
  parentPort.once('message', (value) => {
    assert(value.hereIsYourPort instanceof MessagePort);
    value.hereIsYourPort.postMessage('the worker is sending this');
    value.hereIsYourPort.close();
  });
}

四、 多程序 vs 多執行緒

程序是資源分配的最小單位,執行緒是CPU排程的最小單位


五、 知識拓展

1. IPC

IPC (Inter-process communication) 即程序間通訊,由於每個程序建立之後都有自己的獨立地址空間,實現 IPC 的目的就是為了程序之間資源共用存取。

實現 IPC 的方式有多種:管道、訊息佇列、號誌、Domain Socket,Node.js 通過 pipe 來實現。

9.png

實際上,父程序會在建立子程序之前,會先建立 IPC 通道並監聽這個 IPC,然後再建立子程序,通過環境變數(NODE_CHANNEL_FD)告訴子程序和 IPC 通道相關的檔案描述符,子程序啟動的時候根據檔案描述符連線 IPC 通道,從而和父程序建立連線。

10.png

2. 控制程式碼傳遞

控制程式碼是一種可以用來標識資源的參照的,它的內部包含了指向物件的檔案資源描述符。

一般情況下,當我們想要將多個程序監聽到一個埠下,可能會考慮使用主程序代理的方式處理:

11.png

然而,這種代理方案會導致每次請求的接收和代理轉發用掉兩個檔案描述符,而系統的檔案描述符是有限的,這種方式會影響系統的擴充套件能力。

所以,為什麼要使用控制程式碼?原因是在實際應用場景下,建立 IPC 通訊後可能會涉及到比較複雜的資料處理場景,控制程式碼可以作為 send() 方法的第二個可選引數傳入,也就是說可以直接將資源的標識通過 IPC 傳輸,避免了上面所說的代理轉發造成的檔案描述符的使用。

12.png

以下是支援傳送的控制程式碼型別:

  • net.Socket
  • net.Server
  • net.Native
  • dgram.Socket
  • dgram.Native

3.孤兒程序

父程序建立子程序之後,父程序退出了,但是父程序對應的一個或多個子程序還在執行,這些子程序會被系統的 init 程序收養,對應的程序 ppid 為 1,這就是孤兒程序。通過以下程式碼範例說明。

# worker.js

const http = require('http');
const server = http.createServer((req, res) => {
	res.end('I am worker, pid: ' + process.pid + ', ppid: ' + process.ppid); 
        // 記錄當前工作程序 pid 及父程序 ppid
});

let worker;
process.on('message', function (message, sendHandle) {
	if (message === 'server') {
		worker = sendHandle;
		worker.on('connection', function(socket) {
			server.emit('connection', socket);
		});
	}
});
# master.js

const fork = require('child_process').fork;
const server = require('net').createServer();
server.listen(3000);
const worker = fork('worker.js');

worker.send('server', server);
console.log('worker process created, pid: %s ppid: %s', worker.pid, process.pid);
process.exit(0); 
// 建立子程序之後,主程序退出,此時建立的 worker 程序會成為孤兒程序

控制檯進行測試,輸出當前工作程序 pid 和 父程序 ppid

13.png

由於在 master.js 裡退出了父程序,活動監視器所顯示的也就只有工作程序。

14.png

再次驗證,開啟控制檯呼叫介面,可以看到工作程序 5611 對應的 ppid 為 1(為 init 程序),此時已經成為了孤兒程序

15.png

4. 守護行程

守護行程執行在後臺不受終端的影響,什麼意思呢?
Node.js 開發的同學們可能熟悉,當我們開啟終端執行 node app.js 開啟一個服務程序之後,這個終端就會一直被佔用,如果關掉終端,服務就會斷掉,即前臺執行模式
如果採用守護行程程序方式,這個終端我執行 node app.js 開啟一個服務程序之後,我還可以在這個終端上做些別的事情,且不會相互影響。

4.1 建立步驟

  • 建立子程序

  • 在子程序中建立新對談(呼叫系統函數 setsid)

  • 改變子程序工作目錄(如:「/」 或 「/usr/ 等)

  • 父程序終止

4.2 Node.js 編寫守護行程 Demo及測試

  • index.js 檔案裡的處理邏輯使用 spawn 建立子程序完成了上面的第一步操作。
  • 設定 options.detached 為 true 可以使子程序在父程序退出後繼續執行(系統層會呼叫 setsid 方法),這是第二步操作。
  • options.cwd 指定當前子程序工作目錄若不做設定預設繼承當前工作目錄,這是第三步操作。
  • 執行 daemon.unref() 退出父程序,這是第四步操作。
// index.js
const spawn = require('child_process').spawn;

function startDaemon() {
    const daemon = spawn('node', ['daemon.js'], {
        cwd: '/usr',
        detached : true,
        stdio: 'ignore',
    });

    console.log('守護行程開啟 父程序 pid: %s, 守護行程 pid: %s', process.pid, daemon.pid);
    daemon.unref();
}

startDaemon()

daemon.js 檔案裡處理邏輯開啟一個定時器每 10 秒執行一次,使得這個資源不會退出,同時寫入紀錄檔到子程序當前工作目錄下

/usr/daemon.js
const fs = require('fs');
const { Console } = require('console');

// custom simple logger
const logger = new Console(fs.createWriteStream('./stdout.log'), fs.createWriteStream('./stderr.log'));

setInterval(function() {
	logger.log('daemon pid: ', process.pid, ', ppid: ', process.ppid);
}, 1000 * 10);

守護行程實現 Node.js 版本 原始碼地址

https://github.com/Q-Angelo/project-training/tree/master/nodejs/simple-daemon

17.png

16.png

4.3 守護行程總結

在實際工作中對於守護行程並不陌生,例如 PM2、Egg-Cluster 等,以上只是一個簡單的 Demo 對守護行程做了一個說明,在實際工作中對守護行程的健壯性要求還是很高的,例如:程序的異常監聽、工作程序管理排程、程序掛掉之後重新啟動等等,這些還需要去不斷思考。

5. 程序的當前工作目錄

目錄是什麼?

程序的當前工作目錄可以通過 process.cwd() 命令獲取,預設為當前啟動的目錄,如果是建立子程序則繼承於父程序的目錄,可通過 process.chdir() 命令重置,例如通過 spawn 命令建立的子程序可以指定 cwd 選項設定子程序的工作目錄。

有什麼作用?

例如,通過 fs 讀取檔案,如果設定為相對路徑則相對於當前程序啟動的目錄進行查詢,所以,啟動目錄設定有誤的情況下將無法得到正確的結果。還有一種情況程式裡參照第三方模組也是根據當前程序啟動的目錄來進行查詢的。

// 範例
process.chdir('/Users/may/Documents/test/') // 設定當前程序目錄

console.log(process.cwd()); // 獲取當前程序目錄

更多node相關知識,請存取:!

以上就是深入解析NodeJS中的程序管理的詳細內容,更多請關注TW511.COM其它相關文章!