淺談NodeJS多進程服務架構基本原理


閱讀目錄

一:nodejs進程進化及多進程架構原理

NodeJS是基於chrome瀏覽器的V8引擎構建的,它是單線程單進程模式,nodeJS的單線程指js的引擎只有一個實列。且是在主線程執行的,這樣的
優點是:可以減少線程間切換的開銷。並且不用考慮鎖和線程池的問題。

那么nodejs是單線程嗎?如果嚴格的來講,node存在着多種線程。比如包括:js引擎執行的線程、定時器線程、異步http線程等等這樣的。

nodejs是在主線程執行的,其他的異步IO和事件驅動相關的線程是通過libuv來實現內部的線程池和線程調度的。libuv存在着一個Event Loop,通過 Event Loop(事件循環)來切換實現類似多線程的效果。Event Loop 是維持一個執行棧和一個事件隊列,在執行棧中,如果有異步IO及定時器等函數的話,就把這些異步回調函數放入到事件隊列中。等執行棧執行完成后,會從事件隊列中,按照一定的順序執行事件隊列中的異步回調函數。
nodeJS中的單線程是指js引擎只在唯一的主線程上運行的。其他的異步操作是有獨立的線程去執行。通過libuv的Event Loop實現了類似多線程的上下文切換以及線程池的調度。線程是最小的進程,因此node也是單進程的。

理解服務器進程進化

1. 同步單進程服務器

該服務器是最早出現的,執行模型是同步的。它的服務模式是一次只能處理一個請求。其他的請求需要按照順序依次等待處理執行。也就是說如果當前的請求正在處理的話,那么其他的請求都處於阻塞等待的狀態。因此這樣的服務器處理速度是不好的。

2. 同步多進程服務器

為了解決上面同步單進程服務器無法處理並發的問題,我們就出來一個同步多進程服務器,它的功能是一個請求需要一個進程來服務,也就是說如果有100個請求就需要100個進程來進行服務。那么這樣就會有很大進程的開銷問題了。並且相同的狀態在內存中會有多種,這樣就會造成資源浪費。

3. 同步多進程多線程服務器

為了解決上面多進程中資源浪費的問題,我們就引入了多進程多線程服務器模式,從我們之前一個進程處理一個請求,現在我們改成為一個線程來處理一個請求,線程相對於進程來說開銷會少很多,並且線程之間還可以共享數據。並且我們還可以使用線程池來減少創建和銷毀線程的開銷。
但是多線程也有缺點,比如多個請求需要使用多個線程來服務,但是每個線程需要一定的內存來存放自己的堆和棧的。這樣就會導致占用太多的內存。第二就是:CPU核心只能處理一件事情,系統是通過將CPU切分為時間片的方法來讓線程可以均勻地使用CPU的資源的。在系統切換線程的過程中也會進行線程上下文切換,當線程數量過多時進行上下文切換會非常耗費時間的。因此在很大的並發量下,多線程還是無法做到很好的伸縮性。Apache服務器就是這樣架構的。

4. 單進程單線程基於事件驅動的服務器

為了解決上面的問題,我們出現了單進程單線程基於事件驅動的模式出現了,使用單線程的優點是:避免內存開銷和上下文切換的開銷。
所有的請求都在單線程上執行的,其他的異步IO和事件驅動相關的線程是通過libuv中的事件循環來實現內部的線程池和線程調度的。可伸縮性比之前的都好,但是影響事件驅動服務模型性能的只有CPU的計算能力,但是只能使用單核的CPU來處理事件驅動,但是我們的計算機目前都是多核的,我們要如何使用多核CPU呢?如果我們使用多核CPU的話,那么CPU的計算能力就會得到一個很大的提升。

5. NodeJS的實現多進程架構

如上第四點,面對單線程單進程對多核使用率不好的問題,因此我們使用多進程,每個進程使用一個cpu,因此我們就可以實現多核cpu的利用。
Node提供了child_process模塊和cluster模塊來實現多進程以及進程的管理。也就是我們常說的 Master-Worker模式。也就是說進程分為Master(主)進程 和 worker(工作)進程。master進程負責調度或管理worker進程,那么worker進程負責具體的業務處理。在服務器層面來講,worker可以是一個服務進程,負責出來自於客戶端的請求,多個worker就相當於多個服務器,因此就構成了一個服務器群。master進程則負責創建worker,接收客戶端的請求,然后分配到各個服務器上去處理,並且監控worker進程的運行狀態及進行管理操作。

如下圖所示:

二:node中child_process模塊實現多進程

nodejs 是單進程的,因此無法使用多核cpu,node提供了child_process模塊來實現子進程。從而會實現一個廣義上的多進程模式,通過child_process模塊,可以實現一個主進程,多個子進程模式,主進程叫做master進程,子進程叫做worker(工作)進程,在子進程中不僅可以調用其他node程序,我們還可以調用非node程序及shell命令等。執行完子進程后,我們可以以流或回調形式返回給主進程。

child_process提供了4個方法,用於創建子進程,這四個方法分別為 spawn, execFile, exec 和 fork. 所有的方法都是異步的。

該如上4個方法的區別是什么?

spawn: 子進程中執行的是非node程序,提供一組參數后,執行的結果以流的形式返回。
execFile: 子進程中執行的是非node程序, 提供一組參數后,執行的結果以回調的形式返回。
exec: 子進程執行的是非node程序,提供一串shell命令,執行結果后以回調的形式返回,它與 execFile不同的是,exec可以直接執行一串
shell命令。

fork: 子進程執行的是node程序,提供一組參數后,執行的結果以流的形式返回,它與spawn不同的是,fork生成的子進程只能執行node應用。

2.1 execFile 和 exec

該兩個方法的相同點和不同點如下:

相同點:執行的都是非node應用,且執行的結果以回調函數的形式返回。
不同點:execFile執行的是一個應用,exec執行的是一段shell命令。

比如來說:echo是Unix系統的一個自帶命令,我們可以直接在命令行中執行如下命令:

echo hello world

如下所示:

如上可以看到,我們在命令行中會打印 hello world. 因此這個我們可以使用 exec 來實現。

1)通過exec來實現:

exec執行shell命令代碼如下:

const cp = require('child_process');
console.log(cp);
cp.exec('echo hello world', function(err, res) {
  console.log(res);
});

執行如下圖所示:

如上我們可以看到,我們的 child_process模塊有如下屬性:

{ ChildProcess: [Function: ChildProcess],
  fork: [Function: fork],
  _forkChild: [Function: _forkChild],
  exec: [Function: exec],
  execFile: [Function: execFile],
  spawn: [Function: spawn],
  spawnSync: [Function: spawnSync],
  execFileSync: [Function: execFileSync],
  execSync: [Function: execSync] }

執行如上exec命令后,結果輸出為 hello world.

2) 通過execFile實現

const cp = require('child_process');
cp.execFile('echo', ['hello', 'world'], function(err, res) {
  console.log(res);
});

如上結果也是為 "hello world".

2.2 spawn

spawn是用於執行非node應用的,並且是不能直接執行shell。spawn執行的結果是以流的形式輸出的,通過流的方式可以節約內存的。

2.3 fork

在node中提供了fork方法,通過使用fork方法在單獨的進程中執行node程序,通過使用fork新建worker進程,上下文都復制主進程。並且通過父子之間的通信,子進程接收父進程的信息,並執行子進程后結果信息返回給父進程。降低了大數據運行的壓力。

現在我們來理解下使用fork()方法來創建子進程,fork()方法只需要指定要執行的javascript文件模塊,即可創建Node的子進程。下面我們是簡單的hello world的demo,master進程根據cpu的數量來創建出相應數量的worker進程,worker進程利用進程ID來標記。

|------ 項目
|  |--- master.js
|  |--- worker.js
|  |--- package.json
|  |--- node_modules

如上是我們的簡單項目結構,其中 worker.js 代碼如下:

console.log('Worker-' + process.pid + ': Hello world.');

master.js 代碼如下:

const childProcess = require('child_process');
const cpuNum = require('os').cpus().length;

for (let i = 0; i < cpuNum; ++i) {
  childProcess.fork('./worker.js');
}

console.log('Master: xxxx');

然后我們進入項目中的根目錄,執行 node master.js 命令即可看到打印信息如下:

如上圖可以看到,我們的master創建了4個worker進程后輸出 hello world信息。如上就是根據cpu的數量創建了4個工作進程。

三:父子進程間如何通信?

如上創建了4個worker進程后,現在我們需要考慮的是如何實現 master進程與worker進程通信的問題。

在NodeJS中父子進程之間通信可以通過 on('message') 和 send()方法來實現通信,on('message') 是監聽message事件的。
當該進程收到其他進程發送的消息時候,便會觸發message事件。send()方法則是用於向其他進程發送消息的。

具體如何做呢?

master進程中可以調用 child_process的fork()方法后會得到一個子進程的實列,通過該實列我們可以監聽到來自子進程的消息或向子進程發送消息。而worker進程則通過process對象接口來監聽父進程的消息或向父進程發送消息。現在我們把master.js 代碼改成如下:

const childProcess = require('child_process');
const worker = childProcess.fork('./worker.js');

// 主進程向子進程發送消息
worker.send('Hello World');

// 監聽子進程發送過來的消息
worker.on('message', (msg) => {
  console.log('Received message from worker:' + msg);
});

worker.js 代碼如下:

// 接收主進程發來的消息
process.on('message', (msg) => {
  console.log('Received message from master:' + msg);
  // 子進程向主進程發送消息
  process.send('Hi master.');
});

我們繼續在命令中執行 node master.js 命令后,看到如下信息被打印了 

3.2 Master實現對Worker的請求進行分發

如上只是簡單的父進程和子進程進行通信的demo實列,現在我們繼續來看一個更復雜一點的demo。我們知道master進程最主要是創建子進程,及對子進程進行管理和分配,而子進程最主要做的事情是處理具體的請求及業務。

進程通信除了使用到上面的send()方法,發送一些普通對象以外,我們還可以發送句柄,什么是句柄呢,句柄是一種引用,可以用來標識資源。
比如通過句柄可以標識一個socket對象等。我們可以利用該句柄實現請求的分發。

現在我們通過master進程來創建一個TCP服務器來監聽一些特定的端口,master進程會收到客戶端的請求,我們會得到一個socket對象,通過這個socket對象就可以和客戶端進行通信,從而我們可以處理客戶端的請求。

比如如下demo實列,master創建TCP服務器並且監聽8989端口,收到該請求后會將請求分發給worker處理,worker收到master發來的socket以后,通過socket對客戶端的響應。

|------ 項目
|  |--- master.js
|  |--- worker.js
|  |--- tcp_client.js
|  |--- package.json
|  |--- node_modules

master.js 代碼如下:

const childProcess = require('child_process');
const net = require('net');

// 獲取cpu的數量
const cpuNum = require('os').cpus().length;

let workers = [];
let cur = 0;

for (let i = 0; i < cpuNum; ++i) {
  workers.push(childProcess.fork('./worker.js'));
  console.log('worker process-' + workers[i].pid);
}

// 創建TCP服務器
const tcpServer = net.createServer();

/*
 服務器收到請求后分發給工作進程去處理
*/
tcpServer.on('connection', (socket) => {
  workers[cur].send('socket', socket);
  cur = Number.parseInt((cur + 1) % cpuNum);
});

tcpServer.listen(8989, () => {
  console.log('Tcp Server: 127.0.0.8989');
});

worker.js 代碼如下:

// 接收主進程發來的消息
process.on('message', (msg, socket) => {
  if (msg === 'socket' && socket) {
    // 利用setTimeout 模擬異步請求
    setTimeout(() => {
      socket.end('Request handled by worker-' + process.pid);
    },100);
  }
});

tcp.client.js 代碼如下:

const net = require('net');
const maxConnectCount = 10;

for (let i = 0; i < maxConnectCount; ++i) {
  net.createConnection({
    port: 8989,
    host: '127.0.0.1'
  }).on('data', (d) => {
    console.log(d.toString());
  })
}

如上代碼,tcp_client.js 負責創建10個本地請求,master.js 首先根據cpu的數量,創建多個worker進程,然后創建一個tcp服務器,使用connection來監聽net中 createConnection 方法創建事件,當有事件來的時候,就使用worker子進程依次進行分發事件,最后我們通過worker.js 來使用 process中message事件對事件進行監聽。如果收到消息的話,就打印消息出來,比如如下代碼:

// 接收主進程發來的消息
process.on('message', (msg, socket) => {
  if (msg === 'socket' && socket) {
    // 利用setTimeout 模擬異步請求
    setTimeout(() => {
      socket.end('Request handled by worker-' + process.pid);
    },100);
  }
});

為了查看效果,我們可以在項目的根目錄下 運行 命令 node master.js 啟動服務器,然后我們打開另一個命令行,執行 node tcp_client.js 啟動客戶端,然后我們會看到我們的10個請求被分發到不同的服務器上進行處理,如下所示:

3.3 Worker監聽同一個端口

我們之前已經實現了句柄可以發送普通對象及socket對象外,我們還可以通過句柄的方式發送一個server對象。我們在master進程中創建一個TCP服務器,將服務器對象直接發送給worker進程,讓worker進程去監聽端口並處理請求。因此master進程和worker進程就會監聽了相同的端口了。當我們的客戶端發送請求時候,我們的master進程和worker進程都可以監聽到,我們知道我們的master進程它是不會處理具體的業務的。
因此需要使用worker進程去處理具體的事情了。因此請求都會被worker進程處理了。

那么在這種模式下,主進程和worker進程都可以監聽到相同的端口,當網絡請求到來的時候,會進行搶占式調度,只有一個worker進程會搶到鏈接然后進行服務,由於是搶占式調度,可以理解為誰先來誰先處理的模式,因此就不能保證每個worker進程都能負載均衡的問題。下面是一個demo如下:

master.js 代碼如下:

const childProcess = require('child_process');
const net = require('net');

// 獲取cpu的數量
const cpuNum = require('os').cpus().length;

let workers = [];
let cur = 0;

for (let i = 0; i < cpuNum; ++i) {
  workers.push(childProcess.fork('./worker.js'));
  console.log('worker process-' + workers[i].pid);
}

// 創建TCP服務器
const tcpServer = net.createServer();

tcpServer.listen(8989, () => {
  console.log('Tcp Server: 127.0.0.8989');
  // 監聽端口后將服務器句柄發送給worker進程
  for (let i = 0; i < cpuNum; ++i) {
    workers[i].send('tcpServer', tcpServer);
  }
  // 關閉master線程的端口監聽
  tcpServer.close();
});

worker.js 代碼如下:

// 接收主進程發來的消息
process.on('message', (msg, tcpServer) => {
  if (msg === 'tcpServer' && tcpServer) {
    tcpServer.on('connection', (socket) => {
      setTimeout(() => {
        socket.end('Request handled by worker-' + process.pid);
      }, 100);
    })
  }
});

tcp_client.js 代碼如下:

const net = require('net');
const maxConnectCount = 10;

for (let i = 0; i < maxConnectCount; ++i) {
  net.createConnection({
    port: 8989,
    host: '127.0.0.1'
  }).on('data', (d) => {
    console.log(d.toString());
  })
}

如上代碼,我們運行 node master.js 代碼后,運行結果如下所示:

然后我們進行 運行 node tcp_client.js 命令后,運行結果如下所示:

如上我們可以看到 進程id為 37660 調度的比較多。

3.4 實現進程重啟

worker進程可能會因為其他的原因導致異常而退出,為了提高集群的穩定性,我們的master進程需要監聽每個worker進程的存活狀態,當我們的任何一個worker進程退出之后,master進程能監聽到並且能夠重啟新的子進程。在我們的Node中,子進程退出時候,我們可以在父進程中使用exit事件就能監聽到。如果觸發了該事件,就可以斷定為子進程已經退出了,因此我們就可以在該事件內部做出對應的處理,比如說重啟子進程等操作。

下面是我們上面監聽同一個端口模式下的代碼demo,但是我們增加了進程重啟的功能。進程重啟時,我們的master進程需要重新傳遞tcpServer對象給新的worker進程。但是master進程是不能被關閉的。否則的話,句柄將為空,無法正常傳遞。

master.js 代碼如下:

const childProcess = require('child_process');
const net = require('net');

// 獲取cpu的數量
const cpuNum = require('os').cpus().length;

let workers = [];
let cur = 0;

for (let i = 0; i < cpuNum; ++i) {
  workers.push(childProcess.fork('./worker.js'));
  console.log('worker process-' + workers[i].pid);
}

// 創建TCP服務器
const tcpServer = net.createServer();

/*
 服務器收到請求后分發給工作進程去處理
*/
tcpServer.on('connection', (socket) => {
  workers[cur].send('socket', socket);
  cur = Number.parseInt((cur + 1) % cpuNum);
});

tcpServer.listen(8989, () => {
  console.log('Tcp Server: 127.0.0.8989');
  // 監聽端口后將服務器句柄發送給worker進程
  for (let i = 0; i < cpuNum; ++i) {
    workers[i].send('tcpServer', tcpServer);
    // 監聽工作進程退出事件
    workers[i].on('exit', ((i) => {
      return () => {
        console.log('worker-' + workers[i].pid + ' exited');
        workers[i] = childProcess.fork('./worker.js');
        console.log('Create worker-' + workers[i].pid);
        workers[i].send('tcpServer', tcpServer);
      }
    })(i));
  }
  // 不能關閉master線程的,否則的話,句柄將為空,無法正常傳遞。
  // tcpServer.close();
});

worker.js 代碼如下:

// 接收主進程發來的消息
process.on('message', (msg, tcpServer) => {
  if (msg === 'tcpServer' && tcpServer) {
    tcpServer.on('connection', (socket) => {
      setTimeout(() => {
        socket.end('Request handled by worker-' + process.pid);
      }, 100);
    })
  }
});

tcp_client.js 代碼如下:

const net = require('net');
const maxConnectCount = 10;

for (let i = 0; i < maxConnectCount; ++i) {
  net.createConnection({
    port: 8989,
    host: '127.0.0.1'
  }).on('data', (d) => {
    console.log(d.toString());
  })
}

當我們在命令中 運行 node master.js  和 node tcp_client.js 執行后,如下圖所示:

然后我們進入我們的電腦后台(我這邊是mac電腦),進入活動監視器頁面,結束某一個進程,如下圖所示:

結束完成后,我們再來看下我們的 node master.js 命令可以看到,先打印 某某工作進程被退出了,然后某某工作進程被創建了,如下圖所示

然后我們再到我們的 活動監視器可以看到新的 進程號被加進來了,如下圖所示:

四:理解cluster集群

如上我們了解了使用 child_process實現node集群操作,現在我們來學習使用cluster模塊實現多進程服務充分利用我們的cpu資源以外,還能夠幫我們更好地進行進程管理。我們使用cluster模塊來實現我們上面同樣的功能,代碼如下:

master.js 代碼如下:

const cluster = require('cluster');
if (cluster.isMaster) {
  const cpuNum = require('os').cpus().length;
  for (let i = 0; i < cpuNum; ++i) {
    cluster.fork();
  }

  // 創建進程完成后輸出信息
  cluster.on('online', (worker) => {
    console.log('Create worker-' + worker.process.pid);
  });

  // 監聽子進程退出后重啟事件
  cluster.on('exit', (worker, code, signal) => {
    console.log('[Master] worker ' + worker.process.pid + ' died with code:' + code + ', and' + signal);
    cluster.fork(); // 重啟子進程
  });
} else {
  const net = require('net');
  net.createServer().on('connection', (socket) => {
    setTimeout(() => {
      socket.end('Request handled by worker-' + process.pid);
    }, 10)
  }).listen(8989)
}

如上代碼,我們可以使用 cluster.isMaster 來判斷是主進程還是子進程,如果是主進程的話,我們使用cluster創建了和cpu數量相同的worker進程,並且通過監聽 cluster中的online事件來判斷worker是否創建成功。並且使用了 cluster監聽了 exit事件,當worker進程退出后,會觸發master進程中cluster的online事件來判斷worker是否創建成功。如下圖我們在命令行中運行命令:

如下所示:

我們現在同樣的道理,我們去 活動監視器去吧 47575這個端口號結束掉。在看看我們的命令行如下所示:

從上圖我們也可以看到 47575 進程結束掉,並且47898進程重啟了。如上代碼使用 cluster模塊實現了child_process集群的操作。

有關更多的cluster中的API可以看這篇文章(http://wiki.jikexueyuan.com/project/nodejs/cluster.html)

我們在下一篇文章會深入學習使用cluster的應用場景demo。 基本原理先到這里。

注:我也是在看資料學習的。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM