Node.js 약속 벤치마킹
게시 됨: 2022-03-11우리는 용감한 새로운 세상에 살고 있습니다. JavaScript로 가득 찬 세상. 최근 몇 년 동안 JavaScript는 웹을 지배하여 업계 전체를 폭풍으로 몰아넣었습니다. Node.js가 도입된 후 JavaScript 커뮤니티는 언어의 단순성과 역동성을 활용하여 서버 측, 클라이언트 측을 모두 처리하는 유일한 언어가 될 수 있었고 과감하게 기계 학습에 대한 입장을 주장하기도 했습니다. 그러나 JavaScript는 지난 몇 년 동안 언어로서 크게 변화했습니다. 화살표 기능 및 약속과 같이 이전에는 없었던 새로운 개념이 도입되었습니다.
아, 약속. Node.js를 처음 배우기 시작했을 때 약속과 콜백의 전체 개념은 나에게 별로 의미가 없었습니다. 나는 코드를 실행하는 절차적인 방법에 익숙했지만 시간이 지나면서 그것이 왜 중요한지 이해하게 되었습니다.
이것은 우리에게 왜 콜백과 약속이 도입되었는지에 대한 질문으로 이어집니다. JavaScript에서 순차적으로 실행되는 코드를 작성할 수 없는 이유는 무엇입니까?
글쎄, 기술적으로 당신은 할 수 있습니다. 하지만 당신은해야합니까?
이 기사에서는 JavaScript와 그 런타임에 대해 간략히 소개하고 더 중요한 것은 동기 코드가 성능 면에서 하위 수준이며 어떤 의미에서는 그냥 악의적이며 절대로 사용된다. 이 신화가 정말 사실입니까?
시작하기 전에 이 기사는 귀하가 JavaScript의 promise에 이미 익숙하다고 가정하지만, 그렇지 않거나 복습이 필요한 경우 JavaScript Promises: A Tutorial with Examples 를 참조하십시오.
주의 이 기사는 순수한 JavaScript 환경이 아닌 Node.js 환경에서 테스트되었습니다. Node.js 버전 10.14.2를 실행 중입니다. 모든 벤치마크와 구문은 Node.js에 크게 의존합니다. 테스트는 2.3GHz의 기본 클럭 속도를 실행하는 Intel i5 8세대 쿼드 코어 프로세서가 장착된 MacBook Pro 2018에서 실행되었습니다.
이벤트 루프
JavaScript 작성의 문제는 언어 자체가 단일 스레드라는 것입니다. 이것은 스레드를 생성하고 커널 스레드 또는 프로세스 스레드에서 동시에 여러 프로시저를 실행할 수 있는 Go 또는 Ruby와 같은 다른 언어와 달리 한 번에 하나 이상의 단일 프로시저를 실행할 수 없음을 의미합니다. .
코드를 실행하기 위해 JavaScript는 여러 단계로 구성된 이벤트 루프라는 절차에 의존합니다. JavaScript 프로세스는 각 단계를 거치고 마지막에는 처음부터 다시 시작됩니다. 자세한 내용은 여기에서 node.js의 공식 가이드를 참조하세요.
그러나 JavaScript는 차단 문제에 맞서 싸울 수 있는 수단이 있습니다. I/O 콜백.
스레드를 생성해야 하는 실제 사용 사례의 대부분은 언어가 담당하지 않는 일부 작업(예: 데이터베이스에서 일부 데이터 가져오기 요청)을 요청한다는 사실입니다. 다중 스레드 언어에서 요청을 생성한 스레드는 단순히 중단되거나 데이터베이스의 응답을 기다립니다. 이것은 자원 낭비일 뿐입니다. 또한 스레드 풀에서 올바른 수의 스레드를 선택해야 하는 개발자에게 부담을 줍니다. 이는 앱 수요가 많을 때 메모리 누수 및 많은 리소스 할당을 방지하기 위한 것입니다.
JavaScript는 I/O 작업을 처리하는 다른 어떤 요소보다 한 가지 면에서 탁월합니다. JavaScript를 사용하면 데이터베이스에서 데이터 요청, 메모리로 파일 읽기, 디스크에 파일 쓰기, 셸 명령 실행 등과 같은 I/O 작업을 호출할 수 있습니다. 작업이 완료되면 콜백을 실행합니다. 또는 Promise의 경우 결과로 Promise를 해결하거나 오류로 거부합니다.
JavaScript 커뮤니티는 항상 I/O 작업을 수행할 때 동기 코드를 사용하지 말라고 조언합니다. 잘 알려진 이유는 코드가 다른 작업을 실행하는 것을 차단하고 싶지 않기 때문입니다. 단일 스레드이기 때문에 파일을 동기적으로 읽는 코드가 있는 경우 해당 코드는 읽기가 완료될 때까지 전체 프로세스를 차단합니다. 대신 비동기 코드에 의존하는 경우 여러 I/O 작업을 수행하고 완료될 때 각 작업의 응답을 개별적으로 처리할 수 있습니다. 어떤 차단도 없습니다.
하지만 우리가 많은 프로세스를 처리하는 데 전혀 신경 쓰지 않는 환경에서 동기 및 비동기 코드를 사용하는 것은 전혀 차이가 없겠죠?
기준
우리가 실행할 테스트는 동기화 및 비동기 코드 실행 속도와 성능 차이가 있는지에 대한 벤치마크를 제공하는 것을 목표로 합니다.
테스트할 I/O 작업으로 파일 읽기를 선택하기로 결정했습니다.
먼저 Node.js Crypto 모듈로 생성된 임의의 바이트로 채워진 임의의 파일을 작성하는 함수를 작성했습니다.
const fs = require('fs'); const crypto = require('crypto'); fs.writeFileSync( "./test.txt", crypto.randomBytes(2048).toString('base64') )
이 파일은 파일을 읽는 다음 단계에서 상수 역할을 합니다. 코드는 다음과 같습니다.
const fs = require('fs'); process.on('unhandledRejection', (err)=>{ console.error(err); }) function synchronous() { console.time("sync"); fs.readFileSync("./test.txt") console.timeEnd("sync") } async function asynchronous() { console.time("async"); let p0 = fs.promises.readFile("./test.txt"); await Promise.all([p0]) console.timeEnd("async") } synchronous() asynchronous()
이전 코드를 실행하면 다음과 같은 결과가 나타납니다.
달리다 # | 동조 | 비동기 | 비동기/동기 비율 |
---|---|---|---|
1 | 0.278ms | 3.829ms | 13.773 |
2 | 0.335ms | 3.801ms | 11.346 |
삼 | 0.403ms | 4.498ms | 11.161 |
이것은 예상치 못한 일이었습니다. 나의 초기 기대는 그들이 같은 시간을 가져야 한다는 것이었다. 다른 파일을 추가하고 1개 대신 2개의 파일을 읽는 것은 어떻습니까?
생성된 test.txt 파일을 복사하여 test2.txt라고 불렀습니다. 업데이트된 코드는 다음과 같습니다.
function synchronous() { console.time("sync"); fs.readFileSync("./test.txt") fs.readFileSync("./test2.txt") console.timeEnd("sync") } async function asynchronous() { console.time("async"); let p0 = fs.promises.readFile("./test.txt"); let p1 = fs.promises.readFile("./test2.txt"); await Promise.all([p0,p1]) console.timeEnd("async") }
나는 단순히 각각에 대해 다른 읽기를 추가했고 약속에서 병렬로 실행되어야 하는 읽기 약속을 기다리고 있었습니다. 결과는 다음과 같습니다.

달리다 # | 동조 | 비동기 | 비동기/동기 비율 |
---|---|---|---|
1 | 1.659ms | 6.895ms | 4.156 |
2 | 0.323ms | 4.048ms | 12.533 |
삼 | 0.324ms | 4.017ms | 12.398 |
4 | 0.333ms | 4.271ms | 12.826 |
첫 번째는 이어지는 3개의 실행과 완전히 다른 값을 갖습니다. 내 생각에는 각 실행에서 코드를 최적화하는 JavaScript JIT 컴파일러와 관련이 있습니다.
따라서 비동기 기능에 대한 상황은 그다지 좋지 않습니다. 좀 더 다이나믹하게 만들고 앱에 좀 더 스트레스를 주면 다른 결과가 나올 수 있습니다.
그래서 다음 테스트는 100개의 다른 파일을 작성한 다음 모두 읽는 것입니다.
먼저 테스트를 실행하기 전에 100개의 파일을 작성하도록 코드를 수정했습니다. 파일은 거의 동일한 크기를 유지하지만 실행할 때마다 다르기 때문에 매번 실행하기 전에 이전 파일을 지웁니다.
업데이트된 코드는 다음과 같습니다.
let filePaths = []; function writeFile() { let filePath = `./files/${crypto.randomBytes(6).toString('hex')}.txt` fs.writeFileSync( filePath, crypto.randomBytes(2048).toString('base64') ) filePaths.push(filePath); } function synchronous() { console.time("sync"); /* fs.readFileSync("./test.txt") fs.readFileSync("./test2.txt") */ filePaths.forEach((filePath)=>{ fs.readFileSync(filePath) }) console.timeEnd("sync") } async function asynchronous() { console.time("async"); /* let p0 = fs.promises.readFile("./test.txt"); let p1 = fs.promises.readFile("./test2.txt"); */ // await Promise.all([p0,p1]) let promiseArray = []; filePaths.forEach((filePath)=>{ promiseArray.push(fs.promises.readFile(filePath)) }) await Promise.all(promiseArray) console.timeEnd("async") }
그리고 정리 및 실행을 위해:
let oldFiles = fs.readdirSync("./files") oldFiles.forEach((file)=>{ fs.unlinkSync("./files/"+file) }) if (!fs.existsSync("./files")){ fs.mkdirSync("./files") } for (let index = 0; index < 100; index++) { writeFile() } synchronous() asynchronous()
그리고 달려봅시다.
결과 표는 다음과 같습니다.
달리다 # | 동조 | 비동기 | 비동기/동기 비율 |
---|---|---|---|
1 | 4.999ms | 12.890ms | 2.579 |
2 | 5.077ms | 16.267ms | 3.204 |
삼 | 5.241ms | 14.571ms | 2.780 |
4 | 5.086ms | 16.334ms | 3.213 |
이러한 결과는 여기서 결론을 내리기 시작합니다. 수요 또는 동시성이 증가함에 따라 오버헤드 약속이 이해되기 시작함을 나타냅니다. 자세히 설명하자면 서버당 초당 수백 또는 수천 개의 요청을 실행해야 하는 웹 서버를 실행하는 경우 동기화를 사용하여 I/O 작업을 실행하면 이점이 매우 빨리 손실되기 시작합니다.
실험을 위해 실제로 Promise 자체의 문제인지 아니면 다른 문제인지 봅시다. 이를 위해 나는 전혀 아무것도 하지 않는 프라미스를 해결하는 시간과 100개의 빈 프라미스를 해결하는 시간을 계산하는 함수를 작성했습니다.
코드는 다음과 같습니다.
function promiseRun() { console.time("promise run"); return new Promise((resolve)=>resolve()) .then(()=>console.timeEnd("promise run")) } function hunderedPromiseRuns() { let promiseArray = []; console.time("100 promises") for(let i = 0; i < 100; i++) { promiseArray.push(new Promise((resolve)=>resolve())) } return Promise.all(promiseArray).then(()=>console.timeEnd("100 promises")) } promiseRun() hunderedPromiseRuns()
달리다 # | 하나의 약속 | 100가지 약속 |
---|---|---|
1 | 1.651ms | 3.293ms |
2 | 0.758ms | 2.575ms |
삼 | 0.814ms | 3.127ms |
4 | 0.788ms | 2.623ms |
흥미로운. 약속이 지연의 주요 원인이 아닌 것으로 보이며, 이는 지연의 원인이 실제 읽기를 수행하는 커널 스레드라고 추측하게 합니다. 지연의 주요 원인에 대한 결정적인 결론을 내리기 위해서는 좀 더 많은 실험이 필요할 수 있습니다.
마지막 말
그래서 당신은 약속을 사용해야합니까? 제 의견은 다음과 같습니다.
파이프라인 또는 단일 사용자에 의해 트리거된 특정 흐름이 있는 단일 시스템에서 실행되는 스크립트를 작성하는 경우 동기화 코드를 사용하십시오. 많은 트래픽과 요청을 처리하는 웹 서버를 작성하는 경우 비동기 실행에서 발생하는 오버헤드가 동기화 코드의 성능을 극복할 것입니다.
이 문서의 모든 기능에 대한 코드는 저장소에서 찾을 수 있습니다.
약속에서 JavaScript 개발자 여정의 논리적 다음 단계는 async/await 구문입니다. 이에 대한 자세한 내용과 여기까지 온 방법을 알고 싶다면 Asynchronous JavaScript: From Callback Hell to Async and Await 를 참조하십시오.