왓풀(whatpull)

Multi-Process server program 본문

웹개발/[지식] 프로그래밍

Multi-Process server program

이연수(Allen) 2022. 8. 1. 13:29

https://betterprogramming.pub/scaling-node-js-applications-with-multiprocessing-b0c25511832a

 

Scaling Node.js Applications With Multiprocessing

You might be underutilizing your multi-core server environment without even knowing

betterprogramming.pub

 

멀티프로세싱이란?

멀티프로세싱은 Node의 서버 측 확장 기술로, 두 개 이상의 Node 인스턴스(이상적으로는 각 프로세서 코어에 대해 하나씩)를 실행할 수 있습니다. 여러 노드 인스턴스를 사용하면 여러 기본 스레드가 있으므로 스레드 중 하나가 점유되거나 충돌하더라도 들어오는 요청을 처리할 다른 스레드가 있습니다. 그렇게 하면 애플리케이션이 다음과 같이 보이지 않습니다.

Node의 내장 클러스터 모듈이나 child_process 모듈을 사용하여 다중 처리를 구현할 수 있습니다.

 

 

multi processing은 CPU-intensive한 작업을 처리할 때 선택하는 것이 좋고 multi threading은 I/O-intensive한 작업을 처리할 때 선택하는 것이 좋다.

 

worker_thread 사용 예시

const { Worker } = require('worker_threads')

const runService = (workerData) => {
  return new Promise((resolve, reject) => {
    const worker = new Worker(__filename,{ workerData });
    worker.on('message', resolve);
    worker.on('error', reject);
    worker.on('exit', (code) => {
      if (code !== 0)
        reject(new Error(`Worker stopped with exit code ${code}`));
    })
  })
}

const run = async () => {
  const result = await runService('test')
}

run().catch(err => console.error(err))

cluster 사용 예시(PM2)

const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
// main process가 실행
  console.log(`Master ${process.pid} is running`);

  // Fork workers.
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.on('exit', (worker, code, signal) => {
    console.log(`worker ${worker.process.pid} died`);
  });
} else {
// worker process가 실행
  http.createServer((req, res) => {
    res.writeHead(200);
    res.end('hello world\n');
  }).listen(8000);

  console.log(`Worker ${process.pid} started`);
}

Javascript 파일을 처음 실행하는 process가 마스터가 된다. 마스터에서는 fork() 메서드를 통해서 새로운 worker를 생성한다.

 

https://blog.appleseed.dev/post/nodejs-non-blocking-io-and-multicore-processing/

 

NodeJS의 Non-Blocking IO와 멀티코어 프로세싱

A dev blog of appleseed

blog.appleseed.dev

 

NodeJS의 Non-Blocking IO와 멀티코어 프로세싱

Why Asynchronous, Non-Blocking IO

왜 non-blocking IO가 NodeJS의 특장점인지, CPU 코어의 활용을 극대화할 수 있는지를 이해하려면 먼저 IO의 특성과 blocking, non-blocking하는 코드들에 대한 이해가 필요하다.

 

IO in general

IO는 Input/Output의 줄임말이다. 하지만 컴퓨터과학에서 그 의미는 범위에 따라 아예 다른 작업을 뜻할 수도 있다. 어떤 데이터가 CPU에서 처리되기 위해서는 자신이 있는 위치부터 메모리 계층의 최상위에 있는 레지스터까지 전달되어야 한다. 몇몇 데이터 소스는 실행을 심각하게 늦추지 않는 선에서 레지스터까지의 데이터 전달을 보장한다. 하지만 대부분의 데이터 소스는 데이터를 요청했을 때 일정 시간 안에 데이터를 받을 수 있을 거라는 보장이 없기 때문에, 프로그램은 어떤 방식으로든 데이터를 받기까지 실행이 심각하게 늦춰진다고 느껴질 만큼 대기하는 시간이 생긴다. 현대의 컴퓨터 구조에서 전자는 메모리, 즉 RAM과 그 상위 메모리 계층을 가리키고 후자는 디스크 - 하드디스크 및 SSD - 와 네트워크를 통한 데이터 교환을 의미한다. 어떤 IO가 blocking하는지, non-blocking하는지는 주로 후자에 해당하는 디스크 혹은 네트워크로부터 데이터를 가져오려고 할 때 프로그래밍 언어 혹은 런타임이 데이터가 도착하기를 대기하는지에 따라 구분된다.

 

Blocking IO

Block은 막는다는 뜻으로 blocking IO는 직역하여 IO 작업시 프로그램의 실행을 막는다는 뜻으로 해석될 수 있다. 실제로 blocking하는 프로그래밍 언어들이나 런타임들은 디스크나 네트워크를 통한 데이터가 레지스터까지 도달하는 동안 그 줄에서 명렁을 더 실행하지 않고 대기한다. 낮은 레벨 관점에서 보면, 이 상태의 CPU 코어는 busy-waiting 한다. 실제로 어떤 명령을 수행하고 있는 것이 아니라, 실행 의미가 없는 idle 명령을 계속 반복하고 있는 것이다. 자동차로 치면, 공회전하고 있는 것이다. 결과적으로 IO와 상관 없는 작업들도 IO 작업이 병목이 되어 CPU 사이클을 낭비하게 된다.

사실 blocking IO가 항상 CPU를 idle 상태로 공회전 시키는 것은 아니다. IO 작업은 컴퓨터가 처리하는 작업들 중 가장 오래 걸리는 작업들 중 하나이기 때문에, 소프트웨어 개발자들, 운영체제 개발자들뿐만 아니라 CPU 아키텍트와 엔지니어들도 IO 성능을 향상시키기 위해 노력한다. IO 작업들로 인해 병목이 되는 작업들의 성능을 조금이라도 향상시키기 위해 하드웨어적으로는 하이퍼스레딩3, OS와 소프트웨어적으로는 멀티프로세싱이나 멀티스레딩이 개발되었으며, 덕분에 최신 CPU에 최신 소프트웨어를 구동했을 때 물려있는 작업을 두고 노는 일은 거의 없다. 멀티프로세싱과 멀티스레딩은 아래에서 더 자세히 알아볼 것이다.

 

Non-Blocking IO & Asynchronous programming

Non-blocking은 그 반대로 IO 작업을 막지 않는다. 즉 IO 작업을 요청한 후 결과를 기다리지 않고 다른 작업을 수행한다. 이는 실제로는 IO 작업을 요청한 후 다른 작업에 코어를 양보하는 것처럼 달성된다. 양보를 한 작업과 양보받은 작업 둘 다 일단 작업이 시작되고 나서는 서로의 진행 상황과 상태 값들에 접근할 수 없다. 즉 두 작업은 비동기적으로 수행되므로, non-blocking IO는 비동기 작업을 허용하는 런타임에서만 달성될 수 있으며, 그 개념과 장단점 또한 다수 공유한다.

Non-blocking IO를 달성하고 나면 위에서 지적된 코어가 놀고 있는 상황이 거의 없어진다. IO에서 데이터를 받아오는 동안 CPU는 공회전하지 않고 다른 작업을 먼저 수행한 다음, 결과가 도착하면 도착하는 대로 그 작업을 다시 수행한다. 이것이 NodeJS가 자랑하는 IO 처리의 효율성 극대화이다. NodeJS는 기본적으로 V8 엔진의 개발 로드맵을 따라가기 때문에 처음 NodeJS가 나왔을 때는 콜백 패턴 또는 EventEmitter를 구독(subscribe)하는 방법을 주로 썼었다. 최신 JS에서는 Promise, async/await, generator, rxjs 및 기타 비동기 라이브러리 등 다양하고 쉬운 방법으로 비동기 로직을 작성하는 것이 가능하며, 이것이 권장된다.

비슷한 목적을 달성하기 위해 최근 여러 프로그래밍 언어 및 런타임이 “가벼운” 비동기 로직의 작성을 지원하기 위해 노력했다. 최신 파이썬 또한 yield를 이용하여 generator를 작성하거나 async/await을 이용한 코루틴 작성이 가능하며, Go의 goroutine은 가벼운 비동기 작업들을 작성할 수 있게 하는 Go 언어의 특장점들 중 하나이다. Rust도 async/await 키워드를 최근 언어의 스펙에 포함했다. 이외에도 Java, C++ 등 여러 언어들이 가벼운 비동기 로직을 지원하기 위해 언어를 확장하고 있다.

 

Sync != Blocking, Async != Non-Blocking

Async와 non-blocking이 주로 짝지어지고 sync와 blocking이 주로 짝지어지지만 이 둘은 완전히 동일한 개념은 아니다. Sync하면서 non-blocking할 수도 있고 async하면서 blocking할 수도 있지만, 비효율적이고 부자연스럽기 때문에 그렇게 쓰이지 않을 뿐이다. 두 개념이 분명히 다르고, 아주 일부 상황에서 이러한 패턴들이 쓰일 수 있다는 것만 짚고 넘어가자. 특히 polling은 ajax 만으로 실시간 소통을 달성하기 위해서 쓰일 정도로 흔한 패턴이다.

 

Event Loop

Event loop은 V8 런타임의 스펙이며, 이는 JS 코드가 어떻게 외부의 자극에 반응하는지를 정의한다. 또한 가벼운 비동기 로직을 작성할 수 있게 하는 원천이기도 하다. 동기 스크립트의 실행이 끝나고 나면 모든 비동기 작업들은 일종의 큐에 들어가며, 작업들이 끝나는 순서대로 큐에서 튀어나온다. 파일 시스템에서 특정 파일을 읽어오는 일, 네트워크 요청 이후 응답을 기다리는 일, 혹은 반대로 새로운 네트워크 연결 요청이 들어오기를 기다리는 일, DB에 쿼리를 보내고 응답을 기다리는 일들 등 IO 작업들이 대부분 요청이 끝나면 큐에 들어간다. Event loop는 이 시점부터 계속 순회하며 큐에 있는 작업들 중 완료된 작업들이 있는지 확인하고, 만약 있다면 여기에 물려있는 콜백들을 실행한다. 일반적인 콜백, Promise의 then 혹은 catch 콜백, await 혹은 yield 이후의 스크립트들이나 이벤트를 구독하고 있는 모든 함수들이 여기에 해당된다.

프론트엔드에서 이벤트의 원천은 사용자의 입력 또는 AJAX를 통한 네트워크 요청으로 한정된다. libuv 라이브러리는 일반적인 비동기 IO 작업을 추상화한 인터페이스를 제공하고, NodeJS는 libuv와 V8의 event loop을 성공적으로 결합하여 JS 스크립트로 좀 더 낮은 레벨의 네트워크 작업이나 디스크에 접근하는 작업 등 일반적인 IO 작업들을 가능하게 했다.

 

Blocking Event Loop

하지만 Non-blocking IO를 활용하는 코드를 짜는 것은 쉽지 않다. 위에서 언급한 장점들이 모두 발휘되기 위해서는 작성하는 코드 또한 non-blocking하게 작성되어야 한다. NodeJS의 모듈들은 같은 API를 async한 것과 sync한 것 두 종류를 모두 제공하는데, sync한 종류를 사용하는 것은 event loop의 사이클을 막고 해당 작업들이 끝날 때까지 대기한다. fs 모듈의 readFile과 readFileSync같은 메소드들이 그러하다. readFile은 파일을 읽는 동안 대기하지 않고 다음 작업을 수행한 후, 파일 읽기가 끝나면 콜백 작업을 수행한다. 반면 readFileSync는 파일 읽기가 끝날 때까지 NodeJS의 모든 작업을 중단시키고 기다린다.

많은 계산을 요구하는 작업들또한 event loop을 막을 수 있다. 프로그램이 계산을 해야지 뭘 한다는 말인가? 라고 할 수 있지만, JS는 애초에 그런 작업들을 하기 위해 만들어진 언어가 아니고 NodeJS또한 그러하다. NodeJS 또한 그런 작업들을 JS가 아닌 다른 언어로 작성된 모듈이나 스크립트를 실행하는 것으로 대체하기를 권장한다. NodeJS는 오로지 IO에만 집중해야 한다. 암호, 복호 연산이나 행렬 연산 같은 CPU를 많이 사용하는 작업들은 JS 스크립트로 작성되었을 때 아주 비효율적이며, 더욱 치명적인 점은 이 계산이 이루어지는 동안 event loop가 대기한다는 것이다. 직접 다른 언어를 사용해서 그런 작업들을 구현하고 이를 비동기적으로 NodeJS와 연결할 수도 있겠으나, 대부분은 이미 npm에 구현체가 올라와 있을 것이다. argon2 암호화 함수의 구현체인 node-argon2는 C++로 구현되어 있으며, 비동기적으로 실행되어 event loop을 막지 않는다.

 

Multi core NodeJS

이렇게 NodeJS로 비동기적이고 IO intensive한 코드를 짜고 나면, CPU가 IO 작업을 위해 기다리는 일은 없다. 코어는 계속 새로운 IO 작업을 스케줄하거나 결과를 처리할 것이다. 하지만 결정적인 문제는 NodeJS가 싱글스레드라는 것이다. 이러한 방식으로 실행되는 스크립트는 오로지 한 코어에서만 효율적이며, 절대로 다른 코어에 작업을 분산시킬 수 없다. 멀티코어 시대에 이러한 구조는 절대로 일반적인 서비스 레벨의 로드를 받아낼 수 없다. 멀티코어를 활용할 수 있도록 NodeJS 애플리케이션을 작성하려면 다른 방법이 필요하다.

 

Multiprocessing vs Multithreading

이제 OS의 관점에서 어떤 식으로 작업들을 CPU와 메모리에 할당하는지 이해해야 할 필요가 있다. 실제로 컴퓨터와 OS는 제한된 계산 성능을 잘 배분하여 여러 애플리케이션을 동시에 실행하는 것처럼 보이게 할 수 있다. 이하는 주로 POSIX 계열 운영체제에서의 프로세스와 스레드를 다룬다.

프로세스는 OS 입장에서 가장 작은 작업의 단위이다. 각 작업은 할당된 메모리를 가지며, 서로 할당된 영역에 간섭할 수 없다. 멀티프로세싱이란 말 그대로 여러 프로세스의 실행으로, 대부분 여러 프로세스의 동시 실행 또는 유사 동시 실행의 의미를 내포한다. 유사 동시 실행이라 함은, 한 프로세스가 CPU를 사람이 인지할 수 없는 시간 동안 사용하고 다른 프로세스에 CPU를 양보하게 함으로써 충분한 시간 간격을 두고 보면 마치 모든 프로세스가 동시에 적당한 CPU 점유율로 실행된 것처럼 보이도록 하는 것이다. 동시 실행은 실제로 두 프로세스가 동시에 실행되는 것으로, CPU의 코어 개수만큼의 프로세스는 항상 동시 실행될 수 있다. 각 프로세스가 언제 실행되고 종료될지는 OS에 의해 결정된다.

스레드는 프로세스 내에서 작업을 쪼개는 단위이다. 프로세스와의 가장 큰 차이점은 스레드가 몇개든 OS 입장에서는 그 스레드들을 들고 있는 프로세스의 자원만 관리하면 된다는 것이다. 여기에서 프로세스와 스레드의 차이점이 대부분 파생된다. 먼저, 한 프로세스가 관리하는 여러 스레드는 같은 메모리 공간을 공유할 수 있다. 각 프로세스가 서로의 메모리 공간에 간섭하지 못하는 것과 대조적이다. 그리고 프로세스의 교체는 *문맥 교환(context switch)*을 동반한다. 문맥 교환은 프로세스를 교체해야 할 시점에 중지될 프로세스의 레지스터와 스택 등 임시 데이터들을 메모리로 옮기고 재개될 프로세스의 레지스터와 스택은 다시 채워넣는 작업 등을 의미하는데, 시스템 전체적으로 봤을 때 자원을 상당히 소모하는 작업이다. 멀티스레딩은 문맥 교환 없이도 구동이 가능하기 때문에, 같은 작업들을 프로세스로 분할하는 것보다 스레드로 분할하는 것이 더 가볍다.

멀티프로세싱과 멀티스레딩 모두 하나 이상의 CPU 코어를 활용하여 작업을 처리할 수 있다. 프로세스들의 동시 실행은 OS의 역할로 위임되고, 한 프로세스 내의 스레드들의 동시 실행은 코드를 실행하는 런타임 또는 OS가 맡는다. Java의 JVM, Go의 런타임, POSIX 운영체제들의 pthread API 등이 스레드의 관리를 맡는다. NodeJS나 Python5 인터프리터 런타임들은 이러한 의미의 멀티스레딩을 달성할 수 없다.

 

Multiprocessing Single-Threaded Programs

하지만 멀티스레딩이 태생적으로 불가능한 런타임으로도 멀티코어를 온전히 활용하는 방법이 있다. 동시에 작업을 수행하는 다른 방법인 멀티프로세싱은 싱글스레드 프로그램들을 통해서도 달성이 가능하다. 리눅스 계열 운영체제에서는 fork라는 API를 통해 자신과 동일한 프로세스를 생성할 수 있다. 그렇다면 머신의 코어 개수와 동일한 개수의 프로세스를 fork해서 작업을 수행한다면 머신의 모든 코어들은 거의 대부분의 시간을 해당 코드를 실행하는데 할애할 것이다. 머신의 코어 개수보다 적은 수를 fork한다면 남은 코어들은 OS의 다른 작업들을 위해 사용될 것이고, 더 많은 수를 fork한다면 같은 코어에서 같은 작엄을 위해 문맥 교환이 일어나면서 비효율적인 멀티프로세싱이 될 것이다.

 

Stateless Server

이러한 방식으로 여러 프로세스를 복제해 실행할 때도 주의해야 할 점이 있다. 서버의 상태가 프로세스간에 공유되지 않는다는 것이다. 전술했듯 서로 다른 프로세스는 서로 다른 메모리 영역을 할당받고 서로의 영역에 간섭할 수 없다. 따라서 지역 변수 또는 객체로서 선언된 값들은 같은 입력에 대해서 항상 같은 결과를 보장하지 않는다. 예를 들어, 다음과 같은 express API가 있다고 하자.

let a = 0;

app.get('/', (req, res) => {
  a += 1;
  res.send(a);
});

두 개의 fork된 프로세스가 같은 입력을 핸들하는 상황이라면, a의 값은 두 프로세스에서 다를 것이다. 만약 프로세스 0이 해당 API에 대해서 4번 응답하고 그 후 프로세스 1이 1번 응답했다면, 사용자는 다섯 번째 요청에 대해서 1이라는 이상한 답을 받을 것이다.

따라서 이러한 방식으로 작성되는 서버는 stateless해야 한다. 즉, mutable한 변수 등 사용쟈의 입력 외의 상태를 가져서는 안된다. Express를 포함한 NodeJS 프레임워크들은 콜백 패턴과 함수형 프로그래밍을 권장하기 때문에 let 대신 const를 쓰고 메모리 누수에 주의하는 등의 몇 가지 규칙만 엄수한다면 서버를 stateless하게 작성하는 것은 어렵지 않다. 만약 사용자의 입력에 따라 일관적이고 영구적인 기록을 남겨야 한다면, 별도의 DB를 두자. 만약 세션 등의 중요하진 않지만 임시로 들고 있어야 할 데이터를 처리해야 한다면, 지역변수 대신 Redis 등의 인메모리 DB를 활용하자. DB와의 커뮤니케이션은 아주 일반적인 IO 작업의 일종이고, NodeJS가 특화된 분야이기도 하다.

 

NodeJS Cluster Mode

NodeJS는 멀티프로세싱을 통한 멀티코어의 활용을 적극 권장한다. NodeJS의 Cluster mode는 fork를 통해 생성한 자식 프로세스들이 OS의 같은 네트워크 인터페이스를 공유할 수 있도록 해준다. 즉, 같은 머신의 같은 포트로 들어오는 요청이 각 프로세스들의 상태에 따라 프로세스 0번에 배정될 수도, 프로세스 1번에 배정될 수도 있게 하는 것이다. 가장 일반적인 HTTP API 서버를 생각하면, 모든 포크 프로세스들은 80번 포트로 들어오는 요청들을 받을 수 있다. Cluster mode를 사용하고 있는 마스터 프로세스는 80번 포트로 들어오는 모든 요청을 일단 받고, 자식 프로세스들에 요청을 배분한다. 그리고 자식 프로세스들은 마치 80번 포트에서 요청을 받은 것처럼 완전히 동일한 방식으로 응답할 수 있다.

 

Process Manager, PM2

직접 클러스터링 코드를 작성하는 것보다 좋은 방법은 프로세스 매니저를 활용하는 것이다. 대표적인 프로세스 매니저로 PM2가 있는데, PM2를 활용하면 자동으로 시스템의 코어 개수에 맞는 프로세스들을 생성하고 각 프로세스의 상태에 따라서 로드 밸런싱도 해준다. 즉 80번 포트로 들어오는 막대한 트래픽을 각 프로세스에 적절히 배분하여 한 코어가 너무 많은 작업을 떠안지 않도록 한다.

 

Scale-out w/ NodeJS

PM2를 이용해 한 머신 안에서의 코어 활용도를 최대화하는 데까지 성공했다면, 같은 작업을 더욱 병렬화 하는 것은 쉽다. 전통적인 방식으로는 여러 머신에서 PM2를 돌리면서 전방의 로드밸런서로 각 머신들로 요청을 분산시키는 방법이 가능하겠다. 지금의 나라면 Docker와 Kubernetes 등을 적극 활용하여 클라우드 내에서 활용할 수 있는 CPU 코어의 수를 최적화하고 로드의 크기에 따라 auto scale 할 수 있도록 작업하고 싶다.

'웹개발 > [지식] 프로그래밍' 카테고리의 다른 글

B-Tree 자료 구조  (0) 2022.08.01
Hash 자료 구조  (0) 2022.08.01
Memory 구조(Heap/Stack)  (0) 2022.08.01
Queue 자료 구조  (0) 2022.08.01
OOP(Object-Oriented Programming)  (0) 2022.08.01
Comments