왜 Goroutine은 확장되는가: 스택 성장, 컴파일러 트릭, 그리고 컨텍스트 스위칭

발행: (2026년 1월 4일 오전 05:03 GMT+9)
9 분 소요
원문: Dev.to

Source: Dev.to

번역을 진행하려면 번역하고자 하는 본문 텍스트를 제공해 주시겠어요? 현재는 링크만 포함되어 있어 번역할 내용이 없습니다. 텍스트를 알려주시면 그대로 한국어로 번역해 드리겠습니다.

Thread Overhead in C++ and Java

위에서 언급한 언어들에서 스레드는 컨텍스트 전환에 많은 CPU 시간을 소모하고 생성 시 상대적으로 큰 메모리를 차지하는 동시성 수단입니다. 하나의 스레드는 일반적으로 ~1 MiB 정도의 스택 공간을 예약합니다. 따라서 100 000개의 스레드를 생성하면 약 100 GiB의 RAM이 필요하게 되며, 이는 대부분의 소프트웨어 프로젝트에서 경제적으로 실현하기 어렵습니다.

동시성을 유지하기 위해 CPU는 일반적으로 타임‑슬라이싱을 사용해 각 스레드에 동일한 수의 CPU 사이클을 할당합니다. 이 과정에서 CPU는 컨텍스트 스위치를 수행해야 하는데, 이는 상당히 비용이 많이 듭니다:

  • 현재 스레드의 상태가 TCB(Thread Control Block)에 저장됩니다.
  • 새로운 스레드의 TCB가 메모리로 로드됩니다.
  • 컨텍스트 스위치는 캐시 지역성을 파괴하여 L1/L2 캐시 미스가 빈번하게 발생합니다.

수천 개의 스레드가 있을 경우, CPU는 실제 코드를 실행하기보다 컨텍스트 전환에 더 많은 시간을 소비하게 됩니다.

고루틴이 이를 최적화하는 방법

고루틴은 OS 커널이 아니라 Go 런타임에 의해 사용자 공간에서 완전히 관리되는 “경량 스레드”입니다.

메모리 효율성

  • 일반적인 OS 스레드는 고정된 1 MiB 스택을 예약합니다.
  • 고루틴은 2 KiB 크기의 스택으로 시작합니다.

비교: 거대한 1 MiB OS 스레드 스택 vs 작은 2 KiB 고루틴 스택

수학: 2 KiB는 대략 **0.2 %**의 1 MiB에 해당합니다.
영향: 수천 개의 스레드에 제한되는 대신, 표준 노트북에서도 RAM이 부족해지지 않도록 수백만 개의 고루틴을 손쉽게 생성할 수 있습니다.

“무한” 스택

생성 시 고정된 스택 크기를 갖는 OS 스레드와 달리, 고루틴 스택은 동적입니다:

  1. 고루틴은 2 KiB 스택으로 시작합니다.
  2. 공간이 부족하면 런타임이 더 큰 세그먼트를 할당(보통 현재 크기의 두 배)하고 스택을 그곳으로 이동합니다.

흐름도: Go 런타임이 제한에 도달했을 때 더 큰 스택을 할당하고 데이터를 복사하는 과정

  • OS 스레드 제한: 고정 (≈1–8 MiB). 이 제한에 도달하면 크래시가 발생합니다.
  • 고루틴 제한: 동적 (64비트 시스템에서 최대 ~1 GiB).

따라서 실질적으로 고루틴 재귀 깊이는 사용 가능한 메모리만큼만 제한되고, OS 스레드는 초기 예약량에 의해 제한됩니다.

더 빠른 컨텍스트 전환

다이어그램: 사용자 공간에서 실행되는 고루틴과 커널 공간에서 실행되는 OS 스레드 비교

OS 스레드 전환고루틴 전환
일반적인 지연 시간~1–2 µs~200 ns (≈10× 빠름)
저장되는 내용모든 CPU 레지스터(무거운 FP/AVX 레지스터 포함) → TCB3개의 레지스터만(PC, SP, DX) → 작은 Go 구조체(g)
발생 위치커널 모드(트랩)사용자 공간(런타임)
캐시 영향캐시를 플러시하고 지역성을 잃음캐시가 유지되고 지역성이 보존됨

고루틴 전환이 사용자 공간에 머물기 때문에 오버헤드는 무시할 수 있습니다.

Goroutine 스택 할당 작동 방식

Go 컴파일러는 모든 함수 시작 부분에 함수 프롤로그를 삽입합니다. 프롤로그는 다음과 같은 검사를 수행합니다:

  1. 현재 스택 포인터 (SP)를 스택 가드라고 불리는 제한값과 비교합니다.
  2. 남은 공간이 부족하면 runtime.morestack 으로 분기합니다.
  3. runtime.morestack 은 더 큰 스택 세그먼트(보통 현재 크기의 2배)를 할당합니다.
  4. 런타임은 기존 스택 내용을 새로운 세그먼트로 복사하고 모든 포인터를 조정하여 새로운 주소를 가리키게 합니다.
  5. 실행은 더 큰 스택 위에서 재개됩니다.

예시

package main

import "fmt"

func main() {
    fmt.Println("Hello Ayush")
}

-gcflags -S 옵션을 사용해 컴파일러를 실행하면 main.main에 대해 생성된 어셈블리를 확인할 수 있습니다:

main.main STEXT size=83 args=0x0 locals=0x40 funcid=0x0 align=0x0
    0x0000 00000 (/Users/ayushanand/concurrency/main.go:7)  TEXT    main.main(SB), ABIInternal, $64-0
    0x0000 00000 (/Users/ayushanand/concurrency/main.go:7)  CMPQ    SP, 16(R14)          // compare SP with stack guard
    0x0004 00004 (/Users/ayushanand/concurrency/main.go:7)  PCDATA  $0, $-2
    0x0004 00004 (/Users/ayushanand/concurrency/main.go:7)  JLS     76                    // jump to morestack if SP )    NOP
0x002d 00045 (/usr/local/Cellar/go/1.25.4/libexec/src/fmt/print.go:314) LEAQ    go:itab.*os.File,io.Writer(SB), AX
0x0034 00052 (/usr/local/Cellar/go/1.25.4/libexec/src/fmt/print.go:314) LEAQ    main..autotmp_8+40(SP), CX
0x0039 00057 (/usr/local/Cellar/go/1.25.4/libexec/src/fmt/print.go:314) MOVL    $1, DI
0x003e 00062 (/usr/local/Cellar/go/1.25.4/libexec/src/fmt/print.go:314) MOVQ    DI, SI
0x0041 00065 (/usr/local/Cellar/go/1.25.4/libexec/src/fmt/print.go:314) PCDATA  $1, $0
0x0041 00065 (/usr/local/Cellar/go/1.25.4/libexec/src/fmt/print.go:314) CALL    fmt.Fprintln(SB)
0x0046 00070 (/Users/ayushanand/concurrency/main.go:9)  ADDQ    $56, SP
0x004a 00074 (/Users/ayushanand/concurrency/main.go:9)  POPQ    BP
0x004b 00075 (/Users/ayushanand/concurrency/main.go:9)  RET
0x004c 00076 (/Users/ayushanand/concurrency/main.go:9)  NOP
0x004c 00076 (/Users/ayushanand/concurrency/main.go:7)  PCDATA  $1, $-1
0x004c 00076 (/Users/ayushanand/concurrency/main.go:7)  PCDATA  $0, $-2
0x004c 00076 (/Users/ayushanand/concurrency/main.go:7)  CALL    runtime.morestack_noctxt(SB)
0x0051 00081 (/Users/ayushanand/concurrency/main.go:7)  PCDATA  $0, $-1
0x0051 00081 (/Users/ayushanand/concurrency/main.go:7)  JMP

스택 크기를 확인하는 어셈블리 코드를 확인할 수 있습니다.

마무리 메모

고루틴은 단순히 “스레드보다 작다”는 것이 아니라 동시성을 관리하는 방식에 대한 근본적인 재고입니다. 스택 관리를 OS 커널에서 Go 런타임으로 옮김으로써 다음을 얻습니다:

  • 대규모 확장성: 10만 개 제한에서 수백만 개 고루틴까지.
  • 동적 메모리: 사용한 만큼만 비용을 지불 (≈ 2 KB), 사용 가능성에 따라 (≈ 1 MB) 지불하지 않음.
  • 낮은 지연 시간: 컨텍스트 전환이 약 10배 빠름.

다음에 go func()를 입력할 때는 배경에서 “무한”하게 동작하도록 작은 2 KB 스택과 스마트 컴파일러가 작동하고 있다는 점을 기억하세요.

Back to Blog

관련 글

더 보기 »