1. 程式人生 > 其它 >理解NodeJS多程序

理解NodeJS多程序

序言

一次面試中,我提到自己用過pm2,面試接著問:「那你知道pm2父子程序通訊方式嗎」。我大概聽說pm2有cluster模式,但不清楚父子程序如何通訊。面試結束後把NodeJS的多程序重新整理了一下。

對於前端開發同學,一定很清楚js是單執行緒非阻塞的,這決定了NodeJS能夠支援高效能的服務的開發。 JavaScript的單執行緒非阻塞特性讓NodeJS適合IO密集型應用,因為JavaScript在訪問磁碟/資料庫/RPC等時候不需要阻塞等待結果,而是可以非同步監聽結果,同時繼續向下執行。

但js不適合計算密集型應用,因為當JavaScript遇到耗費計算效能的任務時候,單執行緒的缺點就暴露出來了。後面的任務都要被阻塞,直到耗時任務執行完畢。

為了優化NodeJS不適合計算密集型任務的問題,NodeJS提供了多執行緒和多程序的支援。

多程序和多執行緒從兩個方面對計算密集型任務進行了優化,非同步和併發

  1. 非同步,對於耗時任務,可以新建一個執行緒或者程序來執行,執行完畢再通知主執行緒/程序。

看下面例子,這是一個koa介面,裡面有耗時任務,會阻塞其他任務執行。

const Koa = require('koa');
const app = new Koa();

app.use(async ctx => {
    const url = ctx.request.url;
    if (url === '/') {
        ctx.body = 'hello';
    }

    if (url === '/compute') {
        let sum = 0;
        for (let i = 0; i < 1e20; i++) {
            sum += i;    
        }
        ctx.body = `${sum}`;
    }
});

app.listen(3000, () => {
    console.log('http://localhost:300/ start')
});

可以通過多執行緒和多程序來解決這個問題。

NodeJS提供多執行緒模組worker_threads,其中Woker模組用來建立執行緒,parentPort用在子執行緒中,可以獲取主執行緒引用,子執行緒通過parentPort.postMessage傳送資料給主執行緒,主執行緒通過worker.on接受資料。

//api.js
const Koa = require('koa');
const app = new Koa();

const {Worker} = require('worker_threads');

app.use(async (ctx) => {
    const url = ctx.request.url;
    if (url === '/') {
        ctx.body = 'hello';
    }

    if (url === '/compute') {
        const sum = await new Promise(resolve => {
            const worker = new Worker(__dirname + '/compute.js');
            //接收資訊
            worker.on('message', data => {
                resolve(data);
            })

        });
        ctx.body = `${sum}`;
    }
})

app.listen(3000, () => {
    console.log('http://localhost:3000/ start')
});


//computer.js
const {parentPort} = require('worker_threads')
let sum = 0;
for (let i = 0; i < 1e20; i++) {
    sum += i;
}

//傳送資訊
parentPort.postMessage(sum);

下面是使用多程序解決耗時任務的方法,多程序模組child_process提供了fork方法(後面會介紹更多建立子程序的方法),可以用來建立子程序,主程序通過fork返回值(worker)持有子程序的引用,並通過worker.on監聽子程序傳送的資料,子程序通過process.send給父程序傳送資料。

//api.js
const Koa = require('koa');
const app = new Koa();

const {fork} = require('child_process');

app.use(async ctx => {
    const url = ctx.request.url;
    if (url === '/') {
        ctx.body = 'hello';
    }

    if (url === '/compute') {
        const sum = await new Promise(resolve => {
            const worker = fork(__dirname + '/compute.js');
            worker.on('message', data => {
                resolve(data);
            });
        });
        ctx.body = `${sum}`;
    }
});

app.listen(300, () => {
    console.log('http://localhost:300/ start');
});

//computer.js
let sum = 0;
for (let i = 0; i < 1e20; i++) {
    sum += i;
}
process.send(sum);
  1. 併發,為了可以更好地利用多核能力,通常會對同一個指令碼建立多程序和多執行緒,數量和CPU核數相同,這樣可以讓任務併發執行,最大程度提升了任務執行效率。

本文重點講解多程序的使用。

從實際應用角度,如果我們希望使用多程序,讓我們的應用支援併發執行,提升應用效能,那麼首先要建立多程序,然後程序執行的過程中難免涉及到程序之間的通訊,包括父子程序通訊和兄弟程序之間的通訊,另外還有很重要的一點是程序的管理,因為建立了多個程序,那麼來了一個任務應該交給哪個程序去執行呢?程序必然要支援後臺執行(守護程序),這個又怎麼實現呢?程序崩潰如何重啟?重啟過於頻繁的不穩定程序又如何限制?如何操作程序的啟動、停止、重啟?

這一系列的程序管理工作都有相關的工具支援。

接下來就按照上面說明的建立程序、程序間通訊、程序管理(cluster叢集管理、程序管理工具:pm2和egg-cluster)。

建立多程序

child_process模組用來建立子程序,該模組提供了4個方法用於建立子程序

const {spawn, fork, exec, execFile} = require('child_process');

child_process.spawn(command[, args][, options])

child_process.fork(modulePath[, args][, options])

child_process.exec(command[, options][, callback])

child_process.execFile(file[, args][, options][, callback])

spawn會啟動一個shell,並在shell上執行命令;spawn會在父子程序間建立IO流stdinstdoutstderrspawn返回一個子程序的引用,通過這個引用可以監聽子程序狀態,並接收子程序的輸入流。

const { spawn } = require('child_process');
const ls = spawn('ls', ['-lh', '/usr']);

ls.stdout.on('data', (data) => {
  console.log(`stdout: ${data}`);
});

ls.stderr.on('data', (data) => {
  console.error(`stderr: ${data}`);
});

ls.on('close', (code) => {
  console.log(`child process exited with code ${code}`);
});

forkexecexecFile都是基於spawn擴充套件的。

execspawn不同,它接收一個回撥作為引數,回撥中會傳入報錯和IO流

const { exec } = require('child_process');
exec('cat ./test.txt', (error, stdout, stderr) => {
  if (error) {
    console.error(`exec error: ${error}`);
    return;
  }
  console.log(`stdout: ${stdout}`);
  console.error(`stderr: ${stderr}`);
});

execFileexec不同的是,它不會建立一個shell,而是直接執行可執行檔案,因此效率比exec稍高一些,另外,它傳入的第一個引數是可執行檔案,第二個引數是執行可執行檔案的引數。

const { execFile } = require('child_process');
execFile('cat', ['./test.txt'], (error, stdout, stderr) => {
    if (error) {
      console.error(`exec error: ${error}`);
      return;
    }
    console.log(stdout);
});

fork支援傳入一個NodeJS模組路徑,而非shell命令,返回一個子程序引用,這個子程序的引用和父程序建立了一個內建的IPC通道,可以讓父子程序通訊。

參考 前端面試題詳細解答

// parent.js

var child_process = require('child_process');

var child = child_process.fork('./child.js');

child.on('message', function(m){
    console.log('message from child: ' + JSON.stringify(m));
});

child.send({from: 'parent'});


// child.js

process.on('message', function(m){
    console.log('message from parent: ' + JSON.stringify(m));
});

process.send({from: 'child'});

對於上面幾個建立子程序的方法,有對應的同步版本。

spawnSyncexecSyncexecFileSync

程序間通訊

程序間通訊分為父子程序通訊和兄弟程序通訊,當然也可能涉及遠端程序通訊,這個會在後面提到,本文主要關注本地程序的通訊。

父子程序通訊可以通過標準IO流傳遞json

// 父程序
const { spawn } = require('child_process');

child = spawn('node', ['./stdio-child.js']);
child.stdout.setEncoding('utf8');
// 父程序-發
child.stdin.write(JSON.stringify({
    type: 'handshake',
    payload: '你好吖'
}));
// 父程序-收
child.stdout.on('data', function (chunk) {
  let data = chunk.toString();
  let message = JSON.parse(data);
  console.log(`${message.type} ${message.payload}`);
});

// ./stdio-child.js
// 子程序-收
process.stdin.on('data', (chunk) => {
  let data = chunk.toString();
  let message = JSON.parse(data);
  switch (message.type) {
    case 'handshake':
      // 子程序-發
      process.stdout.write(JSON.stringify({
        type: 'message',
        payload: message.payload + ' : hoho'
      }));
      break;
    default:
      break;
  }
});

使用fork建立的子程序,父子程序之間會建立內建IPC通道(不知道該IPC通道底層是使用管道還是socket實現)。(程式碼見“建立多程序小節”)

因此父子程序通訊是NodeJS原生支援的。

下面我們看兄弟程序如何通訊。

通常程序通訊有幾種方法:共享記憶體、訊息佇列、管道、socket、訊號。

其中對於共享記憶體和訊息佇列,NodeJS並未提供原生的程序間通訊支援,需要依賴第三方實現,比如通過C++shared-memory-disruptor addon外掛實現共享記憶體的支援、通過redis、MQ實現訊息佇列的支援。

下面介紹在NodeJS中通過socket、管道、訊號實現的程序間通訊。

socket

socket是應用層與TCP/IP協議族通訊的中間抽象層,是一種作業系統提供的程序間通訊機制,是作業系統提供的,工作在傳輸層的網路操作API。

socket提供了一系列API,可以讓兩個程序之間實現客戶端-服務端模式的通訊。

通過socket實現IPC的方法可以分為兩種:

  1. TCP/UDP socket,原本用於進行網路通訊,實際就是兩個遠端程序間的通訊,但兩個程序既可以是遠端也可以是本地,使用socket進行通訊的方式就是一個程序建立server,另一個程序建立client,然後通過socket提供的能力進行通訊。
  2. UNIX Domain socket,這是一套由作業系統支援的、和socket很相近的API,但用於IPC,名字雖然是UNIX,實際Linux也支援。socket 原本是為網路通訊設計的,但後來在 socket 的框架上發展出一種 IPC 機制,就是 UNIX domain socket。雖然網路 socket 也可用於同一臺主機的程序間通訊(通過 loopback 地址 127.0.0.1),但是 UNIX domain socket 用於 IPC 更有效率:不需要經過網路協議棧,不需要打包拆包、計算校驗和、維護序號和應答等,只是將應用層資料從一個程序拷貝到另一個程序。這是因為,IPC 機制本質上是可靠的通訊,而網路協議是為不可靠的通訊設計的。

開源的node-ipc方案就是使用了socket方案

NodeJS如何使用socket進行通訊呢?答案是通過net模組實現,看下面的例子。

// server
const net = require('net');

net.createServer((stream => {
  stream.end(`hello world!\n`);
})).listen(3302, () => {
  console.log(`running ...`);
});

// client
const net = require('net');

const socket = net.createConnection({port: 3302});

socket.on('data', data => {
  console.log(data.toString());
});

UNIX Domain socket在NodeJS層面上提供的API和TCP socket類似,只是listen的是一個檔案描述符,而不是埠,相應的,client連線的也是一個檔案描述符(path)。

// 建立程序
const net = require('net')
const unixSocketServer = net.createServer(stream => {
  stream.on('data', data => {
    console.log(`receive data: ${data}`)
  })
});

unixSocketServer.listen('/tmp/test', () => {
  console.log('listening...');
});

// 其他程序

const net = require('net')

const socket = net.createConnection({path: '/tmp/test'})

socket.on('data', data => {
  console.log(data.toString());
});

socket.write('my name is vb');

// 輸出結果

listening...

管道

管道是一種作業系統提供的程序通訊方法,它是一種半雙工通訊,同一時間只能有一個方向的資料流。

管道本質上就是核心中的一個快取,當程序建立一個管道後,Linux會返回兩個檔案描述符,一個是寫入端的描述符(fd[1]),一個是輸出端的描述符(fd[0]),可以通過這兩個描述符往管道寫入或者讀取資料。

NodeJS中也是通過net模組實現管道通訊,與socket區別是server listen的和client connect的都是特定格式的管道名。

管道的通訊效率比較低下,一般不用它作為程序通訊方案。

下面是使用net實現程序通訊的示例。

var net = require('net');

var PIPE_NAME = "mypipe";
var PIPE_PATH = "\\.\pipe\" + PIPE_NAME;

var L = console.log;

var server = net.createServer(function(stream) {
    L('Server: on connection')

    stream.on('data', function(c) {
        L('Server: on data:', c.toString());
    });

    stream.on('end', function() {
        L('Server: on end')
        server.close();
    });

    stream.write('Take it easy!');
});

server.on('close',function(){
    L('Server: on close');
})

server.listen(PIPE_PATH,function(){
    L('Server: on listening');
})

// == Client part == //
var client = net.connect(PIPE_PATH, function() {
    L('Client: on connection');
})

client.on('data', function(data) {
    L('Client: on data:', data.toString());
    client.end('Thanks!');
});

client.on('end', function() {
    L('Client: on end');
})

// Server: on listening
// Client: on connection
// Server: on connection
// Client: on data: Take it easy!
// Server: on data: Thanks!
// Client: on end
// Server: on end
// Server: on close

訊號

作為完整健壯的程式,需要支援常見的中斷退出訊號,使得程式能夠正確的響應使用者和正確的清理退出。

訊號是作業系統殺掉程序時候給程序傳送的訊息,如果程序中沒有監聽訊號並做處理,則作業系統一般會預設直接粗暴地殺死程序,如果程序監聽訊號,則作業系統不預設處理。

這種程序通訊方式比較侷限,只用在一個程序殺死另一個程序的情況。

在NodeJS中,一個程序可以殺掉另一個程序,通過制定要被殺掉的程序的id來實現:process.kill(pid, signal)/child_process.kill(pid, signal)

程序可以監聽訊號:

process.on('SIGINT', () => {
    console.log('ctl + c has pressed');
});

cluster

現在設想我們有了一個啟動server的腳步,我們希望能更好地利用多核能力,啟動多個程序來執行server指令碼,另外我們還要考慮如何給多個程序分配請求。

上面的場景是一個很常見的需求:多程序管理,即一個指令碼執行時候建立多個程序,那麼如何對多個程序進行管理?

實際上,不僅是在server的場景有這種需求,只要是多程序都會遇到這種需求。而server的多程序還會遇到另一個問題:同一個server指令碼監聽的埠肯定相同,那啟動多個程序時候,埠一定會衝突。

為了解決多程序的問題,並解決server場景的埠衝突問題,NodeJS提供了cluster模組。

這種同樣一份程式碼在多個例項中執行的架構叫做叢集,cluster就是一個NodeJS程序叢集管理的工具。

cluster提供的能力:

  1. 建立子程序
  2. 解決多子程序監聽同一個埠導致衝突的問題
  3. 負載均衡

cluster主要用於server場景,當然也支援非server場景。

先來看下cluster的使用

import cluster from 'cluster';
import http from 'http';
import { cpus } from 'os';
import process from 'process';

const numCPUs = cpus().length;

if (cluster.isPrimary) {
  console.log(`Primary ${process.pid} is running`);

  // Fork workers.
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.on('exit', (worker, code, signal) => {
    console.log(`worker ${worker.process.pid} died`);
  });
} else {
  // Workers can share any TCP connection
  // In this case it is an HTTP server
  http.createServer((req, res) => {
    res.writeHead(200);
    res.end('hello world\n');
  }).listen(8000);

  console.log(`Worker ${process.pid} started`);
}

可以看到使用cluster.fork建立了子程序,實際上cluster.fork呼叫了child_process.fork來建立子程序。建立好後,cluster會自動進行負載均衡。

cluster支援設定負載均衡策略,有兩種策略:輪詢和作業系統預設策略。可以通過設定cluster.schedulingPolicy = cluster.SCHED_RR;指定輪詢策略,設定cluster.schedulingPolicy = cluster.SCHED_NONE;指定用作業系統預設策略。也可以設定環境變數NODE_CLUSTER_SCHED_POLICYrr/none來實現。

讓人比較在意的是,cluster是如何解決埠衝突問題的呢?

我們看到程式碼中使用了http.createServer,並監聽了埠8000,但實際上子程序並未監聽8000,net模組的server.listen方法(http繼承自net)判斷在cluster子程序中不監聽埠,而是建立一個socket併發送到父程序,以此將自己註冊到父程序,所以只有父程序監聽了埠,子程序通過socket和父程序通訊,當一個請求到來後,父程序會根據輪詢策略選中一個子程序,然後將請求的控制代碼(其實就是一個socket)通過程序通訊傳送給子程序,子程序拿到socket後使用這個socket和客戶端通訊,響應請求。

那麼net中又是如何判斷是否是在cluster子程序中的呢?cluster.fork對程序做了標識,因此net可以區分出來。

cluster是一個典型的master-worker架構,一個master負責管理worker,而worker才是實際工作的程序。

程序管理:pm2與egg-cluster

除了叢集管理,在實際應用執行時候,還有很多程序管理的工作,比如:程序的啟動、暫停、重啟、記錄當前有哪些程序、程序的後臺執行、守護程序監聽程序崩潰重啟、終止不穩定程序(頻繁崩潰重啟)等等。

社群也有比較成熟的工具做程序管理,比如pm2和egg-cluster

pm2

pm2是一個社群很流行的NodeJS程序管理工具,直觀地看,它提供了幾個非常好用的能力:

  1. 後臺執行。
  2. 自動重啟。
  3. 叢集管理,支援cluster多程序模式。

其他的功能還包括0s reload、日誌管理、終端監控、開發除錯等等。

pm2的大概原理是,建立一個守護程序(daemon),用來管理機器上通過pm2啟動的應用。當用戶通過命令列執行pm2命令對應用進行操作時候,其實是在和daemon通訊,daemon接收到指令後進行相應的操作。這時一種C/S架構,命令列相當於客戶端(client),守護程序daemon相當於伺服器(server),這種模式和docker的執行模式相同,docker也是有一個守護程序接收命令列的指令,再執行對應的操作。

客戶端和daemon通過rpc進行通訊,daemon是真正的“程序管理者”。

由於有守護程序,在啟動應用時候,命令列使用pm2客戶端通過rpc向daemon傳送資訊,daemon建立程序,這樣程序不是由客戶端建立的,而是daemon建立的,因此客戶端退出也不會收到影響,這就是pm2啟動的應用可以後臺執行的原因。

daemon還會監控程序的狀態,崩潰會自動重啟(當然頻繁重啟的程序被認為是不穩定的程序,存在問題,不會一直重啟),這樣就實現了程序的自動重啟。

pm2利用NodeJS的cluster模組實現了叢集能力,當配置exec_modecluster時候,pm2就會自動使用cluster建立多個程序,也就有了負載均衡的能力。

egg-cluster

egg-cluster是egg專案開源的一個程序管理工具,它的作用和pm2類似,但兩者也有很大的區別,比如pm2的程序模型是master-worker,master負責管理worker,worker負責執行具體任務。egg-cluster的程序模型是master-agent-worker,其中多出來的agent有什麼作用呢?

有些工作其實不需要每個 Worker 都去做,如果都做,一來是浪費資源,更重要的是可能會導致多程序間資源訪問衝突

既然有了pm2,為什麼egg要自己開發一個程序管理工具呢?可以參考作者的回答

  1. PM2 的理念跟我們不一致,它的大部分功能我們用不上,用得上的部分卻又做的不夠極致。
  2. PM2 是AGPL 協議的,對企業應用不友好。

pm2雖然很強大,但還不能說完美,比如pm2並不支援master-agent-worker模型,而這個是實際專案中很常見的一個需求。因此egg-cluster基於實際的場景實現了程序管理的一系列功能。

答案

通過上面的介紹,我們知道了pm2使用cluster做叢集管理,cluster又是使用child_process.fork來建立子程序,所以父子程序通訊使用的是內建預設的IPC通道。