AI로 대화했던 내용들 요약 버전 감수 안함
| 축 | 질문 | 예시 |
|---|---|---|
| Stackful vs Stackless | yield 시 실행 상태를 어디에 저장하는가? | Stackful: Go, Java Loom / Stackless: Kotlin, JS |
| Explicit vs Implicit | yield point가 코드에서 보이는가? | Explicit: await / Implicit: Go |
| Cooperative vs Preemptive | 누가 yield를 결정하는가? | Cooperative: JS / Preemptive: OS 스레드 |
이 기준은 생각하다가 나눠진 기준이고, MECE하거나 명확한 기준은 아니라고 생각한다.
Stackless는 컴파일러가 state machine을 생성해야 하므로 Explicit 경향이 있고, Stackful은 스택 전체를 보존하므로 Implicit이 가능하다. 하지만 필연적 연결은 아니다 (Lua: Stackful + Explicit, Java Loom: Stackful + Implicit + Cooperative).
| 언어 | Stackful/Stackless | Explicit/Implicit | Cooperative/Preemptive |
|---|---|---|---|
| Go | Stackful | Implicit | Semi-preemptive |
| Java Loom | Stackful | Implicit | Cooperative |
| Erlang | Stackful | Implicit | Preemptive (reduction) |
| Kotlin | Stackless | Explicit | Cooperative |
| JavaScript | Stackless | Explicit | Cooperative |
| Lua | Stackful | Explicit | Cooperative |
Stackful 모델에서 "별도 스택을 관리한다"는 것은 구체적으로 무엇을 의미하는가?
CPU는 하나인데 실행 단위(goroutine, BEAM process, virtual thread)는 여러 개다. A에서 B로 전환할 때, A가 쓰던 실행 상태를 어딘가에 보존해야 나중에 A를 재개할 수 있다.
Stackful 모델은 호출 스택 어느 깊이에서든 yield 가능하다. foo() → bar() → baz() 호출 중 baz에서 yield해도 전체 call chain이 보존되어야 한다. 이것이 Stackless와의 핵심 차이다.
goroutine A의 스택:
┌─────────────┐
│ baz() frame │ ← 여기서 yield
├─────────────┤
│ bar() frame │
├─────────────┤
│ foo() frame │
└─────────────┘
↓
전체를 보존해야 함
컨텍스트 스위칭 시 최소한 다음 레지스터들을 저장해야 한다:
| 레지스터 | 용도 | 왜 필요한가 |
|---|---|---|
| SP (Stack Pointer) | 현재 스택 위치 | 스택 프레임 위치 복원 |
| BP (Base Pointer) | 콜스택 프레임 기준점 | 스택 프레임 탐색 |
| PC (Program Counter) | 다음 실행할 명령어 | 중단된 지점에서 재개 |
| 범용 레지스터 | 계산 중인 값들 | 연산 상태 복원 |
Go 런타임은 gobuf 구조체에 이 값들을 저장한다:
// https://github.com/golang/go/blob/release-branch.go1.26/src/runtime/runtime2.go#L303
type gobuf struct {
sp uintptr // 스택 포인터
pc uintptr // 프로그램 카운터
g guintptr // 현재 goroutine 포인터
ctxt unsafe.Pointer // 클로저 컨텍스트
ret uintptr // 반환값
lr uintptr // 링크 레지스터 (ARM)
bp uintptr // 베이스 포인터
}모든 레지스터를 저장할 필요는 없다. ABI(Application Binary Interface)에 따라 레지스터는 두 종류로 나뉜다:
| 종류 | 책임 | 컨텍스트 스위칭 시 |
|---|---|---|
| Caller-saved | 호출자가 함수 호출 전에 저장 | 이미 스택에 있음 → 저장 불필요 |
| Callee-saved | 피호출자가 사용 전에 저장 | 명시적 저장 필요 |
goroutine 스위칭은 함수 호출 형태로 일어난다. 스위칭 함수를 호출하는 순간, caller-saved 레지스터는 ABI 규약에 따라 이미 스택에 저장되어 있다. 따라서 callee-saved 레지스터만 추가로 저장하면 된다.
x86-64 System V ABI 기준:
| Caller-saved (저장 불필요) | Callee-saved (저장 필요) |
|---|---|
| RAX, RCX, RDX, RSI, RDI, R8-R11 | RBX, RBP, R12-R15 |
Go 런타임의 실제 어셈블리 코드에서 이를 확인할 수 있다:
// https://github.com/golang/go/blob/release-branch.go1.26/src/runtime/asm_amd64.s
// func gogo(buf *gobuf)
// gobuf에서 상태를 복원하고 실행 재개
TEXT runtime·gogo(SB), NOSPLIT, $0-8
MOVQ buf+0(FP), BX // gobuf 포인터
MOVQ gobuf_g(BX), DX // g 포인터
...
MOVQ gobuf_sp(BX), SP // 스택 포인터 복원
MOVQ gobuf_bp(BX), BP // 베이스 포인터 복원
MOVQ gobuf_pc(BX), BX // PC 복원
JMP BX // 해당 위치로 점프Stackful 모델은 각 실행 단위마다 별도 스택 공간이 필요하다. 이 공간은 힙에 할당된다.
Go의 스택 관리 변천사:
| 방식 | 동작 | 문제점 |
|---|---|---|
| 세그먼트 스택 (초기) | 스택 부족 시 새 세그먼트 할당 후 연결 | 경계에서 호출/반환 반복 시 할당/해제 thrashing |
| 연속 스택 (현재) | 스택 부족 시 2배 크기 배열 할당 후 복사 | 복사 비용 (하지만 amortized O(1)) |
세그먼트 스택:
┌────────┐ ┌────────┐ ┌────────┐
│ seg 1 │ → │ seg 2 │ → │ seg 3 │
└────────┘ └────────┘ └────────┘
문제: 경계에서 함수 호출/반환 시 seg 생성/삭제 반복
연속 스택:
┌────────────────────────┐
│ 기존 스택 (복사됨) │
├────────────────────────┤
│ 새로 확보된 공간 │
└────────────────────────┘
스택 부족 시 전체를 더 큰 공간으로 복사
goroutine 초기 스택 크기는 2KB로 시작해 필요에 따라 확장된다. 최대 1GB까지 가능.
Java Loom도 Stackful이지만 JVM 특성상 구현이 다르다:
| Go | Java Loom |
|---|---|
| 네이티브 스택 직접 관리 | JVM 스택 프레임을 객체로 관리 |
| 어셈블리로 레지스터 저장/복원 | JVM이 스택 프레임 직렬화/역직렬화 |
| 2KB 초기 스택 | 스택 프레임 단위 힙 할당 |
Java의 경우 carrier thread(실제 OS 스레드)에서 virtual thread를 실행하다가, blocking 연산을 만나면 스택 프레임을 힙 객체로 "unmount"하고 다른 virtual thread를 실행한다.
pinning 문제: synchronized 블록이나 native 코드 실행 중에는 스택을 unmount할 수 없어 carrier thread에 고정되는 문제가 있었다. JDK 24에서 JEP 491이 도입되어 synchronized에 대한 pinning은 해결되었다. native 코드 실행 중 pinning은 여전히 존재한다.
Erlang은 Stackful이면서 가장 강력한 격리를 제공한다:
| 특성 | Go/Java | Erlang BEAM |
|---|---|---|
| 메모리 공유 | 공유 힙 | 프로세스별 별도 힙 |
| GC | 전역 GC | 프로세스별 독립 GC |
| 상태 공유 | 가능 (mutex 필요) | 불가능 (메시지 전달만) |
BEAM 프로세스는 완전히 격리되어 있어 레지스터 복원이 단순하다. 모든 상태가 프로세스 내부에 있고 공유되는 것이 없기 때문이다.
진정한 선점형은 임의의 시점에 실행을 중단할 수 있어야 한다. 이것은 하드웨어 타이머 인터럽트 + 특권 모드가 필요하다.
| 능력 | 커널 | 사용자 공간 런타임 |
|---|---|---|
| 하드웨어 인터럽트 처리 | O | X |
| 임의 시점 실행 중단 | O | X |
| 레지스터 강제 저장 | O | X |
사용자 공간에서 "preemptive"라 불리는 것들은 특정 지점에 검사 코드를 삽입한 것이다.
Go:
- Go 1.14 이전: 함수 프롤로그, syscall, channel 연산에서만 전환 가능
- Go 1.14 이후: SIGURG 신호로 비동기 preemption 추가. 하지만 신호 핸들러가 실행되는 "안전한 지점"에서만 작동
Erlang:
- reduction counting: 함수 호출마다 카운트 감소, 0이 되면 전환
- 모든 코드가 BEAM 바이트코드라 런타임이 완전히 제어 가능
"Preemptive"로 분류되는 사용자 공간 런타임은 실제로 Cooperative + Implicit이다.
| 실제 | 표면 |
|---|---|
| 특정 지점에서만 전환 (Cooperative) | 아무 데서나 전환되는 것처럼 보임 |
| 런타임이 검사 코드 삽입 | 프로그래머가 yield 명시 안 함 (Implicit) |
Cooperative vs Preemptive 구분보다 Explicit vs Implicit, Stackful vs Stackless가 실제 프로그래밍 모델에 더 큰 영향을 미친다.