Node.js進階之進程與線程

進程與線程在服務端研發中是一個非常重要的概念,如果您在學習的時候對這一塊感到混亂或者不是太理解,可以閱讀下本篇內容,本篇在介紹進程和線程的概念之外,列舉了很多 Demo 希望能從實戰角度幫助您更好的去理解。

作者簡介:五月君,Nodejs Developer,熱愛技術、喜歡分享的 90 後青年,公衆號 “Nodejs技術棧”,Github 開源項目 https://www.nodejs.red

快速導航

進程

進程(Process)是計算機中的程序關於某數據集合上的一次運行活動,是系統進行資源分配和調度的基本單位,是操作系統結構的基礎,進程是線程的容器(來自百科)。我們啓動一個服務、運行一個實例,就是開一個服務進程,例如 Java 裏的 JVM 本身就是一個進程,Node.js 裏通過 node app.js 開啓一個服務進程,多進程就是進程的複製(fork),fork 出來的每個進程都擁有自己的獨立空間地址、數據棧,一個進程無法訪問另外一個進程裏定義的變量、數據結構,只有建立了 IPC 通信,進程之間纔可數據共享。

關於進程通過一個簡單的 Node.js Demo 來驗證下,執行以下代碼 node process.js,開啓一個服務進程
// process.js
const http = require('http');

http.createServer().listen(3000, () => {
    process.title = '測試進程 Node.js' // 進程進行命名
    console.log(`process.pid: `, process.pid); // process.pid: 20279
});

以下爲 Mac 系統自帶的監控工具 “活動監視器” 所展示的效果,可以看到我們剛開啓的 Nodejs 進程 20279

圖片描述

線程

線程是操作系統能夠進行運算調度的最小單位,首先我們要清楚線程是隸屬於進程的,被包含於進程之中。一個線程只能隸屬於一個進程,但是一個進程是可以擁有多個線程的。

同一塊代碼,可以根據系統CPU核心數啓動多個進程,每個進程都有屬於自己的獨立運行空間,進程之間是不相互影響的。同一進程中的多條線程將共享該進程中的全部系統資源,如虛擬地址空間,文件描述符和信號處理等。但同一進程中的多個線程有各自的調用棧(call stack),自己的寄存器環境(register context),自己的線程本地存儲(thread-local storage),線程又有單線程和多線程之分,具有代表性的 JavaScript、Java 語言。

單線程

單線程就是一個進程只開一個線程,想象一下一個癡情的少年,對一個妹子一心一意用情專一。

Javascript 就是屬於單線程,程序順序執行,可以想象一下隊列,前面一個執行完之後,後面纔可以執行,當你在使用單線程語言編碼時切勿有過多耗時的同步操作,否則線程會造成阻塞,導致後續響應無法處理。你如果採用 Javascript 進行編碼時候,請儘可能的使用異步操作。

一個計算耗時造成線程阻塞的例子

先看一段例子,運行下面程序,瀏覽器執行 http://127.0.0.1:3000/compute 大約每次需要 15657.310ms,也就意味下次用戶請求需要等待 15657.310ms,下文 Node.js 進程創建一節 將採用 child_process.fork 實現多個進程來處理。

// compute.js
const http = require('http');
const [url, port] = ['127.0.0.1', 3000];

const computation = () => {
    let sum = 0;
    console.info('計算開始');
    console.time('計算耗時');

    for (let i = 0; i < 1e10; i++) {
        sum += i
    };

    console.info('計算結束');
    console.timeEnd('計算耗時');
    return sum;
};

const server = http.createServer((req, res) => {
    if(req.url == '/compute'){
        const sum = computation();

        res.end(`Sum is ${sum}`);
    }

    res.end(`ok`);
});

server.listen(port, url, () => {
    console.log(`server started at http://${url}:${port}`);
});

單線程使用總結

  • Node.js 雖然是單線程模型,但是其基於事件驅動、異步非阻塞模式,可以應用於高併發場景,避免了線程創建、線程之間上下文切換所產生的資源開銷。
  • 如果你有需要大量計算,CPU 耗時的操作,開發時候要注意。

多線程

多線程就是沒有一個進程只開一個線程的限制,好比一個風流少年除了愛慕自己班的某個妹子,還在想着隔壁班的漂亮妹子。Java 就是多線程編程語言的一種,可以有效避免代碼阻塞導致的後續請求無法處理。

對於多線程的說明 Java 是一個很好的例子,看以下代碼示例,我將 count 定義在全局變量,如果定義在 test 方法裏,又會輸出什麼呢?
public class TestApplication {
    Integer count = 0;

    @GetMapping("/test")
    public Integer Test() {
        count += 1;
        return count;
    }

    public static void main(String[] args) {
        SpringApplication.run(TestApplication.class, args);
    }
}

運行結果,每次執行都會修改count值,所以,多線程中任何一個變量都可以被任何一個線程所修改。

1 # 第一次執行
2 # 第二次執行
3 # 第三次執行
我現在對上述代碼做下修改將 count 定義在 test 方法裏
public class TestApplication {
    @GetMapping("/test")
    public Integer Test() {
        Integer count = 0; // 改變定義位置
        count += 1;
        return count;
    }

    public static void main(String[] args) {
        SpringApplication.run(TestApplication.class, args);
    }
}

運行結果如下所示,每次都是 1,因爲每個線程都擁有了自己的執行棧

1 # 第一次執行
1 # 第二次執行
1 # 第三次執行

多線程使用總結

多線程的代價還在於創建新的線程和執行期上下文線程的切換開銷,由於每創建一個線程就會佔用一定的內存,當應用程序併發大了之後,內存將會很快耗盡。類似於上面單線程模型中例舉的例子,需要一定的計算會造成當前線程阻塞的,還是推薦使用多線程來處理,關於線程與進程的理解推薦閱讀下 阮一峯:進程與線程的一個簡單解釋

Nodejs的線程與進程

Node.js 是 Javascript 在服務端的運行環境,構建在 chrome 的 V8 引擎之上,基於事件驅動、非阻塞I/O模型,充分利用操作系統提供的異步 I/O 進行多任務的執行,適合於 I/O 密集型的應用場景,因爲異步,程序無需阻塞等待結果返回,而是基於回調通知的機制,原本同步模式等待的時間,則可以用來處理其它任務,在 Web 服務器方面,著名的 Nginx 也是採用此模式(事件驅動),Nginx 採用 C 語言進行編寫,主要用來做高性能的 Web 服務器,不適合做業務。Web業務開發中,如果你有高併發應用場景那麼 Node.js 會是你不錯的選擇。

在單核 CPU 系統之上我們採用 單進程 + 單線程 的模式來開發。在多核 CPU 系統之上,可以通過 child_process.fork 開啓多個進程(Node.js 在 v0.8 版本之後新增了Cluster 來實現多進程架構) ,即 多進程 + 單線程 模式。注意:開啓多進程不是爲了解決高併發,主要是解決了單進程模式下 Node.js CPU 利用率不足的情況,充分利用多核 CPU 的性能。

Process

Node.js 中的進程 Process 是一個全局對象,無需 require 直接使用,給我們提供了當前進程中的相關信息。官方文檔提供了詳細的說明,感興趣的可以親自實踐下 Process 文檔

  • process.env:環境變量,例如通過 process.env.NODE_ENV 獲取不同環境項目配置信息
  • process.nextTick:這個在談及 Event Loop 時經常爲會提到
  • process.pid:獲取當前進程id
  • process.ppid:當前進程對應的父進程
  • process.cwd():獲取當前進程工作目錄
  • process.platform:獲取當前進程運行的操作系統平臺
  • process.uptime():當前進程已運行時間,例如:pm2 守護進程的 uptime 值
  • 進程事件:process.on('uncaughtException', cb) 捕獲異常信息、process.on('exit', cb)進程推出監聽
  • 三個標準流:process.stdout 標準輸出、process.stdin 標準輸入、process.stderr 標準錯誤輸出

以上僅列舉了部分常用到功能點,除了 Process 之外 Node.js 還提供了 child_process 模塊用來對子進程進行操作,在下文 Nodejs進程創建一節 會講述。

關於 Node.js 進程的幾點總結

  • Javascript 是單線程,但是做爲宿主環境的 Node.js 並非是單線程的。
  • 由於單線程原故,一些複雜的、消耗 CPU 資源的任務建議不要交給 Node.js 來處理,當你的業務需要一些大量計算、視頻編碼解碼等 CPU 密集型的任務,可以採用 C 語言。
  • Node.js 和 Nginx 均採用事件驅動方式,避免了多線程的線程創建、線程上下文切換的開銷。如果你的業務大多是基於 I/O 操作,那麼你可以選擇 Node.js 來開發。

Nodejs進程創建

Node.js 提供了 child_process 內置模塊,用於創建子進程,更多詳細信息可參考 Node.js 中文網 child_process

四種方式

  • child_process.spawn():適用於返回大量數據,例如圖像處理,二進制數據處理。
  • child_process.exec():適用於小量數據,maxBuffer 默認值爲 200 * 1024 超出這個默認值將會導致程序崩潰,數據量過大可採用 spawn。
  • child_process.execFile():類似 child_process.exec(),區別是不能通過 shell 來執行,不支持像 I/O 重定向和文件查找這樣的行爲
  • child_process.fork(): 衍生新的進程,進程之間是相互獨立的,每個進程都有自己的 V8 實例、內存,系統資源是有限的,不建議衍生太多的子進程出來,通長根據系統 CPU 核心數設置。

方式一:spawn

child_process.spawn(command, args)

創建父子進程間通信的三種方式:

  • 讓子進程的stdio和當前進程的stdio之間建立管道鏈接 child.stdout.pipe(process.stdout);
  • 父進程子進程之間共用stdio
  • 事件監聽
const spawn = require('child_process').spawn;
const child = spawn('ls', ['-l'], { cwd: '/usr' }) // cwd 指定子進程的工作目錄,默認當前目錄

child.stdout.pipe(process.stdout);
console.log(process.pid, child.pid); // 主進程id3243 子進程3244

方式二:exec

const exec = require('child_process').exec;

exec(`node -v`, (error, stdout, stderr) => {
    console.log({ error, stdout, stderr })
    // { error: null, stdout: 'v8.5.0\n', stderr: '' }
})

方式三:execFile

const execFile = require('child_process').execFile;

execFile(`node`, ['-v'], (error, stdout, stderr) => {
    console.log({ error, stdout, stderr })
    // { error: null, stdout: 'v8.5.0\n', stderr: '' }
})

方式四:fork

const fork = require('child_process').fork;
fork('./worker.js'); // fork 一個新的子進程

fork子進程充分利用CPU資源

上文單線程一節 例子中,當 CPU 計算密度大的情況程序會造成阻塞導致後續請求需要等待,下面採用 child_process.fork 方法,在進行 cpmpute 計算時創建子進程,子進程計算完成通過 send 方法將結果發送給主進程,主進程通過 message 監聽到信息後處理並退出。

fork_app.js
const http = require('http');
const fork = require('child_process').fork;

const server = http.createServer((req, res) => {
    if(req.url == '/compute'){
        const compute = fork('./fork_compute.js');
        compute.send('開啓一個新的子進程');

        // 當一個子進程使用 process.send() 發送消息時會觸發 'message' 事件
        compute.on('message', sum => {
            res.end(`Sum is ${sum}`);
            compute.kill();
        });

        // 子進程監聽到一些錯誤消息退出
        compute.on('close', (code, signal) => {
            console.log(`收到close事件,子進程收到信號 ${signal} 而終止,退出碼 ${code}`);
            compute.kill();
        })
    }else{
        res.end(`ok`);
    }
});

server.listen(3000, 127.0.0.1, () => {
    console.log(`server started at http://${127.0.0.1}:${3000}`);
});
fork_compute.js

針對 上文單線程一節 的例子需要進行計算的部分拆分出來單獨進行運算。

const computation = () => {
    let sum = 0;
    console.info('計算開始');
    console.time('計算耗時');

    for (let i = 0; i < 1e10; i++) {
        sum += i
    };

    console.info('計算結束');
    console.timeEnd('計算耗時');
    return sum;
};

process.on('message', msg => {
    console.log(msg, 'process.pid', process.pid); // 子進程id
    const sum = computation();

    // 如果Node.js進程是通過進程間通信產生的,那麼,process.send()方法可以用來給父進程發送消息
    process.send(sum);
})

Nodejs多進程架構模型

多進程架構解決了單進程、單線程無法充分利用系統多核 CPU 的問題,通過上文對 Node.js 進程有了初步的瞭解,本節通過一個 Demo 來展示如何啓動一批 Node.js 進程來提供服務。

編寫主進程

master.js 主要處理以下邏輯:

  • 創建一個 server 並監聽 3000 端口。
  • 根據系統 cpus 開啓多個子進程
  • 通過子進程對象的 send 方法發送消息到子進程進行通信
  • 在主進程中監聽了子進程的變化,如果是自殺信號重新啓動一個工作進程。
  • 主進程在監聽到退出消息的時候,先退出子進程在退出主進程
// 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'

const workers = {};
const createWorker = () => {
    const worker = fork('worker.js')
    worker.on('message', function (message) {
        if (message.act === 'suicide') {
            createWorker();
        }
    })
    worker.on('exit', function(code, signal) {
        console.log('worker process exited, code: %s signal: %s', code, signal);
        delete workers[worker.pid];
    });
    worker.send('server', server);
    workers[worker.pid] = worker;
    console.log('worker process created, pid: %s ppid: %s', worker.pid, process.pid);
}

for (let i=0; i<cpus.length; i++) {
    createWorker();
}

process.once('SIGINT', close.bind(this, 'SIGINT')); // kill(2) Ctrl-C
process.once('SIGQUIT', close.bind(this, 'SIGQUIT')); // kill(3) Ctrl-\
process.once('SIGTERM', close.bind(this, 'SIGTERM')); // kill(15) default
process.once('exit', close.bind(this));

function close (code) {
    console.log('進程退出!', code);

    if (code !== 0) {
        for (let pid in workers) {
            console.log('master process exited, kill worker pid: ', pid);
            workers[pid].kill('SIGINT');
        }
    }

    process.exit(0);
}

工作進程

worker.js 子進程處理邏輯如下:

  • 創建一個 server 對象,注意這裏最開始並沒有監聽 3000 端口
  • 通過 message 事件接收主進程 send 方法發送的消息
  • 監聽 uncaughtException 事件,捕獲未處理的異常,發送自殺信息由主進程重建進程,子進程在鏈接關閉之後退出
// worker.js
const http = require('http');
const server = http.createServer((req, res) => {
    res.writeHead(200, {
        'Content-Type': 'text/plan'
    });
    res.end('I am worker, pid: ' + process.pid + ', ppid: ' + process.ppid);
    throw new Error('worker process exception!'); // 測試異常進程退出、重建
});

let worker;
process.title = 'node-worker'
process.on('message', function (message, sendHandle) {
    if (message === 'server') {
        worker = sendHandle;
        worker.on('connection', function(socket) {
            server.emit('connection', socket);
        });
    }
});

process.on('uncaughtException', function (err) {
    console.log(err);
    process.send({act: 'suicide'});
    worker.close(function () {
        process.exit(1);
    })
})

測試

控制檯執行 node master.js 可以看到已成功創建了四個工作進程

$ node master
worker process created, pid: 19280 ppid: 19279
worker process created, pid: 19281 ppid: 19279
worker process created, pid: 19282 ppid: 19279
worker process created, pid: 19283 ppid: 19279

打開活動監視器查看我們的進程情況,由於在創建進程時對進程進行了命名,很清楚的看到一個主進程對應多個子進程。

圖片描述以上 Demo 簡單的介紹了多進程創建、異常監聽、重啓等,但是做爲企業級應用程序我們還需要考慮的更完善,例如:進程的重啓次數限制、與守護進程結合、多進程模式下定時任務處理等,感興趣的同學推薦看下阿里 Egg.js 多進程模式

守護進程

關於守護進程,是什麼、爲什麼、怎麼編寫?本節將解密這些疑點

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

創建步驟

  1. 創建子進程
  2. 在子進程中創建新會話(調用系統函數 setsid)
  3. 改變子進程工作目錄(如:“/” 或 “/usr/ 等)
  4. 父進程終止

Node.js 編寫守護進程 Demo 展示

index.js 文件裏的處理邏輯使用 spawn 創建子進程完成了上面的第一步操作。設置 options.detached 爲 true 可以使子進程在父進程退出後繼續運行(系統層會調用 setsid 方法),參考 options_detached,這是第二步操作。options.cwd 指定當前子進程工作目錄若不做設置默認繼承當前工作目錄,這是第三步操作。運行 daemon.unref() 退出父進程,參考 options.stdio,這是第四步操作。

// 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 版本 源碼地址

運行測試

$ node index.js
守護進程開啓 父進程 pid: 47608, 守護進程 pid: 47609

打開活動監視器查看,目前只有一個進程 47609,這就是我們需要進行守護的進程

圖片描述

守護進程閱讀推薦

守護進程總結

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章