import ( "코딩", "행복", "즐거움" )

Golang sync.Pool 기본 원리 본문

GO

Golang sync.Pool 기본 원리

더코드마니아 2023. 3. 3. 14:33

sync.Pool은 임시 객체를 캐시하여 임시 객체의 빈번한 생성으로 인한 GC의 소비와 부담을 피하는 데 사용할 수 있는 Golang의 내장 객체 풀링 기술입니다.

sync.Pool은 잘 알려진 많은 오픈 소스 라이브러리에서 광범위하게 사용되는 것을 볼 수 있습니다. 예를 들어, HTTP 프레임워크 Gin은 sync.Pool을 사용하여 각 요청에 따라 생성되는 gin.Context 객체를 재사용합니다. sync.Pool은 grpc-Go, kubernates 등에서도 찾아볼 수 있습니다.

그러나 sync.Pool 캐시된 객체는 언제든지 예고 없이 지워질 수 있으므로 영구 객체가 저장되는 시나리오에서는 sync.Pool을 사용해서는 안 됩니다.

 

sync.Pool은 고루틴에 내장된 공식 라이브러리로 매우 잘 설계되어 있으며, 동시성에 안전할 뿐만 아니라 잠금을 해제할 수 있고 배울 점이 많습니다.

 

이 문서에서는 go-1.16 소스 코드를 기반으로 sync.Pool의 기본 구현을 살펴봅니다. ( 현재 go 1.20버전임으로 예전 버전 주의 ) 

 


 

기본 사용법
기본 sync.Pool에 대해 이야기하기 전에 sync.Pool의 기본 사용법을 살펴봅시다. 샘플 코드는 다음과 같습니다.

type Test struct {
	A int
}

func main() {
	pool := sync.Pool{
		New: func() interface{} {
			return &Test{
				A: 1,
			}
		},
	}

	testObject := pool.Get().(*Test)
	println(testObject.A) // print 1

	pool.Put(testObject)
}


sync.Pool이 초기화될 때 사용자는 객체에 대한 생성자 New를 제공해야 합니다. 사용자는 Get을 사용하여 객체 풀에서 객체를 가져오고 Put을 사용하여 객체를 객체 풀로 반환합니다. 전체 사용법은 비교적 간단합니다.

다음으로 sync.Pool이 어떻게 구현되는지 자세히 살펴보겠습니다.

 


 

sync.Pool의 기본 구현
sync.Pool에 대해 이야기하기 전에 Golang의 GMP 스케줄링에 대해 이야기해 보겠습니다. GMP 스케줄링 모델에서 M은 시스템 스레드를 나타내며, M에서 동시에 하나의 P만 실행할 수 있는데, 이는 스레드 차원에서는 P의 로직이 단일 스레드임을 의미합니다.

sync.Pool은 GMP의 이 기능을 활용합니다. 동일한 sync.Pool의 경우, 각 P에는 자체 로컬 객체 풀 poolLocal이 있습니다. 이는 아래 그림에 나와 있습니다.

출저: https://www.sobyte.net/post/2022-03/think-in-sync-pool/

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

	victim     unsafe.Pointer // local from previous cycle
	victimSize uintptr        // size of victims array

	New func() interface{}
}


그중에서도 다음 세 가지 필드에 집중해야 합니다.

local은 P의 개수만큼의 길이를 가진 배열입니다. 요소 유형은 poolLocal입니다. 이것은 각 P에 해당하는 객체의 로컬 풀을 저장합니다. [P]poolLocal로 근사화할 수 있습니다.


localSize. 는 로컬 배열의 길이를 나타냅니다. 런타임에 runtime.GOMAXPROCS를 호출하여 P를 수정할 수 있으므로 로컬 배열의 길이에 대응하기 위해 localSize를 사용해야 합니다.


New는 객체를 생성하는 사용자 제공 함수입니다. 이 옵션도 필수는 아닙니다. Get은 채워지지 않은 경우 nil을 반환할 수 있습니다.


지금 당장은 크게 신경 쓸 필요가 없는 몇 가지 다른 필드에 대해 간략히 살펴보겠습니다.


victim 및 victimSize 이 변수 쌍은 마지막 정리 전의 오브젝트 풀을 나타내며, local 및 localSize와 동일한 의미를 갖습니다. 피해자는 아래에서 더 자세히 설명합니다.

noCopy는 복사를 비활성화하는 Golang 소스 코드의 탐지 메서드입니다. sync.Pool의 복사본은 go vet 명령으로 감지할 수 있습니다.
각 P에는 고유한 poolLocal이 있기 때문에 Get과 Put 연산은 풀에 우선적으로 액세스합니다. P의 특성상 로컬 객체 풀을 조작할 때 전체 동시성 문제가 훨씬 간단해지며, 동시성 충돌을 최대한 피할 수 있습니다.

 

// 각 P에는 하나의 풀로컬 위치가 있습니다.
type poolLocal struct {
	poolLocalInternal

	pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte
}

type poolLocalInternal struct {
	private interface{}
	shared  poolChain
}

pad 변수의 역할은 아래에서 다루며 지금은 여기서는 다루지 않겠습니다. 각 로컬 객체 풀에 두 개의 항목이 포함되어 있는 poolLocalInternal의 정의를 직접 살펴볼 수 있습니다: * private 개인 변수.

private 개인 변수; Get 및 Put 연산은 개인 변수에 우선적으로 액세스하고, 개인 변수가 상황을 충족할 수 있는 경우 그보다 더 깊이 들어가지 않습니다.
shard. 유형은 poolChain이며, 이름에서 링크된 테이블 구조임을 쉽게 알 수 있으며, 이것은 P의 로컬 객체 풀입니다.

 



poolChain 구현
poolChain의 전체 저장소 구조는 다음 그림과 같습니다.

출저: https://www.sobyte.net/post/2022-03/think-in-sync-pool/

 

이름에서 짐작할 수 있듯이, 풀체인은 가장 최근에 할당된 엘리먼트 항목을 가리키는 헤드 HEAD가 있는 링크 리스트 구조입니다. 체인의 각 항목은 poolDequeue 객체입니다. poolDequeue는 본질적으로 링 버퍼 구조입니다. 해당 코드는 다음과 같이 정의됩니다.

 

type poolChain struct {
	head *poolChainElt
	tail *poolChainElt
}

type poolChainElt struct {
	poolDequeue
	next, prev *poolChainElt
}

type poolDequeue struct {
	headTail uint64
	vals []eface
}

풀체인은 왜 링크 리스트 + 링 버퍼의 복잡한 구조를 가지고 있을까요? 각 링크 테이블 항목에 대해 단일 요소를 사용하면 안 될까요?

링 버퍼는 다음과 같은 장점이 있기 때문에 사용됩니다.

메모리가 미리 할당되어 있고 할당된 메모리 항목을 반복해서 재사용할 수 있습니다.
링 버퍼는 본질적으로 배열이기 때문에 연속적인 메모리 구조로 CPU 캐시에 매우 편리합니다. 풀디큐의 한 항목에 접근할 때 그 주변의 모든 데이터 항목이 통합된 캐시 라인에 로드될 수 있어 접근 속도가 빨라집니다.
링 버퍼의 이 두 가지 기능은 동기화에 이상적입니다.

링 버퍼로서 풀디큐어는 당연히 헤드와 테일의 값을 기록해야 합니다. 그러나 poolDequeue의 정의에서 head와 tail은 별도의 변수가 아니라 uint64 headTail 변수 하나만 있습니다.

 

이는 headTail 변수가 머리와 꼬리를 함께 감싸기 때문입니다. 즉, 높은 32비트는 머리 변수이고 낮은 32비트는 꼬리 변수입니다. 이는 다음 그림에 나와 있습니다.

출저: https://www.sobyte.net/post/2022-03/think-in-sync-pool/

왜 이렇게 복잡한 패킹 작업이 필요할까요? 사실 이것은 매우 일반적인 락 프리 최적화입니다.

poolDequeue의 경우, 둘 이상의 P가 동시에 액세스할 수 있으며(아래 Get 함수의 객체 훔치기 로직 참조), 이는 동시성 문제를 일으킬 수 있습니다.

 

예를 들어 링 버퍼 공간이 하나만 남았을 때(즉, 머리-꼬리 = 1). 여러 P가 동시에 링 버퍼에 액세스하는 경우, 동시성 측정 없이 두 P가 모두 객체를 가져올 수 있으며, 이는 예상치 못한 결과입니다.

sync.Pool은 어떻게 Mutex 잠금을 도입하지 않고 이를 달성할 수 있을까요? sync.Pool은 원자 패키지의 CAS 연산을 활용합니다. 두 P가 모두 객체를 가져올 수 있지만, 최종적으로 headTail이 설정되면 하나의 P만 성공적으로 CAS를 호출하고 다른 CAS는 실패합니다.

 

atomic.CompareAndSwapUint64(&d.headTail, ptrs, ptrs2)

헤드와 테일을 업데이트할 때도 원자 변수 + 비트 연산으로 연산을 수행합니다. 예를 들어 head++를 구현할 때 다음 코드가 필요합니다.

 

const dequeueBits = 32

atomic.AddUint64(&d.headTail, 1<<dequeueBits)

 


Put의 구현
Put 함수의 구현을 살펴봅시다. Put 함수를 사용하면 사용하지 않는 객체를 sync.Pool에 다시 넣거나 앞으로 넣을 수 있습니다. Put 함수의 코드 로직은 다음과 같습니다.

func (p *Pool) Put(x interface{}) {
	if x == nil {
		return
	}

	l, _ := p.pin()

	if l.private == nil {
		l.private = x
		x = nil
	}

	if x != nil {
		l.shared.pushHead(x)
	}

	runtime_procUnpin()
}

위의 코드에서 볼 수 있듯이 Put 함수에서 pin()이 가장 먼저 호출됩니다. 핀 함수는 매우 중요한데, 세 가지 함수가 있습니다.

로컬 배열을 초기화하거나 다시 생성합니다. 로컬 배열이 비어 있거나 현재 runtime.GOMAXPROCS와 일치하지 않으면 P의 수에 맞게 로컬 배열을 다시 생성합니다. ** 로컬 배열이 비어 있거나 현재 `runtime`과 일치하지 않는 경우.

코드의 로직은 매우 간단합니다. 인덱스에 따라 로컬 배열에서 요소를 가져옵니다. 이 단락의 로직은 다음과 같습니다.

 

func indexLocal(l unsafe.Pointer, i int) *poolLocal {
    lp := unsafe.Pointer(uintptr(l) + uintptr(i)*unsafe.Sizeof(poolLocal{}))
    return (*poolLocal)(lp)
}

현재 P가 선점되는 것을 방지합니다. 이것은 매우 중요합니다. Go 1.14부터 Golang은 선제적 스케줄링을 구현했습니다. 고루틴이 너무 오랫동안 P를 점유하면 스케줄러에 의해 강제로 중단됩니다. 고루틴이 Put 또는 Get 중에 중단되면 다음에 복원될 때 바인딩이 마지막 바인딩과 동일한 P가 아닐 수 있습니다. 그러면 전체 프로세스가 완전히 엉망이 됩니다. 따라서 런타임 패키지의 procPin을 사용하여 일시적으로 P를 선점하지 못하도록 합니다.

다음으로 Put 함수는 현재 poolLocal 비공개 변수를 기본 설정으로 비공개로 설정합니다. 개인 변수가 성공적으로 설정되면 공유 캐시 풀에 쓰지 않습니다. 이렇게 하면 작업이 더 효율적입니다.

개인 변수가 이미 설정되어 있으면 현재 P의 로컬 캐시 풀의 poolChain에만 쓰게 됩니다. 다음으로 sync.Pool의 각 P의 내부 캐시 poolChain이 어떻게 구현되는지 살펴봅시다.

 

넣을 때, 풀체인 체인의 헤드 엘리먼트인 HEAD를 직접 가져옵니다.

HEAD가 존재하지 않으면 버퍼 길이가 8인 새 풀디큐가 생성되고 객체가 그 안에 배치됩니다.
HEAD가 존재하고 버퍼가 가득 차 있지 않은 경우, 요소는 풀디큐에 직접 배치됩니다.
HEAD가 존재하지만 버퍼가 꽉 차면 이전 HEAD 길이의 2배로 새 poolDequeue를 생성합니다. 동시에 풀체인의 HEAD는 새 엘리먼트를 가리킵니다.
Put의 프로세스는 비교적 간단하며, 전체 프로세스가 다른 P의 poolLocal과 상호 작용할 필요가 없습니다.

 



Get의 구현
Put이 어떻게 구현되는지 이해했다면, 이제 Get 구현으로 넘어가겠습니다. Get 작업을 사용하면 sync.Pool에서 객체를 가져올 수 있습니다.

Put 함수에 비해 Get 구현은 더 복잡합니다. 현재 P의 로컬 객체 풀을 조작해야 할 뿐만 아니라 다른 P의 로컬 객체 풀에서 객체를 훔쳐와야 하기 때문입니다. 코드 로직은 다음과 같습니다. 코드 로직은 다음과 같습니다.

 

func (p *Pool) Get() interface{} {
	l, pid := p.pin()

	x := l.private
	l.private = nil

	if x == nil {
		x, _ = l.shared.popHead()

		if x == nil {
			x = p.getSlow(pid)
		}
	}
	runtime_procUnpin()

	if x == nil && p.New != nil {
		x = p.New()
	}
	return x
}

pin()의 역할과 개인 객체의 역할은 PUT 연산에서와 동일하므로 여기서는 다루지 않겠습니다. 나머지 로직에 집중해 보겠습니다.

먼저 Get 함수는 현재 P의 로컬 오브젝트 풀의 풀체인에서 오브젝트를 가져오려고 시도합니다. 현재 P의 풀체인에서 데이터를 가져올 때, 데이터는 체인의 헤드에서 가져옵니다. 구체적으로는 체인 헤드에 있는 풀디큐를 먼저 가져온 다음 풀디큐의 헤드에서 데이터를 가져옵니다.

for i := 0; i <int(size); i++ {
	l := indexLocal(locals, (pid+i+1)%int(size))
	if x, _ := l.shared.popTail(); x != nil {
		return x
	}
}

다른 P의 풀체인에서 팝테일이 호출되면 체인 끝에 있는 풀디큐를 먼저 가져온 다음, 풀디큐의 끝에서 데이터를 가져옵니다. 이 풀Dequeue에서 데이터를 가져오지 않으면 풀Dequeue가 비어 있음을 의미하며, 풀Dequeue는 풀체인에서 바로 제거되고 다음 풀Dequeue를 시도합니다.

다른 P의 로컬 객체 풀에서 데이터를 가져오지 않으면 해당 데이터도 가져오지 않습니다. 다음으로 피해자에서 데이터를 가져오려고 시도합니다. 위에서 언급했듯이 victim은 이전 라운드에서 정리된 오브젝트 풀이며, victim에서 오브젝트를 가져오는 방식은 popTail과 동일합니다.

 

마지막으로 모든 캐시 풀에 데이터가 부족하면 사용자가 설정한 New 함수가 호출되어 새 객체를 만듭니다.

 

출저: https://www.sobyte.net/post/2022-03/think-in-sync-pool/

sync.Pool은 로컬 풀체인을 푸시 또는 팝으로 조작할 때 헤드에서 시작하도록 설계되었습니다. 그리고 다른 P의 풀체인에서 데이터를 가져올 때는 꼬리 부분인 팝테일만 가져올 수 있습니다. 이렇게 하면 동시성 충돌을 최소화할 수 있습니다.

 

 



객체 정리 ( 가비지 )
sync.Pool에는 오브젝트 정리 정책이나 정리 인터페이스가 공개되어 있지 않습니다. 위에서 언급했듯이 다른 P에서 객체를 훔칠 때 빈 poolDequeue가 단계적으로 사라지지만, sync.Pool에도 다른 객체 정리 메커니즘이 있어야 하며, 그렇지 않으면 객체 풀이 무한정 확장되어 메모리 누수가 발생할 수 있습니다.

sync.Pool에 대한 Golang의 정리 로직은 매우 간단하고 잔인합니다. Golang의 런타임은 각 GC 라운드 전에 poolCleanup 호출을 트리거하여 모든 Pool을 정리합니다.

 

func poolCleanup() {
	for _, p := range oldPools {
		p.victim = nil
		p.victimSize = 0
	}

	for _, p := range allPools {
		p.victim = p.local
		p.victimSize = p.localSize
		p.local = nil
		p.localSize = 0
	}

	oldPools, allPools = allPools, nil
}

여기서는 Get 함수의 객체 훔치기 로직에서도 언급된 sync.Pool의 희생자 메커니즘을 공식적으로 소개할 필요가 있습니다.

sync.Pool 정리의 각 라운드에서 객체 풀은 당분간 완전히 정리되지 않고 피해 객체에 배치됩니다. 즉, 각 GC 라운드가 끝나면 sync.Pool의 오브젝트 풀이 피해자에게 전송되고 이전 라운드의 피해자는 비워집니다.

왜 이렇게 할까요? 이는 GC 후 sync.Pool이 갑자기 비워져 프로그램 성능에 영향을 미치는 것을 방지하고자 하기 때문입니다. 따라서 먼저 희생자를 트랜지션으로 사용하고, 현재 라운드의 객체 풀에서 데이터를 가져올 수 없는 경우 희생자에서도 데이터를 가져올 수 있으므로 프로그램 성능이 더 원활해집니다.

희생자 메커니즘은 CPU 캐시에서 처음 사용되었으며, 자세한 내용은 이 위키를 참조하세요: Victim_cache에서 자세한 내용을 확인할 수 있습니다.

 




기타 최적화
잘못된 공유 문제 방지

type poolLocal struct {
	poolLocalInternal

	// Prevents false sharing on widespread platforms with
	// 128 mod (cache line size) = 0 .
	pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte
}

위에서 풀로컬에 대해 이야기할 때 이상한 구조를 발견할 수 있습니다. 풀로컬에는 패드 속성이 있는데, 이 속성이 정의되는 방식을 보면 분명히 128바이트의 정수 배수까지 반올림하도록 설계되어 있습니다. 왜 이렇게 할까요?

CPU 캐시와의 잘못된 공유 문제를 피하기 위해서입니다: CPU 캐시 라인은 일반적으로 64바이트 또는 128바이트 단위입니다. 이 시나리오에서 각 P의 풀로컬은 배열 형태입니다. CPU 캐시 라인이 128바이트이고 풀로컬이 128바이트보다 작으면 캐시 라인은 다른 P의 풀로컬의 메모리 데이터를 가져와서 전체 캐시 라인을 구성합니다. 인접한 두 개의 P가 동시에 두 개의 다른 CPU 코어에서 실행 중이면 캐시라인을 덮어쓰고 캐시라인이 반복적으로 실패하여 CPU 캐시가 쓸모없게 됩니다.

CPU 캐시는 CPU에 가장 가까운 캐시이며 잘 사용하면 프로그램의 성능을 크게 향상시킬 수 있습니다. golang은 잘못된 공유를 방지하기 위해 패드 메서드를 사용하여 캐시라인이 다른 P의 풀로컬과 공유되지 않도록 합니다.

 


 

sync.Pool의 성능
요약하자면, sync.Pool은 다음과 같은 방법으로 성능을 극대화합니다.

GMP 기능을 사용하여 각 P에 대해 로컬 객체 풀 poolLocal을 생성하여 동시성 충돌을 최소화합니다.
각 풀로컬에는 프라이빗 오브젝트가 있으며, 복잡한 로직을 피하기 위해 프라이빗 오브젝트에 대한 액세스가 우선시됩니다.
핀을 사용하여 Get 및 Put 중에 현재 P를 잠그면 고루틴이 선점되어 프로그램 혼란을 야기하는 것을 방지할 수 있습니다.
오브젝트 훔치기 메커니즘을 사용하여 Get 중에 다른 P의 로컬 오브젝트 풀과 피해자로부터 오브젝트를 가져옵니다.
CPU 캐시 기능을 최대한 활용하여 프로그램 성능을 개선합니다.

 

 

원문 : https://www.sobyte.net/post/2022-03/think-in-sync-pool/

 

Deep analysis of Golang sync.Pool underlying principles

In this article, we will explore the underlying implementation of sync.Pool based on go-1.16 source code.

www.sobyte.net

 

 

'GO' 카테고리의 다른 글

커넥션 풀링 설정 테스트하기  (0) 2023.03.23
API 서버에서의 오류 설계  (0) 2023.03.13
고루틴??  (0) 2023.02.23
Go 리플렉션 성능이슈  (0) 2022.11.16
GO 백엔드 개발자 학습 로드맵  (0) 2022.11.16