>  기사  >  백엔드 개발  >  Go: 포인터 및 메모리 관리

Go: 포인터 및 메모리 관리

Patricia Arquette
Patricia Arquette원래의
2024-11-22 01:51:14464검색

Go: Pointers & Memory Management

TL;DR: 포인터, 스택 및 힙 할당, 이스케이프 분석 및 가비지 수집을 포함한 Go의 메모리 처리를 예제와 함께 살펴보세요

처음 Go를 배우기 시작했을 때 Go의 메모리 관리 접근 방식, 특히 포인터에 대한 접근 방식에 흥미를 느꼈습니다. Go는 효율적이고 안전한 방식으로 메모리를 처리하지만 내부를 들여다보지 않으면 약간의 블랙박스가 될 수 있습니다. Go가 포인터, 스택 및 힙을 사용하여 메모리를 관리하는 방법과 이스케이프 분석 및 가비지 수집과 같은 개념에 대한 통찰력을 공유하고 싶습니다. 그 과정에서 이러한 아이디어를 실제로 보여주는 코드 예제를 살펴보겠습니다.

스택 및 힙 메모리 이해

Go에서 포인터를 살펴보기 전에 스택과 힙이 어떻게 작동하는지 이해하는 것이 도움이 됩니다. 이는 변수를 저장할 수 있는 두 가지 메모리 영역으로, 각각 고유한 특성을 가지고 있습니다.

  • 스택: 후입선출 방식으로 작동하는 메모리 영역입니다. 빠르고 효율적이며 함수 내의 지역 변수와 같이 수명이 짧은 변수를 저장하는 데 사용됩니다.
  • : 이는 함수에서 반환되어 다른 곳에서 사용되는 데이터와 같이 함수 범위를 벗어나야 하는 변수에 사용되는 더 큰 메모리 풀입니다.

Go에서 컴파일러는 사용 방법에 따라 스택에 변수를 할당할지 힙에 변수를 할당할지 결정합니다. 이러한 의사결정 과정을 탈출 분석이라고 하며, 이에 대해서는 나중에 자세히 살펴보겠습니다.

값 전달: 기본 동작

Go에서는 정수, 문자열, 부울과 같은 변수를 함수에 전달하면 자연스럽게 값으로 전달됩니다. 이는 변수의 복사본이 만들어지고 함수가 해당 복사본과 함께 작동함을 의미합니다. 즉, 함수 내부의 변수에 대한 변경 사항은 해당 범위 밖의 변수에 영향을 미치지 않습니다.

다음은 간단한 예입니다.

package main

import "fmt"

func increment(num int) {
    num++
    fmt.Printf("Inside increment(): num = %d, address = %p \n", num, &num)
}

func main() {
    n := 21
    fmt.Printf("Before increment(): n = %d, address = %p \n", n, &n)
    increment(n)
    fmt.Printf("After increment(): n = %d, address = %p \n", n, &n)
}

출력:

Before increment(): n = 21, address = 0xc000012070 
Inside increment(): num = 22, address = 0xc000012078 
After increment(): n = 21, address = 0xc000012070

이 코드에서는:

  • increment() 함수는 n의 복사본을 받습니다.
  • main()의 n 주소와 increment()의 num 주소가 다릅니다.
  • increment() 내부의 num 수정은 main()의 n에 영향을 주지 않습니다.

요점: 값 전달은 안전하고 간단하지만 대규모 데이터 구조의 경우 복사가 비효율적일 수 있습니다.

포인터 소개: 참조로 전달

함수 내부의 원래 변수를 수정하려면 해당 변수에 포인터를 전달할 수 있습니다. 포인터는 변수의 메모리 주소를 보유하므로 함수가 원본 데이터에 액세스하고 수정할 수 있습니다.

포인터를 사용하는 방법은 다음과 같습니다.

package main

import "fmt"

func incrementPointer(num *int) {
    (*num)++
    fmt.Printf("Inside incrementPointer(): num = %d, address = %p \n", *num, num)
}

func main() {
    n := 42
    fmt.Printf("Before incrementPointer(): n = %d, address = %p \n", n, &n)
    incrementPointer(&n)
    fmt.Printf("After incrementPointer(): n = %d, address = %p \n", n, &n)
}

출력:

Before incrementPointer(): n = 42, address = 0xc00009a040 
Inside incrementPointer(): num = 43, address = 0xc00009a040 
After incrementPointer(): n = 43, address = 0xc00009a040 

이 예에서는:

  • n의 주소를 incrementPointer()에 전달합니다.
  • main()과 incrementPointer()는 모두 동일한 메모리 주소를 참조합니다.
  • incrementPointer() 내부에서 num을 수정하면 main()의 n에 영향을 줍니다.

요점: 포인터를 사용하면 함수가 원래 변수를 수정할 수 있지만 메모리 할당에 대해 고려해야 할 사항이 있습니다.

포인터를 사용한 메모리 할당

변수에 대한 포인터를 생성할 때 Go는 포인터가 지속되는 동안 변수가 지속되는지 확인해야 합니다. 이는 스택이 아닌 에 변수를 할당하는 것을 의미하는 경우가 많습니다.

이 기능을 고려해보세요:

package main

import "fmt"

func increment(num int) {
    num++
    fmt.Printf("Inside increment(): num = %d, address = %p \n", num, &num)
}

func main() {
    n := 21
    fmt.Printf("Before increment(): n = %d, address = %p \n", n, &n)
    increment(n)
    fmt.Printf("After increment(): n = %d, address = %p \n", n, &n)
}

여기서 num은 createPointer() 내의 지역 변수입니다. num이 스택에 저장된 경우 함수가 반환되면 정리되어 매달린 포인터가 남습니다. 이를 방지하기 위해 Go는 createPointer()가 종료된 후에도 유효한 상태로 유지되도록 힙에 num을 할당합니다.

매달린 포인터

매달린 포인터는 포인터가 이미 해제된 메모리를 참조할 때 발생합니다.

Go는 가비지 수집기를 사용하여 포인터 매달림을 방지하여 메모리가 참조되는 동안 메모리가 해제되지 않도록 합니다. 그러나 포인터를 필요 이상으로 길게 유지하면 특정 시나리오에서 메모리 사용량이 증가하거나 메모리 누수가 발생할 수 있습니다.

이스케이프 분석: 스택 및 힙 할당 결정

이스케이프 분석은 변수가 해당 기능 범위를 넘어서 살아야 하는지 여부를 결정합니다. 변수가 반환되거나, 포인터에 저장되거나, 고루틴에 의해 캡처되면 이스케이프되어 힙에 할당됩니다. 그러나 변수가 이스케이프되지 않더라도 컴파일러는 최적화 결정이나 스택 크기 제한과 같은 다른 이유로 이를 힙에 할당할 수 있습니다.

변수 이스케이프의 예:

Before increment(): n = 21, address = 0xc000012070 
Inside increment(): num = 22, address = 0xc000012078 
After increment(): n = 21, address = 0xc000012070

이 코드에서는:

  • createSlice()의 슬라이스 데이터는 main()에서 반환되어 사용되기 때문에 이스케이프됩니다.
  • 슬라이스의 기본 배열은 에 할당됩니다.

go build -gcflags '-m'을 사용한 이스케이프 분석 이해

-gcflags '-m' 옵션을 사용하면 Go 컴파일러가 무엇을 결정하는지 확인할 수 있습니다.

package main

import "fmt"

func incrementPointer(num *int) {
    (*num)++
    fmt.Printf("Inside incrementPointer(): num = %d, address = %p \n", *num, num)
}

func main() {
    n := 42
    fmt.Printf("Before incrementPointer(): n = %d, address = %p \n", n, &n)
    incrementPointer(&n)
    fmt.Printf("After incrementPointer(): n = %d, address = %p \n", n, &n)
}

이렇게 하면 변수가 힙으로 이스케이프되는지 여부를 나타내는 메시지가 출력됩니다.

Go의 가비지 컬렉션

Go는 가비지 수집기를 사용하여 힙에서 메모리 할당 및 할당 해제를 관리합니다. 더 이상 참조되지 않는 메모리를 자동으로 해제하여 메모리 누수를 방지합니다.

예:

Before incrementPointer(): n = 42, address = 0xc00009a040 
Inside incrementPointer(): num = 43, address = 0xc00009a040 
After incrementPointer(): n = 43, address = 0xc00009a040 

이 코드에서는:

  • 1,000,000개의 노드로 연결된 목록을 만듭니다.
  • 각 노드는 createLinkedList()의 범위를 벗어나므로 힙에 할당됩니다.
  • 가비지 수집기는 목록이 더 이상 필요하지 않을 때 메모리를 해제합니다.

요점: Go의 가비지 수집기는 메모리 관리를 단순화하지만 오버헤드가 발생할 수 있습니다.

포인터의 잠재적인 함정

포인터는 강력하지만 주의 깊게 사용하지 않으면 문제가 발생할 수 있습니다.

매달린 포인터(계속)

Go의 가비지 수집기가 매달린 포인터를 방지하는 데 도움이 되더라도 포인터를 필요 이상으로 오래 붙잡고 있으면 여전히 문제가 발생할 수 있습니다.

예:

package main

import "fmt"

func increment(num int) {
    num++
    fmt.Printf("Inside increment(): num = %d, address = %p \n", num, &num)
}

func main() {
    n := 21
    fmt.Printf("Before increment(): n = %d, address = %p \n", n, &n)
    increment(n)
    fmt.Printf("After increment(): n = %d, address = %p \n", n, &n)
}

이 코드에서는:

  • 데이터는 힙에 할당된 큰 조각입니다.
  • 참조([]int)를 유지함으로써 가비지 수집기가 메모리를 해제하는 것을 방지합니다.
  • 제대로 관리하지 않으면 메모리 사용량이 증가할 수 있습니다.

동시성 문제 - 포인터와의 데이터 경쟁

다음은 포인터가 직접적으로 관련된 예입니다.

Before increment(): n = 21, address = 0xc000012070 
Inside increment(): num = 22, address = 0xc000012078 
After increment(): n = 21, address = 0xc000012070

이 코드가 실패하는 이유:

  • 여러 고루틴은 동기화 없이 포인터 counterPtr을 역참조하고 증가시킵니다.
  • 여러 고루틴이 동기화 없이 동시에 동일한 메모리 위치에 액세스하고 수정하기 때문에 데이터 경쟁이 발생합니다. *counterPtr 작업은 여러 단계(읽기, 증가, 쓰기)를 포함하며 스레드로부터 안전하지 않습니다.

데이터 경쟁 수정:

뮤텍스를 사용하여 동기화를 추가하면 이 문제를 해결할 수 있습니다.

package main

import "fmt"

func incrementPointer(num *int) {
    (*num)++
    fmt.Printf("Inside incrementPointer(): num = %d, address = %p \n", *num, num)
}

func main() {
    n := 42
    fmt.Printf("Before incrementPointer(): n = %d, address = %p \n", n, &n)
    incrementPointer(&n)
    fmt.Printf("After incrementPointer(): n = %d, address = %p \n", n, &n)
}

이 수정 사항의 작동 방식:

  • mu.Lock() 및 mu.Unlock()은 한 번에 하나의 고루틴만 포인터에 액세스하고 수정하도록 보장합니다.
  • 이렇게 하면 경쟁 조건이 방지되고 카운터의 최종 값이 정확해집니다.

Go의 언어 사양은 무엇을 말하나요?

Go의 언어 사양은 변수가 스택에 할당되는지 힙에 할당되는지를 직접적으로 지시하지 않는다는 점에 주목할 가치가 있습니다. 이는 런타임 및 컴파일러 구현 세부 정보로, Go 버전이나 구현에 따라 달라질 수 있는 유연성과 최적화를 허용합니다.

즉,

  • 메모리 관리 방식은 Go 버전에 따라 다를 수 있습니다.
  • 특정 메모리 영역에 할당되는 변수에 의존해서는 안 됩니다.
  • 메모리 할당을 제어하기보다는 명확하고 올바른 코드를 작성하는 데 집중하세요.

예:

스택에 변수가 할당될 것으로 예상하더라도 컴파일러는 분석에 따라 해당 변수를 힙으로 이동하기로 결정할 수 있습니다.

package main

import "fmt"

func increment(num int) {
    num++
    fmt.Printf("Inside increment(): num = %d, address = %p \n", num, &num)
}

func main() {
    n := 21
    fmt.Printf("Before increment(): n = %d, address = %p \n", n, &n)
    increment(n)
    fmt.Printf("After increment(): n = %d, address = %p \n", n, &n)
}

요점: 메모리 할당 세부 사항은 일종의 내부 구현이며 Go 언어 사양의 일부가 아니므로 이러한 정보는 일반적인 지침일 뿐이며 나중에 변경될 수 있는 고정 규칙이 아닙니다.

성능과 메모리 사용량의 균형

값 전달과 포인터 전달 중에서 결정할 때는 데이터 크기와 성능에 미치는 영향을 고려해야 합니다.

값으로 큰 구조체 전달:

Before increment(): n = 21, address = 0xc000012070 
Inside increment(): num = 22, address = 0xc000012078 
After increment(): n = 21, address = 0xc000012070

포인터로 큰 구조체 전달:

package main

import "fmt"

func incrementPointer(num *int) {
    (*num)++
    fmt.Printf("Inside incrementPointer(): num = %d, address = %p \n", *num, num)
}

func main() {
    n := 42
    fmt.Printf("Before incrementPointer(): n = %d, address = %p \n", n, &n)
    incrementPointer(&n)
    fmt.Printf("After incrementPointer(): n = %d, address = %p \n", n, &n)
}

고려사항:

  • 값 전달은 안전하고 간단하지만 대규모 데이터 구조에는 비효율적일 수 있습니다.
  • 포인터를 전달하면 복사가 방지되지만 동시성 문제를 방지하려면 신중하게 처리해야 합니다.

현장 경험에서:

초창기, 대용량 데이터 세트를 처리하는 Go 애플리케이션을 최적화하던 시절이 떠올랐습니다. 처음에는 코드에 대한 추론을 단순화할 것이라고 가정하여 큰 구조체를 값으로 전달했습니다. 하지만 우연히 비교적 높은 메모리 사용량과 빈번한 가비지 수집 일시 중지를 발견했습니다.

선배님과 페어 프로그래밍에서 Go의 pprof 도구를 사용하여 애플리케이션을 프로파일링한 후 큰 구조체를 복사하는 데 병목 현상이 발생한다는 사실을 발견했습니다. 값 대신 포인터를 전달하도록 코드를 리팩터링했습니다. 이로 인해 메모리 사용량이 줄어들고 성능이 크게 향상되었습니다.

그러나 변화에는 어려움이 없지 않았습니다. 이제 여러 고루틴이 공유 데이터에 액세스하고 있으므로 코드가 스레드로부터 안전한지 확인해야 했습니다. 뮤텍스를 사용하여 동기화를 구현하고 잠재적인 경쟁 조건에 대한 코드를 주의 깊게 검토했습니다.

교훈: Go가 메모리 할당을 처리하는 방법을 조기에 이해하면 보다 효율적인 코드를 작성하는 데 도움이 됩니다. 성능 향상과 코드 안전성 및 유지 관리성의 균형을 맞추는 것이 중요하기 때문입니다.

최종 생각

Go의 메모리 관리 접근 방식(다른 곳에서와 마찬가지로)은 성능과 단순성 사이의 균형을 유지합니다. 많은 하위 수준 세부 정보를 추상화함으로써 개발자는 수동 메모리 관리에 얽매이지 않고 강력한 애플리케이션을 구축하는 데 집중할 수 있습니다.

기억해야 할 핵심 사항:

  • 값 전달은 간단하지만 대규모 데이터 구조에는 비효율적일 수 있습니다.
  • 포인터를 사용하면 성능이 향상될 수 있지만 데이터 경합과 같은 문제를 방지하려면 주의 깊게 처리해야 합니다.
  • Escape 분석은 변수가 스택에 할당되는지 힙에 할당되는지 결정하지만 이는 내부 세부 사항입니다.
  • 가비지 수집은 메모리 누수를 방지하는 데 도움이 되지만 오버헤드가 발생할 수 있습니다.
  • 동시성은 공유 데이터가 포함될 때 동기화가 필요합니다.

이러한 개념을 염두에 두고 Go의 도구를 사용하여 코드를 프로파일링하고 분석하면 효율적이고 안전한 애플리케이션을 작성할 수 있습니다.


포인터를 사용한 Go의 메모리 관리에 대한 탐구가 도움이 되기를 바랍니다. Go를 막 시작했거나 이해를 심화시키려는 경우, 코드를 실험하고 컴파일러와 런타임이 어떻게 작동하는지 관찰하는 것은 학습할 수 있는 좋은 방법입니다.

당신의 경험이나 질문이 있으면 자유롭게 공유하세요. 저는 항상 Go!에 대해 토론하고 배우고 더 많은 글을 쓰고 싶습니다.

보너스 콘텐츠 - 직접 포인터 지원

아시나요? 포인터는 특정 데이터 유형에 대해 직접 생성될 수 있지만 일부 데이터 유형에는 생성될 수 없습니다. 이 짧은 테이블이 그것을 다루고 있습니다.


Type Supports Direct Pointer Creation? Example
Structs ✅ Yes p := &Person{Name: "Alice", Age: 30}
Arrays ✅ Yes arrPtr := &[3]int{1, 2, 3}
Slices ❌ No (indirect via variable) slice := []int{1, 2, 3}; slicePtr := &slice
Maps ❌ No (indirect via variable) m := map[string]int{}; mPtr := &m
Channels ❌ No (indirect via variable) ch := make(chan int); chPtr := &ch
Basic Types ❌ No (requires a variable) val := 42; p := &val
time.Time (Struct) ✅ Yes t := &time.Time{}
Custom Structs ✅ Yes point := &Point{X: 1, Y: 2}
Interface Types ✅ Yes (but rarely needed) var iface interface{} = "hello"; ifacePtr := &iface
time.Duration (Alias of int64) ❌ No duration := time.Duration(5); p := &duration
유형 직접 포인터 생성을 지원합니까? 예 구조체 ✅ 예 p := &Person{이름: "앨리스", 나이: 30} 배열 ✅ 예 arrPtr := &[3]int{1, 2, 3} 슬라이스 ❌ 아니요(변수를 통한 간접) slice := []int{1, 2, 3}; 슬라이스Ptr := &슬라이스 지도 ❌ 아니요(변수를 통한 간접) m := 지도[문자열]int{}; mPtr := &m 채널 ❌ 아니요(변수를 통한 간접) ch := make(chan int); chPtr := &ch 기본 유형 ❌ 아니요(변수 필요) 발 := 42; p := &발 time.Time(구조체) ✅ 예 t := &time.Time{} 사용자 정의 구조체 ✅ 예 point := &Point{X: 1, Y: 2} 인터페이스 유형 ✅ 예(그러나 거의 필요하지 않음) var iface 인터페이스{} = "안녕하세요"; ifacePtr := &iface time.Duration(int64 별칭) ❌ 아니요 기간 := time.Duration(5); p := 지속시간(&D)

원하시면 댓글로 알려주세요. 앞으로는 이런 보너스 내용을 기사에 추가해보도록 하겠습니다.

읽어주셔서 감사합니다! 더 많은 콘텐츠를 원하시면 다음을 고려해 보세요.

코드가 함께하길 바랍니다 :)

내 소셜 링크: LinkedIn | GitHub | ? (이전 트위터) | 서브스택 | Dev.to | 해시노드

위 내용은 Go: 포인터 및 메모리 관리의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.