Operating Systems : Three Easy Pieces를 보고 번역 및 정리한 내용들입니다.
이번 장에서는 UNIX 시스템의 메모리 할당 인터페이스들에 대해 논의한다.
UNIX/C 프로그램들에서, 견고하고 신뢰성 있는 소프트웨어를 설계하는 데에 있어 중요한 것은 어떻게 메모리를 할당하고 관리하는지를 이해하는 것이다. 자주 쓰이는 인터페이스에는 어떤 게 있을까? 어떤 실수는 피해야 할까?
C 프로그램을 실행할 때 할당되는 메모리에는 두 개의 타입이 있다.
첫 번째는 스택 메모리다. 이 메모리는 컴파일러에 의해 암묵적으로 할당/해제된다. 그렇기 때문에 이는 종종 자동 메모리라고도 불린다.
C에서 스택에 메모리를 선언하는 것은 쉽다. 예를 들어 어떤 함수 func()
에서 정수 x
에 대한 공간이 필요하다고 해보자. 이를 위한 메모리를 선언하기 위해서는 그냥 다음과 같이 하기만 하면 된다.
void func(){
int x; //스택에 정수를 선언
}
func()
를 호출했을 때 스택에 공간을 만들어주는 것 등의 나머지 작업들은 컴파일러가 전부 해준다. 함수가 반환을 하면 컴파일러는 메모리 할당을 해제한다. 그러므로 함수 호출 이후에도 남아있어야 하는 정보들이 있는 경우, 해당 정보는 스택에 남겨두지 않는 편이 좋다. 이렇게 오랫동안 유지되어야 하는 메모리에 대한 수요가 다음 타입의 메모리가 필요한 이유다.
메모리의 두 번째 타입은 힙 메모리다. 이 메모리의 모든 할당과 해제는 프로그래머에 의해 명시적으로 일어난다. 이는 프로그래머의 역량에 따라 많은 버그들의 원인이 되기도 하지만, 충분한 주의를 기울인다면 이와 관련한 인터페이스들을 올바로, 큰 어려움 없이 사용할 수 있을 것이다. 다음은 힙에 정수 메모리를 할당하는 예시이다.
void func(){
int *x = (int*) malloc(sizeof(int));
}
위 코드에서 주의할 점은, 스택에서의 할당과 힙에서의 할당이 한 줄에서 동시에 일어나고 있다는 것이다. 컴파일러는 int *x
코드를 보고 정수형 변수의 포인터를 위한 공간을 만든다. 이어 프로그램은 malloc()
을 호출함으로써 정수형 변수에 대한 공간을 힙에 요청한다. 이 루틴은 성공하는 경우 해당하는 정수의 공간을 반환하며, 그 정보는 프로그램에 의해 스택에 저장된다.
malloc()
callmalloc()
은 상당히 간단하다. 사용자는 힙에 할당하기를 원하는 사이즈를 매개변수로 전달하며, 만약 이 요청이 성공한다면 malloc()
은 새롭게 할당된 공간의 포인터를, 실패한다면NULL
을 반환한다.
malloc()
을 사용하기 위해서는 그냥 헤더 파일 stdlib.h
을 포함하기만 하면 된다. 사실은 이것조차도 할 필요가 없는데, C 프로그램들이 기본적으로 링크하는 C 라이브러리 안에는 이미 malloc()
의 코드가 있기 때문이다. 헤더에 이를 추가하는 것은 malloc()
을 제대로 호출하고 있는지를 컴파일러가 체크할 수 있게 해주는 역할 밖에 없다.
malloc()
의 유일한 파라미터는 size_t
타입을 가지는데, 이는 단순히 필요한 바이트가 얼마나 되는지를 표시하기 위함이다. 하지만 많은 프로그래머들이 그 양을 직접 숫자로 쓰지는 않고, 그 대신 많은 루틴들과 매크로들을 사용한다. 다음의 예시를 보자.
double *d = (double *d) malloc(sizeof(double));
여기서는 요청되는 공간의 크기를 알리기 위해 sizeof()
연산자를 사용하고 있다. sizeof()
는 C에서 보통 컴파일 시간 연산자로 다뤄진다. 다시 말해 그 실제 크기가 컴파일 타임에 알려지고 그 숫자가 malloc()
의 인자로 들어가게 된다는 것이다. 그렇기 때문에 sizeof()
는 함수 호출보다는 연산자라 생각하는 게 낫다.
sizeof()
에는 타입만이 아니라 변수의 이름도 전달할 수 있는데, 이는 가끔 원하지 않는 결과를 낼 수도 있으므로 주의해야한다. 다음의 예시를 보자
int *x = malloc(10 * sizeof(int));
printf("%d\n", sizeof(x));
첫 번째 줄에서는 10개의 정수형 변수가 담겨있는 배열의 공간을 선언한다. 하지만 다음 줄에서 sizeof()
를 사용할 때, 이는 4, 또는 8과 같은 작은 값을 반환한다. 이 경우에서 그 이유는 sizeof()
가 단순히 x, 즉 정수형 변수의 포인터의 크기를 묻는 것이라 생각하기 때문이다.
다음의 예시는 또 다른 결과를 보여준다.
int x[10];
printf("%d\n", sizeof(x));
위 예시에서는 컴파일러에게 40 바이트가 할당되었다는 충분한 정보를 제공한다.
malloc()
은 void
형의 포인터를 반환한다. 이는 C가 주소를 반환하고 프로그래머가 이걸 가지고 뭘 할지 결정하게 하는 방식이다. 프로그래머는 형변환(cast) 를 사용하곤 하는데, 이 형변환은 사실 컴파일러나 내 코드를 읽는 다른 프로그래머들에게 "나는 지금 내가 뭘하고 있는지를 알고 있다."라는 것을 알려주는 역할 정도 외에는 아무 것도 하지 않는다.
free()
call메모리를 할당하는 것은 쉬워 보인다. 한편 언제, 어떻게, 심지어는 메모리 할당을 해제할지의 여부를 정하는 것은 어렵다. 프로그래머는 더 이상 사용되지 않는 힙 메모리를 해제하기 위해 free()
를 호출한다.
int * x = malloc(10 * sizeof(int));
...
free(x);
이 루틴은 malloc()
으로부터 반환받은 포인터를 인자로 받는다. free()
의 경우 malloc()
과는 달리, 할당된 영역의 크기를 사용자가 알려줄 필요가 없고, 메모리 할당 라이브러리가 스스로 추적한다.
malloc()
과 free()
를 사용할 때 일어나는 많은 흔한 오류들이 있다. 올바른 메모리 관리는 여러 새롭게 등장한 언어들이 자동 메모리 관리를 지원하게 되면서 중요한 문제가 되고 있다. 그런 언어들에서 프로그래머는 malloc()
과 같은 것으로 메모리를 할당하기는 해야하지만, 메모리 해제를 위해서는 따로 뭔가를 호출하지 않아도 된다. 가비지 컬렉터(garbage collector) 가 돌아가면서 어떤 메모리가 더 이상 참조되고 있지 않은지를 판단하고 메모리 할당을 해제해주기 때문이다.
많은 루틴들은 호출되기 전에 메모리가 할당되어야 한다. 하지만 주의를 기울이지 않으면 다음과 같은 코드를 작성하게 될 수도 있다.
char *src = "hello";
char *dst;
strcpy(dst, src);
위 코드를 실행하면 segmentation fault가 발생하게 될 것이다.
위와 관련된 오류에는 buffer overflow
라 불리는, 충분한 메모리를 할당하지 않음에 따라 발생하는 오류가 있다.
char *src = "hello";
char *dst = (char *) malloc(strlen(src));
strcpy(dst, src);
문자열의 경우, 맨 마지막에 문자열이 끝났음을 알리기 위한 비어있는 1 바이트가 추가로 필요한데, 위 코드의 두 번째 줄에서는 문자열의 길이만큼만 할당하고 있다. 이상하게도, malloc
이 어떻게 구현되어 있는지에 따라, 위 코드는 제대로 실행되기도 한다.
어떤 경우, 문자열 복사는 dst
에 할당된 공간의 밖에 한 바이트를 덮어 쓰기도 한다. 이는 그렇게 덮어 쓰인 공간에 더 이상 사용되지 않는 변수가 있는 경우에는 그다지 해롭지 않지만, 어떤 경우에는 아주 해로우며, 실제로도 많은 시스템 보안 취약점의 원인이 되기도 한다.
다른 경우, malloc
라이브러리는 어떻게든 추가적인 공간을 할당해, 프로그램이 다른 변수의 값 위에 쓰는 일 없이 잘 돌아가게도 하고, 또 다른 경우에는 프로그램이 그냥 충돌을 일으켜버리기도 한다.
따라서 다음을 명심해두자. 프로그램이 한 번 잘 돌아갔다고 해서, 프로그램이 올바르게 짜였다는 말은 아니다.
malloc()
을 제대로 호출하기는 했지만, 새롭게 할당된 데이터 타입의 값으로 그 자리를 채우지 않았을 때 발생하는 오류다. 만약 초기화를 잊어버리면, 프로그램은 해당 주소에 담겨있는 알 수 없는 쓰레기 값을 불러오게 될 것이다.
다른 흔한 오류는 메모리 누수(memory leak) 이라 불리는 것으로 할당된 메모리를 해제하는 것을 잊었을 때 발생한다. 오랫동안 실행되는 프로그램이나 시스템에 있어서 이는 큰 문제가 되는데, 조금씩 누수되는 메모리가 결국에는 메모리 부족으로 이어져 재실행을 필요로 하게 할 수도 있기 때문이다. 그러므로 메모리 사용을 끝낸 경우에는 반드시 메모리 할당을 해제해야 한다. 가비지 컬렉터가 있는 언어를 쓴다고 해서 이 문제가 해결되는 것은 아니라는 점을 명심하자. 만약 메모리에 대한 참조가 남아있다면 가비지 컬렉터는 이를 해제하지 않을 것이기 때문이다.
어떤 경우에는 free()
를 호출하지 않는 게 합리적으로 보일 수도 있다. 예를 들어 짧게 살아있어 곧 종료될 프로그램의 경우, OS는 프로세스가 종료되면 저절로 할당된 페이지들을 비우고 메모리 누수가 일어나지 않게 할 것이다. 물론 이런 경우에는 문제없이 작동하기는 하겠지만, 그렇다고 이것이 좋은 습관이라는 것은 아니다. 그렇게 할 필요가 없다 하더라도, 명시적으로 할당한 공간은 꼭 해제하는 좋은 버릇을 들여 놓도록 하자.
가끔 프로그램은 그 사용을 끝내기도 전에 메모리를 해제할 수도 있다. 이런 실수를 dangling pointer라고 부른다. 이렇게 해제된 메모리를 계속해서 사용하려는 것은 시스템에 충돌을 일으키거나 유효한 메모리에 덮어 쓰는 결과로 이어질 수 있다.
double free라 알려진 이 문제는, 프로그램이 이미 해제된 메모리를 다시 해제하려 할 때 일어난다. 이렇게 했을 때의 결과는 정해져 있지 않기에, 메모리 할당 라이브러리는 혼란스러워 무슨 짓을 할지도 모른다.
free()
Incorrectly마지막 문제는 free()
를 잘못 호출하는 것이다. free()
는 이전에 호출된 malloc()
으로부터 반환받은 포인터를 전달받기를 원한다. 이때 다른 포인터를 전달하면 당연히 문제가 발생할 것이다.
malloc()
, free()
에 대해 이야기할 때, 시스템 콜에 대해서는 이야기하지 않았음을 알 수 있다. 그 이유는 그것들이 시스템 콜이 아니라 라이브러리 콜이기 때문이다. malloc
라이브러리는 가상 주소 공간에서 공간을 관리하지만, 그 스스로는 시스템 콜이 아니라 OS의 여러 시스템 콜 위에서 작동한다.
그 시스템 콜들 중 하나는 brk
이다. 이는 프로그램의 break, 즉 힙의 최상단 위치를 바꾸는 데에 사용된다. brk
는 새로운 break의 주소를 인자로 받아, 새 break가 현재의 break보다 큰지 작은지에 따라 힙의 크기를 늘리거나 줄인다. 또 다른 콜에는 sbrk
가 있는데, 이는 새 break의 위치가 아니라 증가시키거나 감소시킬 메모리의 크기를 인자로 brk
와 같은 효과를 낸다.
다만 brk
나 sbrk
를 직접 호출해서는 안 된다는 것에 주의하자. 이것들은 메모리 할당 라이브러리에서 사용된다. 만약 이것들을 직접 사용하려하면 심각하게 잘못된 결과를 낼 수도 있다. 반드시 malloc()
이나 free()
를 사용하도록 하자.
마지막으로 mmap()
은 OS로부터 메모리를 얻기 위해 사용된다. 제대로 된 인자를 넘기면 mmap()
은 프로그램에 익명의 메모리 공간을 만들어 낸다. 이 공간은 특정한 파일을 위한 게 아니라, 스왑 공간(swap space) 을 위한 공간이다.
이외에도 메모리 할당 라이브러리가 지원하는 여러 콜들이 있다.
calloc()
realloc()