進程與線程在服務端研發中是一個非常重要的概念,如果您在學習的時候對這一塊感到混亂或者不是太理解,可以閱讀下本篇內容,本篇在介紹進程和線程的概念之外,列舉了很多 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
開啓一個服務進程之後,我還可以在這個終端上做些別的事情,且不會相互影響。
創建步驟
- 創建子進程
- 在子進程中創建新會話(調用系統函數 setsid)
- 改變子進程工作目錄(如:“/” 或 “/usr/ 等)
- 父進程終止
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 index.js
守護進程開啓 父進程 pid: 47608, 守護進程 pid: 47609
打開活動監視器查看,目前只有一個進程 47609,這就是我們需要進行守護的進程
守護進程閱讀推薦
守護進程總結
在實際工作中對於守護進程並不陌生,例如 PM2、Egg-Cluster 等,以上只是一個簡單的 Demo 對守護進程做了一個說明,在實際工作中對守護進程的健壯性要求還是很高的,例如:進程的異常監聽、工作進程管理調度、進程掛掉之後重啓等等,這些還需要我們去不斷思考。