후드 아래 엔진: Go의 GMP, Java의 Locks, 그리고 Erlang의 Heaps

발행: (2026년 1월 11일 오후 07:09 GMT+9)
7 min read
원문: Dev.to

Source: Dev.to

Introduction

As backend engineers we often treat concurrency as a black box: we write go func(){} or spawn() and expect magic. Understanding how the runtime schedules these tasks separates a senior engineer from an architect.

Source:

GMP 스케줄러

Go의 스케줄러는 G‑M‑P 모델을 따릅니다:

ComponentDescription
G (Goroutine)경량 사용자‑스페이스 스레드 (약 2 KB 스택으로 시작). 명령 포인터와 스택을 보유합니다.
M (Machine)커널이 관리하는 OS 스레드. 실제로 CPU 명령을 실행하는 작업자입니다.
P (Processor)로컬 런 큐와 메모리 캐시의 일부를 소유하는 논리 토큰. M은 G를 실행하려면 P를 보유해야 합니다.
  • 규칙: M은 G를 실행하려면 P를 반드시 가져야 합니다.
  • P = 논리 코어: 기본적으로 GOMAXPROCS는 CPU 코어 수와 동일하며, 병렬성을 제한하면서 무제한 동시성을 허용합니다.

G는 언제 생성되나요?

go func(){}를 호출할 때마다 goroutine이 생성됩니다. 이는 Go 런타임에 의해 사용자 공간에서 할당되며, 약 2 KB를 차지하고 현재 P의 로컬 런 큐에 배치됩니다.

M는 언제 생성되나요?

런타임은 M의 수를 최소로 유지하며, 새로운 OS 스레드를 다음 경우에만 생성합니다:

  1. goroutine이 비동기적으로 처리할 수 없는 차단 시스템 호출(예: CGO, 대용량 파일 I/O)을 수행할 때.
  2. 현재 M이 OS 커널 안에서 멈춰 있을 때.
  3. 다른 P들이 작업을 기다리고 있지만 사용 가능한 M이 없을 때(새 M을 만드는 비용이 약 1–2 MB).

The Watcher: sysmon and SIGURG

What is sysmon?

sysmon (system monitor) 은 P 를 보유하지 않고 전용 M 위에서 실행되는 특수 런타임 스레드입니다. 공정성을 보장하기 위해 주기적으로 (20 µs – 10 ms) 깨어납니다.

How preemption works

Go 1.14 이후, 스케줄러는 시그널을 사용해 작업 스틸링을 강제합니다:

  1. sysmon 은 모든 P 를 스캔합니다. 만약 어떤 goroutine 이 10 ms 이상 프로세서에서 실행된 것을 발견하면, 해당 goroutine 을 실행 중인 M 에 SIGURG 를 보냅니다.
  2. 왜 SIGURG인가?
    • Out‑of‑band: 현대 애플리케이션에서 거의 사용되지 않으므로 사용자 시그널과 충돌하지 않습니다.
    • Non‑destructive: SIGINT 와 달리 프로세스를 종료하지 않습니다.
    • Libc‑safe: CGO 를 사용하는 프로그램에서도 안전합니다.
  3. OS 가 M 을 인터럽트하고, Go 의 시그널 핸들러가 asyncPreempt 호출을 goroutine 의 스택에 삽입합니다.
  4. goroutine 은 양보하고, 전역 실행 큐로 이동한 뒤, P 가 새로운 G 를 선택해 실행합니다.

Source:

모델 비교: “메모리를 공유하며 소통하기” vs. “소통을 통해 메모리 공유하기”

Go / Java: 공유 힙

모든 스레드가 동일한 힙을 공유합니다. 데이터는 공유 객체를 변형함으로써 전달됩니다.

실패 모드 (Java 예시)

// Java: 명시적 락 (병목 현상)
class Counter {
    private int count = 0;

    // synchronized는 OS가 다른 스레드를 일시 정지하도록 강제함 (컨텍스트 스위치)
    public synchronized void increment() {
        count++;
    }
}
  • 경쟁 상태: synchronized를 빼먹으면 데이터가 손상됩니다.
  • 성능: 락은 OS 개입을 필요로 하며 수천 사이클의 비용이 듭니다.
  • 데드락: 순환 대기 상황이 발생하면 애플리케이션이 멈출 수 있습니다.

Erlang: 프라이빗 힙

각 프로세스가 자체 힙을 가지고 있어 “시끄러운 이웃” 효과를 없앱니다.

왜 Erlang이 “더 나은가” (은행 예시)

-module(bank_server).
-behaviour(gen_server).

%% 1. 안전한 은행 프로세스
init([]) -> {ok, 100}.   %% 잔액은 $100

%% 2. 위험한 크래시 프로세스
trigger_crash() ->
    spawn(fun() ->
        %% A. 이것은 프라이빗 힙에 1 GB를 할당합니다
        CrashList = lists:seq(1, 100000000),
        %% B. 즉시 크래시 발생
        1 / 0
    end).
  • 할당: 스폰된 프로세스가 프라이빗 힙에 1 GB를 할당합니다. Java/Go에서는 전역 힙을 채워서 stop‑the‑world GC를 유발할 수 있습니다.
  • 크래시: 프로세스가 (0으로 나누기) 즉시 죽습니다.
  • 정리: Erlang VM은 단순히 프라이빗 힙을 버립니다.
  • GC 비용 0: 다른 프로세스의 메모리를 스캔할 필요가 없습니다.
  • 영향 0: bank_server는 $100 잔액을 마이크로초 지연으로 계속 처리하며, 크래시와 무관합니다.

최종 요약

  • Java의 공유‑메모리 모델은 엔지니어에게 큰 정확성 부담을 주어 대규모 동시성을 이해하기 어렵게 만든다.
  • Erlang은 개인 힙이 “시끄러운 이웃”이 시스템 전체에 영향을 주는 것을 방지하기 때문에 신뢰성이 뛰어나다.
  • Go는 실용적인 중간 지점을 제공한다: 원시 속도를 위해 공유 힙을 사용하고(데이터 복사 없음) 명시적 락의 복잡성을 피하기 위해 CSP‑스타일 통신(채널)을 장려한다.
Back to Blog

관련 글

더 보기 »

Gin vs Spring Boot: 자세한 비교

Gin vs Spring Boot: 자세한 비교용 커버 이미지 https://media2.dev.to/dynamic/image/width=1000,height=420,fit=cover,gravity=auto,format=auto/https%3A%...