스택, 힙, 이스케이프 분석
이 챕터에서 다루는 것
Go에는 malloc도 free도 없고, "이 값이 스택에 있는지 힙에 있는지"를 코드에 적는
문법도 없다. 컴파일러가 정한다. 그 판단 규칙이 이스케이프 분석(escape analysis) 이고,
go build -gcflags=-m으로 결과를 읽을 수 있다. 2-6에서
예고한 내용을 여기서 마무리한다.
문제 — 이 코드는 왜 안전한가
func newConfig(name string) *Config {
c := Config{Name: name, Retries: 3}
return &c
}
C나 C++에서 왔다면 이건 명백한 버그다. c는 스택 프레임 위의 지역 변수이고, 함수가
반환되면 그 프레임은 사라진다. 반환된 포인터는 이미 죽은 메모리를 가리킨다 —
댕글링 포인터다. C 컴파일러는 경고를 낸다.
Go에서는 완전히 안전하고, 심지어 관용적이다. 생성자 함수는 거의 다 이 모양이다.
이유는 간단하다. 컴파일러가 c가 함수 밖으로 나간다는 것을 알고, 애초에 스택이 아니라
힙에 할당하기 때문이다. 프로그래머는 어디에 할당됐는지 알 필요가 없고, 알 방법도
문법에는 없다. GC가 마지막 참조가 사라질 때 회수한다.
GC 개요
Go의 GC에 대해 지금 알아야 할 것은 많지 않다. 성능 튜닝은 Part 12의 주제이고, 여기서는 세 줄이면 충분하다.
- 추적형(tracing) GC다. 루트(전역 변수, 각 고루틴의 스택)에서 출발해 도달 가능한 객체를 표시하고, 표시되지 않은 것을 회수한다. 참조 카운팅이 아니라서 순환 참조도 문제없이 회수된다.
- 동시(concurrent) GC다. 프로그램을 멈추지 않고 대부분의 작업을 진행한다. 멈추는 구간(STW)은 있지만 보통 수백 마이크로초 수준이다.
- 비압축(non-moving) GC다. 객체가 이사하지 않으므로 포인터 값이 도중에 바뀌지 않는다. (스택은 자랄 때 이사하지만 런타임이 포인터를 함께 고쳐 준다.)
Go 1.26부터 Green Tea GC가 기본이다. 이전 대비 GC 오버헤드가 10~40% 줄었다. 켜고 끄는 스위치가 아니라 이미 켜져 있는 상태다.
GC가 있어도 할당은 공짜가 아니다. 할당 자체에 비용이 있고, 할당이 늘면 GC가 더 자주 돌고, GC가 돌면 CPU를 쓴다. 그래서 "얼마나 할당하는가"가 Go 성능의 주된 지표다.
스택과 힙
| 스택 | 힙 | |
|---|---|---|
| 할당 비용 | 포인터 하나 옮기기 (사실상 공짜) | 할당기 호출 |
| 해제 | 함수 반환 시 자동 | GC가 회수 |
| GC 부담 | 없음 | 있음 |
| 크기 | 고루틴당 2KB로 시작해 자람 | 프로세스 전체 |
스택 할당이 압도적으로 싸다. 그래서 컴파일러는 가능하면 스택에 두려 하고, "밖으로 나갈 수 있다"고 판단될 때만 힙으로 보낸다. 이 판단이 이스케이프 분석이다.
핵심 규칙은 하나다. 함수가 반환된 뒤에도 값이 살아 있어야 한다면 힙으로 간다.
-gcflags=-m으로 읽기
package main
import "fmt"
type Config struct {
Name string
Retries int
}
// newConfig는 지역 변수의 주소를 반환한다. C였다면 댕글링 포인터다.
func newConfig(name string) *Config {
c := Config{Name: name, Retries: 3}
return &c
}
// retries는 c를 밖으로 내보내지 않는다. 스택에 남을 수 있다.
func retries(name string) int {
c := Config{Name: name, Retries: 3}
return c.Retries
}
// sumLocal의 buf도 함수 밖으로 나가지 않는다.
func sumLocal(n int) int {
buf := make([]int, 8)
for i := range buf {
buf[i] = i * n
}
total := 0
for _, v := range buf {
total += v
}
return total
}
// sumEscaping은 같은 버퍼를 반환한다. 이쪽은 힙으로 간다.
func sumEscaping(n int) []int {
buf := make([]int, 8)
for i := range buf {
buf[i] = i * n
}
return buf
}
func main() {
c := newConfig("api")
fmt.Println(c.Name, c.Retries)
fmt.Println(retries("db"))
fmt.Println(sumLocal(2))
fmt.Println(sumEscaping(2))
}
go run ./06-escape
api 3
3
56
[0 2 4 6 8 10 12 14]
이제 컴파일러의 판단을 들여다보자. -m은 최적화 결정을 출력하게 하는 플래그다.
go build -gcflags=-m -o /tmp/esc ./06-escape
# example.com/composite-types/06-escape
06-escape/main.go:11:6: can inline newConfig
06-escape/main.go:17:6: can inline retries
06-escape/main.go:23:6: can inline sumLocal
06-escape/main.go:36:6: can inline sumEscaping
06-escape/main.go:45:16: inlining call to newConfig
06-escape/main.go:46:13: inlining call to fmt.Println
06-escape/main.go:47:21: inlining call to retries
06-escape/main.go:47:13: inlining call to fmt.Println
06-escape/main.go:48:22: inlining call to sumLocal
06-escape/main.go:48:13: inlining call to fmt.Println
06-escape/main.go:49:25: inlining call to sumEscaping
06-escape/main.go:49:13: inlining call to fmt.Println
06-escape/main.go:11:16: leaking param: name
06-escape/main.go:12:2: moved to heap: c
06-escape/main.go:17:14: name does not escape
06-escape/main.go:24:13: make([]int, 8) does not escape
06-escape/main.go:37:13: make([]int, 8) escapes to heap
06-escape/main.go:46:13: ... argument does not escape
06-escape/main.go:46:15: c.Name escapes to heap
06-escape/main.go:46:23: c.Retries escapes to heap
06-escape/main.go:47:13: ... argument does not escape
06-escape/main.go:47:21: ~r0 escapes to heap
06-escape/main.go:48:13: ... argument does not escape
06-escape/main.go:48:22: ~r0 escapes to heap
06-escape/main.go:48:22: make([]int, 8) does not escape
06-escape/main.go:49:13: ... argument does not escape
06-escape/main.go:49:25: ~r0 escapes to heap
06-escape/main.go:49:25: make([]int, 8) escapes to heap
(줄 번호는 이 파일 기준이다. -o /tmp/esc를 붙인 것은 go build가 디렉터리 이름과
같은 실행 파일을 만들려다 실패하는 것을 피하기 위해서다.)
출력이 길지만 읽는 법은 단순하다. escape 또는 moved to heap이 들어간 줄만 보면 된다.
| 메시지 | 뜻 |
|---|---|
moved to heap: c | 지역 변수 c가 힙으로 갔다 |
X escapes to heap | 값 X가 힙으로 간다 |
X does not escape | 스택에 남는다 |
leaking param: name | 매개변수가 함수 밖으로 흘러 나간다 |
can inline / inlining call to | 이스케이프와 무관한 인라인 결정 |
하나씩 읽기
12:2: moved to heap: c — newConfig의 c다. &c를 반환하니 함수가 끝난 뒤에도
살아 있어야 한다. 컴파일러가 힙에 할당했고, 그래서 반환된 포인터가 안전하다. 이것이
이 챕터 첫 질문의 답이다.
17:14: name does not escape — retries의 name은 안에서 쓰이고 끝난다.
c에 대한 언급이 아예 없는 것도 눈여겨보자. 아무 데도 새지 않는 값은 조용히 스택에
남는다.
24:13: make([]int, 8) does not escape vs 37:13: make([]int, 8) escapes to heap —
같은 코드인데 결과가 갈렸다. sumLocal은 buf를 안에서 다 쓰고 int 하나를 반환한다.
sumEscaping은 buf 자체를 반환한다. 차이는 반환값 하나뿐이고, 그것으로 할당
위치가 결정됐다.
46:15: c.Name escapes to heap — fmt.Println에 넘기는 값이다. Println은
...any를 받는다. 구체 타입을 any(인터페이스)에 담으면 컴파일러는 그 값이 어디로 갈지
알 수 없으므로 힙으로 보낸다. fmt.Println 한 줄이 할당을 만든다는 뜻이고, 뜨거운
경로에서 로그를 찍을 때 실제로 문제가 된다.
leaking param 은 "이 매개변수가 반환값이나 힙 객체에 저장돼 함수 밖으로 흘러
나간다"는 뜻이다. 매개변수 자체가 힙에 할당된다는 말이 아니라, 호출자 쪽 값의 이스케이프
여부에 영향을 준다는 정보다.
:::tip 더 자세히 보려면
-gcflags='-m -m'처럼 -m을 두 번 주면 판단 근거까지 나온다. 처음에는 정보가 너무
많으니 -m 하나로 시작해서 escape가 들어간 줄만 grep하는 편이 낫다.
go build -gcflags=-m -o /tmp/esc ./06-escape 2>&1 | grep escape
-gcflags=-m은 빌드하는 패키지에만 적용된다. 의존 패키지까지 보려면
-gcflags=all=-m을 쓴다. 출력이 폭발하니 각오하고 쓰자.
:::
무엇이 이스케이프를 일으키는가
컴파일러의 정확한 규칙은 버전마다 바뀌지만, 큰 줄기는 안정적이다.
- 포인터를 반환한다.
return &c. 가장 명확한 경우다. - 인터페이스에 담는다.
fmt.Println(x),var a any = x. 컴파일러가 이후 흐름을 추적할 수 없다. - 크기를 컴파일 시점에 모른다.
make([]int, n)에서n이 변수이고 충분히 클 수 있으면 힙으로 간다. 상수 크기의 작은 슬라이스는 스택에 남을 수 있다. - 너무 크다. 스택 프레임에 넣기엔 큰 값(대략 수십 KB 이상)은 힙으로 간다.
- 클로저가 붙잡는다. 2-6에서 본 내용이다. 클로저가 함수 밖으로 나가면 붙잡은 변수도 함께 나간다.
- 다른 힙 객체에 저장한다. 전역 변수, 힙에 있는 구조체의 필드, 슬라이스 요소 등.
반대로 포인터를 쓴다고 반드시 힙은 아니다. 함수 안에서만 쓰이고 밖으로 안 나가는 포인터는 스택에 남는다. "포인터 = 힙 할당"이라는 오해는 흔하지만 틀렸다.
:::warning 포인터로 바꾸면 오히려 느려질 수 있다 3-5에서 "작은 구조체는 값이 빠를 수 있다"고 했다. 이유가 여기 있다. 값으로 넘기면 스택 복사로 끝나지만, 포인터로 넘기면서 그 포인터가 이스케이프하면 힙 할당 + GC 추적이 생긴다. 16바이트 복사보다 훨씬 비싸다. :::
할당을 줄이는 감각
이스케이프 분석을 이해했다고 코드를 그것에 맞춰 비틀 필요는 없다. 실제로 효과가 크고 가독성을 해치지 않는 습관은 몇 개 안 된다.
package main
import (
"fmt"
"runtime"
"strconv"
"strings"
)
const n = 100_000
// totalAllocMiB는 프로그램 시작 이후 누적 할당량을 MiB로 돌려준다.
func totalAllocMiB() float64 {
var m runtime.MemStats
runtime.ReadMemStats(&m)
return float64(m.TotalAlloc) / (1 << 20)
}
func naive() []int {
var s []int
for i := range n {
s = append(s, i)
}
return s
}
func prealloc() []int {
s := make([]int, 0, n)
for i := range n {
s = append(s, i)
}
return s
}
func concatNaive(words []string) string {
out := ""
for _, w := range words {
out += w
}
return out
}
func concatBuilder(words []string) string {
var sb strings.Builder
sb.Grow(len(words))
for _, w := range words {
sb.WriteString(w)
}
return sb.String()
}
func measure(label string, f func()) {
before := totalAllocMiB()
f()
runtime.GC()
fmt.Printf("%-22s 누적 할당 %7.2f MiB\n", label, totalAllocMiB()-before)
}
func main() {
measure("append (미리 안 잡음)", func() { runtime.KeepAlive(naive()) })
measure("append (cap 미리 잡음)", func() { runtime.KeepAlive(prealloc()) })
words := make([]string, 20_000)
for i := range words {
words[i] = strconv.Itoa(i % 10)
}
measure("문자열 += ", func() { runtime.KeepAlive(concatNaive(words)) })
measure("strings.Builder", func() { runtime.KeepAlive(concatBuilder(words)) })
}
go run ./06-prealloc
append (미리 안 잡음) 누적 할당 3.93 MiB
append (cap 미리 잡음) 누적 할당 0.77 MiB
문자열 += 누적 할당 201.03 MiB
strings.Builder 누적 할당 0.02 MiB
(세 번 실행해서 마지막 자리만 0.01 정도 흔들렸다. 자릿수는 안정적이다.)
1. cap을 미리 잡는다. 10만 개를 append하는 데 3.93MiB를 썼다. 최종 결과는
0.77MiB(= 100,000 × 8바이트)뿐인데 나머지 3.16MiB는 중간 단계의 배열들이다.
make([]int, 0, n)으로 시작하면 그게 전부 사라진다.
3-2의 재할당 이야기가 숫자로 나타난 것이다.
2. 문자열 연결은 strings.Builder로. 201MiB 대 0.02MiB, 1만 배다.
out += w는 반복마다 새 문자열을 할당하고 전체를 복사한다. n번 반복이면 O(n²)이다.
2-3에서 개념으로만 말했던 것이
여기서 실측된다. sb.Grow(n)으로 버퍼까지 미리 잡으면 재할당도 없다.
나머지 습관들.
3. 뜨거운 경로에서 인터페이스로 감싸지 않는다. fmt.Sprintf, fmt.Println,
log.Printf는 인자를 any로 받아 전부 이스케이프시킨다. 초당 수만 번 도는 루프
안이라면 의미가 있고, 그 밖에서는 신경 쓸 가치가 없다.
4. 버퍼를 재사용한다. 매번 make하는 대신 슬라이스를 s = s[:0]으로 되감아 다시
쓴다. 요소에 포인터가 있으면 3-2에서 말한 대로
clear(s)를 먼저 한다. 더 나아가면 sync.Pool인데, 그건 Part 12의 영역이다.
5. 작은 값은 값으로 넘긴다. 앞의 경고 그대로다.
:::danger 순서를 지키자
이 다섯 가지 중 1번과 2번만 "항상 해도 되는" 것이다. 나머지는 측정한 뒤에 한다.
Go에는 go test -bench와 -benchmem, pprof가 있고(Part 8, Part 12), 그것들이
알려 주기 전에 코드를 비트는 것은 가독성만 잃는 거래다.
runtime.ReadMemStats를 직접 부르는 이 예제 방식은 교재용이다. 실제 측정은
벤치마크로 한다.
:::
흔히 하는 실수
1. "포인터를 쓰면 힙 할당"이라고 생각한다
아니다. 밖으로 안 나가는 포인터는 스택에 남는다. 반대로 포인터를 안 써도 인터페이스에 담기면 힙으로 간다.
2. 지역 변수의 주소를 반환하기를 두려워한다
C 습관이다. Go에서는 안전하고 관용적이다. newX() 생성자는 전부 이 모양이다.
3. 이스케이프 분석 결과에 맞춰 코드를 비튼다
-gcflags=-m을 처음 보면 힙 할당을 전부 없애고 싶어진다. 대부분은 무의미하고,
컴파일러 버전이 바뀌면 판단도 바뀐다. 벤치마크가 가리키는 곳만 손댄다.
4. make([]T, 0, n)을 쓰면서 n을 잘못 잡는다
너무 작으면 재할당이 다시 생기고, 너무 크면 메모리를 낭비한다. 정확한 값을 모르면
어림값이라도 낫지만, len(input)처럼 정확히 알 수 있는 경우가 의외로 많다.
5. GC를 수동으로 돌린다
runtime.GC()를 프로덕션 코드에서 부르는 것은 거의 항상 잘못이다. 이 챕터의 예제처럼
측정 목적으로만 쓴다.
6. 큰 배열을 스택에 두려 한다
var buf [10 << 20]byte처럼 큰 지역 배열은 어차피 힙으로 가거나 스택을 폭증시킨다.
큰 버퍼는 슬라이스로 만들고 재사용한다.
정리
- 컴파일러가 스택/힙을 정한다. 문법으로 지정할 방법이 없다.
- 규칙 하나: 함수 반환 후에도 살아 있어야 하면 힙으로 간다.
- 그래서 지역 변수의 주소를 반환해도 안전하다. 댕글링 포인터가 생기지 않는다.
go build -gcflags=-m으로 판단을 읽는다.escape,moved to heap이 든 줄만 보면 된다.- 이스케이프 원인: 포인터 반환, 인터페이스에 담기, 크기 미상, 과대 크기, 클로저 캡처, 힙 객체에 저장.
- 포인터 ≠ 힙. 밖으로 안 나가면 스택이다.
- 항상 해도 되는 최적화는 둘:
cap미리 잡기, 문자열은strings.Builder. - 나머지는 측정 후에. Go 1.26은 Green Tea GC가 기본이고, 대개는 그것으로 충분하다.
연습문제
-
06-escape/main.go의retries가*Config를 반환하도록 고치고-gcflags=-m출력이 어떻게 달라지는지 확인해 보자.c에 대한 메시지가 생기는가? -
make([]int, k)에서k를 (a) 상수, (b) 함수 인자, (c) 아주 큰 상수로 각각 바꿔 가며 이스케이프 여부를 확인해 보자. 경계가 어디쯤인가? 힌트: 컴파일러는 스택 프레임에 넣을 수 있는 크기에 상한을 둔다. -
concatNaive를strings.Join으로도 써 보고 세 방식의 누적 할당을 비교해 보자.strings.Builder보다 나은가 못한가? 왜 그런가?