Go의 메모리 관리 방식 (Stack vs. Heap)
Go의 문법은 단순하지만 가끔씩 변수가 스택에 저장되는지, 힙에 저장되는지 불명확할 때가 있다. 프로그래밍을 하면서 메모리 관련 버그가 발생할지 불안감에 휩싸일 수도 있다.
우선 간단하게 스택과 힙에 대해 짚고 넘어가보자. 이들은 메모리 공간에서 서로 겹치지 않는 독립된 기능을 수행하는 영역이다.
스택은 무엇인가?
스택은 프로그램의 각 쓰레드에게 하나씩 할당된 공간으로, 함수의 call frame을 저장하는 데에 사용한다. 이 call frame에는 로컬 변수, 함수 파라미터, 함수 콜의 리턴 주소가 저장되어 있다. 함수가 새로 호출될 때 스택에 새로운 프레임이 생기고, 함수가 종료될 때 그 프레임을 스택에서 제거한다 (함수의 호출 규약).
스택의 장점 중 하나는 접근 속도가 빠르다는 점이다. 스택은 최근 함수가 콜되었을 때 사용된 값들이 저장되어 있기 때문에, 스택 메모리 접근은 최근에 접근한 영역 근처를 접근하고, 따라서 캐시된 메모리(페이지)를 활용할 가능성이 높다.
힙은 무엇인가?
힙 또한 프로그램의 데이터를 저장하는 또 다른 영역이다. 스택에 저장한 변수들은 함수 호출이 종료될 때 자동으로 메모리가 할당되고 지워지는 반면, 힙의 경우 사용 종료가 된 변수가 자동으로 컴파일러나 런타임에 의해서 할당/해제(free)되지 않는다. 따라서 힙 메모리는 프로그래머가 직접 관리해주는 코드를 작성해주어야 하는데, 이게 C의 malloc과 free이다.
힙은 런타임에 동적으로 자라나기 때문에, 스택에 넣기에 너무 큰 데이터를 힙에 넣을 수도 있고, 함수 호출이 종료된 이후에도 사용될 데이터는 힙에서 관리한다. 하지만 힙은 자동으로 관리되지 않기 때문에 메모리 누수, use-after-free 등 메모리 오류에 더 취약하다.
Go의 변수 할당 방식 (스택? 힙?)
Go는 모든 변수가 자동으로 관리되고, 변수를 스택에 넣을지 힙에 넣을지도 컴파일러가 스스로 관리한다. 다음은 Go 문서의 FAQ에 있는 내용이니 참고하면 도움이 된다. 따라서 프로그래머는 변수가 어디에 저장되는지 신경쓰지 않아도 되고 모든 것은 컴파일러가 정적분석으로 알아서 결정한다.
Q. How do I know whether a variable is allocated on the heap or the stack?
A. From a correctness standpoint, you don’t need to know. Each variable in Go exists as long as there are references to it. The storage location chosen by the implementation is irrelevant to the semantics of the language.
The storage location does have an effect on writing efficient programs. When possible, the Go compilers will allocate variables that are local to a function in that function’s stack frame. However, if the compiler cannot prove that the variable is not referenced after the function returns, then the compiler must allocate the variable on the garbage-collected heap to avoid dangling pointer errors. Also, if a local variable is very large, it might make more sense to store it on the heap rather than the stack.
In the current compilers, if a variable has its address taken, that variable is a candidate for allocation on the heap. However, a basic escape analysis recognizes some cases when such variables will not live past the return from the function and can reside on the stack.
예를 들면 다음과 같다.
func createPointer() *int {
num := 100
return &num
}
이 함수에서는 로컬 변수를 생성했지만, 이 변수의 포인터를 반환하여 그 함수 밖에서 쓸 수 있도록 만들었다. 이 변수를 스택에 넣게 되면 함수 호출이 종료된 이후 그 변수가 저장된 공간이 지워지기 때문에 정상적인 포인터가 반환되지 않고 무의미한 값을 가리키는 포인터(dangling pointer)를 반환하게 된다.
따라서 이 변수는 힙에서 관리해야 하고, 실제로 Go 컴파일러는 자동으로 그렇게 컴파일한다. Go 컴파일러는 escape analysis라는 정적 분석을 하는데, 변수가 로컬 스코프를 벗어날 수 있는지 여부를 판단하여 그럴 수 있다면 변수를 힙에 저장하도록 컴파일한다.
이런 경우는 어떻게 할까?
func conditional_local(flag bool) *int{
num := 100
if flag {
return &num
} else {
return nil
}
}
flag의 값이 거짓이면 로컬변수 num이 리턴되지 않지만, 참이면 로컬변수가 리턴된다. 따라서,
- flag가 거짓: num을 스택에 저장하는게 더 최적이다.
- flag가 참: num을 힙에 저장해야 한다.
이렇게 flag 값에 따라서 변수를 스택에 저장하는게 좋을지, 힙에 저장하는게 좋을지 결정되는 상황이다.
이 때 Go는 설령 flag=거짓으로 함수가 호출되는 경우가 없다고 하더라도 num은 언제나 힙에 저장한다. 그런 편이 안전하고, 컴파일러가 수행하는 정적분석만으로는 이와 같은 동적으로 있는 죽은 코드(dead code)를 알 수 없기 때문이다.
마지막으로, 만약 변수가 힙에 저장된다면 사용이 종료된 이후 보통 직접 free를 해줘야 하지만, Go는 가비지 컬렉터를 통해 이를 자동으로 관리하기 때문에 그럴 필요가 없다.
정리
- Go는 변수를 힙/스택 중 어떤 메모리 공간에 저장할지 지 컴파일러가 결정한다.
- 컴파일러는 escape analysis라는 정적분석을 통해 변수가 함수 바깥으로 전파될 수 있는지 여부를 판단한다.
- 힙에 저장되는 변수는 가비지 컬렉션을 통해 관리되기 때문에 직접 free를 해줄 필요가 없다.
참고자료
[1] https://tip.golang.org/src/cmd/compile/internal/escape/escape.go