후드 아래 엔진: Go의 GMP, Java의 Locks, 그리고 Erlang의 Heaps
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 모델을 따릅니다:
| Component | Description |
|---|---|
| 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 스레드를 다음 경우에만 생성합니다:
- goroutine이 비동기적으로 처리할 수 없는 차단 시스템 호출(예: CGO, 대용량 파일 I/O)을 수행할 때.
- 현재 M이 OS 커널 안에서 멈춰 있을 때.
- 다른 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 이후, 스케줄러는 시그널을 사용해 작업 스틸링을 강제합니다:
sysmon은 모든 P 를 스캔합니다. 만약 어떤 goroutine 이 10 ms 이상 프로세서에서 실행된 것을 발견하면, 해당 goroutine 을 실행 중인 M 에 SIGURG 를 보냅니다.- 왜 SIGURG인가?
- Out‑of‑band: 현대 애플리케이션에서 거의 사용되지 않으므로 사용자 시그널과 충돌하지 않습니다.
- Non‑destructive:
SIGINT와 달리 프로세스를 종료하지 않습니다. - Libc‑safe: CGO 를 사용하는 프로그램에서도 안전합니다.
- OS 가 M 을 인터럽트하고, Go 의 시그널 핸들러가
asyncPreempt호출을 goroutine 의 스택에 삽입합니다. - 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‑스타일 통신(채널)을 장려한다.