聊聊Node.js 中的進程

blank

聊聊Node.js 中的進程

## 前言

進程與線程作為操作系統的基礎概念,是一個軟件開發工程師必須需要掌握的。而在開發Node.js 程序時,進程與線程也會發揮比較大的作用。

本文主要主要講Node.js 中多進程的需求場景與解決方案。

本文的所有demo見: github.com/ImHype/proce

## 進程是什麼?

進程是近現代操作系統的產物。計算機硬件層面其實沒有進程與線程的概念,它做的事情就是不停地運行機器碼。

早期的操作系統設計出來是單任務執行機器碼的,任務的執行前後包含配置任務,和提取結果兩個動作:我們把他們稱為”上機”與”下機”。

而隨著計算機計算能力的提升,人們發現“上機”和”下機”的動作會成為整個系統的瓶頸(人的速度太慢了)。所以便設計了多道程序批處理操作系統。

此時的操作系統能夠給計算機安排一個隊列了,讓計算機不斷從隊列中讀取任務,執行,最終將結果記錄到存儲介質裡面,有點像現在的CI Runner。

但問題似乎沒有根本解決,人們發現當一些任務執行需要等待一些外部操作,比如網絡請求、文件讀寫,此時計算資源還是會處於浪費狀態。

問題得到實質性的解決,是時分複用操作系統的引入,每個任務按以下狀態不停切換。

blank

通過這種方式,多個任務能夠被「並發」地執行。創建任務後,通過時分複用的機制,由CPU 調度執行;當任務需要等待外部資源時,則進入阻塞態,此時無法再由CPU 調度執行,直到外部資源已就緒後,由系統中斷觸發,將其任務狀態再次置為就緒態,而後該任務又可由CPU 調度執行。

真正讓多進程作用發揮到極致,是對稱多處理技術(SMP,允許多個處理器共享內存和計算機資源) 的成熟,能夠讓多任務的執行在多個處理器上並行運行。

上述提到的任務,就是進程,又稱調度主體。同樣作為調度主體而存在的還有「線程」,在Linux 2.4 及更早,線程的實現其實也是進程;而在往後的版本里,任務調度的主體變成了線程,進程成為了線程容器。

進程作為線程的容器,具備彼此獨立的內存空間。這塊怎麼理解呢?

我們在程序中使用的,都是邏輯地址(比如0x0),這個地址映射到真實的物理地址需要一個重定向過程,由硬件層面的MMU 完成。

在現代操作系統分幀分頁的內存管理模式下,進程的邏輯地址被分段、分幀表示。分段是指:代碼段、數據段、bss 段、堆段、棧段;分頁是指將進程內的使用空間將邏輯地址分成一個個連續的大小相等的單元。將實際的物理內存上也分成一個個和頁大小相等的單元,稱之為幀,但幀內數據存儲可以不連續。實際的內存讀取過程是,MMU 會從邏輯地址計算得到頁號和頁內偏移量,結合pid 查反向頁表得到幀號,再拼接幀號和頁內偏移量得到真實的物理地址。分頁技術解決的是內存外部碎片問題。

通常情況下,不同的進程只有用到同一個動態鏈接庫時,代碼段會共享內存幀,其餘的段都是不共享的。最終導致的是,不同的進程擁有各自獨立的內存空間及資源,如文件描述符,端口等等;而同一個進程中的不同線程,可以共享進程容器的內存空間與資源。

## 多進程的需求

在常規的web 服務器領域,默認模式下運行Node 程序通常會有這兩個痛點:

  1. 我們Node.js 程序,會運行在非單核CPU 的宿主環境,而上文提到JavaScript 的執行是在單線程中執行的。也就是說如果我們的應用是「計算密集型」 應用,單進程的運行模式下我們是無法充分利用CPU 的資源的;
  2. 單進程的Node.js 程序崩潰退出了怎麼辦?

## 多進程的解決方案

目前主流的有兩個解決方案:

  1. 隨著Docker 的普及,本身提供了Daemon 的機制,可以幫助重啟我們的應用,並且Docker 也支持分配1 core 的計算資源;
  2. 使用進程管理工具- PM2,以“cluster” 模式啟動。

如何對兩種方案進行取捨呢?

我認為如果你的公司提供了Docker 的部署方案,可以嘗試模擬下進程異常退出的情況,觀察下進程重新拉起的時間,和pm2 方案做一個簡單對比,如果拉起的速度基本無差,可以使用方案1;否則可以繼續使用pm2 的方案。

那麼通常我們在Docker中運行pm2 ,是要以no-daemon模式運行的(否則Docker容器會不斷重啟):

$ pm2 start server.js --no-daemon -i 2

好,目前為止,我們了解了Node.js 在常規web 服務開發的需求場景,接下來我們嘗試一起來實現一個「麻雀雖小,五臟俱全」的進程管理工具,以幫助大家熟悉Node.js 中原生的進程操作。

## 實現一款類似pm2 的進程管理工具

特別說明,我們做這個進程管理工具的目的,不是為了讓它能夠在真實的線上環境上跑,而是為了通過這個例子,幫助加深對Node.js 的進程機制的理解。

先整理下一個基本的進程管理工具所需要擁有的功能:

  1. 基礎的子進程創建能力;
  2. 均衡地將請求分發到各個子進程;
  3. 子進程異常退出時,要能重啟子進程;
  4. 守護進程要能在後台常駐運行;
  5. 接收退出命令,退出所有子進程,並釋放資源;
  6. 收集工作進程的日誌。

我們一個個來看。

### 創建子進程的能力

Node.js提供的基礎多進程處理能力幾種在child_process模塊

提供了四種創建子進程的方式:

  • exec
  • execFile
  • fork
  • spawn

其中,exec 與execFile 主要用來執行系統命令;而spawn 是其餘三個函數的基礎,其他三個函數內部都會調用spawn 函數。

對於本次需求,我們主要講解fork 函數。

寫個例子來感受下fork函數的基礎用法:

首先是main.js

// main.js const { fork } = require ( 'child_process' ); const sub = fork ( './child.js' , { stdio : 'inherit' }); sub . on ( 'message' , ( msg ) => { console . log ( msg ); });

然後是child.js

// child.js process . send ( 'hello' ); console . log ( 'hello world' );

最後的輸出是:

> hello world > hello

一次簡單的子進程創建便完成了。

### 多進程啟動web server

使用上一節學習到的fork API來試著創建我們的服務,看例子

// fork.js const { fork } = require ( 'child_process' ); for ( let i = 0 ; i < 4 ; i ++ ) { fork ( './main.js' ); } // main.js const http = require ( 'http' ); http . createServer (( req , res ) => { res . end ( 'hello' ); }). listen ( 3000 );

似乎和我們pm2 的使用姿勢類似,期望能夠實現我們的需求。但發現最終的結果是這樣的:

blank

提示你端口被佔用了,為什麼會這樣呢?

  • 前面我們提到進程是擁有獨立的資源,包括內存空間以及端口及文件描述符等;
  • 所以,當一個端口被一個進程佔用之後,其他進程就無法再佔用了。

那問題怎麼解決呢?一般有下面幾種方案:

第一種方案是,類型Nginx 的模式,起一個master 進程用於接收http 請求,再通過http 代理的形式代理到子進程。但在同一台機器內的父子進程之間通過這樣的形式進行交互是可能性能會出現瓶頸。

第二種方案便是應用child_process 提供給我們的進程間通信機制:

  1. process.on('message', callback) ;
  2. process.send('message', handle);

來看例子

// master.jsconst{fork}=require('child_process');constnet=require('net');constsubprocess=fork('child.js',[]);constserver=net.createServer((socket)=>{subprocess.send('accept',socket);});server.listen(9999);// child.jsprocess.on('message',(msg,handle)=>{if(msg==='accept'){constbody=Buffer.from('<html><head><title>Wrox Homepage</title></head><body>hello world</body></html>');handle.end(`HTTP/1.1 200 OKrnDate: Sat, 31 Dec 2005 23:59:59 GMTrnContent-Type: text/html;charset=utf-8rnContent-Length:${Buffer.byteLength(body)}rnrn${body}`);}})

node master.js執行,可以發現成功啟動了,並且訪問localhost:9999也成功響應了hello world 。這個方案能運行的原理是:

  1. 文件描述符機制(以下稱fd);
  2. 系統級提供IPC 機制允許進程見傳遞fd;
  3. Node.js 層面利用以上系統級特性做了封裝,支持process.send 方式傳遞socket。

但這個方案也暴露了一個問題, 對啟動的server 代碼有侵入性。

第三個方案是使用Node.js本身提供的cluster機制,由於cluster模塊內部以hack的形式幫我們做了類似方案二的事情,使得多進程啟動的邏輯不再侵入server的代碼, 看例子

// master.jsconstcluster=require('cluster');cluster.setupMaster({exec:'worker.js',});constworkers=[];for(leti=0;i<4;i++){constchild=cluster.fork()workers.push(child);}// worker.jsconsthttp=require('http');http.createServer((req,res)=>{res.end('hello');}).listen(9000,()=>{console.log('process %s started',process.pid)});

方案三cluster 的機制看起來是一種較優的機制,用於解決多進程啟動的問題。

### 進程守護

如果我們的server 因為某個異常導致了進程退出,這個時候該怎麼辦?

作為一款進程管理工具,需要做的就是重啟子進程,我們稱它為進程守護。大致分為兩個步驟:

  1. 監聽子進程退出;
  2. 進程退出後重新`refork` 子進程。

監聽子進程退出,需要利用的Node.js提供的機制是子進程事件機制,尤其要用到exit事件。來看例子。我們的master.js 可以改成這個樣子。

// master.jsconstcluster=require('cluster');cluster.setupMaster({exec:'worker.js',});constworkers=[];constfork=()=>{constchild=cluster.fork();console.log('pid %s',child.pid);child.on('exit',()=>{workers.splice(workers.indexOf(child),1);setTimeout(()=>{fork();},1000);});workers.push(child);}for(leti=0;i<4;i++){fork();}

這個時候我們試著用kill 命令殺掉對應的子進程,發現子進程重啟成功。

也就是我們的進程守護已生效。

### 後台常駐的需求

我們知道pm2 有個進程常駐後台運行的功能。

blank

我們希望我們的程序在終端退出之後還能運行,怎麼實現呢?

熟悉bash 的同學知道bash 裡面可以這樣

$ nohup server.js &

對應到c語言裡面有個setsid ,也是乾這個事情。那node.js 裡面怎麼實現這個需求?

可以在調用fork時傳入options.detached ,我們來看個例子

在之前創建的master.js 和worker.js 的同級目錄下,創建command.js,內容如下。

const{fork}=require('child_process');constchild=fork('./master.js',{detached:true,stdio:'ignore'});process.exit(0);

執行node command.js之後,發現當前進程馬上退出了,而ps查看發現worker和master仍在後台常駐,且master進程的ppid變成了1。

blank

顯然是符合我們期望的。

### 停止後台運行的進程

在前台運行的進程,如果我們想要結束運行,似乎我們可以按下鍵盤的ctrl + c。

而進程在後台運行之後,如果我們想要結束它的時候,該怎麼辦呢?

這裡要介紹信號機制

實際上我們按下鍵盤的ctrl + c 的操作,其實也是觸發了對當前前台進程的一個信號,這個信號就是SIGINT。同樣的,還有我們常用的kill 命令,是shell 給用戶提供的觸發目標進程信號的方式。比如說以下操作會強制結束進程運行。

$ kill -9 pid

當然我們更推薦使用SIGTERM 信號,因為進程在接收到這個信號時,一般允許做一些回收工作,比如“優雅”斷開與遠程服務的長連接。

$ kill pid

那Node.js 裡面怎麼使用呢,我們直接給command.js 加一些代碼,以實現我們需要的“停止後台運行進程”功能。

const{fork}=require('child_process');constfs=require('fs');constpidfile='.pid';if(process.argv[2]==='stop'){constpid=fs.readFileSync(pidfile,'utf8');process.kill(Number(pid),'SIGTERM');fs.unlinkSync(pidfile);}else{constchild=fork('./master.js',{detached:true,stdio:'ignore'});fs.writeFileSync(pidfile,String(child.pid));process.exit(0);}

然後試著在本地執行,發現能夠成功結束在後台運行的進程。

注意:之前運行的進程要先手動殺掉。

### 優雅退出

首先要介紹一個孤兒進程的概念。當前進程在退出時,如果子進程還在運行,子進程會稱為孤兒進程,表現是ppid會變成1(好像和後台常駐進程的結果很像。例子在這裡

所以進程守護工具在結束進程時,要考慮到這一點,也就是在接收到進程退出的命令,結束掉所有的子進程,還是通過上一節提到的信號機制。

這裡進程管理工具需要做兩件事情:

  1. 接收退出信號,對應到Node.js內是信號的事件機制
  2. 利用ps 命令找出當前進程的所有子進程(可以藉助npm 包ps-tree 實現。
constcluster=require('cluster');constutil=require('util');constpstree=require('ps-tree');cluster.setupMaster({exec:'worker.js',});constworkers=[];constfork=()=>{constchild=cluster.fork();child.on('exit',()=>{workers.splice(workers.indexOf(workers),1);setTimeout(()=>{fork();},1000);});workers.push(child);}for(leti=0;i<4;i++){fork();}process.on('SIGTERM',signalExit);process.on('SIGQUIT',signalExit);process.on('SIGINT',signalExit);functionsignalExit(){Promise.all(workers.map((w)=>newPromise((resolve)=>{pstree(w.process.pid,(e,children)=>{if(e){console.log(e);resolve([]);}else{resolve(children);}})}))).then((results)=>{results.forEach((pids)=>{pids.forEach((pid)=>{process.kill(pid.PID,'SIGTERM');})});process.exit(0);}).catch(()=>{process.exit(1);});}

同樣地,我們的SIGTERM 裡面還可以繼續做一些其他的回收工作,比如斷開與服務端的長連接,清除本地的緩存文件等等。

### 日誌記錄

到目前為止,我們都沒有處理標準輸出流,因為我們的進程直接退出了,一些console 打印的日誌似乎就消失無踪了,這是不被希望的。我們希望的是能夠將子進程的標準輸出重定向到本地的一些日誌文件。

可以藉助fork 時傳入的options.stdio 來實現這個需求。

console.log, console.error 等操作其實是往1 和2 的文件描述符裡面寫入內容。而options.stdio 能夠重新定向的文件描述符。以下的操作就能夠將1 和2 的描述符指向本地文件。

const{fork}=require('child_process');constfs=require('fs');constpidfile='.pid';if(process.argv[2]==='stop'){constpid=fs.readFileSync(pidfile,'utf8');process.kill(Number(pid),'SIGTERM');fs.unlinkSync(pidfile);}else{constapplog=fs.openSync('./app.log','a+');consterrorlog=fs.openSync('./app-error.log','a+');constchild=fork('./master.js',{detached:true,stdio:[0,applog,errorlog,'ipc']});fs.writeFileSync(pidfile,String(child.pid));process.exit(0);}

這次啟動之後,發現在子進程裡面的標準輸出最後進入到了app.log 和app-error.log 文件內。完成需求。

## 小結

通過本文的學習,我們通過一個進程管理工具的輪子,熟悉了

  • 創建子進程的方式;
    • exec/execFile/spawn/fork
  • IPC 機制實現進程間共用Socket 的需求;
    • IPC通信傳遞socket (原理是系統級的IPC通信傳遞文件描述符;
  • 最終使用cluster的解決方案;
  • 使用進程事件監聽子進程退出,然後進行refork;
  • 使用options.detached來創建後台常駐進程;
  • 通過信號機制實現後台常駐進程的退出;
  • 了解孤兒進程的存在,並通過監聽信號的方式,來實現優雅退出;
  • 理解文件描述符,通過options.stdio指定子進程的文件描述符,實現標準輸入輸出重定向到日誌的需求。

標粗的部分可以結合Node.js 官方文檔深入理解。再次放上本文例子的鏈接: github.com/ImHype/proce

插播一條廣告。字節跳動誠邀優秀的前端工程師和Node.js工程師加入,一起做有趣的事情,歡迎有意者私信聯繫,或發送簡歷至[email protected]

校招戳這裡 (同樣歡迎實習生同學。

好了,我們下期再會。

What do you think?

Written by marketer

blank

螞蟻金服AntV G6 3.1 潛心

Vapperjs – 一個基於Vue 的SSR 框架