TL;DR
반드시 프로덕션 환경과 동일한 상황에서 벤치마킹하기
응용 프로그램에 작업자 쓰레드를 추가하는 주된 이유는 성능을 향상시키기 위한 것입니다.
그러나 해당 작업은 프로그램에 복잡성을 추가합니다.
"Keep It Simple, Stupid"를 의미하는 KISS 원칙은
애플리케이션이 누구나 코드를 빠르게 보고 이해할 수 있도록 매우 단순해야 한다는 의미입니다.
작성된 코드를 읽을 수 있다는 것은 가장 중요하며,
목적 없이 프로그램에 스레드를 추가하는 것은 KISS를 위반하는 것입니다.
애플리케이션에 스레드를 추가해야 하는 절대적으로 타당한 이유가 있으며,
성능 향상이 증가하는 유지 관리 비용보다 중요하다는 것을 확인했다면,
우리는 스레드를 사용할 만한 상황에 처한 것입니다.
하지만 스레드를 구현하는 모든 작업을 거치지 않고도 스레드가 도움이 되거나 도움이 되지 않는 상황을 어떻게 식별할까요?
스레드의 성능 영향을 측정하는 방법은 무엇인가요?
When Not to Use
스레딩은 애플리케이션의 성능 문제를 해결할 수 있는 마법의 총알이 아닙니다.
일반적으로 성능 측면에서도 최종 수단으로 수행해야 하는 경우가 많습니다.
이것은 멀티스레딩이 다른 언어만큼 커뮤니티에서 널리 이해되지 않는 JavaScript에서 특히 그렇습니다.
스레딩 지원을 추가하려면 애플리케이션에 대대적인 변경이 필요할 수 있습니다.
즉, 먼저 다른 코드 비효율성을 먼저 찾으면 노력 대비 성능 향상이 더 높을 수 있습니다.
다른 영역에서 응용 프로그램의 성능을 최대한 향상시켰다면 "지금이 멀티스레딩을 추가할 적기입니까?"라는 질문이 남습니다.
이 섹션의 나머지 부분에는 스레드를 추가해도 성능상의 이점이 없을 가능성이 높은 몇 가지 상황이 포함되어 있습니다.
이렇게 하면 일부 검색 작업을 거치지 않아도 됩니다.
Low Memory Constraints (낮은 메모리 제약)
JavaScript에서 여러 스레드를 인스턴스화할 때 약간의 추가 메모리 오버헤드가 발생합니다.
이는 브라우저가 새로운 JavaScript 환경을 위한 추가 메모리를 할당해야 하기 때문입니다.
여기에는 코드에 사용할 수 있는 전역 및 API와 엔진 자체에서 사용하는 내부 메모리가 포함됩니다.
이 오버헤드는 Node.js의 경우 일반 서버 환경에서, 브라우저의 경우 성능 좋은 무거운 랩톱에서 최소화될 수 있습니다.
그러나 512MB RAM이 있는 임베디드 ARM 장치에서 코드를 실행하거나
교실의 기증된 넷북을 실행하는 경우 방해가 될 수 있습니다.
추가 스레드의 메모리 영향은 얼마나 될까요?
정량화하기가 조금 어려우며 자바스크립트 엔진과 플랫폼에 따라 달라집니다.
안전한 대답은 대부분의 성능 측면과 마찬가지로 실제 환경에서 측정해야 한다는 것입니다.
그러나 우리는 확실히 몇 가지 구체적인 수치를 얻으려고 노력할 수 있습니다.
먼저 타이머를 시작하는, 서드파티 모듈을 임포트하지 않는 매우 간단한 Node.js 프로그램을 고려해 보겠습니다.
#!/usr/bin/env node
const { Worker } = require('worker_threads');
const count = Number(process.argv[2]) || 0;
for (let i = 0; i < count; i++) {
new Worker(__dirname + '/worker.js');
}
console.log(`PID: ${process.pid}, ADD THREADS: ${count}`);
setTimeout(() => {}, 1 * 60 * 60 * 1000);
프로그램을 실행하고 메모리 사용량을 측정하면 다음과 같습니다.
# Terminal 1
$ node leader.js 0
# PID 10000
# Terminal 2
$ pstree 10000 -pa # Linux only
$ ps -p 10000 -o pid,vsz,rss,pmem,comm,args
pstree 명령은 프로그램에서 사용하는 스레드를 표시합니다.
기본 V8 JavaScript 스레드와 "숨겨진 스레드"에서 다루는 일부 백그라운드 스레드를 표시합니다.
다음은 명령의 출력 예입니다.
node,10000 ./leader.js
├─{node},10001
├─{node},10002
├─{node},10003
├─{node},10004
├─{node},10005
└─{node},10006
ps 명령은 프로세스에 대한 정보, 특히 프로세스의 메모리 사용량을 표시합니다. 다음은 명령의 출력 예입니다.
PID VSZ RSS %MEM COMMAND COMMAND
66766 1409260 48212 0.1 node node ./leader.js
여기에는 프로그램의 메모리 사용량을 측정하는 데 사용되는 두 가지 중요한 변수가 있으며
둘 다 킬로바이트 단위로 측정됩니다.
첫 번째는 VSZ 또는 virtual memory size로,
스왑된 메모리, 할당된 메모리, 공유 라이브러리(예: TLS)에서 사용하는 메모리를 포함하여 프로세스가 액세스할 수 있는 메모리로
약 1.4GB입니다.
다음은 RSS(resident set size)로, 프로세스에서 현재 사용 중인 물리적 메모리의 양인 약 48MB입니다.
메모리 측정은 약간 오차가 존재할 수 있으며, 실제로 얼마나 많은 프로세스가 메모리에 들어갈 수 있는지 추정하는 것은 까다롭습니다.
따라서, 우리는 주로 RSS 값을 볼 것입니다.
이제 스레드를 사용하는 더 복잡한 버전의 프로그램을 살펴보겠습니다.
이번에도 동일한 매우 간단한 타이머 앱이 사용되지만 이 경우에는 총 4개의 스레드가 생성됩니다.
이 경우 새 worker.js 파일이 필요합니다.
console.log(`WPID: ${process.pid}`);
setTimeout(() => {}, 1 * 60 * 60 * 1000);
0보다 큰 숫자 인수로 leader.js 프로그램을 실행하면 프로그램에서 추가 작업자를 만들 수 있습니다.
아래 표는 추가 스레드의 각기 다른 반봇 회차에 대한 ps의 메모리 사용량 출력 목록입니다.
Add Threads |
VSZ |
RSS |
Size |
0 |
318,124 KB |
31,836 KB |
47,876 KB |
1 |
787,880 KB |
38,372 KB |
57,772 KB |
2 |
990,884 KB |
45,124 KB |
68,228 KB |
4 |
1,401,500 KB |
56,160 KB |
87,708 KB |
8 |
2,222,732 KB |
78,396 KB |
126,672 KB |
16 |
3,866,220 KB |
122,992 KB |
205,420 KB |
이 정보를 보면 x86 프로세서에서 Node.js 16.5를 사용하여 각각의 새 스레드를 인스턴스화하는 추가 RSS 메모리 오버헤드는 약 6MB인 것으로 보입니다.
이 숫자는 대략적이므로, 당신의 상황에서 측정해야 합니다.
스레드가 더 많은 모듈을 가져오면 메모리 오버헤드가 복잡해집니다.
각 스레드에서 무거운 프레임워크와 웹 서버를 인스턴스화하는 경우, 프로세스에 수백 메가바이트의 메모리가 추가될 수 있습니다.
32비트 컴퓨터나 스마트폰에서 실행되는 프로그램의 최대 주소 지정 가능 메모리 공간은 4GB입니다.
이 제한은 프로그램의 모든 스레드에서 공유됩니다.
Low Core Count(적은 코어 갯수)
코어 수가 적은 상황에서는 응용 프로그램이 더 느리게 실행됩니다.
이는 머신에 단일 코어가 있는 경우에 특히 해당되며 두 개의 코어만 있는 경우에도 마찬가지일 수 있습니다.
애플리케이션에서 스레드 풀을 사용하고 코어 수에 따라 풀을 확장하더라도 단일 작업자 스레드를 생성하면 애플리케이션이 느려집니다.
추가 스레드를 생성할 때 이제 응용 프로그램에는 적어도 두 개의 스레드(기본 및 작업자)가 있으며
두 스레드는 주의를 끌기 위해 서로 경쟁하기 때문입니다.
응용 프로그램이 느려지는 또 다른 이유는 스레드 간 통신과 관련하여 추가 오버헤드가 있기 때문입니다.
하나의 코어와 두 개의 스레드를 사용하면 두 스레드가 리소스를 놓고 경쟁하지 않더라도,
즉, 작업자가 실행되는 동안 메인 스레드가 할 일이 없거나 그 반대의 경우에도
두 스레드 간에 메시지 전달을 수행할 때 여전히 오버헤드가 있습니다.
이것은 큰 문제가 아닐 수도 있습니다. 예를 들어, 다중 코어 시스템에서 자주 실행되고 단일 코어 시스템에서는 드물게 실행되는 여러 환경에서 실행되는 배포 가능한 애플리케이션을 생성하는 경우 이 오버헤드는 괜찮을 수 있습니다.
그러나 거의 전적으로 단일 코어 환경에서 실행되는 애플리케이션을 구축하는 경우
스레딩을 전혀 추가하지 않는 것이 더 나을 수 있습니다.
즉, 강력한 멀티코어 개발자 노트북을 활용하는 앱을 빌드한 다음
컨테이너 오케스트레이터가 앱을 단일 코어로 제한하는 프로덕션으로 배포해선 안 됩니다.
우리는 얼마나 많은 성능 손실을 이야기하고 있나요?
Linux 운영 체제에서 프로그램과 모든 스레드가 CPU 코어의 하위 집합에서만 실행되어야 한다고 OS에 알리는 것은 간단합니다.
이 명령을 사용하면 개발자가 낮은 코어 환경에서 다중 스레드 응용 프로그램을 실행하는 효과를 테스트할 수 있습니다.
Linux 기반 컴퓨터를 사용하는 경우 다음 예제를 자유롭게 실행해 보세요.
먼저 "Thread Pool"에서 생성한 ch6-thread-pool/ 예제로 돌아갑니다.
두 작업자가 있는 작업자 쓰레드 풀을 생성하도록 애플리케이션을 실행합니다.
$ THREADS=2 STRATEGY=leastbusy node main.js
스레드 풀이 2인 경우 애플리케이션은 3개의 JavaScript 환경을 사용할 수 있으며
libuv는 기본 풀이 5이므로 Node.js v16 기준으로 총 8개의 스레드가 있어야 합니다.
프로그램이 실행되고 컴퓨터의 모든 코어에 액세스할 수 있으면 퀵하게 벤치마크를 실행할 준비가 된 것입니다.
다음 명령을 실행하여 서버에 일련의 요청을 보냅니다.
$ npx autocannon http://localhost:1337/
이 경우 Req/Sec 행과 Avg 열이 있는 출력의 마지막 테이블에서 식별되는 평균 요청 비율에만 관심이 있습니다.
한 샘플 실행에서 값 17.5가 반환되었습니다.
Ctrl+C로 서버를 종료하고 다시 실행하세요
그러나 이번에는 taskset 명령을 사용하여 프로세스(및 모든 하위 스레드)가 동일한 CPU 코어를 사용하도록 강제합니다.
# Linux only command
$ THREADS=2 STRATEGY=leastbusy taskset -c 0 node main.js
이 경우 두 개의 환경 변수 THREADS 및 STRATEGY가 설정된 다음 taskset 명령이 실행됩니다.
-c 0 플래그는 프로그램이 0번째 CPU만 사용할 수 있도록 명령에 지시합니다.
다음 인수는 실행할 명령으로 처리됩니다.
ㅂtaskset 명령을 사용하여 이미 실행 중인 프로세스를 수정할 수도 있습니다.
이 경우 명령은 어떤 일이 발생하는지 알려주는 몇 가지 유용한 출력을 표시합니다.
다음은 16개의 코어가 있는 컴퓨터에서 명령을 사용할 때 해당 출력의 사본입니다.
pid 211154's current affinity list: 0-15
pid 211154's new affinity list: 0
이 경우에는 프로그램이 16개 코어 모두(0–15)에 액세스할 수 있었지만
지금은 하나(0)에만 액세스할 수 있다고 말합니다.
사용 가능한 코어 수가 적은 환경을 에뮬레이트하기 위해 프로그램이 실행 중이고 단일 CPU 코어에 잠겨 있는 상태에서
동일한 벤치마크 명령을 다시 실행합니다.
$ npx autocannon http://localhost:1337/
이러한 실행에서 초당 평균 요청 수는 8.32로 줄었습니다.
즉, 이 특정 프로그램의 처리량은 단일 코어 환경에서 3개의 JavaScript 스레드를 사용하려고 할 때
모든 코어에 액세스하는 것과 비교할 때 48%의 성능 감소를 나타냅니다.
이제 자연스럽게 따르는 질문은 다음과 같습니다.
ch6-thread-pool 애플리케이션의 처리량을 최대화하려면
스레드 풀이 얼마나 커야 하고 애플리케이션에 얼마나 많은 코어를 제공해야 할까요?
답을 찾기 위해 벤치마크의 16가지 순열을 응용 프로그램에 적용하고 성능을 측정했습니다.
외부 요청을 줄이는 데 도움이 되도록 테스트 시간을 2분으로 두 배로 늘렸습니다.
이 데이터의 표 버전은 다음과 같습니다.
|
1 core |
2 core |
3 core |
4 core |
1 thread |
8.46 |
9.08 |
9.21 |
9.19 |
2 threads |
8.69 |
9.60 |
17.61 |
17.28 |
3 threads |
8.23 |
9.38 |
16.92 |
16.91 |
4 threads |
8.47 |
9.57 |
17.44 |
17.75 |
스레드 풀 전용 스레드 수가 2개 이상이고
응용 프로그램에서 사용할 수 있는 코어 수가 3개 이상인 경우 분명한 성능 이점이 있습니다.
그 외에는 데이터에 대해 별로 흥미로운 것이 없습니다.
실제 애플리케이션에서 코어 대 스레드의 효과를 측정할 때 보다 흥미로운 성능 트레이드오프를 보게 될 것입니다.
Containers Versus Threads(컨테이너 vs 쓰레드)
Node.js와 마찬가지로 서버 소프트웨어를 작성할 때는 프로세스가 수평으로 확장되어야 한다는 것이 경험적인 법칙입니다.
이것은 Docker 컨테이너 내에서와 같이 격리된 방식으로 프로그램의 여러 중복 버전을 실행해야 한다는 멋진 용어입니다.
수평적 확장은 개발자가 전체 애플리케이션 제품군의 성능을 미세 조정할 수 있는 방식으로 성능을 향상시킵니다.
스레드 풀의 형태로 프로그램 내 스케일링이 발생할 때 이러한 튜닝은 쉽게 수행될 수 없습니다.
Kubernetes와 같은 Orchestrator는 여러 서버에서 컨테이너를 실행하는 도구입니다.
필요에 따라 애플리케이션을 쉽게 확장할 수 있습니다.
연휴 기간 동안 엔지니어는 실행 중인 인스턴스 수를 수동으로 늘릴 수 있습니다.
오케스트레이터는 CPU 사용량, 트래픽 처리량, 심지어 작업 대기열의 크기와 같은 다른 휴리스틱에 따라
규모를 동적으로 변경할 수도 있습니다.
이 동적 확장이 런타임에 애플리케이션 내에서 수행된다면 어떻게 보일까요?
물론 사용 가능한 스레드 풀의 크기를 조정해야 합니다.
또한 엔지니어가 프로세스에 메시지를 보내 풀 크기를 조정할 수 있도록 일종의 통신이 필요합니다.
이러한 관리 명령을 위해 추가 서버가 포트에서 수신 대기해야 할 수 있습니다.
이러한 기능은 응용 프로그램 코드에 추가할 추가 복잡성을 요구합니다.
스레드 수를 늘리는 대신 추가 프로세스를 추가하면
컨테이너에서 프로세스를 래핑하는 오버헤드는 말할 것도 없고 전반적인 리소스 소비가 증가하지만
대기업에서는 일반적으로 이 접근 방식의 확장 유연성을 선호합니다.
언제 멀티쓰레딩을 사용할까?
때로는 운이 좋아 멀티스레드 솔루션의 이점을 크게 누릴 수 있는 문제로 끝날 수도 있습니다.
다음은 주의해야 할 이러한 문제의 가장 직접적인 특성 중 일부입니다.
당황스러울 정도로 병렬
큰 작업을 더 작은 작업으로 나눌 수 있고
상태 공유가 거의 또는 전혀 필요하지 않은 경우입니다.
강력한 수학적 계산
스레드에 적합한 문제의 또 다른 특징은 수학을 많이 사용하는 CPU 집중 작업과 관련된 문제입니다.
물론 컴퓨터가 하는 모든 일이 수학이라고 말할 수도 있지만,
수학이 많은 응용 프로그램의 반대는 I/O가 많은 응용 프로그램이거나 주로 네트워크 작업을 처리하는 응용 프로그램입니다.
예를 들어 암호화 같은 것이 있스비다.
맵리듀스
MapReduce는 함수형 프로그래밍에서 영감을 받은 프로그래밍 모델입니다.
이 모델은 여러 다른 시스템에 분산된 대규모 데이터 처리에 자주 사용됩니다.
MapReduce는 두 부분으로 나뉩니다.
- 첫 번째는 값 목록을 받아 값 목록을 생성하는 맵입니다.
- 두 번째는 값 목록이 다시 반복되고 단일 값이 생성되는 리듀스 입니다.
이에 대한 단일 스레드 버전은 Array#map() 및 Array#reduce()를 사용하여 JavaScript에서 만들 수 있지만
다중 스레드 버전에는 데이터 목록의 하위 집합을 처리하는 다른 스레드가 필요합니다.
검색 엔진은 Map을 사용하여 수백만 개의 문서에서 키워드를 검색한 다음
Reduce를 사용하여 점수를 매기고 순위를 지정하여 사용자에게 관련 결과 페이지를 제공합니다.
Hadoop 및 MongoDB와 같은 데이터베이스 시스템은 MapReduce의 이점을 얻습니다.
그래픽 처리
많은 그래픽 처리 작업도 다중 스레드의 이점을 얻습니다.
이미지는 픽셀 그리드로 표시됩니다.
이미지는 3바이트 또는 4바이트(빨간색, 녹색, 파란색 및 선택적 알파 투명도)를 사용할 가능성이 높습니다.
이미지 필터링은 이미지를 더 작은 이미지로 세분화하고 각 작은 이미지를 스레드 풀의 스레드를 이용해 처리한 뒤,
변경이 완료되면 인터페이스를 업데이트하는 문제가 됩니다.
이것이 멀티스레딩을 사용해야 하는 모든 상황에 대한 전체 목록은 아닙니다.
가장 분명한 사용 사례의 목록일 뿐입니다.
반복되는 주제 중 하나는 공유 데이터가 필요하지 않거나
적어도 공유 데이터에 대한 조정된 읽기 및 쓰기가 필요하지 않은 문제는
여러 스레드를 사용하여 모델링하기가 더 쉽다는 것입니다.
일반적으로도 부작용이 많지 않은 코드를 작성하는 것이 유리하지만
멀티스레드 코드를 작성할 때 이러한 이점이 더해집니다.
JavaScript 애플리케이션에 특히 유용한 또 다른 사용 사례는 템플릿 렌더링입니다.
사용된 라이브러리에 따라 primitive 템플릿을 나타내는 문자열과 템플릿을 수정할 변수가 포함된 객체를 사용하여
템플릿 렌더링을 수행할 수 있습니다.
이러한 사용 사례에서는 일반적으로 고려해야 할 전역 상태가 많지 않고 두 개의 입력만 있으며 단일 문자열 출력이 반환됩니다.
이것은 인기 있는 템플릿 렌더링 패키지 mustache과 handlebar의 경우입니다.
Node.js 애플리케이션의 메인 스레드에서 템플릿 렌더링을 오프로드하는 것은 성능을 얻을 수 있는 합리적인 장소인 것 같습니다.
이 가정을 테스트해 봅시다.
ch8-template-render/라는 새 디렉토리를 만듭니다.
파일이 수정되지 않은 상태로 제대로 동작하더라도 console.log() 문을 주석 처리하여
벤치마크 속도를 늦추지 않도록 합니다.
또한 npm 프로젝트를 초기화하고 몇 가지 기본 패키지를 설치해야 합니다.
다음 명령을 실행하여 이를 수행할 수 있습니다.
$ npm init -y
$ npm install fastify@3 mustache@4
다음으로 server.js라는 파일을 만듭니다. 이것은 요청을 받을 때 기본 HTML 렌더링을 수행하는 HTTP 애플리케이션을 나타냅니다.
이 벤치마크는 실제 npm 패키지를 사용할 것입니다.
#!/usr/bin/env node
// npm install fastify@3 mustache@4
const Fastify = require('fastify');
const RpcWorkerPool = require('./rpc-worker.js');
const worker = new RpcWorkerPool('./worker.js', 4, 'leastbusy');
const template = require('./template.js');
const server = Fastify();
이 파일은 Fastify 웹 프레임워크와 4개의 작업자가 있는 작업자 스레드 풀을 인스턴스화하여 시작합니다.
애플리케이션은 또한 웹 애플리케이션에서 사용하는 템플릿을 렌더링하는 데 사용할 template.js라는 모듈을 로드합니다.
이제 일부 경로를 선언하고 서버에 요청을 수신하도록 지시할 준비가 되었습니다.
예제 파일의 내용을 추가하여 파일을 계속 편집합니다.
ch8-template-render/server.js (part 2)
server.get('/main', async (request, reply) =>
template.renderLove({ me: 'Thomas', you: 'Katelyn' }));
server.get('/offload', async (request, reply) =>
worker.exec('renderLove', { me: 'Thomas', you: 'Katelyn' }));
server.listen(3000, (err, address) => {
if (err) throw err;
console.log(`listening on: ${address}`);
});
애플리케이션에 두 가지 경로(route)가 도입되었습니다.
첫 번째는 GET /main이며 기본 스레드에서 요청 렌더링을 수행합니다.
이것은 단일 스레드 응용 프로그램을 나타냅니다.
두 번째 경로는 렌더링 작업이 별도의 작업자 스레드로 오프로드되는 GET /offload입니다.
마지막으로 서버는 포트 3000에서 수신하도록 지시받습니다.
응용 프로그램의 기능 개발은 완료됩니다.
그러나 추가 보너스로 서버가 바쁘게 수행하는 작업의 양을 정량화할 수 있다면 좋을 것입니다.
HTTP 요청 벤치마크를 사용하여 이 애플리케이션의 효율성을 주로 테스트할 수 있는 것은 사실이지만
때로는 다른 수치도 살펴보는 것이 좋습니다.
ch8-template-render/server.js (part 3)
const timer = process.hrtime.bigint;
setInterval(() => {
const start = timer();
setImmediate(() => {
console.log(`delay: ${(timer() - start).toLocaleString()}ns`);
});
}, 1000);
이 코드는 매초마다 실행되는 setInterval 호출을 사용합니다.
setImmediate() 호출을 래핑하여 호출 전후의 현재 시간을 나노초 단위로 측정합니다.
완벽하지는 않지만 프로세스가 현재 받고 있는 부하량을 추정하는 한 가지 방법입니다.
프로세스에 대한 이벤트 루프가 더 바빠지면 delay 숫자가 높아집니다.
또한 이벤트 루프의 혼잡도는 프로세스 전체에서 비동기 작업의 지연에 영향을 미칩니다.
따라서 이 수치를 낮게 유지하는 것이 좋습니다.
다음으로 worker.js라는 파일을 만듭니다.
ch8-template-render/worker.js
const { parentPort } = require('worker_threads');
const template = require('./template.js');
function asyncOnMessageWrap(fn) {
return async function(msg) {
parentPort.postMessage(await fn(msg));
}
}
const commands = {
renderLove: (data) => template.renderLove(data)
};
parentPort.on('message', asyncOnMessageWrap(async ({ method, params, id }) => ({
result: await commands[method](...params), id
})));
템플릿 렌더링 함수에서 사용할 키 값 쌍이 있는 객체를 수락하는 renderLove()라는 단일 명령이 사용됩니다.
ch8-template-render/template.js
const Mustache = require('mustache');
const love_template = "<em>{{me}} loves {{you}}</em> ".repeat(80);
module.exports.renderLove = (data) => {
const result = Mustache.render(love_template, data);
// Mustache.clearCache();
return result;
};
실제 애플리케이션에서 이 파일은 디스크에서 템플릿 파일을 읽고 값을 대체하여 전체 템플릿 목록을 표시하는 데 사용될 수 있습니다.
이 간단한 예제에서는 단일 템플릿 렌더러만 내보내고 하드 코딩된 단일 템플릿이 사용됩니다.
이 템플릿은 me와 you라는 두 가지 변수를 사용합니다.
문자열은 실제 애플리케이션이 사용할 수 있는 템플릿의 길이와 유사하도록 여러번 반복됩니다.
템플릿이 길수록 렌더링하는 데 더 오래 걸립니다.
이제 파일이 생성되었으므로 애플리케이션을 실행할 준비가 되었습니다.
다음 명령을 실행하여 서버를 실행한 다음 이에 대한 벤치마크를 시작합니다.
# Terminal 1
$ node server.js
# Terminal 2
$ npx autocannon -d 60 http://localhost:3000/main
$ npx autocannon -d 60 http://localhost:3000/offload
강력한 16코어 노트북에서 실행한 테스트에서 메인 스레드에서 템플릿을 완전히 렌더링할 때
애플리케이션의 평균 처리량은 초당 13,285건이었습니다.
그러나 템플릿 렌더링을 작업자 스레드로 오프로드하면서 동일한 테스트를 실행할 때 평균 처리량은 초당 18,981개의 요청이었습니다.
이 특정 상황에서 이는 처리량이 약 43% 증가했음을 의미합니다.
이벤트 루프 대기 시간도 크게 줄었습니다.
프로세스가 유휴 상태일 때 setImmediate()를 호출하는 데 걸리는 시간을 샘플링하면 평균 약 87μs가 됩니다.
메인 스레드에서 템플릿 렌더링을 수행할 때 대기 시간은 평균 769μs입니다.
렌더링을 작업자 스레드로 오프로드할 때 가져온 동일한 샘플은 평균 232μs입니다.
두 값에서 유휴 상태를 빼면 스레드를 사용할 때 약 4.7배 향상됩니다.
아래 그림은 60초 벤치마크 동안 시간 경과에 따라 이러한 샘플을 비교합니다.
이는 렌더링을 다른 스레드로 오프로드하기 위해 응용 프로그램을 실행하고 리팩터링해야 한다는 것을 의미하나요?
반드시 그런 것은 아닙니다.
이 예제의 응용 프로그램은 추가 스레드를 이용해 더 빨라졌지만
이 벤치마크는 16코어 시스템에서 수행되었습니다.
프로덕션 애플리케이션이 더 적은 수의 코어에 액세스할 가능성이 매우 높습니다.
즉, 이것을 테스트하는 동안 가장 큰 성능 차별화 요소는 템플릿의 크기였습니다.
문자열을 반복하지 않는 것과 같이 템플릿이 훨씬 더 작으면 단일 스레드에서 템플릿을 렌더링하는 것이 더 빠릅니다.
문자열이 작을 때 멀티 쓰레딩의 속도가 느려지는 이유는
스레드 간에 템플릿 데이터를 전달하는 오버헤드가 작은 템플릿을 렌더링하는 데 걸리는 시간보다 훨씬 클 것이기 때문입니다.
명심하세요!
추가 스레드의 이점이 있는지 확인하려면 프로덕션 환경에서 애플리케이션으로 이러한 변경 사항을 테스트해야 합니다.
주의 사항 요약
JavaScript에서 스레드로 작업할 때 주의해야 할 사항들의 목록립니다.
복잡도
응용 프로그램은 공유 메모리를 사용할 때 더 복잡한 경향이 있습니다.
Atomics 호출을 직접 작성하고 SharedBufferArray 인스턴스를 수동으로 사용하는 경우 특히 그렇습니다.
서드파티 모듈을 사용하여 이러한 복잡성을 응용 프로그램에서 숨길 수 있습니다.
이러한 경우 메인 스레드에서 작업자와 통신하고,
모든 상호 통신 및 조정을 추상화하여 깔끔한 방식으로 작업자를 나타낼 수 있습니다.
메모리 오버헤드
프로그램에 추가되는 각 스레드에는 추가 메모리 오버헤드가 있습니다.
이 메모리 오버헤드는 각 스레드에 많은 모듈이 로드되는 경우 복잡해집니다.
최신 컴퓨터에서는 오버헤드가 큰 문제가 아닐 수 있지만
안전을 위해 궁극적으로 실행될 최종 하드웨어에서 테스트할 가치가 있습니다.
이 문제를 완화하는 데 도움이 되는 한 가지 방법은 별도의 스레드에 로드되는 코드를 감사하는 것입니다.
부엌 싱크대에 불필요하게 물건을 올려놓지 않도록 하세요!
공유 객체 없음
스레드 간 객체를 공유할 수 없기 때문에 단일 스레드 응용 프로그램을 다중 스레드 응용 프로그램으로 쉽게 변환하기 어려울 수 있습니다. 대신 객체를 변경하는 경우 단일 위치에 있는 객체를 변경하는 메시지를 전달해야 합니다.
DOM 접근 불가
브라우저 기반 애플리케이션의 메인 스레드만 DOM에 액세스할 수 있습니다.
이로 인해 UI 렌더링 작업을 다른 스레드로 오프로드하기 어려울 수 있습니다.
즉, 기본 스레드가 DOM 변경을 담당할 때,
추가 스레드가 무거운 작업을 수행하고 데이터 변경 사항을 기본 스레드로 반환하여 UI를 업데이트해야 합니다.
API 제약사항
DOM 액세스 불가능과 같은 맥락에서 스레드에서 사용할 수 있는 API에 약간의 변경 사항이 있습니다.
브라우저에서 이는 alert()에 대한 호출이 없음을 의미하며
개별 작업자 타입에는
차단 XMLHttpRequest#open() 요청, localStorage 제한, top level await 등을 허용하지 않는 것과 같은 더 많은 규칙이 있습니다.
모든 JavaScript 컨텍스트에서 모든 코드가 수정되지 않은 상태로 실행될 수 있는 것은 아닙니다.
공식 문서를 자주 확인해야 합니다.
구조화된 복제 알고리즘 제약사항(Structured clone algorithm constraints)
서로 다른 스레드 간에 특정 클래스 인스턴스를 전달하기 어렵게 만들 수 있는 구조화된 복제 알고리즘에 대한 몇 가지 제약이 있습니다.
현재 두 스레드가 동일한 클래스 정의에 액세스할 수 있더라도 스레드 간에 전달되는 클래스의 인스턴스는 일반 Object 인스턴스가 됩니다. 데이터를 클래스 인스턴스로 다시 수화하는 것이 가능하지만 수동 작업이 필요합니다.
브라우저에는 특수 헤더가 필요합니다
브라우저에서 SharedArrayBuffer를 통해 공유 메모리로 작업할 때
서버는 페이지에서 사용하는 HTML 문서에 대한 요청에 두 개의 추가 헤더를 제공해야 합니다.
서버를 완전히 제어할 수 있는 경우 이러한 헤더를 쉽게 도입할 수 있습니다.
하지만, 특정 호스팅 환경에서는 이러한 헤더를 제공하는 것이 어렵거나 불가능할 수 있습니다.
스레드 준비 감지
생성된 스레드가 공유 메모리로 작업할 준비가 되었는지 알 수 있는 기본 제공 기능이 없습니다.