[Concurrency] Go와 Node의 런타임에 대해

Posted on Nov 29, 2024

소개

동시성 프로그래밍은 서버나 네트워크 애플리케이션에서 성능 최적화를 위해 필수적입니다. 특히 I/O 바운드 또는 CPU 바운드 작업을 효율적으로 처리할 수 있는 런타임 모델은 개발자의 선택에 큰 영향을 미칩니다.

이 문서에서는 Node.js와 Go의 동시성 모델을 비교하고, 각 언어의 런타임 구조, 스케줄링 전략, 시스템 자원 활용 방식 등 전문적인 기술 요소를 중심으로 분석합니다.

Node.js의 동시성 모델

Node.js는 싱글 스레드 기반의 이벤트 루프(Event Loop) 구조를 채택합니다. 이는 리눅스의 epoll/kqueue 같은 커널 레벨의 I/O 멀티플렉싱을 활용하여 논블로킹 I/O를 가능하게 합니다.

이벤트 루프와 libuv

Node.js의 이벤트 루프는 내부적으로 C 기반의 libuv 라이브러리를 통해 동작합니다. libuv는 플랫폼 독립적인 비동기 I/O 추상화를 제공하며, 다양한 운영체제의 I/O 시스템 호출(epoll, kqueue, IOCP 등)을 내부적으로 통합하여 처리합니다.

libuv는 크게 두 부분으로 구성됩니다:

  • 이벤트 루프 (event loop): 콜백 큐를 순회하며 등록된 비동기 작업 완료 후 콜백을 실행
  • 스레드 풀 (thread pool): 블로킹 연산(예: 파일 시스템, DNS 조회 등)을 백그라운드에서 처리하고 완료 시 콜백으로 알림

이 구조 덕분에 Node.js는 메인 스레드가 블로킹 없이 다양한 종류의 I/O 작업을 효율적으로 처리할 수 있으며, C 레벨에서의 멀티스레딩과 고수준 자바스크립트 인터페이스 간의 성능 균형을 유지합니다. libuv는 스레드 풀을 구성하여 블로킹 연산을 내부적으로 분산 처리하며, 파일 시스템 접근이나 DNS 질의 등 일부 I/O는 스레드 풀에 오프로드됩니다.

worker_threads를 통한 병렬성

Node.js는 CPU 집약적 작업을 위해 worker_threads 모듈을 제공합니다. 이는 V8 인스턴스를 분리하여 병렬 작업을 가능하게 하지만, 메모리 공간이 분리되어 있어 공유 메모리보다는 메시지 패싱 비용이 있습니다.

예제: Node.js에서 I/O 및 CPU 작업 처리

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

// I/O 바운드 작업
fs.readFile('./example.txt', 'utf8', (err, data) => {
  if (err) throw err;
  console.log('File Content:', data);
});

// CPU 바운드 작업
if (isMainThread) {
  const worker = new Worker(__filename);
  worker.on('message', (result) => {
    console.log('CPU Task Result:', result);
  });
  worker.on('exit', (code) => {
    console.log(`Worker exited with code ${code}`);
  });
} else {
  const compute = () => {
    let sum = 0;
    for (let i = 0; i < 1e9; i++) sum += i;
    return sum;
  };
  parentPort.postMessage(compute());
}

Go의 동시성 모델

Go는 고루틴(Goroutines)GMP 스케줄링 모델을 통해 경량화된 멀티스레드 동시성을 제공합니다. 이는 OS 스레드보다 가볍고, Go 런타임 내부에서 사용자 레벨 스레드로 스케줄링됩니다.

GMP 모델

Go의 런타임 스케줄러는 전통적인 OS 스레드 스케줄링 방식이 아닌, 자체적으로 구현된 M:N 스케줄링 모델을 채택합니다. 이 모델은 다음 세 가지 핵심 구성 요소로 이루어져 있습니다.

P의 수 결정 방식

P(Processer)의 개수는 runtime.GOMAXPROCS() 값에 따라 결정되며, 기본적으로는 시스템의 논리 CPU 수와 동일하게 설정됩니다. 예를 들어, 8코어 CPU 환경에서는 P가 기본적으로 8개 할당됩니다. 개발자는 다음과 같이 수동으로 조정할 수도 있습니다:

runtime.GOMAXPROCS(4)

P의 수는 동시에 실행 가능한 고루틴 수의 상한을 결정하기 때문에, I/O가 적고 CPU 바운드 성격이 강한 애플리케이션의 경우 조정이 성능에 큰 영향을 줄 수 있습니다. Go의 런타임 스케줄러는 전통적인 OS 스레드 스케줄링 방식이 아닌, 자체적으로 구현된 M:N 스케줄링 모델을 채택합니다. 이 모델은 다음 세 가지 핵심 구성 요소로 이루어져 있습니다.

  • G (Goroutine): 실행 단위
  • M (Machine): 실제 OS 스레드
  • P (Processor): 고루틴을 실행시킬 수 있는 실행 컨텍스트

각 P(Processor)는 자신의 로컬 큐에 고루틴을 보유하고 있으며, M(Machine)은 해당 P에 연결되어 고루틴을 실행합니다. 이 구조는 OS 스레드 수를 제한하면서도 수천~수백만 개의 고루틴을 효율적으로 처리할 수 있게 해 줍니다. 또한, Go의 런타임은 각 P의 로컬 큐에 고루틴이 부족할 경우 다른 P의 큐에서 고루틴을 가져오는 워크 스틸링(Work Stealing) 전략을 사용해 부하를 자동으로 분산합니다.

워크 스틸링은 각 P가 자신의 로컬 큐가 비었을 때, 무작위로 선택된 다른 P의 로컬 큐에서 고루틴을 ‘절반 정도’ 가져오는 방식으로 동작합니다. 이때 정확히 절반(50%)을 가져오는 것은 아니며, 고루틴의 수나 시스템의 상황에 따라 동적으로 조절됩니다. 일반적으로 큐의 길이를 기준으로 중간 이후 위치의 고루틴들을 선형 복사해오는 방식이며, 이는 큐의 head에는 최근에 스케줄된 작업이, tail에는 오래된 작업이 쌓이는 구조임을 고려한 설계입니다.

이 기법은 전체 시스템의 균형을 맞추는 데 효과적이며, 특정 스레드에 작업이 편중되어 병목이 생기는 상황을 방지합니다. Go 런타임에서는 이 스틸링 과정이 매우 경량화되어 있어, 워크 스틸링 자체로 인한 오버헤드는 최소화됩니다.

고루틴 간에는 명시적인 프리엠션(preemption)은 존재하지 않지만, 최근 Go 1.14 이후에는 스택 검사를 통해 선점형 스케줄링이 일부 가능해졌습니다. 이러한 개선 덕분에 무한 루프나 장시간 실행되는 고루틴이 런타임 전체의 응답성을 저해하는 현상이 줄어들었습니다.

추가적으로, P는 일정 시간 이상 유휴 상태일 경우 M과의 연결을 해제하고, M은 커널 수준에서 대기 상태에 들어갑니다. 이를 통해 런타임은 동시성과 에너지 효율 사이의 균형도 달성합니다.워크 스틸링(Work Stealing) 기법을 사용하여 다른 P의 고루틴을 가져옵니다. 이를 통해 병렬성과 부하 분산을 동시에 달성합니다.

네트워크 I/O와 스케줄링

Go는 netpoller라는 자체 시스템 콜 기반의 논블로킹 I/O 루프를 가지고 있으며, 이는 리눅스의 epoll, macOS의 kqueue에 기반합니다. 내부 구현은 C로 작성되었으며, 런타임과 긴밀히 통합되어 성능이 뛰어납니다.

예제: Go에서 I/O 및 CPU 작업 처리

package main

import (
  "fmt"
  "io/ioutil"
  "sync"
)

func cpuTask(wg *sync.WaitGroup, result chan int) {
  defer wg.Done()
  sum := 0
  for i := 0; i < 1e9; i++ {
    sum += i
  }
  result <- sum
}

func ioTask(wg *sync.WaitGroup) {
  defer wg.Done()
  data, err := ioutil.ReadFile("example.txt")
  if err != nil {
    fmt.Println("Error:", err)
    return
  }
  fmt.Println("File Content:", string(data))
}

func main() {
  var wg sync.WaitGroup
  cpuResult := make(chan int)

  wg.Add(1)
  go ioTask(&wg)

  wg.Add(1)
  go cpuTask(&wg, cpuResult)

  wg.Add(1)
  go func() {
    fmt.Println("CPU Task Result:", <-cpuResult)
    wg.Done()
  }()

  wg.Wait()
  close(cpuResult)
}

Node.js vs Go 비교

항목Node.jsGo
스레드 모델싱글 스레드 + libuv 스레드풀사용자 수준 고루틴 + GMP 스케줄러
I/O 처리 방식논블로킹 콜백 기반 (epoll, libuv)논블로킹 네트워크 폴링 + 스케줄러 연동 (netpoller)
CPU 작업 처리worker_threads 통해 별도 스레드고루틴 기반 병렬 처리
메시지 전달워커 간 메시지 패싱채널 기반 동기/비동기 통신
스케줄링 전략이벤트 루프 기반워크 스틸링 + P당 로컬 큐

요약

  • Node.js: 고도로 최적화된 I/O 모델(libuv + 이벤트 루프)을 제공하며, 비동기 네트워크 서비스에 탁월함. 다만 CPU 작업은 별도 워커가 필요하고 비용이 큼.
  • Go: 런타임 수준에서 스케줄링, 메모리 관리, 고루틴 모델을 통해 시스템 자원을 효율적으로 사용하는 동시에, 병렬성과 확장성을 높게 유지함.

고성능 API 서버나 멀티서비스 환경에서는 Go의 GMP 모델이 더 나은 효율성을 제공할 수 있으며, 빠르게 개발하고자 하는 스타트업이나 초기 프로토타이핑 환경에서는 Node.js의 생태계와 생산성이 도움이 될 수 있습니다.


참고 자료