참조 링크: https://nodejs.org/uk/docs/guides/dont-block-the-event-loop/
Node.js 는 일반적으로 Single Thread (osx/linux 환경 기준으로, ps -ef 로 조회했을 때, 한개의 processId) 로 동작합니다.
이는 비동기 작업을 처리할때, CPU를 full-load 로 사용하게 되는 작업이 진행중일때는,
다른 요청을 막는(block) 현상이 발생됩니다.
이는 성능에 중대한 위협입니다.
이를 확인하기 위한 기준으로, 처리량(throughput) 이 있습니다.
처리량 = 요청 갯수 / 초
즉, 1초 이상 blocking 이 발생할 경우, throughput 이 1 이하로 떨어지게 되며,
사용자 입장에서 - 너무 느림, 또는 동작하지 않음 의 사용자 경험을 갖게 됩니다.
즉, DoS 공격을 받은것 같은 상태가 되는 겁니다.☠️
우리가 개발을 하면서 알아야 할 중요한 문제가 있습니다.
한개의 Promise가 결과값을 resolve 하기 전 까지는 Promise 자체가 프로세스를 점유합니다!
이는 상당히 심각한 문제로, Promise 에서 계산 집약적인 작업, 또는 오래걸리는 작업이 실행되는 동안은, 다른 요청을 막게 되므로, 처리량을 극단적으로 떨어트립니다.
즉, 한 개의 Promise를 수행하는데, 대략, 0.3초가 걸린다면!
요청을 3개만 받아도, 처리량이 1 이하로 떨어지는 개복치 프로그램을 작성하고 있는 것 일 수 있습니다.
JSON.parse
JSON.stringify
우리는 obj2 ^ 21 크기 의 객체 를 만들고 문자열에서 JSON.stringify실행 indexOf한 다음 JSON.parse를 실행합니다. JSON.stringify'D 문자열 50메가바이트이다. 객체를 문자열 화하는 데 0.7 초, 50MB 문자열의 indexOf에 0.03 초, 문자열을 구문 분석하는 데 1.3 초가 걸립니다.
비동기 JSON API를 제공하는 npm 모듈이 있습니다. 예를 들어 :
스트림 API가있는 JSONStream
. 아래에 요약 된 이벤트 루프 기반 패러다임을 사용하여 표준 API의 비동기 버전뿐만 아니라 스트림 API가 있습니다.
암호화 :
crypto.randomBytes
(동기 버전)
crypto.randomFillSync
crypto.pbkdf2Sync
또한 암호화 및 암호 해독 루틴에 큰 입력을 제공 할 때주의해야합니다.
압축:
zlib.inflateSync
zlib.deflateSync
파일 시스템:
아동 과정 :
child_process.spawnSync
child_process.execSync
child_process.execFileSync
const n = 10000000
let sum = 0
let eventLoopCount = 0
setInterval(() => {
eventLoopCount++
}, 10)
console.time('calc')
for (let i = 0; i < n; i++) { sum += i }
const avg = sum / (n - 1)
console.timeEnd('calc')
console.log('avg: ' + avg, 'eventLoopCount', eventLoopCount)
node sniffets/normalLoop.js
calc: 20.160ms
avg: 5000000 eventLoopCount 0
아주 간단한 연산의 경우, nodejs가 최적화를 진행하므로, 성능 차이가 비교적 크게 나타날 수 있습니다.
이는, Blocking을 막기 위해, 루프를 돌면서 setImmediate 를 통해 우선순위를 뒤로 미루는 방식으로 작동합니다.
const n = 1000000
let eventLoopCount = 0
setInterval(() => {
eventLoopCount++
}, 10)
function asyncAvg (n, avgCB) {
// Save ongoing sum in JS closure.
let sum = 0
function help (i, cb) {
sum += i
if (i === n) {
cb(sum)
return
}
// "Asynchronous recursion".
// Schedule next operation asynchronously.
setImmediate(help.bind(null, i + 1, cb))
}
// Start the helper, with CB to call avgCB.
help(1, function (sum) {
const avg = sum / n
avgCB(avg)
})
}
console.time('calc')
asyncAvg(n, function (avg) {
console.timeEnd('calc')
console.log('avg: ' + avg, 'eventLoopCount', eventLoopCount)
})
node sniffets/partitionedLoop.js
calc: 165139.864ms
avg: 5000000 eventLoopCount 16481
이 예제에서는 성능차이가 극단적으로 나타납니다. 20ms vs 165139ms
다만, 10ms 에 한번씩 외부 이벤트 루프(setInterval)이 동작하는 것을 확인할 수 있습니다.
대략 16,514 회 실행되었어야 하는데, 16481 회 실행되었습니다. 기대한것에 비해, 0.2% 정도의 오차밖에 나지 않습니다.
이 방식도 어디엔가 쓸모가 있겠죠.
이걸 실제로 적용하기엔 성능이 너무 슬픕니다.
다른 해결책을 찾아 보도록 하겠습니다.
하위 프로세스(child process)를 이용하는 방식입니다. 하위 프로세스를 만들기 위한 overload 가 좀 있는 편입니다.
직접 구현할 수도 있지만 손이 많이 갑니다. 이미 잘 작성된 외부 라이브러리를 이용해 보겠습니다.
$ npm i workerpool
const workerpool = require('workerpool')
const pool = workerpool.pool()
let eventLoopCount = 0
setInterval(() => {
eventLoopCount++
}, 10)
function calcAvg (n) {
let sum = 0
for (let i = 0; i < n; i++) { sum += i }
const avg = sum / (n - 1)
return avg
}
console.time('calc')
pool.exec(calcAvg, [100000000]).then(avg => {
console.timeEnd('calc')
console.log('avg: ' + avg, 'eventLoopCount', eventLoopCount)
})
node sniffets/workerPoolLoop.js
calc: 249.350ms
avg: 50000000 eventLoopCount 22
나쁘지 않네요!
속도는 대략 13배정도 느려 졌지만, 여전히 300ms 이내로 참아 줄만한 속도입니다.
또한 외부 이벤트도 거의 blocking 되지 않고 있습니다.
24번 정도 실행 되길 기대했는데, 22번 실행됩니다.
Node.js 10 버전에서 실험 기능으로 도입되었던 worker_threads 가 Node.js 12 버전부터는 stable 한 상태로 변경되어, 사용을 고려해 볼 수 있습니다.
const {
Worker, isMainThread, parentPort, workerData
} = require('worker_threads')
let eventLoopCount = 0
setInterval(() => {
eventLoopCount++
}, 10)
if (isMainThread) {
const calcAvg = function calcAvg (script) {
return new Promise((resolve, reject) => {
const worker = new Worker(__filename, {
workerData: script
})
worker.on('message', resolve)
worker.on('error', reject)
worker.on('exit', (code) => {
if (code !== 0) { reject(new Error(`Worker stopped with exit code ${code}`)) }
})
})
}
module.exports = calcAvg
const n = 10000000
console.time('calc')
calcAvg(n).then(avg => {
console.timeEnd('calc')
console.log('avg: ' + avg, 'eventLoopCount', eventLoopCount)
})
} else {
const n = workerData
let sum = 0
for (let i = 0; i < n; i++) { sum += i }
const avg = sum / (n - 1)
parentPort.postMessage(avg)
}
Node.js 10 버전에서 사용해보기 위해, —experimental-worker flag를 이용해봅니다.
node --experimental-worker sniffets/workerLoop.js
calc: 80.591ms
avg: 5000000 eventLoopCount 7
꽤 놀라운 결과입니다.
계산 시간은 그렇게 많이 (그래도 4배 수준이지만..) 늘어나지 않았고,
이벤트 루프도 정상적으로 실행되었습니다.
숫자가 더 커지면 어떻게 될까요?
node sniffets/normalLoop.js
calc: 130.112ms
avg: 50000000 eventLoopCount 0
node --experimental-worker sniffets/workerLoop.js
calc: 189.253ms
avg: 50000000 eventLoopCount 15
훌륭합니다.
const n = 10000000
let sum = 0
let eventLoopCount = 0
setInterval(() => {
eventLoopCount++
}, 10)
console.time('calc')
const N = n-1
const sum = ((1 + N) * N) / 2
const avg = sum / N
console.timeEnd('calc')
console.log('avg: ' + avg, 'eventLoopCount', eventLoopCount)
node sniffets/smartLoop.js
calc: 0.215ms
avg: 5000000 eventLoopCount 0
역시 가장 똑똑한 방법은, 오래 걸릴 일을 만들지 않는것 입니다.
위 알고리즘은, 아주 유명한 가우스 알고리즘 입니다.
저희는 위워크 홍대점에서 일하고 있습니다. 포지션 무관, 합류에 관심이 있으시다면 지원서를 제출해 주세요. (경력/학력/성별 무관)
와우 👏👏