golang - 스택과 힙에 대해

  • 실행 시 동적으로 메모리를 확보하는 영역으로서 스택과 힙이 있다.
  • 스택 메모리는 함수 호출 스택을 저장하고 로컬 변수, 인수, 반환 값도 여기에 둔다.
  • 스택의 Push와 Pop은 고속이므로 객체를 스택 메모리에 저장하는 비용은 작다. 단 함수를 나오면 스택이 Pop 되어 해제되므로 함수의 수명을 넘는 객체는 살 수 없다.
  • 힙 메모리는 콜 스택과는 관계 없으므로 함수 범위에 얽매이지 않고 객체를 저장해 둔다. 다만 빈 영역을 찾고, GC로 쓸모 없게된 객체를 회수하기도 하므로 처리 비용이 든다.
    ※ Go 풍으로는 값이라고 해야 할지도 모르겠지만 이 글에서는 메모리를 사용하는 어떤 실체의 것은 객체라고 부른다
  • Go 언어는 컴파일러가 객체를 스택에 확보할지 힙에 확보할지 결정하므로 프로그래머가 의식할 필요는 보통 없다.
    ※또한 Go 언어는 스택 메모리가 부족하면 새로운 청크를 확보하여 추가하므로 스택에 메모리를 너무 많이 쌓아서 죽는(StackOverflow) 경우는 생기기 어렵다.
    때에 따라서 큰 객체를 쌓아도 좋다(이 부분은 컴파일러가 알아서 잘 해 준다).
  • 의식할 필요가 없다고 하지만 스택보다 힙이 처리 비용이 큰 경우에 상황에 따라서 성능 상의 문제가 될 수 있다. 어떠한 경우에 개체가 스택 또는 힙이 사용되는지 확인해 본다.

간단히 말하면

  • 함수 내에서만 사용되는 값은 스택에 둔다.
  • 어떤 함수 내에서 확보한 값이 함수 밖에서도 필요하게 된다면 힙에 놓인다.

단순하게 말하면 결론은 위와 같다(예외는 있지만 대부분은).
이것만 보면 당연한 결론이라고 말할 수 있지만 몇 가지 재미 있는 패턴이 있으므로 관심 있는 사람은 계속 보기 바란다.

확인 방법

  • 컴파일러에 플래그를 전달하면 자세히 알 수 있다.
> go build -gcflags -m hello.go

아래와 같은 표시가 나온다.

./hello.go:10: moved to heap: n
./hello.go:11: &n escapes to heap
./hello.go:17: m dones not escape

위에서는 변수 n이 힙에 놓이고, 변수 m이 스택에 있는 것을 알 수 잇다.

정의 부분

이 기사에서 테스트 스니펫의 공통 정의 부분은 아래와 같다

type Duck struct{}

func (d *Duck) Sound() {
    fmt.Println("quack")
}

type Sounder interface {
    Sound()
}

Duck이라는 quack과 우는 구조체가 있다. 또 Sound 메서드를 가진 Sounder라는 인터페이스가 있다. Duck은 Sounder에 준거하고 있으므로 Sounder이다.

로컬 변수&

1) 값형(포인터가 아니라)변수, 함수 내에서만 사용하고 있다.

func test1() {
    var d Duck = Duck{}
    d.Sound()
}

「스택」이 된다.
※ 이것은 많은 다른 언어에서도 스택이 되는 예상대로의 패턴

2) 포인터 형의 변수, 함수 내에서만 사용하고 있다

func test2() {
    var d *Duck = &Duck{}
    d.Sound()
}

이것은 아래와 같이 써도 같은 의미이다.

func test2b() {
    var d *Duck = new(Duck)
    d.Sound()
}

「스택」이 된다.
※ 이른바 new 를 해도 스택에 놓이는 패턴
※ 코드를 1)에 옮겨도 의미가 같은데다 변수가 함수 내에 머물러 있으므로 스택에 두는 것이 좋다고 컴파일러가 판단

변수를 반환 값으로 돌려준다.

3) 값 타입 변수를 값 타입의 반환 값으로 되돌려준다

func test3() Duck {
    var d Duck = Duck{}
    return d
}

「스택」이 된다.
※ 값 형을 반환 값으로 돌려주면 값 복사가 되어 변수 d는 함수 내에 자리를 굳힌다.

4) 포인터 타입의 변수를 값 타입의 반환 값으로 되돌려준다

func test4() Duck {
    var d *Duck = &Duck{}
    return *d
}

「스택」이 된다. 3)과 거의 값다.

5) 값 타입 변수의 주소를 포인터 타입의 반환 값으로 되돌려준다

func test5() *Duck {
    var d Duck = Duck{}
    return &d
}

「힙」이 된다.
변수 d는 반환된 뒤에도 사용될 가능성이 있으므로 스택에 둘 수 없다. 따라서 힙.
※ C 언어 등의 다른 언어로 이런 코드를 쓰면 안 된다. 지역 변수의 주소를 반환 하는 것은 엉뚱한 짓이다.
※ Go 언어는 괜찮다. 언어 사양으로 문제 없다.

다만 이 함수는 인라인 전개되므로 다음과 같이 함수 test5를 사용한 경우

func hoge() {
    d := test5()
    d.Sound()
}

함수 hoge안에 test5가 인라인 전개된다. 그리고 변수 d는 함수 hoge 안에 머물러 있으므로 스택에 둘 수 있게 되므로 스택에 둔다(따라서 고속).

6) 포인터 타입 변수의 주소를 포인터 타입의 반환 값으로 되돌려준다

func test6() *Duck {
    var d *Duck = &Duck{}
    return d
}

「힙」이 된다.
5)와 거의 같다. 인라인 전개에 대해서도 마찬가지.
5.b) 메소드를 호출하여 변수의 주소를 포인터 형의 반환 값으로 되돌려준다.

func test5b() *Duck {
    var d Duck = Duck{}
    d.Sound()
    return &d
}

「힙」이 된다.
5의 아류이지만 메소드 호출이 함수 내에 존재한다. 이 경우 인라인 전개되지 않는 사양.

interface

7) 변수의 주소를 interface에 대입 함수 내에서만 사용하고 있다

func test7() {
    var d Sounder = &Duck{}
    d.Sound()
}

「힙」이 된다.
2)의 경우와 거의 같은 처리로 변수 d가 Duck 포인터였던 것이 Sounder interface로 바뀐 것. 2)는 스택이지만 이쪽은 힙이다.
※ interface 변수에 넣으면 힙이 되는 사양

8) 변수의 주소를 interface에 대입 반환 값으로 되돌려준다.

func test8() Sounder {
    var d Sounder = &Duck{}
    return d
}

「힙」이 된다.
이는 5)나 6)과 같은 것이므로 역시 힙. 인라인 전개한다.
다만 인라인 전개된 호출 원에서도 힙에 놓인다. 이는 7)의 거동과 같다. interface라서 힙에 있는 것으로 생각된다.

slice, map, array

기본적으로는 struct와 같다.
포인터 형의 슬라이스 []*Duck에 넣으면 힙에 두는 경우가 있으므로 조사하고 싶다.

메소드&

메소드의 리시버가 포인터 타입 *Duck으로 호출 시의 변수가 값 타입 Duck의 경우 등에도 여러 가지 있을 수 있으므로 조사하고 싶다.

정리

  • 함수 내에서만 사용되는 값은 스택
  • 함수 밖에서도 값이 필요하게 된다면 힙
  • new 해도 스택인 경우가 있다
    • 함수 내에서만 사용되는 값이면
  • 로컬 변수의 주소를 반환해도 된다
    • 힙에 두도록 컴파일러가 다룬다
  • 인라인 전개에 따라 잘 최적화해 준다
  • interface에 대입하면 힙

Go 컴파일러 현명하다


이 글은 2019-01-09에 작성되었습니다.