동적 메모리 할당기는 힙 영역을 관리한다.
각각의 프로세스에 대해서 커널은 힙의 꼭대기를 가리키는 변수 brk (break)를 사용한다.
할당기는 힙을 다양한 크기의 블록들의 집합으로 관리한다.
각 블록 => 할당/가용의 가상 메모리의 연속적인 묶음.
할당된 블록은 응용하기 위해 명시적으로 보존됨.
명시적인 할당기 vs 묵시적인 할당기
응용프로그램이 할당하고 반환한다.
C 표준 라이브러리는 malloc 패키지라고 하는 명시적 할당기를 제공한다.
malloc 호출해서 블록 할당 / free 호출해서 블록 반환
응용 프로그램이 할당하지만, 직접 반환하지는 않는다.
이를 위해서는 더 이상 프로그램에 의해 사용되지 않는 블록을 검출할 수 있어야 한다.
예시로는 자바의 가비지 컬렉터가 있다.
malloc 함수는 적절히 정렬된 최소 size 바이트를 갖는 메모리 블록의 포인터를 리턴함.
정렬은 64비트에서 항상 16의 배수인 블록을 리턴한다. (32비트는 8의 배수)
프로그램들은 할당된 힙 블록을 free 함수를 호출해서 반환한다.
free()의 인자로는 malloc에서 획득한 블록의 시작을 가리켜야 한다.
malloc 같은 동적 메모리 할당기는 sbrk 함수를 사용할 수 있다.
#include <unistd.h>
void *sbrk(intptr_t incr);
sbrk 함수는 커널의 brk 포인터에 incr을 더해서 힙을 늘리거나 줄인다.
아래 그림은 c프로그램에서 16워드의 매우 작은 힙을 malloc 과 free가 어떻게 관리할 수 있는지 보여준다.
(b) 에서 malloc은 가용 블록의 앞부분에서 6워드 블록을 할당한다.
이 예제에서 malloc은 가용 블록이 더블 워드 경계에 정렬되도록 하기 위해 블록에 추가적인 워드를 패딩하였다.
32비트 CPU에서 1워드는 4 바이트(32비트) 이고, 64비트 CPU에서는 8바이트(64비트) 이다.
Malloc lab 프로젝트에서는 gcc가 32비트 모드이기 때문에 1 워드는 4 바이트이고, 더블 워드는 8 바이트 객체라고 가정한다.
메모리 할당 요청을 만족시킬 수 없을 때 일어나는 현상.(나쁜 힙 이용도)
내부 단편화는 할당된 블록이 데이터 자체보다 더 클 때 일어난다.
위 그림에서 p2는 int 사이즈 5칸을 요청했지만, (malloc lab 프로젝트의 gcc가 32비트 모드이기 때문에) 할당기의 주소가 8바이트(더블워드)의 배수에 맞게 배치되어야 하고, 따라서 1칸(4바이트)을 더 할당하게 된다. (20이 아닌 24 바이트)
외부 단편화는 할당 요청을 충족하기에 충분한 총 빈 메모리가 있지만, 정렬 문제로 메모리에 할당하지 못 하는 경우 발생한다.
위의 그림을 보면 가용 가능한 블록은 6개가 있다. 즉 총 24바이트의 여유 메모리가 있지만, 4, 2로 나뉘어 있기 때문에 24바이트의 블록을 할당할 수 없다.
외부 단편화는 미래의 요청 패턴의 영향을 받는다. 그렇기에 외부 단편화는 내부 단편화보다 측정하기 어렵고 예측하기가 불가능하다.
이로 인해 할당기들은 많은 수의 더 작은 가용 블록들보다는 더 적은 수의 더 큰 가용 블록들을 유지하려는 방법들을 채택하고 있다.
처리량과 이용도 사이에 좋은 균형을 갖는 실용적인 할당기는 다음 이슈들을 고려해야 한다.
실용적인 할당기는 블록 경계를 구분하고, 할당된 블록과 가용 블록을 구분하는 데이터 구조를 필요로 한다. 대부분의 할당자는 이 정보를 블록 자체에 내장한다.
위 그림은 한 힙 블록의 포멧이다.
한 블록은 1워드의 헤더, 페이로드(데이터), 그리고 추가적인 패딩을 포함한다.
시스템의 정렬 요구 사항과 할당기의 블록 형식 선택이 할당기에게 최소 블록 크기를 부과한다는 것을 이해하는 것이 중요하다. 어떤 할당된 또는 빈 블록도 이 최소 크기보다 작을 수 없다. 예를 들어, 더블 워드 정렬 요구 사항을 가정한다면 각 블록의 크기는 2 워드(8바이트)의 배수여야 한다. 따라서 위 그림의 블록 형식은 최소 블록 크기를 2 워드로 지정한다: 헤더에 1 워드와 정렬 요구 사항을 유지하기 위해 또 다른 워드가 필요하다. 심지어 애플리케이션이 한 바이트를 요청하더라도 할당기는 여전히 2 워드 블록을 생성할 것이다.
헤더는 블록 크기(헤더와 패딩 포함)와 블록이 할당되었는지 빈 상태인지를 인코딩한다. 더블 워드 정렬 제약 조건을 적용하면 블록 크기는 항상 8의 배수이며 블록 크기의 3개의 최하위 비트는 항상 0이다. 따라서 블록 크기의 29개의 최상위 비트만 저장하면 나머지 3개의 비트를 다른 정보를 인코딩하는 데 사용할 수 있다. 이 경우 이러한 비트 중 가장 덜 중요한 비트(할당된 비트)를 사용하여 블록이 할당되었는지 여부를 나타낸다.(a =1 : allocated, a = 0 : free)
예를 들어, 블록 크기가 24(0x18)바이트인 할당된 블록이 있다고 가정해 보자. 그러면 해당 헤더는 다음과 같을 것이다.
0x00000018 | 0x1 = 0x00000019 (할당 되어서 1 => 0x1)
마찬가지로, 블록 크기가 40(0x28)바이트인 빈 블록은 다음과 같은 헤더를 가질 것입니다.
0x00000028 | 0x0 = 0x00000028 (빈 블록이라 0 => 0x0)
헤더 다음에는 malloc을 호출할 때 애플리케이션이 요청한 페이로드가 있다. 페이로드 다음에는 정렬 요구 사항을 충족하기 위해 필요한 크기의 패딩이 있을 수 있다. 패딩이 필요한 이유는 여러 가지가 있다. 예를 들어, 패딩은 외부 단편화를 해결하기 위한 할당자의 전략의 일부로 사용될 수 있다. 또는 정렬 요구 사항을 충족하기 위해 필요할 수도 있다.
위 그림과 같은 블록 형식을 갖춘 경우, 힙을 연속된 할당 혹은 연속된 가용 블록의 배열로 아래 그림처럼 구성할 수 있으며, 이를 묵시적 리스트(Implicit list) 라고 부른다.
가용 블록이 헤더 내 필드에 의해서 묵시적으로 연결되기 때문이다.
즉 implicit 방법은 할당된 블록과 가용한 블록이 모두 연결되어 있다.
할당기는 헤더의 크기 필드를 통해 빈 블록의 전체 집합을 간접적으로 탐색할 수 있다.
주의할 점은 특별히 표시된 종료 블록이 필요하다는 것이다.
위 예제에서 종료하는 블록의 헤더는 할당 비트가 설정되어있고, 크기는 0을 갖는다.(맨 마지막 블록)
암시적 빈 리스트의 장점은 간단함이다. 중요한 단점은 빈 리스트를 검색하는 모든 작업의 비용(예: 할당된 블록 배치)이 힙에 있는 할당된 및 빈 블록의 총 수에 선형적으로 비례한다는 것이다.
ecplicit 방법은 가용 가능한 블록끼리만 연결되어 있다.
프로그램이 malloc(k)를 통해 k 바이트의 블록을 요철할 때 할당기는 요청한 블록을 저장하기에 충분히 큰 가용 블록을 리스트에서 검색한다. 검색을 수행할 때 수행하는 정책에 따라 first fit, next fit, best fit 등이 있다.
가용 free 리스트를 처음부터 탐색하면서 크기가 맞는 첫 번째 가용 블록을 선택한다.
static void *first_fit(size_t asize)
{
void *bp;
for (bp = heap_listp; GET_SIZE(HDRP(bp)) > 0; bp = NEXT_BLKP(bp))
{
if (GET_SIZE(HDRP(bp)) >= asize && (!GET_ALLOC(HDRP(bp))))
{
return bp;
}
}
return NULL;
}
-> 힙의 시작부터, 크기가 > 0일때동안, 즉 에필로그 헤더가 나오기 전까지 다음 블록으로 이동하면서 원하는 크기 이상의 가용 블록을 찾는다. 가장 먼저 찾은 (원하는 크기 이상의 가용)블록을 선택한다.
first fit과 비슷하지만 검색을 리스트의 처음에서 시작하는 대신, 이전 검색이 종료된 지점에서 검색을 시작한다.
즉, 이전 검색에서 발견한 가용 블록을 발견한 위치에서 탐색을 시작하고 조건에 만족하는 블록이 있으면 해당 블록을 할당한다.
(next-fit은 pointp, 즉 탐색 포인터를 변경해줘야 하기 때문에 find_fit 뿐만 아니라 다른 함수의 코드도 수정해야 한다.)
static void *next_fit(size_t asize)
{
void *bp;
void *old_pointp = pointp;
for (bp = pointp; GET_SIZE(HDRP(bp)); bp = NEXT_BLKP(bp))
{
if (!GET_ALLOC(HDRP(bp)) && GET_SIZE(HDRP(bp)) >= asize)
{
pointp = NEXT_BLKP(bp);
return bp;
}
}
for (bp = heap_listp; bp < old_pointp; bp = NEXT_BLKP(bp))
{
if ((!GET_ALLOC(HDRP(bp))) && GET_SIZE(HDRP(bp)) >= asize)
{
pointp = NEXT_BLKP(bp);
return bp;
}
}
return NULL;
}
best fit은 모든 가용 블록을 검사하며 크기가 맞는 가장 작은 블록을 선택한다.
static void *best_fit(size_t asize)
{
void *bp;
void *best = NULL;
for (bp = free_listp; GET_ALLOC(HDRP(bp)) != 1; bp = GET_NEXT(bp))
{
if (asize <= GET_SIZE(HDRP(bp)))
{
if (best == NULL)
{
best = bp;
}
else if (GET_SIZE(HDRP(bp)) <= GET_SIZE(HDRP(best)))
{
best = bp;
}
}
}
if (best != NULL)
{
return best;
}
return NULL;
}
가용리스트 시작부터 마지막 블록까지 다음 블록으로 이동하면서 원하는 크기 이상인 블록 중 가장 작은 블록을 찾아서 선택한다.
할당기가 할당한 블록을 free 할 때 반환되는 블록에 인접한 다른 가용 블록들이 있을 수 있다.
추가적으로 할당할 때 인접한 블록을 연결하면 사용할 수 있지만, 현재는 나눠져 사용할 수 없는 상태의 블록이 되고 이것을 오류 단편화(false fragmentation)이라고 한다.
위 그림에서 할당된 블록을 반환한 결과는 아래 그림과 같다.
위 그림을 보면 중간에 3워드 블록이 2개가 인접해있다. 이 때 4워드만큼 할당을 요청하면 가용한 메모리의 크기는 충분하지만 가용 블록이 2개로 나뉘어 있어 요청을 만족시키지 못한다.
이런 오류 단편화를 극복하기 위해 인접한 가용 블록을 하나로 병합하는 과정이 필요하다 (Coalesce).
=> 언제 연결을 수행할지가 중요.
경계태그는 할당기가 연결을 구현할 때 사용된다.
블록이 Header만 있을 때는 다음 블록과 연결할 때는 상관이 없다.
그러나 이전 블록과 (상수 시간으로 효율적으로) 연결하기 위해서는 경계 태그(footer) 가 필요하다.
즉, 경계 태그(footer)를 사용해 이전 블록의 정보를 쉽게 알 수 있다.
이전과 다음 블록이 모두 할당되어 있을 때.
이전 블록은 할당상태, 다음 블록은 가용상태일때.
이전 블록은 가용상태, 다음 블록은 할당상태일때
이전 블록과 다음 블록 모두 가용상태일때.
단점
malloc 함수를 구현할 때 프롤로그 블록(Prologue Block)과 에필로그 블록(Epilogue Block)은 힙(heap) 메모리 내의 관리 블록을 나타낸다. 이러한 블록은 할당 및 해제된 메모리 블록 사이의 메타데이터를 관리하고 힙의 구조를 유지하기 위해 사용된다.
프롤로그 블록
프롤로그 블록은 힙의 시작 부분에 위치하며, 힙에 대한 정보를 포함한다. 일반적으로 프롤로그 블록은 힙의 크기와 할당 여부 등의 정보를 저장하며, 힙의 시작 지점을 나타낸다. 프롤로그 블록은 헤더와 풋터로만 구성된 8바이트 할당 블록이다. 프롤로그 블록은 할당되지 않은 상태로 남아 있으며, 실제 사용자 데이터를 저장하지 않는다.
에필로그 블록
에필로그 블록 (Epilogue Block): 에필로그 블록은 힙의 끝 부분에 위치하며, 할당 가능한 빈 공간의 크기를 나타낸다. 에필로그 블록은 헤더만으로 구성된 크기가 0으로 할당된 블록이다. 에필로그 블록은 힙의 끝을 나타내는 역할을 하며, 실제 사용자 데이터를 저장하지 않는다. 이 블록은 힙 확장 및 축소 작업에서 유용하게 사용된다.
할당기는 기본적으로 항상 프롤로그 블록을 가리킨다.
(위 그림에서 첫 번째 워드는 더블 워드 경계로 정렬된 미사용 패딩 워드이다.)
extend_heap에서 mem_sbrk를 통해 새로운 블록을 생선한다고 해도 리턴되는 값은 old_brk이다.
이 old_brk는 이전 에필로그 헤더를 가리키고 있다.
이때 PUT(HDRP(bp), PACK(size,0)); 를 통해 이 헤더는 0으로 초기화되면서 새로운 블록의 헤더가 되고, 이 헤더부터 chunksize 만큼의 뒤는 footer가 된다.
그리고 그 다음 블록은 새로운 에필로그 헤더가 된다.
이렇게 기존 에필로그 헤더를 할당 해제 시키고 새로운 에필로그 헤더를 만든다.