성능 튜닝
이 챕터에서 다루는 것
12-4에서 잰 것을 고친다. 할당을 줄이는 구체적인 기법들과, 그 효과를 벤치마크로 증명하는 절차. 그리고 GC 노브를 언제 만지고 언제 만지지 말아야 하는지.
이 챕터의 숫자는 전부 이 머신(darwin/arm64, Apple M4, 10코어)에서 실제로 측정한 것이다. 절대값은 재현되지 않지만 비율과 방향은 재현된다.
원칙 — 재지 않은 최적화는 최적화가 아니다
12-4의 프로파일에서 leaky.Hash가 쓴 시간의 절반이 runtime.mallocgc였다. 그것을
보기 전까지는 "sha256이 느리다"고 생각하고 있었다.
절차는 이렇다.
- 프로파일로 어디가 문제인지 찾는다 (12-4)
- 그 지점의 벤치마크를 쓴다 — 고치기 전에
- 고친다
- benchstat으로 비교한다 — 정말 나아졌는지
- 결과가 같은지 테스트한다 — 빨라졌지만 틀린 답이면 소용없다
5번을 먼저 하는 편이 안전하다. 이 챕터의 예제에서 가장 중요한 테스트는 벤치마크가 아니라 이것이다.
// TestAllImplementationsAgree가 이 패키지에서 가장 중요한 테스트다.
//
// 최적화한 구현이 다른 결과를 낸다면 그것은 최적화가 아니다.
// 벤치마크를 믿으려면 먼저 이 테스트가 통과해야 한다.
func TestAllImplementationsAgree(t *testing.T) {
in := lines(37)
want := render.Concat(in)
got := map[string]string{
"Builder": render.Builder(in),
"BuilderGrow": render.BuilderGrow(in),
"Append": string(render.Append(nil, in)),
"Pooled": render.Pooled(in),
}
문자열 이어 붙이기 — 네 단계
문제는 흔하다. 줄 여러 개를 하나의 문자열로 만드는 것이다.
0단계: +=
// Concat은 문자열을 +=로 이어 붙인다.
//
// Go의 문자열은 불변이다. s += x는 매번 새 문자열을 할당하고 양쪽을
// 복사한다. 줄이 n개면 할당도 n번이고 복사한 총 바이트는 n²에 비례한다.
// Java의 String 연결이나 Python의 str +=와 정확히 같은 함정이다.
func Concat(lines []Line) string {
s := ""
for _, l := range lines {
s += l.Name + "=" + strconv.Itoa(l.Count) + "\n"
}
return s
}
1단계: strings.Builder
// Builder는 strings.Builder로 쌓는다.
//
// Builder는 내부에 []byte를 들고 있다가 String()에서 딱 한 번
// 문자열로 만든다. 그 변환에 복사가 없다는 것이 Builder의 요점이다
// (내부 버퍼를 그대로 문자열 헤더에 넘긴다. 그래서 Builder는
// 복사해서 재사용하면 안 된다).
func Builder(lines []Line) string {
String()이 복사 없이 문자열을 만드는 것이 bytes.Buffer와의 차이다.
bytes.Buffer.String()은 복사한다. 문자열을 만드는 것이 목적이면 strings.Builder다.
:::warning strings.Builder는 복사하면 패닉한다
Builder는 자기 주소를 기억해 두고, 다른 주소에서 쓰이면
strings: illegal use of non-zero Builder copied by value로 패닉한다.
String()이 내부 버퍼를 문자열에 그대로 넘기기 때문에, 복사본이 그 버퍼를 계속
고치면 이미 만들어진 "불변" 문자열이 바뀌어 버리기 때문이다. 값으로 넘기지 말고
*strings.Builder를 넘긴다.
:::
2단계: Grow로 미리 확보
// BuilderGrow는 필요한 크기를 미리 확보한 뒤 쌓는다.
//
// Grow가 없으면 내부 버퍼가 자라면서 여러 번 재할당된다. 크기를
// 정확히 맞출 필요는 없다. 대략만 맞아도 재할당 횟수가 확 준다.
func BuilderGrow(lines []Line) string {
3단계: Append 형태
// Append는 호출자가 준 버퍼에 이어 붙인다.
//
// 반환 타입이 string이 아니라 []byte인 것이 핵심이다. 호출자가 버퍼를
// 재사용할 수 있으면 이 함수는 할당을 한 번도 하지 않을 수 있다.
// 표준 라이브러리의 AppendInt, AppendQuote 계열이 전부 이 모양이다.
func Append(dst []byte, lines []Line) []byte {
for _, l := range lines {
dst = append(dst, l.Name...)
dst = append(dst, '=')
dst = strconv.AppendInt(dst, int64(l.Count), 10)
dst = append(dst, '\n')
}
return dst
}
strconv.Itoa 대신 strconv.AppendInt를 쓴 것이 결정적이다. Itoa는 문자열을
새로 만들지만, AppendInt는 준 버퍼에 숫자를 써 넣는다.
측정 — 벤치마크
func BenchmarkBuilderGrow(b *testing.B) {
in := lines(100)
b.ReportAllocs()
for b.Loop() {
_ = render.BuilderGrow(in)
}
}
b.Loop()는 파트 8-5에서 다룬 형태다. for i := 0; i < b.N; i++와 달리 반복 안의
값이 최적화로 사라지지 않도록 런타임이 붙잡아 준다.
go test -run '^$' -bench . -benchmem -benchtime 300ms -count 6 ./05-performance-tuning/render
실제로 나온 결과다(각 벤치마크의 첫 줄만 옮겼다. 시간은 실행마다 몇 퍼센트씩 흔들린다.
allocs/op는 흔들리지 않고, B/op는 총합을 반복 횟수로 나눈 값이라 반올림 때문에
1바이트 정도만 움직인다 — 다시 재면 72281이 나오기도 한다).
goos: darwin
goarch: arm64
pkg: example.com/production/05-performance-tuning/render
cpu: Apple M4
BenchmarkConcat-10 48712 7394 ns/op 72280 B/op 185 allocs/op
BenchmarkBuilder-10 222132 1598 ns/op 3600 B/op 94 allocs/op
BenchmarkBuilderGrow-10 247468 1454 ns/op 2576 B/op 86 allocs/op
BenchmarkPooled-10 216962 1609 ns/op 1680 B/op 86 allocs/op
BenchmarkAppendReuse-10 542229 665.8 ns/op 0 B/op 0 allocs/op
BenchmarkEvensNaive-10 31005 11540 ns/op 128248 B/op 16 allocs/op
BenchmarkEvensPrealloc-10 57278 6397 ns/op 40960 B/op 1 allocs/op
benchstat — "정말 나아졌나"에 답하기
한 번씩 재서 비교하면 안 된다. 같은 코드를 두 번 재도 몇 퍼센트는 차이가 난다.
-count로 여러 번 재고 benchstat으로 통계를 낸다.
go install golang.org/x/perf/cmd/benchstat@latest
두 파일의 벤치마크 이름이 같아야 비교되므로, Concat과 BuilderGrow의 결과를 각각
Render라는 이름으로 바꿔 저장한 뒤 비교했다.
│ /tmp/b_concat.txt │ /tmp/b_grow.txt │
│ sec/op │ sec/op vs base │
Render-10 7.513µ ± 2% 1.457µ ± 2% -80.61% (p=0.002 n=6)
│ /tmp/b_concat.txt │ /tmp/b_grow.txt │
│ B/op │ B/op vs base │
Render-10 70.586Ki ± 0% 2.516Ki ± 0% -96.44% (p=0.002 n=6)
│ /tmp/b_concat.txt │ /tmp/b_grow.txt │
│ allocs/op │ allocs/op vs base │
Render-10 185.00 ± 0% 86.00 ± 0% -53.51% (p=0.002 n=6)
시간 -80.6%, 메모리 -96.4%. ± 2%는 여섯 번 측정의 흔들림이고, p=0.002는
"우연일 확률"이다. p가 0.05보다 크면 benchstat이 ~를 찍고 차이를 인정하지 않는다.
그 표시를 보면 최적화를 되돌리는 것이 맞다.
한 단계 더 가면.
│ /tmp/b_grow.txt │ /tmp/b_append.txt │
│ sec/op │ sec/op vs base │
Render-10 1456.5n ± 2% 668.7n ± 0% -54.09% (p=0.002 n=6)
│ /tmp/b_grow.txt │ /tmp/b_append.txt │
│ B/op │ B/op vs base │
Render-10 2.516Ki ± 0% 0.000Ki ± 0% -100.00% (p=0.002 n=6)
│ /tmp/b_grow.txt │ /tmp/b_append.txt │
│ allocs/op │ allocs/op vs base │
Render-10 86.00 ± 0% 0.00 ± 0% -100.00% (p=0.002 n=6)
할당 0회. 여기서 더 줄일 것은 없다.
할당 횟수는 시간보다 안정적인 지표다
ns/op은 머신과 부하에 따라 달라져서 CI에서 회귀 테스트로 쓰기 어렵다. 반면
할당 횟수는 결정적이다. testing.AllocsPerRun으로 못 박아 둘 수 있다.
cases := []struct {
name string
want int
fn func()
}{
// Concat은 줄마다 새 문자열을 만든다. 100줄에 185회.
{"Concat", 185, func() { _ = render.Concat(in) }},
// Builder는 내부 버퍼가 자라며 재할당된다.
{"Builder", 94, func() { _ = render.Builder(in) }},
// Grow가 그 재할당을 없앤다. 남은 86회는 전부 strconv.Itoa다
// (0~99는 캐시되어 할당이 없고, 그래서 100이 아니라 86이다).
{"BuilderGrow", 86, func() { _ = render.BuilderGrow(in) }},
// 풀을 써도 Itoa 86회는 그대로다. sync.Pool은 임시 버퍼만 줄인다.
{"Pooled", 86, func() { _ = render.Pooled(in) }},
// append로 키우면 배열을 일곱 번 다시 만든다.
{"EvensNaive", 7, func() { _ = render.EvensNaive(1000) }},
// 미리 잡으면 딱 한 번이다.
{"EvensPrealloc", 1, func() { _ = render.EvensPrealloc(1000) }},
}
86이라는 숫자가 어디서 왔는지가 재미있다. 100줄인데 왜 100이 아닌가?
strconv.Itoa가 0~99를 미리 만들어 둔 문자열 테이블에서 꺼내 쓰기 때문이다. 입력의
Count가 i*7이므로 100 미만인 것은 i = 0..14의 15개, 100 - 15 = 85회가 Itoa
할당이고 거기에 Grow 1회를 더해 86이다.
이런 숫자를 설명할 수 있게 되면 프로파일을 읽는 눈이 생긴다. 설명이 안 되는 숫자는 아직 모르는 무언가가 있다는 신호다.
가장 강한 형태는 0을 못 박는 것이다.
// TestAppendIntoReusedBufferIsZeroAlloc은 Append 형태의 값어치를 보여 준다.
//
// 버퍼를 재사용하면 할당이 0이 된다. 반환값이 string이면 절대 불가능한
// 숫자다. 표준 라이브러리가 Append 계열 함수를 두는 이유가 이것이다.
func TestAppendIntoReusedBufferIsZeroAlloc(t *testing.T) {
in := allocLines(100)
buf := make([]byte, 0, 4096)
got := testing.AllocsPerRun(100, func() {
buf = render.Append(buf[:0], in)
})
if got != 0 {
t.Fatalf("할당 %v회, 0회를 기대", got)
}
buf[:0]이 요령이다. 길이를 0으로 자르되 용량은 그대로 두므로, 다음 append가
같은 배열에 쓴다. 파트 3-3에서 다룬 슬라이스의 길이와 용량 구분이 여기서 값을 한다.
:::warning 할당 횟수 단언은 -race와 함께 돌릴 수 없다
이 테스트들을 go test -race로 돌리면 숫자가 어긋난다. 실제로 이 예제에서
Pooled가 86 → 87, EvensNaive가 7 → 10이 됐다. 레이스 검출기가 자기 메모리를
할당하기 때문이다.
그래서 할당을 세는 테스트만 alloc_test.go로 떼어 내고 파일 맨 위에 빌드 태그를 걸었다.
//go:build !race
go test -race ./...는 이 파일을 아예 컴파일하지 않으므로, CI에서 두 가지를
모두 돌려도 충돌하지 않는다. testing.AllocsPerRun은 병렬 테스트가 돌고 있으면
패닉한다는 제약도 있다(Go 1.25에서 추가됐다).
:::
슬라이스 미리 할당
// EvensPrealloc은 용량을 미리 잡는다.
//
// make([]int, 0, cap)이다. make([]int, cap)이 아니다. 길이까지 잡으면
// 0이 cap개 들어 있는 슬라이스에 append하게 되어 결과가 두 배로 길어진다.
// 파트 3에서 다룬 그 실수다.
func EvensPrealloc(n int) []int {
out := make([]int, 0, (n+1)/2)
│ /tmp/b_en.txt │ /tmp/b_ep.txt │
│ sec/op │ sec/op vs base │
X-10 11.488µ ± 1% 6.443µ ± 1% -43.92% (p=0.002 n=6)
│ /tmp/b_en.txt │ /tmp/b_ep.txt │
│ B/op │ B/op vs base │
X-10 125.24Ki ± 0% 40.00Ki ± 0% -68.06% (p=0.002 n=6)
│ /tmp/b_en.txt │ /tmp/b_ep.txt │
│ allocs/op │ allocs/op vs base │
X-10 16.000 ± 0% 1.000 ± 0% -93.75% (p=0.002 n=6)
할당 16회가 1회로, 시간은 절반이 됐다. make([]T, 0, n) 한 줄로 얻는 것치고는 크다.
"몇 개가 나올지 대략 아는데 var out []T로 시작하는" 코드는 어느 코드베이스에나 있다.
sync.Pool — 실측이 기대와 다른 경우
sync.Pool은 "임시 객체를 재사용해 GC 부담을 줄이는" 도구로 소개된다. 실제로 재 봤다.
│ /tmp/b_bg.txt │ /tmp/b_pool.txt │
│ sec/op │ sec/op vs base │
X-10 1.457µ ± 2% 1.575µ ± 2% +8.10% (p=0.002 n=6)
│ /tmp/b_bg.txt │ /tmp/b_pool.txt │
│ B/op │ B/op vs base │
X-10 2.516Ki ± 0% 1.641Ki ± 0% -34.78% (p=0.002 n=6)
│ /tmp/b_bg.txt │ /tmp/b_pool.txt │
│ allocs/op │ allocs/op vs base │
X-10 86.00 ± 0% 86.00 ± 0% ~ (p=1.000 n=6) ¹
메모리는 35% 줄었는데 시간은 8% 늘었다. 할당 횟수는 그대로다.
sync.Pool을 쓰는 것 자체가 공짜가 아니다. Get/Put은 P별 캐시를 뒤지고, 그
비용이 여기서는 아낀 것보다 컸다. 그리고 할당 횟수가 안 줄어든 이유는 이 함수에서
할당의 대부분이 버퍼가 아니라 strconv.Itoa이기 때문이다 — 엉뚱한 곳을
최적화한 것이다.
:::tip sync.Pool이 실제로 값을 하는 경우
- 재사용하는 객체가 크다. 64KiB 버퍼를 요청마다 만드는 것과 1KiB는 다르다.
- 그 객체가 정말 임시다. 함수 안에서 만들어 함수 안에서 끝난다.
- 동시에 많이 쓰인다. 초당 수천 번.
net/http가 내부에서 sync.Pool을 쓰는 것도 이 조건에 맞기 때문이다.
조건에 안 맞으면 코드만 복잡해지고 느려진다. 그 판단은 재 보고 한다.
:::
sync.Pool을 쓸 때의 두 가지 필수 사항이 코드에 들어 있다.
defer func() {
// Reset을 빠뜨리면 다음 사용자가 남의 데이터를 이어받는다.
// sync.Pool을 쓰다 나는 사고의 대부분이 이것이다.
buf.Reset()
// 너무 커진 버퍼를 풀에 돌려보내면 메모리가 그대로 묶인다.
// 상한을 두고 그보다 크면 버린다.
if buf.Cap() <= 64<<10 {
bufPool.Put(buf)
}
}()
Reset을 빠뜨린 버그는 부하가 있을 때만, 그리고 다른 사용자의 데이터가 응답에
섞여 나오는 형태로 드러난다. 실제 보안 사고로 이어진 사례가 있다.
테스트로 못 박아 둔다.
func TestPooledIsCleanBetweenUses(t *testing.T) {
// Reset을 빠뜨리면 두 번째 호출이 첫 번째 결과를 이어받는다.
// 그 버그는 부하가 있을 때만 재현되므로 여기서 못 박아 둔다.
in := lines(5)
first := render.Pooled(in)
for range 100 {
if got := render.Pooled(in); got != first {
t.Fatalf("풀에서 남은 데이터가 딸려 나왔다:\n%q", got)
}
}
}
GC 노브 — GOGC와 GOMEMLIMIT
할당을 다 줄인 뒤에도 GC가 부담이라면 그때 노브를 만진다. 순서가 중요하다.
== 1. 지금 GC 설정 ==
GOGC = 100%
GOMEMLIMIT = math.MaxInt64 (사실상 없음)
GOMAXPROCS = 10
GC 구현 = Green Tea (Go 1.26 기본값. GOEXPERIMENT=nogreenteagc로 끈다)
GOGC=100의 뜻은 "직전 GC 후 살아남은 힙만큼 더 늘어나면 다시 GC한다"이다.
살아남은 힙이 100MiB면 200MiB에서 다음 GC가 돈다. 200으로 올리면 300MiB에서 돈다.
GC 횟수는 줄고 메모리 사용량은 는다.
== 2. GOGC를 바꾸면 GC 횟수가 바뀐다 (숫자는 실행마다 다름) ==
GOGC=50 GC 156회
GOGC=100 GC 73회
GOGC=400 GC 17회
GOGC=1600 GC 1회
GOGC를 올릴수록 GC가 줄어드는가: true
높이면 GC는 줄고 메모리는 는다. 공짜 노브가 아니다.
:::note 이 숫자들은 실행마다 다르다
같은 워크로드(64KiB 블록 4000개, 총 250MiB)를 돌려도 GC 횟수는 매번 조금씩
달라진다. GC를 언제 시작할지는 힙 크기만이 아니라 백그라운드 스케줄링도 함께
결정하기 때문이다. 변하지 않는 것은 대소 관계이고, 그래서 예제 프로그램은
숫자를 찍는 대신 decreasing(counts)로 그 관계를 직접 확인한다.
:::
GOMEMLIMIT은 소프트 메모리 상한이다. Go 1.19에 들어왔고, 컨테이너에서 특히 쓸모
있다. 힙이 이 값에 가까워지면 GC가 더 자주 돌아 상한을 지키려 한다.
== 3. GOMEMLIMIT은 상한을 강제한다 (숫자는 실행마다 다름) ==
GOGC=off, 한계 없음: GC 0회
GOGC=off, 한계 32MiB: GC 13회
한계를 두면 GC가 다시 도는가: true
GOGC를 끄고 GOMEMLIMIT만 두는 것이 컨테이너에서 쓰는 조합이다.
GOGC=off로 비율 기반 GC를 끄면 GC가 한 번도 안 돈다(0회). 여기에 GOMEMLIMIT만
주면 한계에 부딪힐 때만 돈다(13회). 이것이 컨테이너에서 권장되는 조합이다.
메모리 한도가 512MiB인 파드라면 GOMEMLIMIT=450MiB 정도를 주고 GOGC=off를 둔다.
GC는 최소한만 돌고, OOM Kill은 피한다.
:::danger GOGC=off만 쓰고 GOMEMLIMIT을 안 주면
GC가 영원히 안 돈다. 메모리가 끝없이 늘다가 OOM Kill을 당한다. 위 출력의 "0회"가
그 상태다. 둘은 반드시 짝으로 쓴다.
그리고 GOMEMLIMIT은 소프트 한계다. 정말로 그만큼이 살아 있는 데이터라면 GC가
아무리 돌아도 줄일 수 없고, 그때는 GC가 계속 도느라 CPU만 태우는
"GC 데스 스파이럴"에 빠진다. 한계는 실제 필요량보다 넉넉해야 한다.
:::
Green Tea GC는 이미 켜져 있다
Go 1.26에서 Green Tea GC가 기본값이 됐다(1.25에서는 실험). 메모리 지역성을 살리도록 마킹 방식을 바꾼 것으로, GC 오버헤드가 10~40% 줄어든다.
이 머신의 툴체인 소스에서 확인할 수 있다.
/usr/local/go/src/internal/buildcfg/exp.go
baseline := goexperiment.Flags{
...
GreenTeaGC: true,
켜기 위해 할 일이 없다. 끄는 방법만 있고(GOEXPERIMENT=nogreenteagc), 그 스위치도
1.27에서 없어질 예정이다. 인터넷에서 "Green Tea GC를 켜려면"으로 시작하는 글을
보면 1.25 시절 이야기다.
조기 최적화의 경계
여기까지가 "재고 고치는" 이야기였다. 반대쪽 이야기도 필요하다.
대부분의 코드는 최적화할 가치가 없다. 하루에 한 번 도는 배치의 초기화 함수를 0회 할당으로 만드는 것은 시간 낭비이고, 그 코드를 읽는 다음 사람에게 비용을 떠넘기는 일이다.
기준선은 이렇게 잡는다.
- 초당 수천 번 이상 도는 경로인가? 아니면 그냥 읽기 좋게 쓴다.
- 프로파일에 이름이 나왔는가? 안 나왔으면 건드리지 않는다.
- 얼마나 나아지는가? 전체의 2%를 차지하는 함수를 두 배 빠르게 해도 1%다.
- 얼마나 읽기 나빠지는가?
Append형태는 API가 어색해진다. 그 값을 치를 만한가?
그리고 성능 문제의 대부분은 이 챕터가 다룬 종류가 아니다. N+1 쿼리(11-4),
인덱스 없는 WHERE(11-5), 불필요한 외부 API 호출, 직렬로 해도 되는 것을
직렬로 하는 것(파트 7) — 이런 것들이 상수 배가 아니라 자릿수를 바꾼다.
sync.Pool로 8%를 잃는 동안 쿼리 하나를 줄이면 40%가 빠진다.
흔한 실수
벤치마크를 한 번만 돌리고 비교한다. -count 6 이상 + benchstat.
벤치마크 안에서 입력을 만든다. 재려는 것이 아닌 것을 함께 잰다. 입력은 루프
바깥에서 만들고, 필요하면 b.ResetTimer()를 쓴다(b.Loop()를 쓰면 대체로 불필요하다).
결과를 버려서 컴파일러가 코드를 지운다. 파트 8-5의 그 함정이다. b.Loop()가
막아 주지만, 벤치마크 밖에서 잴 때는 패키지 수준 변수에 담아야 한다.
make([]T, n)과 make([]T, 0, n)을 혼동한다. 앞의 것에 append하면 결과가
두 배 길이가 된다.
최적화하면서 테스트를 안 쓴다. 이 챕터의 첫 테스트가 그 이야기다.
로컬 벤치마크로 운영을 판단한다. 노트북은 발열로 클럭을 낮추고, 다른 프로그램이 CPU를 나눠 쓴다. 절대값이 아니라 비율을 본다.
GC 노브부터 만진다. 할당을 줄이는 것이 먼저다. GOGC를 올리는 것은 문제를
메모리로 미루는 것이지 없애는 것이 아니다.
정리
- 프로파일 → 벤치마크 → 수정 → benchstat → 결과 검증. 순서를 지킨다.
ns/op은 흔들리고allocs/op은 안 흔들린다. 회귀 테스트는 할당 횟수로.- 문자열은
strings.Builder+Grow. 더 가려면Append(dst []byte, ...)형태. - 슬라이스는
make([]T, 0, n). 개수를 대략 아는데도 안 잡는 코드가 흔하다. sync.Pool은 재 보고 쓴다. 이 예제에서는 8% 느려졌다.Reset과 크기 상한은 필수.GOGC를 올리면 GC는 줄고 메모리는 는다. 공짜가 아니다.- 컨테이너에서는
GOGC=off+GOMEMLIMIT. 반드시 짝으로. - Green Tea GC는 이미 기본값이다. 켤 일이 없다.
- 대부분의 코드는 최적화 대상이 아니다. 프로파일에 이름이 나온 것만 건드린다.
연습문제
-
BuilderGrow에서strconv.Itoa를 없애 보자. 힌트: 스택에 잡은var scratch [20]byte에strconv.AppendInt(scratch[:0], ...)로 쓴 뒤b.Write한다. 할당이 86회에서 몇 회로 줄어드는가? 그리고TestAllocCounts의 기대값을 고치는 것 말고 무엇을 더 확인해야 하는가? -
Pooled의 벤치마크를b.RunParallel로 바꿔 병렬로 돌려 보자.sync.Pool은 P별 캐시를 쓰므로 동시성이 높을 때 결과가 달라질 수 있다. 직렬에서는 8% 느렸던 것이 병렬에서는 어떤가? 이 결과가 "언제sync.Pool을 쓰는가"에 대한 답을 어떻게 바꾸는가? -
GOMEMLIMIT을 아주 작게(예: 8MiB) 주고 예제를 돌려 보자. GC 횟수가 어떻게 되는가?GODEBUG=gctrace=1을 함께 켜서 GC 로그를 보고, "GC 데스 스파이럴"이 로그에서 어떻게 보이는지 확인해 보자.