Go sync.Pool

· 6분 읽기

고빈도로 생성되고 짧은 수명을 가지는 객체는 GC에 큰 부담을 준다. sync.Pool은 이런 객체를 재사용하여 할당과 GC 압력을 줄이는 Go 표준 라이브러리의 동시성 안전한 객체 풀이다.

sync.Pool은 단순해 보이지만, 내부적으로는 GMP 모델의 P에 종속된 per-P 로컬 풀, lock-free 자료구조, victim cache 등 정교한 메커니즘으로 구성되어 있다.

기본 사용법

var bufPool = sync.Pool{
    New: func() any {
        return new(bytes.Buffer)
    },
}

func process(data []byte) string {
    buf := bufPool.Get().(*bytes.Buffer)
    buf.Reset()
    defer bufPool.Put(buf)

    buf.Write(data)
    return buf.String()
}

API는 Get(), Put(), New 세 가지뿐이다.

  • Get(): 풀에서 객체를 꺼낸다. 없으면 New를 호출한다
  • Put(): 객체를 풀에 반환한다
  • New: 풀이 비었을 때 새 객체를 생성하는 함수

New를 설정하지 않으면 풀이 비었을 때 nil을 반환한다. 항상 New를 설정하는 것이 좋다.

내부 구조

sync.Pool의 핵심은 P(Processor)마다 독립된 로컬 풀을 가진다는 점이다.

Go의 GMP 모델에서 P는 고루틴을 실행하기 위한 논리 프로세서다. sync.Pool은 이 P 단위로 데이터를 분리하여 경합을 최소화한다.

flowchart TB
    subgraph "sync.Pool"
        subgraph "P0의 poolLocal"
            P0_priv["private (1개)"]
            P0_shared["shared (poolChain)"]
        end
        subgraph "P1의 poolLocal"
            P1_priv["private (1개)"]
            P1_shared["shared (poolChain)"]
        end
        subgraph "P2의 poolLocal"
            P2_priv["private (1개)"]
            P2_shared["shared (poolChain)"]
        end
    end

    P0_priv -.->|"fastest path"| P0_shared
    P0_shared -.->|"work stealing"| P1_shared
    P0_shared -.->|"work stealing"| P2_shared

핵심 자료구조

Pool
├── local        → []poolLocal (P 개수만큼)
├── localSize    → P 개수
├── victim       → []poolLocal (이전 GC 사이클의 local)
├── victimSize
└── New          → func() any

poolLocal
└── poolLocalInternal
    ├── private  → any        (현재 P 전용, 동기화 불필요)
    └── shared   → poolChain  (lock-free 이중 연결 리스트)

poolChain
└── poolDequeue → ring buffer (크기가 2배씩 증가)

poolLocal은 128바이트 경계로 패딩되어 false sharing을 방지한다.

private vs shared

필드접근 주체동기화성능
private현재 P만불필요가장 빠름
shared모든 Plock-free atomic빠름
  • private는 단 하나의 객체만 저장한다. 현재 P에서만 읽고 쓰므로 동기화가 필요 없다
  • sharedpoolChain이라는 lock-free 자료구조다. 현재 P는 head에서 push/pop하고, 다른 P는 tail에서 pop한다(work stealing)

poolChain과 poolDequeue

poolChainpoolDequeue 노드들의 이중 연결 리스트다.

flowchart LR
    subgraph "poolChain"
        D1["poolDequeue<br/>size: 8"] --> D2["poolDequeue<br/>size: 16"] --> D3["poolDequeue<br/>size: 32"]
    end

    Producer["현재 P<br/>(pushHead / popHead)"] --> D3
    Consumer["다른 P<br/>(popTail)"] --> D1

poolDequeue는 고정 크기의 ring buffer이며, 새 노드가 추가될 때마다 크기가 2배씩 증가한다.

  • Single-producer / Multi-consumer: head 조작은 현재 P만 수행하므로 lock이 불필요하고, tail 조작은 atomic CAS로 처리한다
  • 이전에는 Mutex로 보호되는 slice였으나, 이 lock-free 구조로 대체되면서 성능이 크게 향상되었다

Pin 메커니즘

Get과 Put 내부에서 가장 먼저 하는 일은 runtime_procPin()이다.

func (p *Pool) Get() any {
    l, pid := p.pin()  // 현재 고루틴을 P에 고정
    x := l.private
    l.private = nil
    // ...
}

pin()은 현재 고루틴의 preemption을 비활성화하여, Get/Put 도중 다른 P로 이동하는 것을 방지한다. 이렇게 해야 l.private에 안전하게 접근할 수 있다.

Get과 Put의 동작 흐름

Get() 순서

flowchart TD
    Start["Get()"] --> Pin["pin(): P에 고정"]
    Pin --> Private{"private에<br/>객체 있음?"}
    Private -->|Yes| Return["반환"]
    Private -->|No| Shared{"shared에서<br/>popHead"}
    Shared -->|"찾음"| Return
    Shared -->|"없음"| Steal{"다른 P의<br/>shared에서<br/>popTail<br/>(work stealing)"}
    Steal -->|"찾음"| Return
    Steal -->|"없음"| Victim{"victim cache<br/>동일 순서로 탐색"}
    Victim -->|"찾음"| Return
    Victim -->|"없음"| New["New() 호출"]
    New --> Return
  1. pin()으로 현재 P에 고정
  2. 현재 P의 private 확인 → 있으면 즉시 반환 (fastest path)
  3. 현재 P의 shared에서 popHead()
  4. 다른 P들의 shared에서 popTail() (work stealing)
  5. victim cache에서 동일한 순서로 탐색
  6. 모두 실패하면 New() 호출

Put() 순서

  1. pin()으로 현재 P에 고정
  2. 현재 P의 private가 비어 있으면 → private에 저장 (fastest path)
  3. 이미 차 있으면 → sharedpushHead()로 저장

Get과 Put 모두 최선의 경우(private hit) 동기화 오버헤드가 전혀 없다. 이것이 sync.Pool이 빠른 핵심 이유다.

Victim Cache와 GC

Go 1.13 이전의 문제

Go 1.13 이전에는 매 GC 사이클마다 Pool 전체를 비웠다. 이로 인해:

  • GC 직후 대량의 새 객체 할당 발생 (allocation spike)
  • throughput과 latency에 악영향
  • Pool을 쓰는 의미가 반감

Victim Cache 도입 (Go 1.13)

Go 1.13에서 CPU 캐시의 victim cache 개념을 차용하여 이 문제를 해결했다.

flowchart LR
    subgraph "GC 사이클 1"
        L1["local pool<br/>(활성)"]
        V1["victim cache<br/>(비어있음)"]
    end

    subgraph "GC 사이클 2"
        L2["local pool<br/>(새로운 객체)"]
        V2["victim cache<br/>(← 이전 local)"]
    end

    subgraph "GC 사이클 3"
        L3["local pool<br/>(새로운 객체)"]
        V3["victim cache<br/>(← 이전 local)"]
        Drop["이전 victim<br/>완전 삭제"]
    end

    L1 -->|"GC 발생"| V2
    V1 -->|"GC 발생"| Drop

GC 시점에 poolCleanup() 함수가 STW(Stop-The-World) 컨텍스트에서 호출된다:

  1. 기존 victim cache를 완전히 삭제
  2. 현재 local pool을 victim cache로 이동

결과적으로 객체는 2번의 GC 사이클 동안 생존할 수 있다. steady-state에서는 새로운 할당이 거의 발생하지 않는다.

표준 라이브러리의 활용 사례

fmt 패키지

fmt.Println 같은 함수를 호출할 때마다 내부에서 pp(printer) 구조체가 필요하다. 매번 할당하면 GC 부담이 크므로 sync.Pool로 재사용한다.

// fmt/print.go (simplified)
var ppFree = sync.Pool{
    New: func() any { return new(pp) },
}

func newPrinter() *pp {
    p := ppFree.Get().(*pp)
    p.panicking = false
    p.erroring = false
    p.wrapErrs = false
    p.fmt.init(&p.buf)
    return p
}

func (p *pp) free() {
    if cap(p.buf) > 64<<10 {
        p.buf = nil  // 64KB 초과 버퍼는 반환하지 않음
    } else {
        p.buf = p.buf[:0]
    }
    if cap(p.wrappedErrs) > 8 {
        p.wrappedErrs = nil
    }
    ppFree.Put(p)
}

주목할 점은 free()에서 버퍼 크기를 검사한다는 것이다. 비정상적으로 큰 버퍼가 풀에 쌓이는 것을 방지하는 패턴이다.

encoding/json 패키지

JSON 인코딩마다 encodeState 객체(내부에 bytes.Buffer 포함)를 sync.Pool로 재사용한다.

net/http 패키지

HTTP/2 구현에서 frame buffer에 sync.Pool을 활용하여 I/O 작업을 최적화한다.

언제 쓰고, 언제 쓰지 말아야 하는가

적합한 경우

조건예시
고빈도 할당HTTP 요청마다 생성되는 버퍼
할당 비용이 큼gzip.Writer, bytes.Buffer
예측 가능한 크기고정 크기 구조체
단기 수명요청 처리 후 바로 반환

부적합한 경우

조건이유
가벼운 객체Pool 오버헤드(~4ns)가 직접 할당(~0.25ns)보다 큼
영속적 캐시 용도GC가 언제든 풀을 비울 수 있음
객체 수명이 긴 경우풀에 오래 머물다 GC에 수거되면 오히려 낭비

벤치마크 참고

gzip.Writer 재사용:   할당 16회 → 2회, ~40배 속도 향상
bytes.Buffer 재사용:  250 ns/op → 80 ns/op
단순 struct:          Pool 사용이 오히려 ~16배 느림 (3.92ns vs 0.244ns)

반드시 벤치마크로 효과를 확인해야 한다. 가벼운 객체에 sync.Pool을 적용하면 오히려 성능이 나빠진다.

흔한 실수들

1. Get 후 Reset을 하지 않음

// Bad: 이전 데이터가 남아있을 수 있음
buf := bufPool.Get().(*bytes.Buffer)
buf.Write(newData)

// Good: 반드시 초기화
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
buf.Write(newData)

이전 요청의 데이터가 다음 요청에 누출될 수 있다. 보안 문제로도 이어질 수 있다.

2. Put 후에도 참조를 유지

// Bad: Put 후 참조를 계속 사용
buf := bufPool.Get().(*bytes.Buffer)
bufPool.Put(buf)
buf.Write(data)  // 다른 고루틴이 이미 이 버퍼를 Get 했을 수 있음

// Good: Put 후에는 참조를 사용하지 않음
buf := bufPool.Get().(*bytes.Buffer)
buf.Write(data)
result := buf.String()
bufPool.Put(buf)
// buf를 더 이상 사용하지 않음

Go에는 Rust의 소유권 개념이 없으므로, 개발자가 직접 소유권을 관리해야 한다.

3. 큰 객체를 무조건 반환

// Bad: 비정상적으로 큰 버퍼도 풀에 반환
bufPool.Put(hugeBuf)

// Good: fmt 패키지의 패턴을 따름
if cap(buf.Bytes()) > 64<<10 { // 64KB 초과
    return // 풀에 반환하지 않음
}
buf.Reset()
bufPool.Put(buf)

비정상적으로 큰 버퍼가 풀에 쌓이면 메모리 낭비가 발생한다. 표준 라이브러리의 패턴처럼 크기 제한을 두는 것이 좋다.

4. Pool을 캐시로 사용

sync.Pool은 캐시가 아니다. GC가 언제든 객체를 수거할 수 있으므로, 특정 객체가 풀에 남아있을 것이라고 가정하면 안 된다. 캐시가 필요하다면 sync.Map이나 별도의 캐시 라이브러리를 사용해야 한다.

GMP 모델과의 관계

sync.Pool의 설계는 Go 스케줄러의 GMP 모델과 밀접하게 연결되어 있다.

GMP 모델sync.Pool
P마다 Local Run QueueP마다 poolLocal
runnext (1개)private (1개)
Work Stealing (다른 P의 LRQ에서 절반 훔침)Work Stealing (다른 P의 shared에서 popTail)
Global Run Queue (fallback)victim cache (fallback)

동일한 설계 철학이다: P 단위로 데이터를 분리하여 경합을 최소화하고, 유휴 상태에서만 다른 P의 데이터를 훔친다.

References