웹사이트 검색

Node.js에서 멀티스레딩을 사용하는 방법


작성자는 DOnations 프로그램에 쓰기를 선택했습니다.

소개

디스크 또는 네트워크 요청에서 파일 읽기와 같은 I/O 작업을 처리하는 libuv 라이브러리. 숨겨진 스레드를 사용하여 Node.js는 메인 스레드를 차단하지 않고 코드에서 I/O 요청을 할 수 있는 비동기 메서드를 제공합니다.

Node.js에는 숨겨진 스레드가 있지만 이를 사용하여 복잡한 계산, 이미지 크기 조정 또는 비디오 압축과 같은 CPU 집약적인 작업을 오프로드할 수는 없습니다. JavaScript는 CPU를 많이 사용하는 작업이 실행될 때 단일 스레드이기 때문에 메인 스레드를 차단하고 작업이 완료될 때까지 다른 코드가 실행되지 않습니다. 다른 스레드를 사용하지 않고 CPU 바인딩 작업의 속도를 높이는 유일한 방법은 프로세서 속도를 높이는 것입니다.

그러나 최근 몇 년 동안 CPU는 더 빨라지지 않았습니다. 대신 컴퓨터는 추가 코어와 함께 배송되며 이제 컴퓨터에 8개 이상의 코어가 있는 것이 더 일반적입니다. 이러한 추세에도 불구하고 JavaScript는 단일 스레드이기 때문에 코드는 CPU 바인딩 작업의 속도를 높이거나 기본 스레드 중단을 방지하기 위해 컴퓨터의 추가 코어를 활용하지 않습니다.

이를 해결하기 위해 Node.js는 worker-threads 모듈을 도입했습니다. 이 모듈을 사용하면 스레드를 생성하고 여러 JavaScript 작업을 병렬로 실행할 수 있습니다. 스레드가 작업을 마치면 코드의 다른 부분과 함께 사용할 수 있도록 작업 결과가 포함된 메시지를 기본 스레드로 보냅니다. 작업자 스레드를 사용하는 이점은 CPU 바운드 작업이 메인 스레드를 차단하지 않고 작업을 여러 작업자에게 나누어 분산하여 최적화할 수 있다는 것입니다.

이 자습서에서는 기본 스레드를 차단하는 CPU 집약적인 작업으로 Node.js 앱을 만듭니다. 다음으로 worker-threads 모듈을 사용하여 메인 스레드를 차단하지 않도록 CPU 집약적인 작업을 다른 스레드로 오프로드합니다. 마지막으로 CPU 바운드 작업을 분할하고 작업 속도를 높이기 위해 4개의 스레드가 병렬로 작업하도록 합니다.

전제 조건

이 자습서를 완료하려면 다음이 필요합니다.

  • 4개 이상의 코어가 있는 멀티 코어 시스템입니다. 듀얼 코어 시스템에서 1~6단계의 자습서를 계속 따를 수 있습니다. 그러나 7단계에서는 성능 향상을 확인하기 위해 4개의 코어가 필요합니다.\n
  • Node.js 개발 환경. Ubuntu 22.04를 사용 중인 경우 Node.js 설치 및 로컬 개발 환경 생성 방법의 3단계에 따라 최신 버전의 Node.js를 설치합니다.\n
  • 자바스크립트의 이벤트 루프, 콜백 및 약속에 대해 잘 이해하고 있으며 자습서인 자바스크립트의 이벤트 루프, 콜백, 약속 및 비동기/대기 이해에서 찾을 수 있습니다.\n
  • Express 웹 프레임워크를 사용하는 방법에 대한 기본 지식. Node.js 및 Express를 시작하는 방법 가이드를 확인하세요.\n

프로젝트 설정 및 종속성 설치

이 단계에서는 프로젝트 디렉터리를 만들고 npm을 초기화하고 필요한 모든 종속성을 설치합니다.

시작하려면 프로젝트 디렉터리를 만들고 이동합니다.

  1. mkdir multi-threading_demo
  2. cd multi-threading_demo

mkdir 명령은 디렉토리를 생성하고 cd 명령은 작업 디렉토리를 새로 생성된 디렉토리로 변경합니다.

그런 다음 npm init 명령을 사용하여 npm으로 프로젝트 디렉토리를 초기화합니다.

  1. 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를 설치합니다.

  1. npm install express

Express를 사용하여 차단 및 비차단 엔드포인트가 있는 서버 응용 프로그램을 생성합니다.

Node.js는 기본적으로 worker-threads 모듈과 함께 제공되므로 설치할 필요가 없습니다.

이제 필요한 패키지를 설치했습니다. 다음으로 프로세스 및 스레드와 이들이 컴퓨터에서 실행되는 방법에 대해 자세히 알아봅니다.

프로세스 및 스레드 이해

CPU 바운드 작업을 작성하고 이를 별도의 스레드로 오프로드하기 전에 먼저 프로세스와 스레드가 무엇인지, 그리고 이들 간의 차이점을 이해해야 합니다. 가장 중요한 것은 프로세스와 스레드가 단일 또는 다중 코어 컴퓨터 시스템에서 실행되는 방식을 검토하게 됩니다.

프로세스

프로세스는 운영 체제에서 실행 중인 프로그램입니다. 자체 메모리가 있으며 실행 중인 다른 프로그램의 메모리를 보거나 액세스할 수 없습니다. 또한 프로그램에서 현재 실행 중인 명령을 나타내는 명령 포인터도 있습니다. 한 번에 하나의 작업만 실행할 수 있습니다.

이를 이해하기 위해 무한 루프가 있는 Node.js 프로그램을 만들어 실행할 때 종료되지 않도록 합니다.

nano 또는 원하는 텍스트 편집기를 사용하여 process.js 파일을 만들고 엽니다.

  1. 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 속성은 프로그램 명령줄 인수를 포함하는 배열을 반환합니다. 그런 다음 JavaScript의 slice() 메서드를 인수 2와 연결하여 인덱스 2부터 배열의 얕은 복사본을 만듭니다. 이렇게 하면 Node.js 경로와 프로그램 파일 이름인 처음 두 인수를 건너뜁니다. 다음으로 대괄호 표기법 구문을 사용하여 슬라이스된 배열에서 첫 번째 인수를 검색하고 process_name 변수에 저장합니다.

그런 다음 while 루프를 정의하고 루프가 영원히 실행되도록 true 조건을 전달합니다. 루프 내에서 count 변수는 반복할 때마다 1씩 증가합니다. 다음은 count2000인지 4000인지 확인하는 if 문입니다. 조건이 true로 평가되면 console.log() 메서드는 터미널에 메시지를 기록합니다.

CTRL+X를 사용하여 파일을 저장하고 닫은 다음 Y를 눌러 변경 사항을 저장합니다.

node 명령을 사용하여 프로그램을 실행합니다.

  1. node process.js A &

A는 프로그램에 전달되고 process_name 변수에 저장되는 명령줄 인수입니다. 끝에 있는 &는 노드 프로그램이 백그라운드에서 실행되도록 하여 쉘에 더 많은 명령을 입력할 수 있도록 합니다.

프로그램을 실행하면 다음과 유사한 출력이 표시됩니다.

Output
[1] 7754 A: 2000 A: 4000

숫자 7754는 운영 체제에서 할당한 프로세스 ID입니다. A: 2000A: 4000은 프로그램의 출력입니다.

node 명령을 사용하여 프로그램을 실행하면 프로세스가 생성됩니다. 운영 체제는 프로그램에 대한 메모리를 할당하고 컴퓨터 디스크에서 실행 가능한 프로그램을 찾은 다음 프로그램을 메모리에 로드합니다. 그런 다음 프로세스 ID를 할당하고 프로그램 실행을 시작합니다. 그 시점에서 프로그램은 이제 프로세스가 되었습니다.

프로세스가 실행 중일 때 해당 프로세스 ID가 운영 체제의 프로세스 목록에 추가되고 ps와 같은 도구로 볼 수 있습니다. 이 도구는 프로세스에 대한 자세한 정보와 프로세스를 중지하거나 우선 순위를 지정하는 옵션을 제공합니다.

노드 프로세스에 대한 빠른 요약을 보려면 터미널에서 ENTER를 눌러 프롬프트를 다시 받으십시오. 그런 다음 ps 명령을 실행하여 노드 프로세스를 확인합니다.

  1. ps |grep node

ps 명령은 시스템의 현재 사용자와 관련된 모든 프로세스를 나열합니다. 모든 ps 출력을 grep에 전달하는 파이프 연산자 |는 노드 프로세스만 나열하도록 프로세스를 필터링합니다.

명령을 실행하면 다음과 유사한 출력이 생성됩니다.

Output
7754 pts/0 00:21:49 node

단일 프로그램에서 수많은 프로세스를 만들 수 있습니다. 예를 들어 다음 명령을 사용하여 인수가 다른 세 개의 프로세스를 더 만들고 백그라운드에 배치합니다.

  1. 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

출력에서 볼 수 있듯이 각 프로세스는 개수가 20004000에 도달했을 때 프로세스 이름을 터미널에 기록했습니다. 각 프로세스는 실행 중인 다른 프로세스를 인식하지 못합니다. 프로세스 D는 프로세스 C를 인식하지 못하고 그 반대도 마찬가지입니다. 두 프로세스에서 발생하는 모든 것은 다른 Node.js 프로세스에 영향을 미치지 않습니다.

출력을 면밀히 검토하면 출력 순서가 세 프로세스를 생성했을 때의 순서와 같지 않음을 알 수 있습니다. 명령을 실행할 때 프로세스 인수는 B, CD의 순서였습니다. 그러나 이제 순서는 D, BC입니다. 그 이유는 OS가 주어진 시간에 CPU에서 실행할 프로세스를 결정하는 스케줄링 알고리즘을 가지고 있기 때문입니다.

단일 코어 시스템에서 프로세스는 동시에 실행됩니다. 즉, 운영 체제는 일정한 간격으로 프로세스 간에 전환합니다. 예를 들어 프로세스 D는 제한된 시간 동안 실행된 다음 해당 상태가 어딘가에 저장되고 OS는 프로세스 B가 제한된 시간 동안 실행되도록 예약합니다. 이것은 모든 작업이 완료될 때까지 앞뒤로 발생합니다. 출력에서 각 프로세스가 완료될 때까지 실행된 것처럼 보일 수 있지만 실제로는 OS 스케줄러가 프로세스 사이를 계속 전환합니다.

멀티 코어 시스템(코어가 4개 있다고 가정)에서 OS는 각 프로세스가 각 코어에서 동시에 실행되도록 예약합니다. 이것을 병렬이라고 합니다. 그러나 4개의 프로세스를 더 생성하면(총 8개가 됨) 각 코어는 완료될 때까지 2개의 프로세스를 동시에 실행합니다.

스레드

스레드는 프로세스와 같습니다. 자체 명령 포인터가 있고 한 번에 하나의 JavaScript 작업을 실행할 수 있습니다. 프로세스와 달리 스레드에는 자체 메모리가 없습니다. 대신 프로세스의 메모리 내에 상주합니다. 프로세스를 생성할 때 JavaScript 코드를 병렬로 실행하는 worker_threads 모듈로 생성된 여러 스레드를 가질 수 있습니다. 또한 스레드는 메시지 전달 또는 프로세스 메모리의 데이터 공유를 통해 서로 통신할 수 있습니다. 스레드 생성은 운영 체제에서 더 많은 메모리를 요구하지 않기 때문에 프로세스에 비해 가볍습니다.

쓰레드의 실행에 관해서는 프로세스와 비슷한 동작을 합니다. 단일 코어 시스템에서 실행 중인 여러 스레드가 있는 경우 운영 체제는 정기적으로 스레드 간에 전환하여 각 스레드가 단일 CPU에서 직접 실행할 수 있는 기회를 제공합니다. 멀티 코어 시스템에서 OS는 모든 코어에서 스레드를 예약하고 동시에 JavaScript 코드를 실행합니다. 사용 가능한 코어보다 더 많은 스레드를 생성하게 되면 각 코어는 여러 스레드를 동시에 실행합니다.

여기에서 ENTER를 누른 다음 kill 명령을 사용하여 현재 실행 중인 모든 노드 프로세스를 중지합니다.

  1. sudo kill -9 `pgrep node`

pgrepkill 명령에 대해 4개 노드 프로세스 모두의 프로세스 ID를 반환합니다. -9 옵션은 SIGKILL 신호를 보내도록 kill에 지시합니다.

명령을 실행하면 다음과 유사한 출력이 표시됩니다.

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 코드는 단일 스레드에서 실행됩니다. 여기에는 프로그램 소스 코드와 프로그램에 포함된 타사 라이브러리가 포함됩니다. 프로그램이 파일이나 네트워크 요청을 읽기 위해 I/O 작업을 수행하면 메인 스레드가 차단됩니다.

그러나 Node.js는 Node.js 프로세스에 4개의 추가 스레드를 제공하는 libuv 라이브러리를 구현합니다. 이러한 스레드를 사용하면 I/O 작업이 개별적으로 처리되고 작업이 완료되면 이벤트 루프가 I/O 작업과 관련된 콜백을 마이크로태스크 대기열에 추가합니다. 기본 스레드의 호출 스택이 지워지면 콜백이 호출 스택에 푸시된 다음 실행됩니다. 이를 명확히 하기 위해 주어진 I/O 작업과 관련된 콜백은 병렬로 실행되지 않습니다. 그러나 파일이나 네트워크 요청을 읽는 작업 자체는 스레드의 도움과 동시에 발생합니다. I/O 작업이 완료되면 콜백이 기본 스레드에서 실행됩니다.

이 4개의 스레드 외에도 V8 엔진은 자동 가비지 수집과 같은 작업을 처리하기 위한 2개의 스레드도 제공합니다. 이렇게 하면 프로세스의 총 스레드 수가 7개가 됩니다. 하나는 기본 스레드, 4개는 Node.js 스레드, 2개는 V8 스레드입니다.

모든 Node.js 프로세스에 7개의 스레드가 있는지 확인하려면 process.js 파일을 다시 실행하고 백그라운드에 둡니다.

  1. node process.js A &

터미널은 프로세스 ID와 프로그램 출력을 기록합니다.

Output
[1] 9933 A: 2000 A: 4000

프롬프트를 다시 사용할 수 있도록 어딘가에 프로세스 ID를 기록하고 ENTER를 누르십시오.

스레드를 보려면 top 명령을 실행하고 출력에 표시된 프로세스 ID를 전달하십시오.

  1. top -H -p 9933

-H는 프로세스의 스레드를 표시하도록 top에 지시합니다. -p 플래그는 지정된 프로세스 ID의 활동만 모니터링하도록 top에 지시합니다.

명령을 실행하면 출력이 다음과 유사하게 표시됩니다.

Output
top - 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개의 스레드가 있습니다. 하나는 JavaScript 실행을 위한 기본 스레드, 4개의 Node.js 스레드 및 2개의 V8 스레드입니다.

앞에서 설명한 것처럼 4개의 Node.js 스레드는 I/O 작업에 사용되어 비차단 상태로 만듭니다. 해당 작업에 잘 작동하며 I/O 작업을 위해 스레드를 직접 생성하면 애플리케이션 성능이 저하될 수도 있습니다. CPU 바인딩 작업에 대해서도 마찬가지입니다. CPU 바운드 작업은 프로세스에서 사용 가능한 추가 스레드를 사용하지 않고 기본 스레드를 차단합니다.

이제 q를 눌러 top을 종료하고 다음 명령으로 노드 프로세스를 중지합니다.

  1. kill -9 9933

이제 Node.js 프로세스의 스레드에 대해 알았으므로 다음 섹션에서 CPU 바인딩 작업을 작성하고 이것이 기본 스레드에 미치는 영향을 관찰합니다.

작업자 스레드 없이 CPU 바인딩 작업 만들기

이 섹션에서는 비차단 경로와 CPU 바인딩 작업을 실행하는 차단 경로가 있는 Express 앱을 빌드합니다.

먼저 원하는 편집기에서 index.js를 엽니다.

  1. 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 모듈을 가져옵니다. 다음으로 Express 인스턴스를 보유하도록 app 변수를 설정합니다. 그런 다음 서버가 수신해야 하는 포트 번호를 보유하는 port 변수를 정의합니다.

그런 다음 app.get(/non-blocking)을 사용하여 GET 요청이 전송되어야 하는 경로를 정의합니다. 마지막으로 app.listen() 메서드를 호출하여 포트 3000에서 수신을 시작하도록 서버에 지시합니다.

다음으로 CPU를 많이 사용하는 작업을 포함할 다른 경로 /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}`);
});

app.get(\/blocking\)을 사용하여 /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}`);
});

파일을 저장하고 종료한 후 다음 명령으로 서버를 시작합니다.

  1. node index.js

명령을 실행하면 다음과 유사한 출력이 표시됩니다.

Output
App listening on port 3000

이는 서버가 실행 중이고 제공할 준비가 되었음을 나타냅니다.

이제 원하는 브라우저에서 http://localhost:3000/non-blocking을 방문하세요. This page is non-blocking 메시지와 함께 즉각적인 응답이 표시됩니다.

참고: 원격 서버에서 자습서를 따르는 경우 포트 전달을 사용하여 브라우저에서 앱을 테스트할 수 있습니다.

Express 서버가 계속 실행되는 동안 로컬 컴퓨터에서 다른 터미널을 열고 다음 명령을 입력합니다.

  1. 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 경로를 한 번만 방문하면 됩니다. 모든 애플리케이션 경로가 응답하지 않도록 합니다.

보시다시피 메인 스레드를 차단하면 앱에 대한 사용자 경험에 해를 끼칠 수 있습니다. 이 문제를 해결하려면 메인 스레드가 다른 HTTP 요청을 계속 처리할 수 있도록 CPU 바운드 작업을 다른 스레드로 오프로드해야 합니다.

그런 다음 CTRL+C를 눌러 서버를 중지합니다. index.js 파일을 추가로 변경한 후 다음 섹션에서 서버를 다시 시작합니다. 서버가 중지된 이유는 파일에 대한 새로운 변경 사항이 있을 때 Node.js가 자동으로 새로 고쳐지지 않기 때문입니다.

이제 CPU를 많이 사용하는 작업이 애플리케이션에 미칠 수 있는 부정적인 영향을 이해했으므로 약속을 사용하여 기본 스레드 차단을 피하려고 합니다.

약속을 사용하여 CPU 바운드 작업 오프로드

종종 개발자는 CPU 바운드 작업의 차단 효과에 대해 알게 될 때 코드를 차단하지 않도록 하겠다는 약속을 합니다. 이러한 본능은 readFile()writeFile()과 같은 비차단 약속 기반 I/O 메서드 사용에 대한 지식에서 비롯됩니다. 그러나 배운 것처럼 I/O 작업은 CPU 바인딩 작업이 사용하지 않는 Node.js 숨겨진 스레드를 사용합니다. 그럼에도 불구하고 이 섹션에서는 비블로킹을 만들기 위한 시도로 CPU 바운드 작업을 약속으로 래핑합니다. 작동하지 않지만 다음 섹션에서 수행할 작업자 스레드 사용의 가치를 확인하는 데 도움이 됩니다.

편집기에서 index.js 파일을 다시 엽니다.

  1. 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 구문으로 초기화되는 약속을 반환합니다. Promise는 성공 또는 실패를 처리하는 resolvereject 매개변수로 콜백을 받습니다. for 루프 실행이 완료되면 Promise는 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}`);
});

파일을 저장하고 종료한 다음 서버를 다시 시작합니다.

  1. node index.js

웹 브라우저에서 http://localhost:3000/blocking을 방문하고 로드되면 http://localhost:3000/non-blocking 탭을 빠르게 다시 로드합니다. 보시다시피 비차단 경로는 여전히 영향을 받으며 모두 /차단 경로가 로드를 완료할 때까지 기다립니다. 경로는 여전히 영향을 받기 때문에 Promise는 JavaScript 코드를 병렬로 실행하지 않으며 CPU 바인딩 작업을 차단하지 않도록 만드는 데 사용할 수 없습니다.

그런 다음 CTRL+C를 사용하여 응용 프로그램 서버를 중지합니다.

이제 Promise가 CPU 바인딩된 작업을 차단하지 않게 만드는 메커니즘을 제공하지 않는다는 것을 알았으므로 Node.js worker-threads 모듈을 사용하여 CPU 바인딩된 작업을 별도의 스레드로 오프로드합니다.

작업자 스레드 모듈을 사용하여 CPU 바운드 작업 오프로드

이 섹션에서는 기본 스레드 차단을 방지하기 위해 worker-threads 모듈을 사용하여 CPU 집약적인 작업을 다른 스레드로 오프로드합니다. 이를 위해 CPU를 많이 사용하는 작업을 포함할 worker.js 파일을 생성합니다. index.js 파일에서 worker-threads 모듈을 사용하여 스레드를 초기화하고 worker.js 파일에서 작업을 시작합니다. 메인 스레드와 병렬로 실행합니다. 작업이 완료되면 작업자 스레드는 결과가 포함된 메시지를 기본 스레드로 다시 보냅니다.

시작하려면 nproc 명령을 사용하여 코어가 2개 이상인지 확인합니다.

  1. nproc
Output
4

두 개 이상의 코어가 표시되면 이 단계를 진행할 수 있습니다.

다음으로 텍스트 편집기에서 worker.js 파일을 만들고 엽니다.

  1. 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() 메서드를 호출합니다. 이 메서드는 카운터 변수.

파일을 저장하고 종료합니다. 텍스트 편집기에서 index.js를 엽니다.

  1. 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.js 파일의 코드가 다른 코어의 스레드에서 실행되기 시작합니다.

그런 다음 메시지 이벤트를 수신하기 위해 on(\message\) 메서드를 사용하여 worker 인스턴스에 이벤트를 연결합니다. worker.js 파일의 결과가 포함된 메시지가 수신되면 메서드의 콜백에 매개변수로 전달되어 CPU 바인딩 작업의 결과가 포함된 사용자에게 응답을 반환합니다.

다음으로 on(\error\) 메서드를 사용하여 작업자 인스턴스에 다른 이벤트를 연결하여 오류 이벤트를 수신합니다. 오류가 발생하면 콜백은 오류 메시지가 포함된 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}`);
});

파일을 저장하고 종료한 다음 서버를 실행합니다.

  1. node index.js

웹 브라우저에서 http://localhost:3000/blocking 탭을 다시 방문하십시오. 로드가 완료되기 전에 모든 http://localhost:3000/non-blocking 탭을 새로 고칩니다. 이제 /blocking 경로가 로드를 완료할 때까지 기다리지 않고 즉시 로드되고 있음을 알 수 있습니다. 이는 CPU 바운드 작업이 다른 스레드로 오프로드되고 기본 스레드가 들어오는 모든 요청을 처리하기 때문입니다.

이제 CTRL+C를 사용하여 서버를 중지합니다.

이제 작업자 스레드를 사용하여 CPU 집약적인 작업을 비차단으로 만들 수 있으므로 4개의 작업자 스레드를 사용하여 CPU 집약적인 작업의 성능을 향상시킬 것입니다.

4개의 작업자 스레드를 사용하여 CPU 집약적인 작업 최적화

이 섹션에서는 작업을 더 빨리 완료하고 /blocking 경로의 로드 시간을 단축할 수 있도록 4개의 작업자 스레드 간에 CPU 집약적인 작업을 나눕니다.

동일한 작업에서 더 많은 작업자 스레드가 작동하도록 하려면 작업을 분할해야 합니다. 이 작업에는 200억 회 반복이 포함되므로 200억을 사용하려는 스레드 수로 나눕니다. 이 경우 4입니다. 20_000_000_000/4를 계산하면 5_000_000_000이 됩니다. 따라서 각 스레드는 0에서 5_000_000_000까지 루프를 돌고 카운터1씩 증가시킵니다. 각 스레드가 완료되면 결과를 포함하는 메시지를 기본 스레드로 보냅니다. 메인 스레드가 4개의 스레드 모두에서 개별적으로 메시지를 수신하면 결과를 결합하여 사용자에게 응답을 보냅니다.

대규모 배열을 반복하는 작업이 있는 경우 동일한 접근 방식을 사용할 수도 있습니다. 예를 들어 디렉토리에서 800개의 이미지 크기를 조정하려는 경우 모든 이미지 파일 경로를 포함하는 배열을 만들 수 있습니다. 다음으로 8004(스레드 수)로 나누고 각 스레드가 범위에서 작동하도록 합니다. 스레드 1은 배열 인덱스 0에서 199로 이미지 크기를 조정하고 스레드 2는 인덱스 200에서 399로 이미지 크기를 조정합니다. 곧.

먼저 코어가 4개 이상인지 확인합니다.

  1. nproc
Output
4

cp 명령을 사용하여 worker.js 파일의 복사본을 만듭니다.

  1. cp worker.js four_workers.js

현재 index.jsworker.js 파일은 나중에 다시 실행하여 성능을 이 섹션의 변경 사항과 비교할 수 있도록 그대로 유지됩니다.

다음으로 텍스트 편집기에서 four_workers.js 파일을 엽니다.

  1. 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 파일에서 곧 수행할 예정임). . 개체에는 4인 스레드 수를 포함하는 thread_count 속성이 있습니다. 다음으로 for 루프에서 20_000_000_000 값을 4로 나누면 5_000_000_000이 됩니다.

파일을 저장하고 닫은 다음 index.js 파일을 복사합니다.

  1. cp index.js index_four_workers.js

편집기에서 index_four_workers.js 파일을 엽니다.

  1. 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 파일에서 참조한 개체입니다.

Promise가 오류를 해결하거나 throw하는지 확인하려면 다음 강조 표시된 줄을 추가합니다.

...
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}`);
    });
  });
}
...

작업자 스레드가 기본 스레드에 메시지를 보내면 Promise는 반환된 데이터로 해결됩니다. 그러나 오류가 발생하면 약속은 오류 메시지를 반환합니다.

이제 새 스레드를 초기화하고 스레드에서 데이터를 반환하는 함수를 정의했으므로 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}`);
  });
});
...

코드가 삭제된 상태에서 다음 코드를 추가하여 4개의 작업 스레드를 초기화합니다.

...
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 를 붙입니다. 구문을 사용하고 workerPromises를 인수로 사용하여 all() 메서드를 호출합니다. Promise.all() 메서드는 배열의 모든 약속이 해결될 때까지 기다립니다. 이 경우 thread_results 변수에는 약속이 해결한 값이 포함됩니다. 계산이 4명의 작업자 간에 분할되었으므로 대괄호 표기법 구문을 사용하여 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를 실행하여 응답 시간을 측정하세요.

  1. node index.js

그런 다음 로컬 컴퓨터에서 새 터미널을 열고 다음 curl 명령을 입력합니다. 이 명령은 /blocking 경로에서 응답을 받는 데 걸리는 시간을 측정합니다.

  1. time curl --get http://localhost:3000/blocking

time 명령은 curl 명령이 실행되는 시간을 측정합니다. curl 명령은 주어진 URL에 HTTP 요청을 보내고 --get 옵션은 curlGET 를 만들도록 지시합니다. 요청합니다.

명령이 실행되면 출력은 다음과 유사하게 표시됩니다.

Output
real 0m28.882s user 0m0.018s sys 0m0.000s

강조 표시된 출력은 응답을 받는 데 약 28초가 걸린다는 것을 보여 주며 이는 컴퓨터에 따라 다를 수 있습니다.

다음으로 CTRL+C로 서버를 중지하고 index_four_workers.js 파일을 실행합니다.

  1. node index_four_workers.js

두 번째 터미널에서 /blocking 경로를 다시 방문하십시오.

  1. time curl --get http://localhost:3000/blocking

다음과 일치하는 출력이 표시됩니다.

Output
real 0m8.491s user 0m0.011s sys 0m0.005s

출력은 약 8초가 걸린다는 것을 보여줍니다. 즉, 로드 시간이 약 70% 단축되었음을 의미합니다.

4개의 작업자 스레드를 사용하여 CPU 바인딩 작업을 성공적으로 최적화했습니다. 코어가 4개 이상인 시스템이 있는 경우 THREAD_COUNT를 해당 숫자로 업데이트하면 로드 시간이 훨씬 더 단축됩니다.

결론

이 기사에서는 기본 스레드를 차단하는 CPU 바운드 작업으로 Node 앱을 빌드했습니다. 그런 다음 약속을 사용하여 비차단 작업을 시도했지만 실패했습니다. 그런 다음 worker_threads 모듈을 사용하여 CPU 바운드 작업을 다른 스레드로 오프로드하여 비차단 상태로 만들었습니다. 마지막으로 worker_threads 모듈을 사용하여 4개의 스레드를 생성하여 CPU 집약적인 작업의 속도를 높였습니다.

다음 단계로 Node.js에서 코딩하는 방법을 참조하세요.