sync.Pool{}?

sync.Pool{}Gosync 패키지에서 제공하는 구조체로, 일종의 메모리 풀이라고 볼 수 있습니다. 사용을 마친 자원을 풀에 넣어두었다가, 필요할때 다시 꺼내 사용하는 것입니다.

sync.Pool{}의 내부 구조는 다음과 같습니다.

type Pool struct {
    noCopy noCopy

    local     unsafe.Pointer // local fixed-size per-P pool, actual type is [P]poolLocal
    localSize uintptr        // size of the local array

    // New optionally specifies a function to generate
    // a value when Get would otherwise return nil.
    // It may not be changed concurrently with calls to Get.
    New func() interface{}
}

다소 복잡해 보일 수 있지만, 소문자로 시작하는 숨겨진 필드들은 어차피 접근할 수 없기 때문에 대문자로 시작하는 New func() interface{}에 대해서만 생각하시면 됩니다. New 필드는 선택적 필드로 풀이 비어 있을 때 새로운 자원을 만들기 위해 사용됩니다.

sync.Pool{}func (p *Pool) Get() interface{}func (p *Pool) Put(x interface{}) 총 두 가지 메서드를 가지고 있습니다. 아마 이름만 봐도 대충 감이 오실 것입니다.

Get()은 풀에서 자원을 꺼내 올 때 사용하는 메서드로 풀에서 임의의 값을 꺼내 돌려줍니다. 만약, 풀이 비어있다면 nil을 반환하고, New 필드에 자원을 생성하는 함수가 정의되어 있다면 nil 대신에 새로운 자원을 생성하여 반환합니다.

Put() 함수 역시 말 그대로 풀에 자원을 넣어두는 메서드 입니다. 일반적으로 Put()을 하기 전에 자원을 초기화 하지만, 상황에 따라 초기화하지 않고 그냥 넣어두기도 합니다.

Heap Allocation

Go도 여느 프로그래밍 언어와 다름 없이 힙 영역과 스택 영역을 가지고 있습니다. Go의 메모리 할당자는 상황에 알맞은 영역에 메모리를 할당합니다.

스택 영역 밖으로 값을 공유하기 위해선 힙 할당은 꼭 필요하지만, 힙 영역에 메모리를 할당하는 것은 스택 영역에 메모리를 할당하는 것 보다 많은 오버헤드를 가지고 있습니다.

그럼 위에서 설명한 sync.Pool{}을 사용하여 자원을 재사용함으로써 힙 할당을 줄이는 방법에 대해 알아보겠습니다.

Code

본격적으로 살펴보기에 앞서, sync.Pool{}을 사용하지 않는 코드를 먼저 살펴보겠습니다.

아래 코드는 매 루프마다 새로운 슬라이스를 할당하고, 슬라이스에 1 ~ 10 까지의 수를 채워넣은 후 출력하는 아주 간단한 코드입니다.

package main

import (
    "fmt"
)

func main() {
    for n := 0; n < 10; n++ {
        slice := make([]int, 0, 10)

        slice = append(slice, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

        fmt.Println(slice)
    }
}

위 코드의 문제는 매 루프마다 새로운 슬라이스를 생성한다는 것입니다. 슬라이스는 동적인 크기를 갖고있어 새로운 슬라이스의 생성에는 힙 할당이 필연적으로 따라오게 됩니다.

슬라이스에 대해 더 자세히 알고싶으시다면 이 글을 읽어보시면 좋을 것 같습니다.

그럼 위 코드를 sync.Pool{}을 사용해 바꿔보도록 하겠습니다.

package main

import (
    "fmt"
    "sync"
)

func main() {
    p := sync.Pool{
        // 풀이 빈 경우 새로운 슬라이스를 만들어 반환합니다.
        New: func() interface{} {
            return make([]int, 0, 10)
        },
    }

    for n := 0; n < 10; n++ {
        // 풀에서 슬라이슬 받아옵니다.
        // 풀은 interface{}를 반환하기 때문에 type assertion이 필요합니다.
        slice := p.Get().([]int)

        slice = append(slice, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

        fmt.Println(slice)

        // 사용을 마친 슬라이스는 초기화하여 풀에 반환합니다.
        p.Put(slice[:0])
    }
}

기존 코드보다 조금 길어지긴 했지만 sync.Pool{}을 통해 슬라이스의 생성으로 인한 힙 할당을 줄일 수 있었습니다. 물론 이 코드를 보고, "어? 그냥 루프 밖에서 슬라이스를 만들고 재사용해도 되잖아?" 라고 생각하실 수 있습니다.

맞습니다. 위와 같이 슬라이스에 순차적으로 접근하는 경우에는 그냥 루프 밖에서 슬라이스를 생성하고 재사용해도 별다른 문제 없이 풀을 사용하지 않고도 힙 할당을 줄일 수 있습니다. 하지만, 만약 여러 고루틴에서 동시에 슬라이스에 접근하고, 재사용하려 한다면 어떻게 될까요?

package main

import (
    "fmt"
    "sync"
)

func main() {
    wg := sync.WaitGroup{}

    wg.Add(10)

    slice := make([]int, 0, 10)

    for n := 0; n < 10; n++ {
        go func() {
            slice = append(slice, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

            fmt.Println(slice)

            slice = slice[:0]

            wg.Done()
        }()
    }

    wg.Wait()
}


// [1 2 3 4 5 6 7 8 9 10 1 2 3 4 5 6 7 8 9 10]
// [1 2 3 4 5 6 7 8 9 10]
// [1 2 3 4 5 6 7 8 9 10]
// [1 2 3 4 5 6 7 8 9 10]
// [1 2 3 4 5 6 7 8 9 10]
// [1 2 3 4 5 6 7 8 9 10 1 2 3 4 5 6 7 8 9 10]
// [1 2 3 4 5 6 7 8 9 10]
// [1 2 3 4 5 6 7 8 9 10 1 2 3 4 5 6 7 8 9 10 1 2 3 4 5 6 7 8 9 10 1 2 3 4 5 6 7 8 9 10]
// [1 2 3 4 5 6 7 8 9 10 1 2 3 4 5 6 7 8 9 10 1 2 3 4 5 6 7 8 9 10]
// [1 2 3 4 5 6 7 8 9 10]

위 코드처럼 여러 고루틴에서 동시에 슬라이스에 접근한다면 예상치 못한 결과가 발생합니다. 그렇다면 sync.Pool{}은 안전할까요?

package main

import (
    "fmt"
    "sync"
)

func main() {
    wg := sync.WaitGroup{}

    wg.Add(10)

    p := sync.Pool{
        // 풀이 빈 경우 새로운 슬라이스를 만들어 반환합니다.
        New: func() interface{} {
            return make([]int, 0, 10)
        },
    }

    for n := 0; n < 10; n++ {
        go func() {
            slice := p.Get().([]int)

            slice = append(slice, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

            fmt.Println(slice)

            p.Put(slice[:0])

            wg.Done()
        }()
    }

    wg.Wait()
}


// [1 2 3 4 5 6 7 8 9 10]
// [1 2 3 4 5 6 7 8 9 10]
// [1 2 3 4 5 6 7 8 9 10]
// [1 2 3 4 5 6 7 8 9 10]
// [1 2 3 4 5 6 7 8 9 10]
// [1 2 3 4 5 6 7 8 9 10]
// [1 2 3 4 5 6 7 8 9 10]
// [1 2 3 4 5 6 7 8 9 10]
// [1 2 3 4 5 6 7 8 9 10]
// [1 2 3 4 5 6 7 8 9 10]

정상적으로 동작하는 것을 볼 수 있습니다. 공식 문서에서는 sync.Pool{}"A Pool is safe for use by multiple goroutines simultaneously." 라고 설명했습니다. 한 마디로 여러 고루틴에서의 동시 접근에 대해서 안전하다는 것이죠.

마치며

사실 위에서는 sync.Pool{}의 많은 장점중 힙 할당에 대한 부분만 살펴봤습니다. 사실, 굳이 힙 할당을 줄일 목적이 아니더라도 재사용할 수 있는 자원에 대해 여러 고루틴에서 동시에 접근해야한다면, sync.Pool{}을 유용하게 사용하실 수 있을 것입니다.