출처 : https://alvinlal.netlify.app/blog/single-thread-vs-child-process-vs-worker-threads-vs-cluster-in-nodejs
Node.js child process vs worker threads vs cluster
- Node.js 는 Http 요청에 응답하고, DB 에 데이터를 저장, 요청하고 다른 서버와 통신하는 IO 바운드 작업에 탁월하다.
- 그러나 CPU intensive 한 작업을 하면 이벤트 루프가 블로킹되어 성능이 많이 떨어진다.
아래와 같이 피보나치 값을 구하는 간단한 Node.js 서버를 만들고 localhost:3000/getfibonacci?number=600000 로 요청하면
CPU intensive 한 작업을 처리하느라 서버가 새로운 요청을 응답하지 않는다.
그리고 Node.js 는 싱글스레드로 동작하기 때문에 CPU 한 코어만 바쁘게 돌아간다.
// server.js
const express = require("express")
Copy
const app = express()
app.get("/getfibonacci", (req, res) => {
const startTime = new Date()
const result = fibonacci(parseInt(req.query.number)) //parseInt is for converting string to number
const endTime = new Date()
res.json({
number: parseInt(req.query.number),
fibonacci: result,
time: endTime.getTime() - startTime.getTime() + "ms",
})
})
const fibonacci = n => {
if (n <= 1) {
return 1
}
return fibonacci(n - 1) + fibonacci(n - 2)
}
1. child process 를 통한 해결
child_process 모듈은 새로운 프로세스를 만들도록 한다. 생성된 프로세스간의 통신은 IPC 를 사용한다.
1-1. child_process.spawn()
- 자식 프로세스를 비동기 적으로 만든다.
- 터미널에서 실행 가능한 커맨드를 실행할 수 있다.
- spawn 으로 생성된 자식 프로세스도 stdin, stdout, stderr 파이프를 가진다.
- Node.js 의 자식 프로세스는 기본적으로 spawn 으로 이루어지고 exec, fork 등은 spawn 을 사용해 만든 편의 기능들 이다.
// child_spawn_server.js
const express = require("express")
const app = express()
const { spawn } = require("child_process") //equal to const spawn = require('child_process').spawn
app.get("/ls", (req, res) => {
const ls = spawn("ls", ["-lash", req.query.directory])
ls.stdout.on("data", data => {
//Pipe (connection) between stdin,stdout,stderr are established between the parent
//node.js process and spawned subprocess and we can listen the data event on the stdout
res.write(data.toString()) //date would be coming as streams(chunks of data)
// since res is a writable stream,we are writing to it
})
ls.on("close", code => {
console.log(`child process exited with code ${code}`)
res.end() //finally all the written streams are send back when the subprocess exit
})
})
app.listen(7000, () => console.log("listening on port 7000"))
1-2. child_process.fork()
- Node.js 프로세스를 실행하기 위해 특별히 사용된다.
- 부모 프로세스와 IPC 채널이 default 로 생성된다.
- fork 를 활용하면 위와 같이 CPU intensive 한 작업으로 서버가 블로킹 되는 것을 막을 수 있다.
- 그러나 프로세스를 새로 생성하기 때문에 스레드를 활용하는 방법에 비해 시간과 리소스 오버헤드가 크다.
// child_fork_server.js
const express = require("express")
const app = express()
const { fork } = require("child_process")
app.get("/isprime", (req, res) => {
const childProcess = fork("./forkedchild.js") //the first argument to fork() is the name of the js file to be run by the child process
childProcess.send({ number: parseInt(req.query.number) }) //send method is used to send message to child process through IPC
const startTime = new Date()
childProcess.on("message", message => {
//on("message") method is used to listen for messages send by the child process
const endTime = new Date()
res.json({
...message,
time: endTime.getTime() - startTime.getTime() + "ms",
})
})
})
app.get("/testrequest", (req, res) => {
res.send("I am unblocked now")
})
app.listen(3636, () => console.log("listening on port 3636"))
// forkedchild.js
process.on("message", message => {
//child process is listening for messages by the parent process
const result = isPrime(message.number)
process.send(result)
process.exit() // make sure to use exit() to prevent orphaned processes
})
function isPrime(number) {
let isPrime = true
for (let i = 3; i < number; i++) {
if (number % i === 0) {
isPrime = false
break
}
}
return {
number: number,
isPrime: isPrime,
}
}
2. Worker threads 를 통한 해결
child process 와 같이 CPU intensive 한 작업으로 인해 메인 스레드가 블로킹 되는 문제를 해결할 수 있다.
그러나 프로세스 내에서 스레드를 만들어 해결하기 때문에 fork 보다 시간과 자원소모가 적다.
// single_thread_server.js
const express = require("express")
const app = express()
function sumOfPrimes(n) {
var sum = 0
for (var i = 2; i <= n; i++) {
for (var j = 2; j <= i / 2; j++) {
if (i % j == 0) {
break
}
}
if (j > i / 2) {
sum += i
}
}
return sum
}
app.get("/sumofprimes", (req, res) => {
const startTime = new Date().getTime()
const sum = sumOfPrimes(req.query.number)
const endTime = new Date().getTime()
res.json({
number: req.query.number,
sum: sum,
timeTaken: (endTime - startTime) / 1000 + " seconds",
})
})
app.listen(6767, () => console.log("listening on port 6767"))
위 서버를 실행시키고 localhost:6767/sumofprimes?number=600000 요청을 하면 50 초 동안 서버가 블로킹 된다.
600000 을 4개의 작업 구간으로 나누고 4개의 워커 스레드에서 나누어 처리하도록 아래와 같이 서버를 구성하면
서버가 블로킹되는 문제를 해결하고 CPU intensive 한 작업이 완료되는데 걸리는 시간을 줄여준다.
// sumOfPrimesWorker.js
const { workerData, parentPort } = require("worker_threads")
//workerData will be the second argument of the Worker constructor in multiThreadServer.js
const start = workerData.start
const end = workerData.end
var sum = 0
for (var i = start; i <= end; i++) {
for (var j = 2; j <= i / 2; j++) {
if (i % j == 0) {
break
}
}
if (j > i / 2) {
sum += i
}
}
parentPort.postMessage({
//send message with the result back to the parent process
start: start,
end: end,
result: sum,
})
// multi_thread_server.js
const express = require("express")
const app = express()
const { Worker } = require("worker_threads")
function runWorker(workerData) {
return new Promise((resolve, reject) => {
//first argument is filename of the worker
const worker = new Worker("./sumOfPrimesWorker.js", {
workerData,
})
worker.on("message", resolve) //This promise is gonna resolve when messages comes back from the worker thread
worker.on("error", reject)
worker.on("exit", code => {
if (code !== 0) {
reject(new Error(`Worker stopped with exit code ${code}`))
}
})
})
}
function divideWorkAndGetSum() {
// we are hardcoding the value 600000 for simplicity and dividing it
//into 4 equal parts
const start1 = 2
const end1 = 150000
const start2 = 150001
const end2 = 300000
const start3 = 300001
const end3 = 450000
const start4 = 450001
const end4 = 600000
//allocating each worker seperate parts
const worker1 = runWorker({ start: start1, end: end1 })
const worker2 = runWorker({ start: start2, end: end2 })
const worker3 = runWorker({ start: start3, end: end3 })
const worker4 = runWorker({ start: start4, end: end4 })
//Promise.all resolve only when all the promises inside the array has resolved
return Promise.all([worker1, worker2, worker3, worker4])
}
app.get("/sumofprimeswiththreads", async (req, res) => {
const startTime = new Date().getTime()
const sum = await divideWorkAndGetSum()
.then(
(
values //values is an array containing all the resolved values
) => values.reduce((accumulator, part) => accumulator + part.result, 0) //reduce is used to sum all the results from the workers
)
.then(finalAnswer => finalAnswer)
const endTime = new Date().getTime()
res.json({
number: 600000,
sum: sum,
timeTaken: (endTime - startTime) / 1000 + " seconds",
})
})
app.listen(7777, () => console.log("listening on port 7777"))
워커 스레드를 사용하는 서버의 CPU 도 4개 모두 골고루 사용하는 것을 볼 수 있다.
3. cluster 를 통한 해결
클러스터는 주로 Node.js 애플리케이션을 Scale out 할 때 사용한다.
child_process.fork() 를 사용해 자식 프로세스를 생성하고, 클라이언트의 요청을 라운드 로빈 방식으로
로드밸런싱하는 마스터-슬레이브 아키텍처를 구성한다.
이상적인 자식 프로세스 수는 CPU 코어 수와 같다.
const cluster = require("cluster")
const http = require("http")
const cpuCount = require("os").cpus().length //returns no of cores our cpu have
if (cluster.isMaster) {
masterProcess()
} else {
childProcess()
}
function masterProcess() {
console.log(`Master process ${process.pid} is running`)
//fork workers.
for (let i = 0; i < cpuCount; i++) {
console.log(`Forking process number ${i}...`)
cluster.fork() //creates new node js processes
}
cluster.on("exit", (worker, code, signal) => {
console.log(`worker ${worker.process.pid} died`)
cluster.fork() //forks a new process if any process dies
})
}
function childProcess() {
const express = require("express")
const app = express()
//workers can share TCP connection
app.get("/", (req, res) => {
res.send(`hello from server ${process.pid}`)
})
app.listen(5555, () =>
console.log(`server ${process.pid} listening on port 5555`)
)
}
- 위 코드를 처음 실행하면 cluster.isMaster 가 되어 masterProcess() 함수가 실행된다.
- 4개의 Node.js 프로세스가 실행되고, 프로세스가 동일한 파일을 실행하지만 childProcess() 함수를 실행한다.
- childProcess() 함수가 4번 실행되고 4개의서버 인스턴스가 생성된다.