메모리와 할당을 바라보는 관점, 할당의 크기·수명·사용, 수명 세대와 메모리 계층, 그리고 컴파일러가 프로그램에 대해 알고 있는 것의 한계를 다룬다.
메모리 할당은 많은 사람이 어려워하는 주제처럼 보인다. 많은 언어가 서로 다른 전략을 사용해 메모리를 자동으로 처리하려고 한다: 가비지 컬렉션(GC), 자동 참조 카운팅(ARC), 자원 획득은 초기화(RAII), 그리고 소유권(ownership) 시맨틱. 하지만 메모리 할당을 추상화로 감추려는 시도는 대부분의 사람이 생각하는 것보다 훨씬 큰 비용을 치르게 된다.
대부분의 사람은 메모리를 스택과 힙이라는 관점으로 생각하도록 배운다. 여기서 스택은 프로시저 호출을 위해 자동으로 커지고, 힙은 스택보다 더 오래 살아야 하는 메모리를 얻기 위해 사용할 수 있는 어떤 마법 같은 것으로 여겨진다. 이런 이원론적 접근은 메모리를 생각하는 방식으로서 잘못됐다. 이는 프로그래머에게 스택이 특별한 형태의 메모리라는 정신 모델을 준다. 대부분의 아키텍처에는 스택을 가리키는 포인터로 전용 레지스터가 있는데, 이는 자주 사용되며 실용적으로 그게 합리적이기 때문에 추가된 것이다. 그리고 힙은 본질적으로 마법적이라는 인식을 심어 준다.
현대 운영체제는 프로세스 단위로 메모리를 가상화한다. 즉, 프로그램/프로세스 내부에서 사용하는 주소는 그 프로그램/프로세스에만 특화되어 있다. 운영체제가 메모리 공간을 가상화해 주기 때문에, 우리는 메모리를 완전히 다른 방식으로 생각할 수 있다. 메모리는 더 이상 스택 과 힙 이라는 이원론적 모델이 아니라, 모든 것이 가상 메모리인 일원론적 모델이다. 그 가상 주소 공간의 일부는 프로시저 스택 프레임을 위해 예약되어 있고, 일부는 운영체제가 요구하는 것들을 위해 예약되어 있으며, 나머지는 우리가 원하는 대로 무엇이든 사용할 수 있다. 이는 앞서 말한 원래의 이원론적 모델과 비슷하게 들릴 수도 있지만, 가장 큰 차이는 메모리가 가상 매핑되어 있고 선형이며, 그 선형 메모리 공간을 여러 구역으로 나눌 수 있다는 사실을 깨닫는 데 있다.
할당을 이야기할 때 생각해야 할 주요 측면은 세 가지다.
나는 보통 대부분의 문제 영역에서 앞의 두 가지 측면을 다음 표로 떠올린다. 퍼센트는 어떤 범주에 속하는 할당이 전체 할당에서 어느 정도 비중을 차지하는지를 의미한다:
| 크기 알려짐 | 크기 알려지지 않음 | |
|---|---|---|
| 수명 알려짐 | 95% | ~4% |
| 수명 알려지지 않음 | ~1% | <1% |
좌상단 범주(크기 알려짐 + 수명 알려짐)는 이 시리즈에서 내가 가장 많이 다룰 영역이다. 대부분의 경우, 할당의 크기(적어도 상한)는 알고 있고, 문제의 할당 수명도 알고 있다.
우상단 범주(크기 알려지지 않음 + 수명 알려짐)는 필요한 메모리의 양은 모르지만 얼마나 오래 사용할지는 아는 영역이다. 가장 흔한 예는 런타임에 파일을 메모리로 로드하는 경우와, 크기를 알 수 없는 해시 테이블을 채우는 경우다. 사전에(a priori) 필요한 메모리 양을 모를 수 있고, 그 결과 필요한 모든 데이터를 담기 위해 메모리를 “resize/realloc” 해야 할 수도 있다. C에서는 malloc 등(et al)이 이 문제 영역의 해법이다.
좌하단 범주(크기 알려짐 + 수명 알려지지 않음)는 메모리가 얼마나 오래 존재해야 하는지는 모르지만 필요한 메모리의 양은 아는 영역이다. 이 경우, 여러 시스템에 걸친 그 메모리의 “소유권”이 잘 정의되어 있지 않다고 말할 수 있다. 이 문제 영역의 일반적인 해법은 참조 카운팅이나 소유권 시맨틱이다.
우하단 범주(크기 알려지지 않음 + 수명 알려지지 않음)는 필요한 메모리 양도, 얼마나 오래 필요할지도 문자 그대로 전혀 모르는 영역이다. 실제로는 꽤 드물며, 가능하다면 이런 상황은 피하려고 해야 한다. 하지만 이 문제 영역의 일반적인 해법은 가비지 컬렉션이다. 가비지 컬렉션은 컴퓨터 과학에서 실제 세계의 대응물과 용어가 실제로 잘 맞아떨어지는 몇 안 되는 용어 중 하나다.
도메인 특화 영역에서는 이 퍼센트들이 완전히 달라질 수 있다는 점에 유의하라. 예를 들어, 알 수 없는 양의 요청을 처리할 수 있는 웹 서버는 메모리가 제한되어 있다면 어떤 형태의 가비지 컬렉션이 필요할 수도 있고, 또는 메모리를 더 사는 편이 더 저렴할 수도 있다.
일반적인 범주에 대해 내가 취하는 전반적 접근은 메모리 수명을 세대(generation) 관점으로 생각하는 것이다. 할당 세대(allocation generation) 는 메모리 수명을 계층적 구조로 정리하는 방법이다. 이러한 세대는 칼로 자르듯 명확히 나뉘는 것이 아니며, 할당은 (실생활처럼) 이 수명 스펙트럼을 가로질러 존재할 수 있다.
이러한 세대 안의 메모리는 보통 같은 시점에 할당되고 해제된다(함께 태어나고, 함께 살고, 함께 죽는다).
영구 할당(Permanent Allocation): 프로그램이 끝날 때까지 해제되지 않는 메모리. 이 메모리는 프로그램 수명 동안 지속된다.
일시 할당(Transient Allocation): 사이클 기반 수명을 갖는 메모리. 이 메모리는 “사이클” 동안만 지속되며, 이 사이클 끝에서 해제된다. 사이클의 예로는 그래픽 프로그램(예: 게임)에서의 프레임이나 업데이트 루프가 있다.
스크래치/임시 할당(Scratch/Temporary Allocation): 짧게 살고, 빠르게 쓰는 메모리로, 그냥 할당해 두고 잊어버리고 싶을 때. 흔한 사례는 문자열을 생성해서 로그로 출력하려는 경우다.
앞서 말했듯이, 일원론적 메모리 모델은 (현대 시스템에서) 선호되는 메모리 모델이다. 이 세대 기반 접근은 메모리 수명을 계층적인 방식으로 정렬한다. 일시 할당자(transient allocator)나 스크래치 할당자(scratch allocator) 안에서도 준-영구(pseudo-permanent) 메모리를 가질 수 있는데, 차이는 그 메모리의 수명에 비춰 보았을 때 상대적으로 어떻게 사용되는지를 생각하는 데 있다. 메모리가 어떻게 사용되는지를 로컬하게(국소적으로) 생각하는 것은 메모리를 개념화하고 관리하는 데 도움이 된다 — 인간의 뇌가 한 번에 담을 수 있는 것은 한정되어 있다.
같은 로컬리스트(localist) 사고 과정은 메모리 공간/크기에도 적용할 수 있으며, 이는 이 시리즈의 후속 글에서 다룰 것이다.
자동 메모리 관리를 하는 언어에서는 많은 사람이 컴파일러가 프로그램의 사용 방식과 수명에 대해 많이 알고 있다고 가정한다. 이는 사실이 아니다. 당신은 컴파일러가 결코 알 수 없는 수준으로 당신의 프로그램을 더 잘 안다. 소유권 시맨틱이 있는 언어(예: Rust, C++11)의 경우, 언어가 특정 상황에서 도움을 줄 수는 있지만, 언제 미리 대량 할당(pre-allocate)해야 하는지 또는 언제 한꺼번에(bulk) 해제해야 하는지를 알아내는 데는(그게 가능하기라도 하다면) 어려움을 겪는다. 이런 컴파일러의 무지는 많은 성능 문제로 이어질 수 있다.
소유권 시맨틱에 대해 내가 개인적으로 갖는 문제는, 그것이 자연스럽게 시스템이 아니라 단일 객체의 소유권에 초점을 맞춘다는 점이다. 내가 아는 한 Rust 같은 언어에서는 객체의 수명을 어떤 시스템과 연결되도록 기술할 수 있지만, 그러나 내가 나중에 논의할 메모리 할당 전략을 적용하려면 필요한 Rust 코드는 사실상 소유권 시맨틱을 완전히 우회하는 것처럼 동작하고 unsafe를 자유롭게 사용하게 된다. 또한 그런 언어들은 소유권 개념과 수명 개념을 결합하는 경향이 있는데, 이 둘은 반드시 연결되어 있는 것은 아니다.
이 시리즈에서는 사용할 수 있는 다양한 종류의 메모리 모델과 할당 전략을 논의할 것이다. 다룰 주제는 다음과 같다:
malloc