medium.com/a-journey-with-go/go-understand-the-design-of-sync-pool-2dde3024e277

 

Go: Understand the Design of Sync.Pool

ℹ️ This article is based on Go 1.12 and 1.13 and explains the evolution of sync/pool.go between those two versions.

medium.com

이 기사는 Go 1.12와 1.13 버전을 기반으로 작성되었고 두 버전 사이의 sync/pool.go 변화를 설명합니다.

sync 패키지는 가비지 컬렉터(GC) 압력을 줄이기 위해 다시 사용할 수 있는 강력한 인스턴스 풀을 제공합니다. 패키지를 사용하기 전에 애플리케이션을 벤치마킹하는 것이 매우 중요합니다. 내부에서 작동하는 방식을 잘 이해하지 못하면 성능이 저하될 수 있기 때문입니다.

Limitation of Pool

1k 할당으로 매우 간단한 환경에서 작동하는 방식을 살펴보는 기본적인 예를 들어 보겠습니다.

package main

import (
    "sync"
    "testing"
)

type Small struct {
    a int
}

var pool = sync.Pool{
    New: func() interface{} { return new(Small) },
}

func inc(s *Small) {
    s.a++
}

func BenchmarkWithoutPool(b *testing.B) {
    var s *Small
    for i := 0; i < b.N; i++ {
        for j := 0; j < 10000; j++ {
            s = &Small{a: 1}
            b.StopTimer()
            inc(s)
            b.StartTimer()
        }
    }
}

func BenchmarkWithPool(b *testing.B) {
    var s *Small
    for i := 0; i < b.N; i++ {
        for j := 0; j < 10000; j++ {
            s = pool.Get().(*Small)
            s.a = 1
            b.StopTimer()
            inc(s)
            b.StartTimer()
            pool.Put(s)
        }
    }
}

다음은 풀(Pool)을 사용하지 않는 벤치마크와 이를 활용하는 벤치마크 두 가지입니다.

작성자 Go version : go version go1.16 windows/amd64

cpu: Intel(R) Core(TM) i5-8265U CPU @ 1.60GHz
BenchmarkWithoutPool-8               122          11276349 ns/op          160001 B/op      10000 allocs/op
BenchmarkWithPool-8                  324           3764665 ns/op               6 B/op          0 allocs/op

블로그 원본 결과

name time/op alloc/op allocs/op
WithoutPool-8 3.02ms ± 1% 160kB ± 0% 1.05kB ± 1%
WithPool-8 1.36ms ± 6% 1.05kB ± 0% 3.00 ± 0%

반복문에 10,000번 실행되는 반복문이 있기 때문에 풀을 사용하지 않는 벤치마크는 힙에 10,000를 할당했지만 풀이 있는 벤치마크는 3개만 할당했습니다. 할당 3개는 풀에 의해 이루어지지만 struct 의 한 인스턴스만 할당되었습니다. sync.Pool 사용은 훨씬 빠르고 메모리를 적게 사용합니다.

 

그러나 실제 환경에서는 풀을 사용하는 동안 애플리케이션이 힙을 새로 많이 할당할 수 있습니다. 이 경우 메모리가 증가하면 가비지 콜렉터가 실행됩니다. 또한 명령어 runtime.GC()로 벤치마크에서 가비지 콜렉터를 강제로 사용할 수도 있습니다.

name time/op alloc/op allocs/op
WithoutPool-8 993ms ± 1% 249kB ± 2% 10.9k ± 0%
WithPool-8 1.03s ± 4% 10.6MB ± 0% 31.0k ± 0%

이제 풀에서 성능이 저하되고 사용된 할당 및 메모리 수가 훨씬 더 많아졌다는 것을 알 수 있습니다. 그 이유를 이해하기 위해 패키지 안을 더 자세히 살펴보도록 해야합니다.

Internal workflow

sync/pool.go를 자세히 살펴보면 이전 문제에 대응할 수 있는 패키지의 초기화가 표시됩니다.

func init() {
    runtime_registerPoolCleanup(poolCleanup)
}

풀을 정리하는 방법으로 런타임에 등록합니다. 그리고 이 동일한 방법은 전용 파일의 가비지 콜렉터에 의해 트리거됩니다.

runtime/mgc.go:

func gcStart(trigger gcTrigger) {
   [...]
   // clearpools before we start the GC
   clearpools()

그렇기 때문에 가비지 수집기를 호출했을 때 성능이 저하되었습니다. 가비지 수집기가 실행될 때마다 풀이 지워집니다. 설명서에서도 이에 대해 경고하고 있습니다.

 

Any item stored in the Pool may be removed automatically at any time without notification

 

이제 워크플로를 생성하여 항목의 관리 방법을 알아보겠습니다.

 

 

생성하는 각 sync.Pool 에 대해 go는 각 프로세서에 연결된 내부 풀 poolLocal을 생성합니다. 이 내부 풀은 private 및 shared의 두 가지 특성으로 구성됩니다. private 속성은 소유자만 액세스할 수 있는 (푸시 및 팝업이 필요 없어 lock이 필요 없음) 반면 shared 속성은 다른 프로세서가 읽을 수 있으며 동시성에 안전해야 합니다. 실제로 이 풀은 단순한 로컬 캐시가 아니며, 애플리케이션의 모든 스레드/고루틴에서 사용할 수 있습니다.

 

Go 버전 1.13은 공유 항목의 액세스를 개선하고 가비지 콜렉터 및 풀 삭제와 관련된 문제를 해결할 새로운 캐시도 제공합니다.

 

New lock-free pool and victim cache

Go 버전 1.13은 이중 연결 리스트를 lock을 제거하고 공유 액세스를 향상시키는 공유 풀로 가져옵니다. 이것은 캐시를 개선하기 위한 기초입니다. 다음은 공유 액세스의 새 워크플로우입니다.

 

이 새로운 체인 풀에서는 각 프로세서가 큐의 맨 앞에 푸시 및 팝이 있는 반면 공유 액세스는 테일로부터 팝업됩니다. 큐의 헤드는 next/prev 속성 덕분에 이전 구조와 연결될 새 구조를 두 배 더 크게 할당하여 확장할 수 있습니다. 초기 구조의 기본 크기는 8개입니다. 즉, 두 번째 구조에는 16개, 세 번째에서는 32개 등이 포함됩니다.

 

또한 이제 lock이 필요하지 않으며 코드는 원자적인 연산을 사용합니다.

 

새로운 캐시와 관련하여, 새로운 전략은 매우 간단합니다. 이제 활성 풀과 아카이브 풀 두 세트가 있습니다. 가비지 콜렉터가 실행될 때 각 풀의 참조를 해당 풀 내의 새 속성에 유지합니다. 그리고 현재 풀을 없애기 전에 아카이브된 풀에 풀 집합을 복사합니다.

 

// Drop victim caches from all pools.
for _, p := range oldPools {
   p.victim = nil
   p.victimSize = 0
}

// Move primary cache to victim cache.
for _, p := range allPools {
   p.victim = p.local
   p.victimSize = p.localSize
   p.local = nil
   p.localSize = 0
}

// The pools with non-empty primary caches now have non-empty
// victim caches and no pools have primary caches.
oldPools, allPools = allPools, nil

이 전략을 통해 이제 앱에는 백업을 통해 새 항목을 생성/수집하는 가비지 콜렉터의 사이클이 한 번 더 있게 됩니다. 워크플로에서 victim cache (ko.wikipedia.org/wiki/CPU_%EC%BA%90%EC%8B%9C) 는 프로세스 종료 시 공유 풀 다음에 요청됩니다.

+ Recent posts