Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save YangSiJun528/2f2ff6161078a7bfec57d95a689cb06a to your computer and use it in GitHub Desktop.

Select an option

Save YangSiJun528/2f2ff6161078a7bfec57d95a689cb06a to your computer and use it in GitHub Desktop.
Stackful 비동기의 동작 방식 & 비동기 실행 모델의 분류.md

Stackful 비동기의 동작 방식 & 비동기 실행 모델의 분류

AI로 대화했던 내용들 요약 버전 감수 안함

1. 세 가지 분류 축

질문 예시
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

2. Stackful 모델의 컨텍스트 스위칭

Stackful 모델에서 "별도 스택을 관리한다"는 것은 구체적으로 무엇을 의미하는가?

2.1 왜 스택 전체를 저장해야 하는가

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 │
└─────────────┘
     ↓
 전체를 보존해야 함

2.2 저장해야 하는 것들

컨텍스트 스위칭 시 최소한 다음 레지스터들을 저장해야 한다:

레지스터 용도 왜 필요한가
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        // 베이스 포인터
}

2.3 ABI와 레지스터 저장 최적화

모든 레지스터를 저장할 필요는 없다. 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                  // 해당 위치로 점프

2.4 스택 메모리 관리

Stackful 모델은 각 실행 단위마다 별도 스택 공간이 필요하다. 이 공간은 힙에 할당된다.

Go의 스택 관리 변천사:

방식 동작 문제점
세그먼트 스택 (초기) 스택 부족 시 새 세그먼트 할당 후 연결 경계에서 호출/반환 반복 시 할당/해제 thrashing
연속 스택 (현재) 스택 부족 시 2배 크기 배열 할당 후 복사 복사 비용 (하지만 amortized O(1))
세그먼트 스택:
┌────────┐    ┌────────┐    ┌────────┐
│ seg 1  │ → │ seg 2  │ → │ seg 3  │
└────────┘    └────────┘    └────────┘
  문제: 경계에서 함수 호출/반환 시 seg 생성/삭제 반복

연속 스택:
┌────────────────────────┐
│ 기존 스택 (복사됨)      │
├────────────────────────┤
│ 새로 확보된 공간        │
└────────────────────────┘
  스택 부족 시 전체를 더 큰 공간으로 복사

goroutine 초기 스택 크기는 2KB로 시작해 필요에 따라 확장된다. 최대 1GB까지 가능.

2.5 Java Virtual Thread의 접근

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은 여전히 존재한다.

2.6 Erlang BEAM의 특수성

Erlang은 Stackful이면서 가장 강력한 격리를 제공한다:

특성 Go/Java Erlang BEAM
메모리 공유 공유 힙 프로세스별 별도 힙
GC 전역 GC 프로세스별 독립 GC
상태 공유 가능 (mutex 필요) 불가능 (메시지 전달만)

BEAM 프로세스는 완전히 격리되어 있어 레지스터 복원이 단순하다. 모든 상태가 프로세스 내부에 있고 공유되는 것이 없기 때문이다.


3. "진정한 Preemptive"는 사용자 공간에서 불가능하다

3.1 커널만 가능한 이유

진정한 선점형은 임의의 시점에 실행을 중단할 수 있어야 한다. 이것은 하드웨어 타이머 인터럽트 + 특권 모드가 필요하다.

능력 커널 사용자 공간 런타임
하드웨어 인터럽트 처리 O X
임의 시점 실행 중단 O X
레지스터 강제 저장 O X

3.2 Semi-preemptive의 실체

사용자 공간에서 "preemptive"라 불리는 것들은 특정 지점에 검사 코드를 삽입한 것이다.

Go:

  • Go 1.14 이전: 함수 프롤로그, syscall, channel 연산에서만 전환 가능
  • Go 1.14 이후: SIGURG 신호로 비동기 preemption 추가. 하지만 신호 핸들러가 실행되는 "안전한 지점"에서만 작동

Erlang:

  • reduction counting: 함수 호출마다 카운트 감소, 0이 되면 전환
  • 모든 코드가 BEAM 바이트코드라 런타임이 완전히 제어 가능

3.3 결론

"Preemptive"로 분류되는 사용자 공간 런타임은 실제로 Cooperative + Implicit이다.

실제 표면
특정 지점에서만 전환 (Cooperative) 아무 데서나 전환되는 것처럼 보임
런타임이 검사 코드 삽입 프로그래머가 yield 명시 안 함 (Implicit)

Cooperative vs Preemptive 구분보다 Explicit vs Implicit, Stackful vs Stackless가 실제 프로그래밍 모델에 더 큰 영향을 미친다.


참고 자료

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment