작성자는 정신 질환의 오픈 소싱을 기부 대상으로 선택하여 기부를 위한 글쓰기 프로그램의 일환으로 기부합니다.
소개
Node.js는 자바스크립트 코드를 단일 스레드에서 실행하여 코드가 한 번에 하나의 작업만 수행할 수 있음을 의미합니다. 그러나 Node.js 자체는 멀티스레드이며, libuv
라이브러리를 통해 입출력 작업(예: 디스크에서 파일 읽기 또는 네트워크 요청)을 처리하는 숨겨진 스레드를 제공합니다. 숨겨진 스레드를 사용하여 Node.js는 주 스레드를 차단하지 않고 코드가 I/O 요청을 수행할 수 있는 비동기 메서드를 제공합니다.
Node.js에는 숨겨진 스레드가 있지만, 복잡한 계산, 이미지 크기 조정 또는 비디오 압축과 같은 CPU 집약적인 작업을 오프로드하는 데 사용할 수는 없습니다. 자바스크립트는 단일 스레드이므로 CPU 집약적인 작업이 실행되면 주 스레드가 차단되고 작업이 완료될 때까지 다른 코드가 실행되지 않습니다. 다른 스레드를 사용하지 않고 CPU 바운드 작업을 가속화하는 유일한 방법은 프로세서 속도를 높이는 것입니다.
그러나 최근 몇 년 동안 CPU는 빨라지지 않았습니다. 대신, 컴퓨터는 추가 코어와 함께 제공되며, 이제 컴퓨터에 8개 이상의 코어가있는 것이 더 일반적입니다. 그러나 이러한 추세에도 불구하고, JavaScript는 단일 스레드이므로 컴퓨터의 추가 코어를 활용하여 CPU 바운드 작업을 가속화하거나 메인 스레드를 중단하지 않습니다.
이를 해결하기 위해 Node.js는 worker-threads
모듈을 소개했습니다. 이 모듈을 사용하면 스레드를 생성하고 여러 JavaScript 작업을 병렬로 실행할 수 있습니다. 스레드가 작업을 완료하면 결과를 포함하는 메시지를 메인 스레드로 보내 코드의 다른 부분에서 사용할 수 있습니다. worker 스레드를 사용하는 장점은 CPU 바운드 작업이 메인 스레드를 차단하지 않고 작업을 여러 개의 worker에게 나누어 최적화 할 수 있다는 것입니다.
이 튜토리얼에서는 메인 스레드를 차단하는 CPU 집약적인 작업이 포함 된 Node.js 앱을 작성합니다. 다음으로, CPU 집약적인 작업을 메인 스레드를 차단하지 않기 위해 다른 스레드로 오프로드하는 데 worker-threads
모듈을 사용합니다. 마지막으로, CPU 바운드 작업을 분할하고 4개의 스레드가 병렬로 작업하도록하여 작업을 가속화합니다.
사전 요구 사항
이 튜토리얼을 완료하기 위해 다음이 필요합니다:
-
4개 이상의 코어를 가진 멀티코어 시스템입니다. 2개 코어 시스템에서는 여전히 1부터 6단계까지의 튜토리얼을 따를 수 있습니다. 그러나 7단계에서는 성능 향상을 보려면 4개의 코어가 필요합니다.
-
Node.js 개발 환경입니다. Ubuntu 22.04에서는 Ubuntu 22.04에 Node.js 설치하는 방법의 3단계를 따라 최신 버전의 Node.js를 설치하십시오. 다른 운영 체제를 사용하는 경우 Node.js 설치 및 로컬 개발 환경 생성하는 방법을 참조하십시오.
-
JavaScript에서 이벤트 루프, 콜백 및 프로미스에 대한 좋은 이해도가 필요합니다. 이에 대한 자세한 내용은 JavaScript에서 이벤트 루프, 콜백, 프로미스 및 Async/Await 이해하기 튜토리얼을 참조하십시오.
-
Express 웹 프레임워크의 기본적인 사용 방법에 대한 지식이 필요합니다. Node.js 및 Express로 시작하는 방법 가이드를 확인하십시오.
프로젝트 설정 및 종속성 설치
이 단계에서는 프로젝트 디렉토리를 생성하고, npm을 초기화하고 필요한 종속성을 설치합니다.
먼저, 프로젝트 디렉토리를 생성하고 해당 디렉토리로 이동합니다:
- mkdir multi-threading_demo
- cd multi-threading_demo
mkdir 명령어는 디렉토리를 생성하고, cd 명령어는 작업 디렉토리를 새로 생성한 디렉토리로 변경합니다.
이후, npm을 사용하여 프로젝트 디렉토리를 초기화합니다. npm init 명령어를 사용합니다:
- npm init -y
-y 옵션은 모든 기본 옵션을 자동으로 선택합니다.
명령어를 실행하면 출력 결과는 다음과 유사합니다:
Wrote to /home/sammy/multi-threading_demo/package.json:
{
"name": "multi-threading_demo",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
다음으로, Node.js 웹 프레임워크인 Express를 설치합니다:
- npm install express
Express를 사용하여 블로킹 및 논블로킹 엔드포인트를 가진 서버 애플리케이션을 생성할 것입니다.
Node.js는 기본적으로 worker-threads 모듈을 함께 제공하므로 별도로 설치할 필요가 없습니다.
필요한 패키지를 설치했습니다. 다음으로, 프로세스와 스레드에 대해 자세히 알아보고 컴퓨터에서 어떻게 실행되는지 배울 것입니다.
프로세스와 스레드 이해
CPU 바운드 작업을 작성하고 별도의 스레드에 오프로드하기 전에, 먼저 프로세스와 스레드가 무엇이며 그들 간의 차이점을 이해해야 합니다. 가장 중요한 것은 프로세스와 스레드가 단일 또는 다중 코어 컴퓨터 시스템에서 어떻게 실행되는지를 검토할 것입니다.
프로세스
A process is a running program in the operating system. It has its own memory and cannot see nor access the memory of other running programs. It also has an instruction pointer, which indicates the instruction currently being executed in a program. Only one task can be executed at a time.
이를 이해하기 위해 무한 루프가 포함된 Node.js 프로그램을 생성하여 실행되도록 하겠습니다.
nano
또는 선호하는 텍스트 편집기를 사용하여 process.js
파일을 생성하고 엽니다:
- nano process.js
process.js
파일에 다음 코드를 입력합니다:
const process_name = process.argv.slice(2)[0];
count = 0;
while (true) {
count++;
if (count == 2000 || count == 4000) {
console.log(`${process_name}: ${count}`);
}
}
첫 번째 줄에서 process.argv
속성은 프로그램의 커맨드 라인 인수를 포함하는 배열을 반환합니다. 그런 다음, slice()
메소드를 사용하여 인덱스 2부터 배열의 얕은 복사본을 만듭니다. 이렇게 하면 Node.js 경로와 프로그램 파일명인 첫 두 개의 인수를 건너뛰게 됩니다. 그 다음, 대괄호 표기법 구문을 사용하여 슬라이스된 배열에서 첫 번째 인수를 검색하여 process_name
변수에 저장합니다.
그 후에 while
루프를 정의하고 루프가 계속 실행되도록 true
조건을 전달합니다. 루프 내에서는 각 반복마다 count
변수가 1
씩 증가됩니다. 그 다음에는 count
가 2000
또는 4000
과 같은지 확인하는 if
문이 있습니다. 조건이 참으로 평가되면 console.log()
메서드가 터미널에 메시지를 기록합니다.
변경 사항을 저장하고 파일을 닫으려면 CTRL+X
를 누르고 변경 사항을 저장하기 위해 Y
를 누르세요.
node
명령을 사용하여 프로그램을 실행하세요:
- node process.js A &
A
is a command-line argument that is passed to the program and stored in the process_name
variable. The &
at end the allows the Node program to run in the background, which lets you enter more commands in the shell.
프로그램을 실행하면 다음과 유사한 출력이 표시됩니다:
Output[1] 7754
A: 2000
A: 4000
숫자 7754
는 운영 체제가 할당한 프로세스 ID입니다. A: 2000
및 A: 4000
은 프로그램의 출력입니다.
node
명령을 사용하여 프로그램을 실행하면 프로세스가 생성됩니다. 운영 체제는 프로그램을 위해 메모리를 할당하고, 컴퓨터의 디스크에서 프로그램 실행 파일을 찾아 메모리에 로드합니다. 그런 다음 프로세스에 대해 프로세스 ID를 할당하고 프로그램을 실행합니다. 이 시점에서 프로그램은 프로세스가 되었습니다.
프로세스가 실행 중인 경우, 해당 프로세스 ID는 운영 체제의 프로세스 목록에 추가되며 htop
, top
, 또는 ps
와 같은 도구로 볼 수 있습니다. 이 도구는 프로세스에 대한 자세한 정보뿐만 아니라 중지 또는 우선 순위 설정과 같은 옵션도 제공합니다.
터미널에서 Node 프로세스에 대한 간단한 요약을 얻으려면 ENTER
를 눌러 프롬프트를 다시 표시하세요. 그다음, ps
명령을 실행하여 Node 프로세스를 확인하세요:
- ps |grep node
ps
명령은 시스템에서 현재 사용자와 관련된 모든 프로세스를 나열합니다. grep
을 통해 ps
출력을 필터링하여 Node 프로세스만 나열합니다.
위 명령을 실행하면 다음과 유사한 출력이 나타납니다:
Output7754 pts/0 00:21:49 node
하나의 프로그램에서 무수히 많은 프로세스를 생성할 수 있습니다. 예를 들어, 다음 명령을 사용하여 다른 인수를 가진 세 개의 프로세스를 생성하고 백그라운드에 넣을 수 있습니다:
- node process.js B & node process.js C & node process.js D &
위 명령에서는 process.js
프로그램의 세 개의 인스턴스를 생성했습니다. &
기호는 각 프로세스를 백그라운드에 넣습니다.
위 명령을 실행하면 다음과 유사한 출력이 나타납니다(순서는 다를 수 있음):
Output[2] 7821
[3] 7822
[4] 7823
D: 2000
D: 4000
B: 2000
B: 4000
C: 2000
C: 4000
출력에서 볼 수 있듯이, 각 프로세스는 카운트가 2000
과 4000
에 도달할 때마다 프로세스 이름을 터미널에 기록합니다. 각 프로세스는 실행 중인 다른 프로세스를 인식하지 않습니다: 프로세스 D
는 프로세스 C
를 인식하지 않으며 그 반대도 마찬가지입니다. 어느 프로세스에서 발생하는 일은 다른 Node.js 프로세스에 영향을 주지 않습니다.
출력을 자세히 살펴보면, 출력의 순서가 처음에 세 개의 프로세스를 생성할 때의 순서와 동일하지 않음을 알 수 있습니다. 명령을 실행할 때 프로세스 인수의 순서는 B
, C
, D
였습니다. 그러나 지금은 D
, B
, C
의 순서입니다. 그 이유는 운영 체제에는 주어진 시간에 CPU에서 실행할 프로세스를 결정하는 스케줄링 알고리즘이 있기 때문입니다.
단일 코어 기계에서는 프로세스가 동시에 실행됩니다. 즉, 운영 체제는 일정한 간격으로 프로세스 간을 전환합니다. 예를 들어, 프로세스 D
가 제한된 시간 동안 실행되고, 그 상태가 어딘가에 저장되고, 운영 체제는 제한된 시간 동안 프로세스 B
를 실행하도록 일정을 조정합니다. 이런 식으로 계속해서 전환되며 모든 작업이 완료될 때까지 진행됩니다. 출력에서는 각 프로세스가 완료된 것처럼 보일 수 있지만, 실제로는 운영 체제 스케줄러가 계속해서 프로세스 간을 전환하고 있습니다.
다중 코어 시스템에서는 (4개의 코어가 있다고 가정할 때) 운영 체제가 각 프로세스를 동시에 각 코어에서 실행하도록 일정을 조정합니다. 이를 병렬 처리라고 합니다. 그러나 프로세스를 더 만들면(총 8개가 되도록), 각 코어는 두 개의 프로세스를 동시에 실행하고 완료될 때까지 진행합니다.
스레드
스레드는 프로세스와 유사합니다: 자체 명령 포인터를 가지고 있으며 한 번에 하나의 JavaScript 작업을 실행할 수 있습니다. 프로세스와 달리 스레드는 자체 메모리를 갖고 있지 않습니다. 대신, 프로세스의 메모리 내에 존재합니다. 프로세스를 생성할 때는 worker_threads
모듈을 사용하여 여러 개의 스레드를 동시에 실행하는 JavaScript 코드를 생성할 수 있습니다. 게다가, 스레드는 메시지 전달이나 프로세스의 메모리 내에서 데이터를 공유하여 서로 통신할 수 있습니다. 이로 인해 스레드는 프로세스와 비교하여 가벼워지며, 스레드 생성은 운영 체제로부터 추가 메모리를 요청하지 않습니다.
스레드의 실행에 관련하여, 스레드는 프로세스와 유사한 동작을 합니다. 단일 코어 시스템에서 여러 개의 스레드가 실행 중인 경우, 운영 체제는 정기적으로 스레드 간을 전환하여 각 스레드가 단일 CPU에서 직접 실행할 수 있는 기회를 부여합니다. 다중 코어 시스템에서 운영 체제는 스레드를 모든 코어에 고르게 배치하고 동시에 JavaScript 코드를 실행합니다. 사용 가능한 코어보다 많은 스레드를 생성하는 경우, 각 코어는 여러 개의 스레드를 동시에 실행합니다.
이와 함께, ENTER
를 누르고 현재 실행 중인 모든 Node 프로세스를 kill
명령으로 중지하십시오:
- sudo kill -9 `pgrep node`
pgrep
는 네 개의 Node 프로세스의 프로세스 ID를 kill
명령에 반환합니다. -9
옵션은 kill
에 SIGKILL 신호를 보내도록 지시합니다.
명령을 실행하면 다음과 유사한 출력이 표시됩니다.
Output[1] Killed node process.js A
[2] Killed node process.js B
[3] Killed node process.js C
[4] Killed node process.js D
가끔 출력이 지연되어 나중에 다른 명령을 실행할 때 나타날 수 있습니다.
이제 프로세스와 스레드의 차이를 알았으니 다음 섹션에서는 Node.js의 숨겨진 스레드를 사용할 것입니다.
Node.js에서 숨겨진 스레드 이해하기
Node.js는 추가적인 스레드를 제공하기 때문에 다중 스레드로 간주됩니다. 이 섹션에서는 I/O 작업을 비차단으로 만들어주는 Node.js의 숨겨진 스레드를 살펴볼 것입니다.
소개에서 언급한 대로, JavaScript는 단일 스레드이며 모든 JavaScript 코드는 단일 스레드에서 실행됩니다. 이는 프로그램 소스 코드와 프로그램에 포함된 타사 라이브러리를 포함합니다. 프로그램이 파일을 읽거나 네트워크 요청을 수행하는 경우 이는 주 스레드를 차단합니다.
그러나 Node.js는 libuv
라이브러리를 구현하여 Node.js 프로세스에 추가적인 네 개의 스레드를 제공합니다. 이러한 스레드를 사용하여 I/O 작업은 별도로 처리되며 완료되면 이벤트 루프는 해당 I/O 작업과 관련된 콜백을 마이크로태스크 큐에 추가합니다. 메인 스레드의 콜 스택이 비어 있을 때 콜백이 콜 스택에 푸시되어 실행됩니다. 이를 명확히 하기 위해 주어진 I/O 작업과 관련된 콜백은 병렬로 실행되지 않지만, 파일 읽기 또는 네트워크 요청과 같은 작업 자체는 스레드의 도움으로 병렬로 처리됩니다. I/O 작업이 완료되면 콜백은 메인 스레드에서 실행됩니다.
이 외에도 V8 엔진은 자동 가비지 수집과 같은 작업을 처리하기 위해 두 개의 스레드를 제공합니다. 이를 통해 프로세스의 총 스레드 수는 메인 스레드 하나, Node.js 스레드 네 개, V8 스레드 두 개로 총 일곱 개가 됩니다.
모든 Node.js 프로세스가 일곱 개의 스레드를 가지고 있는지 확인하려면 process.js
파일을 다시 실행하고 백그라운드로 실행하세요:
- node process.js A &
터미널에서는 프로세스 ID와 프로그램의 출력이 기록됩니다:
Output[1] 9933
A: 2000
A: 4000
프로세스 ID를 어딘가에 메모하고 ENTER
키를 눌러 프롬프트를 다시 사용할 수 있도록 하세요.
스레드를 확인하려면 top
명령을 실행하고 출력에서 표시된 프로세스 ID를 전달하세요:
- top -H -p 9933
-H
는 top
에게 프로세스 내의 스레드를 표시하도록 지시하는 옵션입니다. -p
플래그는 top
이 지정된 프로세스 ID의 활동만 모니터링하도록 지시합니다.
명령을 실행하면 다음과 비슷한 출력이 나타납니다:
Outputtop - 09:21:11 up 15:00, 1 user, load average: 0.99, 0.60, 0.26
Threads: 7 total, 1 running, 6 sleeping, 0 stopped, 0 zombie
%Cpu(s): 24.8 us, 0.3 sy, 0.0 ni, 75.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
MiB Mem : 7951.2 total, 6756.1 free, 248.4 used, 946.7 buff/cache
MiB Swap: 0.0 total, 0.0 free, 0.0 used. 7457.4 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
9933 node-us+ 20 0 597936 51864 33956 R 99.9 0.6 4:19.64 node
9934 node-us+ 20 0 597936 51864 33956 S 0.0 0.6 0:00.00 node
9935 node-us+ 20 0 597936 51864 33956 S 0.0 0.6 0:00.84 node
9936 node-us+ 20 0 597936 51864 33956 S 0.0 0.6 0:00.83 node
9937 node-us+ 20 0 597936 51864 33956 S 0.0 0.6 0:00.93 node
9938 node-us+ 20 0 597936 51864 33956 S 0.0 0.6 0:00.83 node
9939 node-us+ 20 0 597936 51864 33956 S 0.0 0.6 0:00.00 node
출력된 것처럼 Node.js 프로세스에는 총 7개의 스레드가 있습니다: 자바스크립트를 실행하는 주 메인 스레드 1개, Node.js 스레드 4개, 그리고 V8 스레드 2개입니다.
이전에 설명한 대로, 4개의 Node.js 스레드는 I/O 작업을 비차단으로 수행하기 위해 사용됩니다. 이 작업에 대해서는 잘 작동하며, 직접 I/O 작업을 위해 스레드를 생성하면 애플리케이션 성능이 저하될 수도 있습니다. 그러나 CPU 바운드 작업에 대해서는 이와 같은 말은 할 수 없습니다. CPU 바운드 작업은 프로세스에서 사용 가능한 추가 스레드를 사용하지 않으며, 주 메인 스레드를 차단합니다.
이제 q
를 눌러 top
을 종료하고 다음 명령을 사용하여 Node 프로세스를 중지하세요:
- kill -9 9933
Node.js 프로세스의 스레드에 대해 알게 되었으므로, 다음 섹션에서 CPU 바운드 작업을 작성하고 이 작업이 주 메인 스레드에 어떤 영향을 미치는지 관찰해 보겠습니다.
워커 스레드 없이 CPU 바운드 작업 생성하기
이 섹션에서는 논블로킹 라우트와 CPU 바운드 작업을 실행하는 블로킹 라우트를 가진 Express 앱을 작성합니다.
먼저, 선호하는 편집기에서 index.js
파일을 엽니다:
- nano index.js
index.js
파일에서 기본 서버를 생성하기 위해 다음 코드를 추가합니다:
const express = require("express");
const app = express();
const port = process.env.PORT || 3000;
app.get("/non-blocking/", (req, res) => {
res.status(200).send("This page is non-blocking");
});
app.listen(port, () => {
console.log(`App listening on port ${port}`);
});
다음 코드 블록에서는 Express를 사용하여 HTTP 서버를 생성합니다. 첫 번째 줄에서는 express
모듈을 가져옵니다. 그 다음에는 app
변수를 설정하여 Express의 인스턴스를 보관합니다. 그 다음으로는 서버가 듣기를 시작해야하는 포트 번호를 저장하는 port
변수를 정의합니다.
이후에는 app.get('/non-blocking')
을 사용하여 GET
요청이 전송되어야 하는 경로를 정의합니다. 마지막으로, app.listen()
메서드를 호출하여 서버에 포트 3000
에서의 듣기를 시작하도록 지시합니다.
다음으로, 다른 경로인 /blocking/
을 정의합니다. 이 경로에는 CPU 집약적인 작업이 포함됩니다:
...
app.get("/blocking", async (req, res) => {
let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
counter++;
}
res.status(200).send(`result is ${counter}`);
});
app.listen(port, () => {
console.log(`App listening on port ${port}`);
});
/blocking
경로를 app.get("/blocking")
를 사용하여 정의하며, 두 번째 인수로 비동기 콜백을 사용합니다. 이 콜백은 CPU 집약적인 작업을 실행합니다. 콜백 내부에서는 200억 번 반복하는 for
루프를 생성하고 각 반복에서 counter
변수를 1
씩 증가시킵니다. 이 작업은 CPU에서 실행되며 몇 초가 걸릴 수 있습니다.
이 시점에서 index.js
파일은 다음과 같아야 합니다:
const express = require("express");
const app = express();
const port = process.env.PORT || 3000;
app.get("/non-blocking/", (req, res) => {
res.status(200).send("This page is non-blocking");
});
app.get("/blocking", async (req, res) => {
let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
counter++;
}
res.status(200).send(`result is ${counter}`);
});
app.listen(port, () => {
console.log(`App listening on port ${port}`);
});
파일을 저장하고 종료한 다음 다음 명령을 사용하여 서버를 시작합니다:
- node index.js
명령을 실행하면 다음과 유사한 출력을 볼 수 있습니다:
OutputApp listening on port 3000
이를 통해 서버가 실행 중이며 서비스를 제공할 준비가 되었음을 알 수 있습니다.
이제 웹 브라우저에서 http://localhost:3000/non-blocking
을 방문해 보세요. “This page is non-blocking” 메시지가 즉시 표시됩니다.
참고: 원격 서버에서 튜토리얼을 따르고 있다면, 브라우저에서 앱을 테스트하기 위해 포트 포워딩을 사용할 수 있습니다.
Express 서버가 계속 실행 중인 동안, 로컬 컴퓨터에서 다른 터미널을 열고 다음 명령을 입력하세요:
- ssh -L 3000:localhost:3000 your-non-root-user@yourserver-ip
서버에 연결한 후, 로컬 컴퓨터의 웹 브라우저에서 http://localhost:3000/non-blocking
으로 이동하세요. 이 튜토리얼의 나머지 부분 동안 두 번째 터미널을 열어두세요.
다음으로 새 탭을 열고 http://localhost:3000/blocking
에 방문하세요. 페이지가 로드되는 동안 빠르게 두 개의 추가 탭을 열고 다시 http://localhost:3000/non-blocking
에 방문하세요. 즉시 응답을 받지 못하고 페이지가 계속로드를 시도하는 것을 알 수 있습니다. /blocking
루트가 로드되고 result is 20000000000
을 반환하는 경우에만 나머지 루트가 응답을 반환합니다.
/non-blocking
루트가 /blocking
루트가 로드되는 동안 작동하지 않는 이유는 CPU 바운드 for
루프 때문입니다. 메인 스레드가 차단되면 Node.js는 CPU 바운드 작업이 완료될 때까지 어떤 요청도 처리할 수 없습니다. 따라서 애플리케이션이 /non-blocking
루트에 대해 수천 개의 동시 GET 요청을 가지고 있다면, /blocking
루트를 한 번만 방문하면 모든 애플리케이션 루트가 응답하지 않게 됩니다.
주목할 것처럼, 주 스레드를 차단하는 것은 사용자의 앱 경험에 해를 입힐 수 있습니다. 이 문제를 해결하기 위해 CPU 바운드 작업을 다른 스레드로 오프로드하여 주 스레드가 다른 HTTP 요청을 처리할 수 있도록 해야합니다.
그 후, CTRL+C
를 눌러 서버를 중지하세요. index.js
파일에 더 많은 변경을 가한 후 다음 섹션에서 서버를 다시 시작할 것입니다. 서버를 중지하는 이유는 새로운 변경 사항이 파일에 적용되었을 때 Node.js가 자동으로 새로 고치지 않기 때문입니다.
이제 CPU 집약적인 작업이 응용 프로그램에 미치는 부정적인 영향을 이해했으므로, 이제 프로미스를 사용하여 주 스레드 차단을 피해 보려고 할 것입니다.
프로미스를 사용하여 CPU 바운드 작업 오프로드하기
개발자들이 CPU 바운드 작업의 차단 효과에 대해 알게 되면, 종종 비차단 프로미스 기반 I/O 메서드인 readFile()
및 writeFile()
을 사용하여 코드를 비차단으로 만들기 위해 프로미스를 사용합니다. 그러나 앞에서 배운 대로 I/O 작업은 Node.js의 숨겨진 스레드를 사용하지만, CPU 바운드 작업은 그렇지 않습니다. 그럼에도 불구하고, 이 섹션에서는 CPU 바운드 작업을 프로미스로 래핑하여 차단을 방지하는 시도를 할 것입니다. 이 방법은 작동하지 않지만, 다음 섹션에서 할 일꾼 스레드를 사용하는 가치를 확인하는 데 도움이 될 것입니다.
편집기에서 다시 index.js
파일을 엽니다.
- nano index.js
index.js
파일에서 CPU 집약 작업이 포함된 하이라이트된 코드를 제거하십시오:
...
app.get("/blocking", async (req, res) => {
let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
counter++;
}
res.status(200).send(`result is ${counter}`);
});
...
다음으로 다음 하이라이트된 코드를 포함하는 함수를 추가하십시오. 이 함수는 프로미스를 반환합니다:
...
function calculateCount() {
return new Promise((resolve, reject) => {
let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
counter++;
}
resolve(counter);
});
}
app.get("/blocking", async (req, res) => {
res.status(200).send(`result is ${counter}`);
}
calculateCount()
함수에는 이제 /blocking
핸들러 함수에 있던 계산이 포함되어 있습니다. 이 함수는 new Promise
구문으로 초기화된 프로미스를 사용합니다. 프로미스는 resolve
와 reject
매개변수를 가진 콜백을 사용하여 성공 또는 실패를 처리합니다. for
루프가 실행을 완료하면 프로미스는 counter
변수의 값으로 해결됩니다.
다음으로, index.js
파일의 /blocking/
핸들러 함수에서 calculateCount()
함수를 호출하십시오:
app.get("/blocking", async (req, res) => {
const counter = await calculateCount();
res.status(200).send(`result is ${counter}`);
});
여기서는 프로미스가 해결될 때까지 기다리기 위해 await
키워드를 접두사로 사용하여 calculateCount()
함수를 호출합니다. 프로미스가 해결되면 counter
변수가 해결된 값으로 설정됩니다.
완전한 코드는 다음과 같이 보일 것입니다:
const express = require("express");
const app = express();
const port = process.env.PORT || 3000;
app.get("/non-blocking/", (req, res) => {
res.status(200).send("This page is non-blocking");
});
function calculateCount() {
return new Promise((resolve, reject) => {
let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
counter++;
}
resolve(counter);
});
}
app.get("/blocking", async (req, res) => {
const counter = await calculateCount();
res.status(200).send(`result is ${counter}`);
});
app.listen(port, () => {
console.log(`App listening on port ${port}`);
});
파일을 저장하고 나가고, 서버를 다시 시작하십시오:
- node index.js
웹 브라우저에서 http://localhost:3000/blocking
을 방문하고 로드되는 동안 http://localhost:3000/non-blocking
탭을 빠르게 다시로드하십시오. 알 수 있듯이, non-blocking
경로는 여전히 영향을 받고 /blocking
경로가 로드 완료될 때까지 모두 기다릴 것입니다. 경로가 여전히 영향을 받기 때문에 프로미스는 JavaScript 코드를 병렬로 실행시키지 않고, CPU에 바인딩된 작업을 non-blocking으로 만들기 위해 사용할 수 없습니다.
그런 다음 CTRL+C
로 응용 프로그램 서버를 중지하세요.
이제 프로미스가 CPU 바운드 작업을 비차단으로 만들기 위한 메커니즘을 제공하지 않음을 알았으므로 Node.js의 worker-threads
모듈을 사용하여 CPU 바운드 작업을 별도의 스레드로 오프로드할 것입니다.
worker-threads
모듈을 사용하여 CPU 바운드 작업 오프로드하기
이 섹션에서는 주 스레드를 차단하지 않고 주요 스레드와 병렬로 실행되도록 CPU 집약적인 작업을 다른 스레드로 오프로드할 것입니다. 이를 위해 CPU 집약적인 작업을 포함하는 worker.js
파일을 생성합니다. index.js
파일에서는 worker-threads
모듈을 사용하여 스레드를 초기화하고 worker.js
파일에서 작업을 병렬로 시작합니다. 작업이 완료되면 작업자 스레드는 결과를 포함한 메시지를 주 스레드로 보냅니다.
먼저 nproc
명령을 사용하여 2개 이상의 코어가 있는지 확인하세요:
- nproc
Output4
2개 이상의 코어가 표시되면 이 단계를 진행할 수 있습니다.
다음으로 텍스트 편집기에서 worker.js
파일을 생성하고 엽니다:
- nano worker.js
worker.js
파일에 다음 코드를 추가하여 worker-threads
모듈을 가져오고 CPU 집약적인 작업을 수행하세요.
const { parentPort } = require("worker_threads");
let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
counter++;
}
첫 번째 줄은 worker_threads
모듈을 로드하고 parentPort
클래스를 추출합니다. 이 클래스는 메인 스레드로 메시지를 보내는 데 사용할 수 있는 메서드를 제공합니다. 다음은 현재 index.js
파일의 calculateCount()
함수에 있는 CPU 집약적인 작업입니다. 이 단계에서는 index.js
파일에서 이 함수를 삭제할 것입니다.
계속하여 아래 강조 표시된 코드를 추가하십시오:
const { parentPort } = require("worker_threads");
let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
counter++;
}
parentPort.postMessage(counter);
여기에서는 parentPort
클래스의 postMessage()
메서드를 호출하여 counter
변수에 저장된 CPU 바운드 작업의 결과를 포함하는 메시지를 메인 스레드로 보냅니다.
파일을 저장하고 닫으십시오. 텍스트 편집기에서 index.js
파일을 엽니다:
- nano index.js
worker.js
에서 이미 CPU 바운드 작업을 가지고 있으므로 index.js
에서 강조 표시된 코드를 제거하십시오:
const express = require("express");
const app = express();
const port = process.env.PORT || 3000;
app.get("/non-blocking/", (req, res) => {
res.status(200).send("This page is non-blocking");
});
function calculateCount() {
return new Promise((resolve, reject) => {
let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
counter++;
}
resolve(counter);
});
}
app.get("/blocking", async (req, res) => {
const counter = await calculateCount();
res.status(200).send(`result is ${counter}`);
});
app.listen(port, () => {
console.log(`App listening on port ${port}`);
});
다음으로, app.get("/blocking")
콜백에서 다음 코드를 추가하여 스레드를 초기화합니다:
const express = require("express");
const { Worker } = require("worker_threads");
...
app.get("/blocking", async (req, res) => {
const worker = new Worker("./worker.js");
worker.on("message", (data) => {
res.status(200).send(`result is ${data}`);
});
worker.on("error", (msg) => {
res.status(404).send(`An error occurred: ${msg}`);
});
});
...
먼저, worker_threads
모듈을 가져오고 Worker
클래스를 언팩합니다. app.get("/blocking")
콜백 내에서 new
키워드를 사용하여 worker.js
파일 경로를 인수로 하는 Worker
를 호출하여 Worker
의 인스턴스를 만듭니다. 이렇게 하면 새 스레드가 생성되고 worker.js
파일의 코드가 다른 코어에서 스레드에서 실행됩니다.
이에 따라, 메시지 이벤트를 청취하기 위해 on("message")
메소드를 사용하여 worker
인스턴스에 이벤트를 첨부합니다. worker.js
파일에서 결과를 포함한 메시지를 수신하면, 이를 콜백 메소드의 매개변수로 전달하고 CPU 바운드 작업의 결과를 포함한 응답을 사용자에게 반환합니다.
다음으로, 에러 이벤트를 청취하기 위해 on("error")
메소드를 사용하여 worker 인스턴스에 다른 이벤트를 첨부합니다. 에러가 발생하면, 콜백은 에러 메시지를 포함한 404
응답을 사용자에게 반환합니다.
완성된 파일은 다음과 같습니다:
const express = require("express");
const { Worker } = require("worker_threads");
const app = express();
const port = process.env.PORT || 3000;
app.get("/non-blocking/", (req, res) => {
res.status(200).send("This page is non-blocking");
});
app.get("/blocking", async (req, res) => {
const worker = new Worker("./worker.js");
worker.on("message", (data) => {
res.status(200).send(`result is ${data}`);
});
worker.on("error", (msg) => {
res.status(404).send(`An error occurred: ${msg}`);
});
});
app.listen(port, () => {
console.log(`App listening on port ${port}`);
});
파일을 저장하고 종료한 다음 서버를 실행합니다:
- node index.js
웹 브라우저에서 다시 http://localhost:3000/blocking
탭을 방문합니다. 로딩이 완료되기 전에 모든 http://localhost:3000/non-blocking
탭을 새로고침합니다. 이제 /blocking
경로의 로딩이 완료될 때까지 기다리지 않고 즉시 로딩되는 것을 알 수 있을 것입니다. 이는 CPU 바운드 작업이 다른 스레드로 오프로드되고, 주 스레드가 모든 들어오는 요청을 처리하기 때문입니다.
이제, CTRL+C
를 사용하여 서버를 중지합니다.
CPU 집약적인 작업의 성능을 향상시키기 위해 네 개의 워커 스레드를 사용할 수 있습니다.
네 개의 작업자 스레드를 사용하여 CPU 집약적인 작업을 최적화하기
이 섹션에서는 CPU 집약적인 작업을 네 개의 작업자 스레드에 분할하여 작업을 더 빨리 완료하고 /blocking
경로의 로드 시간을 단축할 것입니다.
동일한 작업에 대해 더 많은 작업자 스레드를 사용하려면 작업을 분할해야 합니다. 작업은 200억 번의 루프를 포함하므로 200억을 사용할 스레드 수로 나누어야 합니다. 이 경우에는 4
입니다. 20_000_000_000 / 4
를 계산하면 5_000_000_000
이 됩니다. 따라서 각 스레드는 0
에서 5_000_000_000
까지 루프를 돌고 counter
를 1
씩 증가시킬 것입니다. 각 스레드가 작업을 완료하면 결과를 포함하는 메시지를 메인 스레드에 보낼 것입니다. 메인 스레드가 네 개의 스레드로부터 개별적으로 메시지를 받으면 결과를 결합하여 사용자에게 응답을 보낼 것입니다.
큰 배열을 반복하는 작업이 있는 경우에도 동일한 방법을 사용할 수 있습니다. 예를 들어, 디렉토리에서 800개의 이미지를 크기 조정하려는 경우, 모든 이미지 파일 경로를 포함하는 배열을 만들 수 있습니다. 그런 다음 800
을 4
(스레드 수)로 나누고 각 스레드가 작업할 범위를 설정합니다. 첫 번째 스레드는 배열 인덱스 0
부터 199
까지의 이미지 크기를 조정하고, 두 번째 스레드는 인덱스 200
부터 399
까지 작업을 수행하고, 이와 같은 식으로 진행됩니다.
먼저, 4개 이상의 코어가 있는지 확인하세요:
- nproc
Output4
worker.js
파일을 cp
명령어를 사용하여 복사하세요:
- cp worker.js four_workers.js
변경사항을 이후에 비교하기 위해 현재의 index.js
와 worker.js
파일을 그대로 두세요.
다음으로, 텍스트 편집기에서 four_workers.js
파일을 엽니다:
- nano four_workers.js
four_workers.js
파일에서, 하이라이트된 코드를 추가하여 workerData
객체를 가져옵니다:
const { workerData, parentPort } = require("worker_threads");
let counter = 0;
for (let i = 0; i < 20_000_000_000 / workerData.thread_count; i++) {
counter++;
}
parentPort.postMessage(counter);
먼저, 스레드가 초기화될 때 메인 스레드로부터 전달된 데이터가 포함된 WorkerData
객체를 추출합니다(이 작업은 곧 index.js
파일에서 수행할 것입니다). 이 객체에는 스레드의 개수를 나타내는 thread_count
속성이 있습니다. for
루프에서는 값을 20_000_000_000
에서 4
로 나누어 5_000_000_000
을 얻습니다.
파일을 저장하고 닫은 다음, index.js
파일을 복사하세요:
- cp index.js index_four_workers.js
index_four_workers.js
파일을 편집기에서 엽니다:
- nano index_four_workers.js
index_four_workers.js
파일에서, 하이라이트된 코드를 추가하여 스레드 인스턴스를 생성합니다:
...
const app = express();
const port = process.env.PORT || 3000;
const THREAD_COUNT = 4;
...
function createWorker() {
return new Promise(function (resolve, reject) {
const worker = new Worker("./four_workers.js", {
workerData: { thread_count: THREAD_COUNT },
});
});
}
app.get("/blocking", async (req, res) => {
...
})
...
먼저, 생성할 스레드의 수를 나타내는 THREAD_COUNT
상수를 정의합니다. 나중에 서버에 더 많은 코어가 있을 때는 THREAD_COUNT
값을 사용할 스레드 수로 변경하여 확장할 수 있습니다.
다음으로, createWorker()
함수는 프로미스를 생성하고 반환합니다. 프로미스 콜백 내에서 Worker
클래스에 첫 번째 인자로 four_workers.js
파일의 파일 경로를 전달하여 새 스레드를 초기화합니다. 그런 다음 두 번째 인자로 객체를 전달합니다. 그 다음, 해당 객체에 값으로 다른 객체를 갖는 workerData
속성을 할당합니다. 마지막으로, 해당 객체에 값으로 THREAD_COUNT
상수의 스레드 수를 갖는 thread_count
속성을 할당합니다. workerData
객체는 이전에 workers.js
파일에서 참조한 객체입니다.
프로미스가 해결되거나 오류가 발생하도록 하기 위해 다음에 강조된 줄을 추가하세요:
...
function createWorker() {
return new Promise(function (resolve, reject) {
const worker = new Worker("./four_workers.js", {
workerData: { thread_count: THREAD_COUNT },
});
worker.on("message", (data) => {
resolve(data);
});
worker.on("error", (msg) => {
reject(`An error ocurred: ${msg}`);
});
});
}
...
작업자 스레드가 메시지를 메인 스레드로 보내면, 프로미스는 반환된 데이터와 함께 해결됩니다. 그러나 오류가 발생할 경우, 프로미스는 오류 메시지를 반환합니다.
이제 새로운 스레드를 초기화하고 스레드에서 반환된 데이터를 반환하는 함수를 정의했으므로, app.get("/blocking")
에서 해당 함수를 사용하여 새 스레드를 생성하겠습니다.
하지만 먼저, 이미 createWorker()
함수에서 이 기능을 정의했으므로 다음에 강조된 코드를 삭제하세요:
...
app.get("/blocking", async (req, res) => {
const worker = new Worker("./worker.js");
worker.on("message", (data) => {
res.status(200).send(`result is ${data}`);
});
worker.on("error", (msg) => {
res.status(404).send(`An error ocurred: ${msg}`);
});
});
...
코드를 삭제한 후, 다음 코드를 추가하여 네 개의 작업 스레드를 초기화하세요:
...
app.get("/blocking", async (req, res) => {
const workerPromises = [];
for (let i = 0; i < THREAD_COUNT; i++) {
workerPromises.push(createWorker());
}
});
...
먼저 비어있는 배열을 포함하는 workerPromises
변수를 생성합니다. 다음으로, THREAD_COUNT
값인 4
만큼 반복합니다. 각 반복에서 createWorker()
함수를 호출하여 새로운 스레드를 생성합니다. 그런 다음, 함수가 반환하는 프로미스 객체를 JavaScript의 push
메서드를 사용하여 workerPromises
배열에 추가합니다. 반복이 완료되면, workerPromises
배열에는 createWorker()
함수를 네 번 호출하여 반환된 각각의 프로미스 객체가 포함됩니다.
이제, 다음과 같이 강조 표시된 코드를 추가하여 프로미스가 해결될 때까지 기다리고 사용자에게 응답을 반환하도록합니다:
app.get("/blocking", async (req, res) => {
const workerPromises = [];
for (let i = 0; i < THREAD_COUNT; i++) {
workerPromises.push(createWorker());
}
const thread_results = await Promise.all(workerPromises);
const total =
thread_results[0] +
thread_results[1] +
thread_results[2] +
thread_results[3];
res.status(200).send(`result is ${total}`);
});
workerPromises
배열에는 createWorker()
를 호출하여 반환된 프로미스가 포함되어 있으므로, Promise.all()
메서드 앞에 await
구문을 추가하고 all()
메서드를 workerPromises
를 인수로 호출합니다. Promise.all()
메서드는 배열의 모든 프로미스가 해결될 때까지 기다립니다. 그런 다음, thread_results
변수에 프로미스가 해결된 값이 포함됩니다. 계산이 네 개의 워커로 분할되었으므로, 대괄호 표기법 구문을 사용하여 thread_results
에서 각 값을 가져와서 모두 더합니다. 더한 후, 총합 값을 페이지에 반환합니다.
완성된 파일은 다음과 같아야 합니다:
const express = require("express");
const { Worker } = require("worker_threads");
const app = express();
const port = process.env.PORT || 3000;
const THREAD_COUNT = 4;
app.get("/non-blocking/", (req, res) => {
res.status(200).send("This page is non-blocking");
});
function createWorker() {
return new Promise(function (resolve, reject) {
const worker = new Worker("./four_workers.js", {
workerData: { thread_count: THREAD_COUNT },
});
worker.on("message", (data) => {
resolve(data);
});
worker.on("error", (msg) => {
reject(`An error ocurred: ${msg}`);
});
});
}
app.get("/blocking", async (req, res) => {
const workerPromises = [];
for (let i = 0; i < THREAD_COUNT; i++) {
workerPromises.push(createWorker());
}
const thread_results = await Promise.all(workerPromises);
const total =
thread_results[0] +
thread_results[1] +
thread_results[2] +
thread_results[3];
res.status(200).send(`result is ${total}`);
});
app.listen(port, () => {
console.log(`App listening on port ${port}`);
});
파일을 저장하고 닫습니다. 이 파일을 실행하기 전에 먼저 index.js
를 실행하여 응답 시간을 측정하십시오.
- node index.js
다음으로, 로컬 컴퓨터에서 새로운 터미널을 열고 다음 curl
명령어를 입력하여 /blocking
경로로부터 응답을 받는 데 걸리는 시간을 측정합니다:
- time curl --get http://localhost:3000/blocking
time
명령어는 curl
명령어의 실행 시간을 측정합니다. curl
명령어는 주어진 URL로 HTTP 요청을 보내고 --get
옵션은 curl
에게 GET
요청을 하도록 지시합니다.
명령어를 실행하면, 출력은 다음과 유사한 모습일 것입니다:
Outputreal 0m28.882s
user 0m0.018s
sys 0m0.000s
강조된 출력은 응답을 받는 데 약 28초가 소요된다는 것을 보여줍니다. 이는 컴퓨터에 따라 다를 수 있습니다.
다음으로, CTRL+C
로 서버를 중지하고 index_four_workers.js
파일을 실행합니다:
- node index_four_workers.js
두 번째 터미널에서 다시 /blocking
경로를 방문하세요:
- time curl --get http://localhost:3000/blocking
다음과 같은 출력이 나타날 것입니다:
Outputreal 0m8.491s
user 0m0.011s
sys 0m0.005s
출력은 약 8초가 소요되는 것을 보여줍니다. 따라서 로드 시간을 대략 70% 정도 단축시켰습니다.
네 개의 워커 스레드를 사용하여 CPU 바운드 작업을 성공적으로 최적화했습니다. 더 많은 코어를 갖고 있는 컴퓨터라면, THREAD_COUNT
를 해당 숫자로 업데이트하면 로드 시간을 더욱 단축시킬 수 있습니다.
결론
이 기사에서는 주 스레드를 차단하는 CPU 바운드 작업을 수행하는 Node 앱을 구축했습니다. 그런 다음 프로미스를 사용하여 작업을 논블로킹으로 만드는 시도를 했지만 실패했습니다. 이후에는 worker_threads
모듈을 사용하여 CPU 바운드 작업을 다른 스레드로 오프로드하여 논블로킹으로 만들었습니다. 마지막으로, CPU 집약적인 작업을 가속화하기 위해 worker_threads
모듈을 사용하여 네 개의 스레드를 생성했습니다.
다음 단계로는 옵션에 대해 자세히 알아보기 위해 Node.js Worker 스레드 문서를 참조하십시오. 또한, CPU 집약적인 작업을 위한 워커 풀을 생성할 수 있는 piscina
라이브러리를 확인할 수 있습니다. Node.js 학습을 계속하려면 Node.js에서 코딩하는 방법 튜토리얼 시리즈를 참조하십시오.
Source:
https://www.digitalocean.com/community/tutorials/how-to-use-multithreading-in-node-js