Back_End/Node.js

[ 백엔드 공부하기 : Node.js ] NodeJS의 스레드 방식은 싱글? 멀티?

안다미로 : APP & Web3 & D.S 2024. 12. 8. 16:12

 

 

 

[ 백엔드 공부하기 : Node.js ] NodeJS의 스레드 방식은 싱글? 멀티?

 


∇백엔드_NodeJS : Node.JS의 스레드 방식은 싱글인가? 멀티인가?

 

 

 

 

 

결론적으로 말하자면, 

Node.js는 싱글로 작동하는 듯 하지만  확실하게 싱글이 아니고,  멀티로 작동하는 듯 하지만  확실하게 멀티는 아닙니다.

   [ 이론적으로는 멀티 스레드인듯 보이지만, 개념적으로는 싱글 스레드 ]

 

 

이게 뭔 개소리냐..할 수 있지만,

이렇게 말할 수 있는게   { 'libuv'의 'thread pool' } 과 {Worket_thread} 의 작동 때문입니다.

 

libuv

 


 

 

Ⅰ . 프로세스와 스레드 ( Process & Thread )


        

           ▣ 프로세스란?

                   - 프로그램이 컴퓨터에서 실행이 되고 있는 상태로 만들어주는 실행 프로그램.

                   - 메모리에 올라와 실행되고 있는 프로그램의 인스턴스 (독립적인 객체) 입니다.

                           => 운영체제로부터 자원을 할당받은 작업의 단위.

 

                           =>> 최소 1개 이상의 스레드를 가집니다.

                           ▷ 프로그램이 실행되는 즉시 CPU로부터 할당받는 자원 영역 ( 메로리 등)

                

           ▣ 스레드란?

                   - 프로세스 내에서 실제로 작업을 처리하는 주체입니다.

                   - 프로세스가 할당받은 자원을 이용하는 실행 흐름의 단위입니다.

                   - 스레드끼리 프로세스의 자원을 공유하면서 프로세스 실행 흐름의 일부가 됩니다.

 

                           ▷ 프로세스 내에서 실행되는 여러 흐름의 단위이며, 

                                   프로세스가 할당받은 시스템자원 ( CPU 시간, 메모리 여역 등)을 이용하는 실행 단위.

 

                           ▷ 스레드는 프로세스의 어떤 자원을 활용하게 될까?

                                   :: 프로세스의 메모리 영역인 -> { Code, Data, BSS, Heap, Stack }에 접근하여 활용하게 됩니다.

 

 

           ▣ 멀티 스레드란?

                   - 하나의 프로세스가 두 개 이상의 스레드를 가지는 경우이며, 여러 작업을 여러 스레드를 사용하여 

                          동시에 처리하는 것을 의미합니다.

                   - 모든 스레드는 프로세스 내 메모리 영역의 내용을 공유합니다.

                   - 메모리 자원을 아낄 수 있으며, 응답 시간이 빠르다는 장점이 있지만,

                     동기화 문제(계속 변동되는 자원에 접근하게 되는 오류)가 발생할 수 있습니다.

 

 


 

 

           ▣ 프로세스의 메모리.

                    :: 프로세스의 메모리는 크게  [ Code, Data, BSS, Heap, Stack ] 영역으로 

 

 

 

                       √ Code.

 

                               - 가장 하단에 위치한 영역이며, 작성한 기계어 또는 코드가 들어갑니다.

                               - 함수, 제어문, 상수 등이 들어갑니다.

                               - Read-only 영역이며, 컴파일러가 만든 코드라고 봐도 무방합니다.

 

 

                       √ Data.

 

                               - 전역변수, 정적변수, 배열, 구조체 등이 저장되는 공간입니다.

                               - 초기화 (initialized) 된 데이터가 저장되는 공간입니다.

                                     즉, 초기값이 있는 static 변수가 들어갑니다.

 

 

                       √ BSS (Block-started  Symbol).

 

                               - 전역변수, 정적변수, 배열, 구조체 등이 저장되는 공간입니다.

                               - 초기화되지 않은 (초기값이 없는) 데이터가 저장됩니다.

 

                                    📌 Code, Data, BSS 모두 정적인 영역이며, compile time에 크기가 결정되어 

                                                이후에 변동되지 않습니다.

                                    📌 또한, 프로세스가 종료될 때까지 계속 작동합니다.

 

                     √ Heap.

 

                             - 프로그래머가 동적으로 사용하는 영역으로,

                                   동적 객체 데이터의 할당 또는 반환이 이루어지는 영역입니다.

 

 

                     √ Stack.

 

                             - 함수가 포함되어 있고, 함수 내의 지역변수, 매개변수 등이 저장되어 있는,

                                   프로그램이 자동으로 사용하는 임시 메모리입니다.

 

                             - LIFO(선입후출) 정책을 사용하며, 함수 호출 시 생성되고 함수 종료 시 반환됩니다.

 

                                    📌 Heap의 영역이 증가하여, Stack 영역을 침범하는 Heap Overflow 의 상황이 되거나

                                          Stack의 영역이 증가하여, Heap의 영역을 침범하는 Stack Overflow의 상황이 될 때 사용되는

                                           메모리의 자유 영역 또한 존재합니다.

 

                                    📌 프로세스 메모리의 속도는 Stack -> Data -> Code -> Heap 순으로 빠릅니다.

 

 

           ▣ Single, Multo-thread

 

                    ▼ Single Thread :

                           - 하나의 프로세스에서 하나의 스레드를 실행하므로, 프로세스 내의 작업을 순차적으로 실행합니다.

                           - 유저와 상호작용하는 앱의 경우, 한 가지 작업이 끝난 뒤에야 다음 작업으로 이동하기 때문에

                                  유저가 원하는 작업 수행이 빠르게 이루어지지 않을 수 있습니다.

                               (low-responsiveness)

 

 

                    ▼ Multi Thread :                                    

                             - 하나의 프로세스 내에 여러 개의 스레드가 실행됩니다.

                             - 각각의 스레드가 다른 작업을 할당받으므로, 프로세스가 병렬적으로 여러 작업을 동시에 수행

                             -  Multi-Thread는 프로세스 내에서 각각 Stack만 따로 할당받고 Code, Data, Heap 영역은 공유합니다.

                             -  Heap 메모리는 서로 동시에 읽고 쓸 수 있으며, 한 스레드가 자원을 변경하면 다른 이웃 스레드도 

                                    그 결과를 즉시 볼 수  있습니다.

                             -  여러개의 스레드가 동일한 메모리 주소를 활용하면서 작업을 여러 개 수행하기 때문에,

                                   메모리 사용이 효율적입니다.

 

Register는 Stack의 최상단 값의 주소를 추적하는 포인터.

 


Ⅱ . JavaScript는 Single-thread인가?


 

   자바스크립트를 작동시키는 것은 바로 V8엔진이며, V8엔진은 Call-Stack과 Memory Heap으로 구성되어 있습니다.

 

   이 V8엔진 덕분에 자바스크립트는 브라우저 내*외부 모두 동작이 가능해졌습니다.

   V8엔진과 런타임이 만나면 이벤트 루프가 작동하게 됩니다.

      -> 이벤트 루프는 비동기 처리를 가능하게 해주는 일종의 시스템.

 

       ◎ "이벤트 루프"는 싱글 스레드로 작동합니다. [ 이것이 자바스크립트가 싱글 스레드 언어라고 불리는 이유 ]

              == 자바스크립트 자체만으로는 코드가 순차적으로 실행됩니다.

                        [이전 라인 코드 실행이 안료되지 않으면, 다음 라인으로 넘어가지 않습니다]

 

                📌자바스크립트는 Node나 웹 브라우저와 같은 멀티 쓰레딩이 가능한 환경에서 실행됩니다.

                         :: 따라서, "자바스크립트(엔진)" 은 싱글 스레드이지만, !

                                         "자바스크립트를 실행하는 런타임"은 완벽하게 싱글 스레드 환경을 제공하지는 않습니다.

 


 

                   

Ⅲ . NodeJs가 Multi-thread로 작동하는 것처럼 하는 libuv


 

     Node.js 는 실행 즉시 하나의 독립된 프로세스가 되어 CPU로부터 리소스를 할당받습니다.

 

     Node에서 자바스크립트를 실행하면 '콜백'을 통해 여러 개의 비동기 함수를 실행할 수 있으며,

         fs.readfile 또는 DNS read 과 같은 작업들도 수행 가능합니다.

 

       그러나!

          이러한 작업들의 문제점은, 이벤트 루프에 큰 부담을 주어 결과적으로 애플리케이션의 성능이 줄어들게 됩니다.

          이를 해결하기 위해서는 Node.js는 libuv 라는 C/C++ 라이브러리를 사용하기 시작합니다.

            libuv는 멀티스레딩을 지원해줍니다.

 

 

        ◆  libuv

                 - I/O-intensive 작업과 CPU-intensive 작업을 지원합니다.

                 - I/O: 입력(Input)/출력(Output)의 약자로,

                            컴퓨터 및 주변장치에 대하여 데이터를 전송하는 프로그램, 운영 혹은 장치입니다.

                 - I/O-intensive : DNS, File System 관련 내용입니다.

                 - I/O-intensive : Crypto, Zlib 관련 내용입니다.

 

 

             ◇ Node.js가 libuv를 사용하여 새로운 스레드를 사용하는 구체적인 상황은???

 

 

                    1. DNS 쿼리를 할 때

 

                         - Host의 ip를 읽어야 하는 상황에서는 메인-스레드인 이벤트 루프를 사용하지 않고

                               새로운 스레드를 만들어서 작업을 수행합니다.

                         - Fetch API 등을 활용 때 등등.

 

                    2. File system에 접근할 때

 

                         - Async (비동기) file system 작업을 할 때,

                             이벤트 루프에서 자체적으로 새로운 스레드로 해당 작업을 넘깁니다.

                         - 주의할 점은, readfilesync 와 같은 동기 함수는 새로운 스레드가 아닌 이벤트 루프에서 실행.

 

                    3. Crypto, Zlip

 

                         - Crypto를 활용한 암호화나 해싱 작업 도한 이벤트 루프가 아닌 스레드를 활용합니다.

                         - Zlip  : 코드 내에서 무언가를 압출할 때 역시 스레드를 활용합니다.

 

                    4. Cpu 집약적인 작업 

 

                         - 암호화 작업과 같은 계산이 많이 필요한 작업을 처리.

 

                    5. 커널에서 지원하지 않는 비동기 작업

 

                         - 운영체제가 기본적으로 비동기 인터페이스를 제공하지 않은 작업을 처리.

 

                    6. 파일 시스템 작업.

 

                         - 일부 파일 I/O 작업을 처리합니다.

 

 

 

   ★ 결론적으로, NodeJS는 두 가지의 스레드로 구성되어 있습니다.

            1. 이벤트 루프 (메인 스레드)

            2. Thread Pool :

                  - 미리 생성된, 비어있는 스레드 그룹입니다.

                  - Thread Pool의 크기를 미리 정해놓으면

                     반복적인 스레드 생성/삭제 절차를 줄임으로써 프로세스의 성능을 개선 가능합니다.

                      [ Node.js의 Thread Pool 기본값은 4개 입니다. ]

 

                ** Nodejs는 이벤트 루프에서 작업을 수행하다가,  fs 나 해싱등의 작업이 필요한 경우,

                        이 작업들을 Thread Pool로 넘겨서 새로운 Thread를 사용합니다.

 

 

📌 중요한 POINT.

        - res.end 등과 같이, networking 관련 작업은,  항상! 메인 스레드인 이벤트 루프를 활용합니다. 

        - 서버의 코드를 작성할 때 해당 코드의 작업이 이벤트 루프에서 작동하는지,

               쓰레드풀의 다른 스레드에서 이루어지는지 판단하는 것은 매우 중요합니다.

           [ 무조건 스레드를 늘려놓는 것 보다, 어떤 코드가 새로운 스레드를 요구하는지 파악하고,

                  이에 맞춰서 pooling 작업을 지속적으로 하는 것이 중요합니다. ]

 

 

 

✨ 정리하면,  Nodejs는 기본적으로 Js의 이벤트 루프를 메인 스레드로 활용하기 때문에

             싱글 스레드이지만,  기본 작업 외 특정 작업들을 수행할 때 추가 스레드가 필요한 경우네는 

             쓰레드 풀에서 새로운 스레드를 생성하여 실행할 수 있는

              (유사) Multi-thread 프로세스이기도 합니다.

 

 


 

 

Ⅳ . NodeJs가 Multi-thread로 작동하는 것처럼 하는 모듈 :: Worker_Threads 모듈


 

   ▣ "워커 스레드" 는 CPU 퍼포먼스 향상을 위해 활용되는 모듈입니다.

            

 

 

 


 

   ▣ Worker Thread 정리.

        Worker_thread는 Node.js에서 JavaScript 코드를 병렬로 실행할 수 있게 해주는 기능입니다.

        주요 특징은 다음과 같습니다

 

    1. 병렬 처리: JavaScript 코드를 동시에 실행할 수 있는 별도의 환경을 제공합니다

 

  1.  


  2. 독립적인 실행: 각 Worker_thread는 독립적인 V8 인스턴스와 이벤트 루프를 가집니다
     


  3. 메시지 기반 통신: 스레드 간 통신은 메시지를 통해 이루어집니다. postMessage() 메소드로 메시지를 보내고, 'message' 이벤트 핸들러로 받을 수 있습니다
     


  4. 리소스 공유: 메모리를 공유하지 않고 메시지만 주고받아 안전성을 높입니다
     


  5. CPU 집약적 작업에 적합: I/O 작업보다는 CPU를 많이 사용하는 작업에 효과적입니다

 

 

   

   ▣ Worker Thread 사용.

       

  • const { worker, parentPort } = require('worker_threads') 
    -> worker 클래스는 독립적인 자바스크립트 실행 스레드를 의미하고, parentPort는 메세지 포트의 인스턴스입니다.
  • new Worker(filename) 이나 new Worker(code, {eval: true}) 
    -> 워커를 시작하는 두 가지 메인 방법입니다(파일명을 넘기거나, 실행하고자 하는 코드를 작성하거나).
        실제 제작시 파일명을 사용하는 편이 권장됩니다.
  • worker.on('message'), worker.postMessage(data)
    -> 다른 스레드간 메세지를 주고받을 때 사용합니다.
  • parentPort.on('message'), parentPort.postMessage(data)
    -> parentPort.postMessage(data) 를 통해 보내진 메세지는 worker.on('message') 를 사용한 부모 스레드에서
        사용 가능합니다. 그리고 worker.postMessage(data) 를 사용한 부모 스레드로부터 보내진 메세지는
        parentPort.on('message') 를 사용한 스레드에서 사용 가능합니다.

 

 

메인 스레드 와 Worker 스레드

  • isMainThread : 현재 코드가 메인 스레드에서 실행되는지, 워커 스레드에서 실행되는지 구분
  • 메인 스레드에서는 new Worker를 통해 현재 파일(__filename)을 워커 스레드에서 실행시킴
const { Worker, isMainThread, parentPort } = require('worker_threads');

if (isMainThread) { // 메인 스레드
	const worker = new Worker(__filename); // 같은 dir폴더에 워커를 생성
    
} else { // 워커스레드
	// 위에서 생성한 worker는 여기서 동작
}

 

 

메인 스레드 <-> Worker 데이터 송수신

  • worker.postMessage로 부모에서 워커로 데이터를 보냄
  • parentPort.on('message')로 부모로부터 데이터를 받고, postMessage로 데이터를 보냄
const { Worker, isMainThread, parentPort } = require('worker_threads');

if (isMainThread) { // 메인 스레드
   const worker = new Worker(__filename);

   worker.on('message', (value) => {
      console.log('워커로부터', value)
   })
   worker.on('exit', (value) => { // parentPort.close()가 일어나면 이벤트 발생
      console.log('워커 끝~');
   })

   worker.postMessage('ping'); // 워커스레드에게 메세지를 보낸다.

} else { // 워커스레드

   parentPort.on('message', (value) => {
      console.log("부모로부터", value);
      parentPort.postMessage('pong');
      parentPort.close(); // 워커스레드 종료라고 메인스레드에 알려줘야 exit이벤트 발생
   })
}

 

 

 

   ▣ Worker Thread 작동 원리.

워커 스레드는 Node.js의 메인 스레드와 별도로 동작하게 됩니다.

개발자는 워커 스레드를 생성하여

복잡한 계산이나 CPU 집약적인 작업을 메인 스레드와는 독립적으로 수행시킬 수 있습니다.

 

    const { Worker, isMainThread, parentPort } = require('worker_threads');

    if (isMainThread) {
        const worker = new Worker(__filename);
        worker.on('message', message => console.log(message));
        worker.postMessage('Hello, Worker!');
    } else {
        parentPort.on('message', (message) => {
            parentPort.postMessage(message + ' Received');
        });
    }

이 코드는 메인 스레드에서 워커 스레드를 생성하고, 워커 스레드와 메시지를 주고받는 간단한 예제입니다.

워커 스레드는 메시지를 받고, 처리한 후 메인 스레드에 결과를 전송합니다.

 

왜냐하면 워커 스레드는 메인 스레드와 독립적으로 실행되기 때문에,

메인 스레드의 실행을 방해하지 않고 복잡한 작업을 처리할 수 있습니다.

 

이는 Node.js 애플리케이션의 성능과 반응성을 향상시키는 데 큰 도움이 됩니다.

 

 

워커 작업의 핵심은 무언가 하드한 코드가 있으면,
이를 개발자가 이 코드를 잘 쪼개서 나뉘어 할당하는 로직을 구현하여 나누는 능력이 필요하고,

워커에서 돌리고 나온 결과나 설정 등등 하나하나 개발자가 세세하게 직접 코딩해야 한다는 특징이 있습니다.

 

 


 

Ⅴ . 그럼 libuv 와 Worket_Thread 의 차이점과 공통점은 뭐야?


 

    ◎ 차이점.

              1. 목적

                      ● libuv thread pool : 주로 I/O 작업과 일부 CPU 집약적인 작업을 처리합니다.

                      ● worker_thread : CPU 집약적인 JS 작업을 수행하는 데 유용합니다.

 

              2. 제어권.

                      ● libuv thread pool : Node.js 내부에서 자동으로 관리되며, 개발자가 직접 제어하지 않습니다,

                      ● worker_thread : 개발자가 명시적으로 생성하고 관리합니다.

 

              3. 사용범위.

                      ● libuv thread pool : 파일 시스템 작업, DNS 조회, 암호화 작업 등에 사용됩니다.

                      ● worker_thread : JS 코드를 실행할 수 있어 더 다양한 작업을 처리할 수 있습니다.

 

              4. 통신 방식.

                      ● libuv thread pool : Node.js 내부에서 자동으로 처리됩니다.

                      ● worker_thread : 메시지 패싱을 통해 통신합니다.  

                                 - 'postMessage()' 와  'message' 이벤트를 사용합니다.

 

 

 

    ◎ 공통점.

         

              1. 멀티 스레딩 지원

                      ● 둘 다 Node.js에서 멀티스레팅을 가능하게 합니다.

 

 

              2. 성능 향상

                      ● 둘 다 Node.js 애플리케이션의 성능을 향상시키는 데 사용될 수 있습니다.

 

 

              3. 병렬 처리

                      ● 둘 다 작업을 병렬로 처리할 수 있게 해줍니다.

 

 

              4. 메인 스레드 블로킹 방지

                      ● 둘 다 메인 이벤트 루프가 블로킹되는 것을 방지하는 데 도움을 줍니다.