Nodejs搭建多進程Web服務器實現過程

前言

上節我們講到,通過 fork() 或者其他API,創建子進程之後,可以通過 send()process.on('message') 進行父子進程間的通信。這樣就實現瞭主進程代理請求到工作進程,實現瞭 Nodejs集群

父子進程間通信

負載均衡

通過代理,可以避免端口不能重復監聽的問題,甚至可以在代理進程上做適當的負載均衡,使得每個子進程可以較為均衡地執行任務。下面我們構建瞭一個簡單的 Web 服務器,並實現在兩個工作進程之間做簡單的負載均衡。

主進程,負責代理到對應進程中:

// main.js
const { fork } = require('child_process');
const normal = fork('subprocess.js', ['normal']);
const special = fork('subprocess.js', ['special']);
// Open up the server and send sockets to child. Use pauseOnConnect to prevent
// 套接字在發送給子進程之前不會被讀取
const server = require('net').createServer({ pauseOnConnect: true });
let flag = 0;
server.on('connection', (socket) => {
  flag++;
  // this is special priority.
  if (flag % 2 === 0) {
    special.send('socket', socket);
    return;
  }
  // This is normal priority.
  normal.send('socket', socket);
});
server.listen(1337);

這是工作進程,接收socket對象並做出響應:

// subprocess.js
process.on('message', (m, socket) => {
  if (m === 'socket') {
    // Check that the client socket exists. 
    // It is possible for the socket to be closed between the time it is
    if (socket) {
      // console.log(`Request handled with ${process.argv[2]} priority`);
      socket.end(`Request handled with ${process.argv[2]} priority, running on ${process.pid}`);
    }
  }
});

然後我又編寫瞭一個 Nodejs 腳本,來發出十個 HTTP 請求:

const cp = require("child_process");
for (let i = 0; i < 10; i++) {
  cp.exec(`curl --http0.9 "http://127.0.0.1:1337"`, (err, stdout, stderr) => {
    console.log(`finished: ${i}, and received: `, stdout);
  })
}

最後運行結果如下:

句柄傳遞

在使用 send() 方法時,我們註意到,除瞭能通過IPC發送數據外,還能發送句柄。第二個可選參數就是一個句柄:

child.send(message, [sendHandle]);

💡 句柄是一種可以用來標識資源的引用,它的內部包含瞭指向對象的文件描述符。比如句柄可以用來標識一個服務器端socket對象、一個客戶端socket對象、一個UDP套接字、一個管道等。

在主進程將句柄發送給子進程之後,工作模型就從主進程響應用戶請求變成瞭子進程監聽用戶活動:

進程對象send()方法可以發送的句柄類型包括如下幾種:

  • net.Socket。TCP套接字。
  • net.Server。TCP服務器,任意建立在TCP服務上的應用層服務都可以享受到它帶來的好處。
  • net.Native。C++層面的TCP套接字或IPC管道。
  • dgram.Socket。UDP套接字。
  • dgram.Native。C++層面的UDP套接字。

💡 另外要註意,send()方法能發送消息和句柄並不意味著它能發送任意對象,message 參數和文件句柄都要先通過 JSON.stringfy() 進行序列化後再放入IPC通道中:

集群

通過 child_process模塊,我們完成瞭父子進程的創建和通信,已經初步搭建瞭一個Node集群。還有一些問題需要考慮:

  • 性能問題。
  • 多個工作進程的存活狀態管理。
  • 工作進程的平滑重啟。
  • 配置或者靜態數據的動態重新載入。
  • 其他細節。

這其中最重要的便是集群的穩定性,這決定瞭該服務模型能否真正用於實踐生成中。雖然我們創建瞭很多工作進程,但每個工作進程依然是在單線程上執行的,它的穩定性還不能得到完全的保障。我們需要建立起一個健全的機制來保障Node應用的健壯性。

子進程事件

父進程能監聽到的,與子進程相關的事件:

  • error:當子進程無法被復制創建、無法被殺死、無法發送消息時會觸發該事件。
  • exit:子進程退出時觸發該事件。如果是正常退出,這個事件的第一個參數為退出碼,否則為null。如果進程是通過kill()方法被殺死的,會得到第二個參數,它表示殺死進程時的信號。
  • close:在子進程的標準輸入輸出流中止時觸發該事件,參數與exit相同。
  • disconnect:在父進程或子進程中調用disconnect()方法時觸發該事件,在調用該方法時將關閉監聽IPC通道

除瞭 send() 外,還能通過 kill() 方法給子進程發送消息。kill() 方法並不能真正地將通過IPC相連的子進程殺死,它隻是給子進程發送瞭一個系統信號。默認情況下,父進程將通過 kill() 方法給子進程發送一個 SIGTERM信號

// 子進程
child.kill([signal]);
// 當前進程
process.kill(pid, [signal]);
// 監聽
process.on(signal, callback)

💡 在POSIX標準中,有一套完備的信號系統,在命令行中執行kill -l可以看到詳細的信號列表,如下所示:

而 Node 提供瞭這些信號對應的信號事件,每個進程都可以監聽這些信號事件。這些信號事件是用來通知進程的,每個信號事件有不同的含義,進程在收到響應信號時,應當做出約定的行為:

process.on('SIGTERM', () => {
    console.log("got sigterm, exiting...");
    process.exit(1);
});
console.log("process running on: ", process.pid);
process.kill(process.pid, "SIGTERM");

自動重啟

有瞭父子進程之間的相關事件之後,就可以在這些關系之間創建出需要的機制瞭,至少我們能夠通過監聽子進程的 exit事件 來獲知其退出的信息。接著前文的多進程架構,我們在主進程上要加入一些子進程管理的機制,比如重新啟動一個工作進程來繼續服務:

主進程代碼:

// master.js
// master.js
const { fork } = require('child_process');
const cpus = require('os').cpus();
const server = require('net').createServer();
server.listen(1337);
const workers = {};
// process.on('uncaughtException', function (err) {
//   console.log(`Master uncaughtException:\r\n`);
//   console.log(err);
// });
const createWorker = () => {
  const worker = fork('./worker.js');
  // 收到信號後立即重啟新進程
  worker.on('message', function (message) {
    if (message.act === 'suicide') {
      createWorker();
    }
  });
  // 某個進程終止時重新啟動新的進程
  worker.on('exit', () => {
    console.log('Worker ' + worker.pid + ' exited.');
    delete workers[worker.pid];
    // createWorker();
  });
  // 句柄轉發
  worker.send('server', server);
  workers[worker.pid] = worker;
  console.log('Create worker. pid: ' + worker.pid);
};
for (let i = 0; i < cpus.length; i++) {
  createWorker();
}
// server.close();
// 進程自己退出時,讓所有工作進程退出
process.on('exit', () => {
  for (let pid in workers) {
    workers[pid].kill();
  }
});

子進程代碼:

// worker.js
const http = require('http');
const server = http.createServer((req, res) => {
  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end('handled by child, pid is ' + process.pid + '\n');
  // 拋出異常,捕獲後終止進程
  throw new Error('throw exception');
});
var worker;
process.on('message', (m, tcp) => {
  if (m === 'server') {
    worker = tcp;
    worker.on('connection', (socket) => {
      server.emit('connection', socket);
    });
  }
});
// 捕獲異常後終止進程
process.on('uncaughtException', (err) => {
  // 主動發出信號,避免等待連接斷開時收到新請求而缺少進程無法響應
  process.send({
    act: 'suicide'
  });
  // 停止接收新的連接
  worker.close(function () {
    // 所有已有連接斷開後,退出進程
    process.exit(1);
  });
  // 避免長連接請求長時間無法終止,5s後自動終止
  setTimeout(() => {
    process.exit(1);
  }, 5000)
});

運行父進程 master.js,控制臺中會打印出開啟的進程 PID

在 Linux 中,你可以直接使用 kill -9 [pid] 來終止進程。在 Windows 中,你需要打開任務管理器,找到 node.exe 的進程,終止其中某個。此時命令行會顯示該進程被終止瞭,然後重新開啟一個新的進程。

當然,你也可以使用我們之前寫的 run.js 腳本,每發起一個請求,子進程響應請求之後會拋出一個異常,異常在捕獲之後會終止該進程。

💡 我們之前寫的 run.js 腳本是並行執行的,此時會存在多個請求被分配到同一個 socket ,即分配到同一個進程中執行。那麼就會存在互斥的問題,即某個請求結束後就終止該進程,導致其他請求無法獲得響應而終止。此時你需要將 exec 方法改為同步方法:

const cp = require("child_process");
const cpus = require("os").cpus();
const sleep = (delay) => {
  const now = Date.now();
  while (Date.now() - now < delay);
  return;
}
for (let i = 0; i < cpus.length; i++) {
  const out = cp.execSync(`curl --http0.9 "http://127.0.0.1:1337"`);
  sleep(1000);
  console.log(out.toString());
}

該模型一旦有異常出現,主進程會創建新的工作進程來為用戶服務,舊的進程一旦處理完已有連接就自動斷開。整個過程使得我們的應用的穩定性和健壯性大大提高:

總結

至此,我們完成瞭一個簡單的基於父子進程通信、具備異常重啟進程功能的 Web服務器 就已經搭建完成瞭。對於 Nodejs 多進程編程你也有瞭初步的瞭解。接下來我們將介紹 cluster模塊,並介紹一下在 Nodejs 中進行多線程編程。

以上就是Nodejs搭建多進程Web服務器實現過程的詳細內容,更多關於Nodejs搭建多進程Web服務器的資料請關註WalkonNet其它相關文章!

推薦閱讀: