왜 Goroutine은 확장되는가: 스택 성장, 컴파일러 트릭, 그리고 컨텍스트 스위칭
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 크기의 스택으로 시작합니다.

수학: 2 KiB는 대략 **0.2 %**의 1 MiB에 해당합니다.
영향: 수천 개의 스레드에 제한되는 대신, 표준 노트북에서도 RAM이 부족해지지 않도록 수백만 개의 고루틴을 손쉽게 생성할 수 있습니다.
“무한” 스택
생성 시 고정된 스택 크기를 갖는 OS 스레드와 달리, 고루틴 스택은 동적입니다:
- 고루틴은 2 KiB 스택으로 시작합니다.
- 공간이 부족하면 런타임이 더 큰 세그먼트를 할당(보통 현재 크기의 두 배)하고 스택을 그곳으로 이동합니다.

- OS 스레드 제한: 고정 (≈1–8 MiB). 이 제한에 도달하면 크래시가 발생합니다.
- 고루틴 제한: 동적 (64비트 시스템에서 최대 ~1 GiB).
따라서 실질적으로 고루틴 재귀 깊이는 사용 가능한 메모리만큼만 제한되고, OS 스레드는 초기 예약량에 의해 제한됩니다.
더 빠른 컨텍스트 전환

| OS 스레드 전환 | 고루틴 전환 | |
|---|---|---|
| 일반적인 지연 시간 | ~1–2 µs | ~200 ns (≈10× 빠름) |
| 저장되는 내용 | 모든 CPU 레지스터(무거운 FP/AVX 레지스터 포함) → TCB | 3개의 레지스터만(PC, SP, DX) → 작은 Go 구조체(g) |
| 발생 위치 | 커널 모드(트랩) | 사용자 공간(런타임) |
| 캐시 영향 | 캐시를 플러시하고 지역성을 잃음 | 캐시가 유지되고 지역성이 보존됨 |
고루틴 전환이 사용자 공간에 머물기 때문에 오버헤드는 무시할 수 있습니다.
Goroutine 스택 할당 작동 방식
Go 컴파일러는 모든 함수 시작 부분에 함수 프롤로그를 삽입합니다. 프롤로그는 다음과 같은 검사를 수행합니다:
- 현재 스택 포인터 (SP)를 스택 가드라고 불리는 제한값과 비교합니다.
- 남은 공간이 부족하면
runtime.morestack으로 분기합니다. runtime.morestack은 더 큰 스택 세그먼트(보통 현재 크기의 2배)를 할당합니다.- 런타임은 기존 스택 내용을 새로운 세그먼트로 복사하고 모든 포인터를 조정하여 새로운 주소를 가리키게 합니다.
- 실행은 더 큰 스택 위에서 재개됩니다.
예시
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 스택과 스마트 컴파일러가 작동하고 있다는 점을 기억하세요.