크래프톤 정글/정글에서 문제풀기

Malloc Lab 구현기 2편 - explicit free list 전환과 코드 흐름의 변화

cedis 2026. 4. 14. 00:33

 

 

 

MALLOC LAB IMPLEMENTATION GUIDE 02
implicit free list에서
explicit free list로 옮길 때 실제로 바뀌는 것
이번 글은 allocator 이론을 새로 설명하는 글이 아니라,
기존 implicit 구조를 최대한 덜 깨뜨리면서 explicit free list를 붙일 때 코드가 어디서 달라지는지를 따라가는 2편이다.
Explicit Free List LIFO Insert First Fit on Free List Coalesce + Relink

왜 2편의 핵심은 explicit free list 자체보다 “어디를 어떻게 고쳤는가”에 있나

1편에서 다뤘던 구조는 implicit free list였다. 힙 전체를 순회하면서 free block을 찾는 방식이라 allocator의 기본 문법을 익히기엔 좋지만, 탐색 비용이 크다. 이번 구현은 그 구조를 통째로 갈아엎기보다, free block만 따로 연결해서 보관하는 층을 기존 코드 위에 덧대는 방식으로 바뀌었다. 그래서 2편의 핵심은 “explicit free list가 뭔가”보다 기존 implicit 코드에서 어떤 줄이 달라졌고, 그 줄 때문에 실행 흐름이 어떻게 달라졌는가를 읽는 데 있다.

이 글이 전제하는 것

앞선 글에서 implicit free list, boundary tag, mm_init, mm_malloc, mm_free, coalesce의 기본 흐름을 이미 본 상태를 전제로 한다. 이번 글은 그 위에 “free block에만 next/prev를 심으면 무엇이 달라지는가”를 덧붙인다.

핵심 관점
implicit에서 explicit로 넘어가도 헤더/풋터, split, coalesce 자체가 사라지는 건 아니다. 달라지는 건 탐색 대상free block을 연결/해제하는 관리 코드다.
이번 글에서 먼저 잡아야 하는 질문
1. implicit free list에서는 힙의 어떤 블록들을 순회했나
2. explicit free list에서는 free block만 따로 어떻게 연결하나
3. 그래서 find_fit, place, coalesce의 코드가 정확히 어느 줄에서 달라지나
4. 바뀐 줄은 메모리 흐름과 free list 흐름을 각각 어떻게 바꾸나

바뀐 핵심 아이디어를 한 줄로 말하면

1편의 implicit allocator는 힙 안에 있는 모든 블록을 대상으로 탐색했다. 2편의 explicit allocator는 free block만 따로 연결한 체인을 대상으로 탐색한다. 그래서 2편은 “블록 구조를 새로 배운다”기보다 “같은 블록 구조 위에 free block용 연결 리스트를 얹는다”는 쪽에 가깝다.

implicit → explicit 핵심 흐름 비교
1편 implicit free list
heap_listp
NEXT_BLKP
alloc / free 모두 순회
맞는 free 발견
2편 explicit free list
free_listp
SUCC(bp)
free block만 순회
맞는 free 발견
즉 바뀌는 본질은 “어디를 순회하느냐”와 “그 순회 대상 집합을 누가 유지하느냐”다. 이 한 줄 때문에 find_fit, place, coalesce에 새 책임이 생긴다.

2편에서 실제로 바뀐 함수는 어디인가

영역 1편 implicit에서 하던 일 2편 explicit에서 추가된 일
매크로 / 전역 변수 헤더/풋터, 인접 블록 계산 MINBLOCKSIZE, PRED, SUCC, free_listp 추가
초기화 프롤로그/에필로그와 첫 free block 생성 free list head를 NULL로 시작하고, 이후 첫 free block을 연결
find_fit 힙 전체를 NEXT_BLKP로 순회 free list만 SUCC로 순회
place 할당 비트 갱신, split 할당 전 remove, split 후 남은 조각 재삽입
coalesce 헤더/풋터 병합 이웃 free block remove 후 병합, 병합 결과 재삽입

먼저, explicit free list가 줄이는 건 “힙 전체 순회”다

implicit free list에서는 find_fit가 프롤로그 뒤부터 에필로그 전까지 블록 전체를 훑는다. 이때 이미 할당된 블록도 전부 지나가야 한다. explicit free list는 이 점만 바꾼다. free block끼리만 따로 체인을 만들고, 탐색도 그 체인에서만 한다. 즉 “할당 정책”보다 먼저 달라지는 건 allocator가 훑는 대상의 범위다.

implicit vs explicit를 그림으로 보면
implicit free list
alloc
free
alloc
alloc
free
alloc
탐색 시 alloc/free를 가리지 않고 힙 전체를 순회한다.
explicit free list
alloc
free
alloc
alloc
free
alloc
free A
free B
탐색 대상은 free block 체인으로 줄어든다. 대신 그 체인을 관리하는 책임이 추가된다.

free block은 이제 “빈 공간”이 아니라 포인터 두 개를 품는 구조체가 된다

explicit free list로 넘어가면 free block payload는 그냥 버려진 빈 공간이 아니다. 그 안에 이전 free block과 다음 free block을 가리키는 포인터를 넣는다. 그래서 최소 블록 크기가 커진다. 현재 구현도 이 점을 반영해서 MINBLOCKSIZE를 따로 정의한다.

1편 코드와 비교하면 여기서 구조가 달라진다
1편 implicit에서 split 조건을 볼 때
if ((csize - asize) >= (2 * DSIZE)) {
    ...
}
2편 explicit에서 바뀐 뒤
#define MINBLOCKSIZE ALIGN(DSIZE + 2 * sizeof(void *))

if ((csize - asize) >= MINBLOCKSIZE) {
    ...
}
즉 2편의 최소 블록 크기는 단순 정렬 기준이 아니다. free block 안에 pred, succ를 실제로 심을 수 있는지까지 포함한 기준으로 바뀐다.
#define MINBLOCKSIZE ALIGN(DSIZE + 2 * sizeof(void *))

#define PRED(bp) (*(void **)(bp))
#define SUCC(bp) (*(void **)((char *)(bp) + sizeof(void *)))

static void *free_listp;
free block 내부 레이아웃
Header
pred pointer
succ pointer
Footer
할당된 블록은 이 포인터 둘이 필요 없지만, free block은 체인 관리용 메타데이터를 payload 쪽에 더 들고 있어야 한다. 그래서 “작은 나머지 조각도 split해서 남긴다”는 기준이 implicit 때보다 더 엄격해진다.
여기서 바뀌는 핵심 한 줄
1편에서는 “헤더/풋터를 쓸 수 있느냐”가 최소 블록 크기를 결정했다. 2편에서는 “헤더/풋터에 더해 free list 포인터 둘을 품을 수 있느냐”가 기준이 된다.

새로 생긴 건 딱 두 함수지만, 이 둘이 흐름 전체를 바꾼다

explicit free list에서 진짜 새로 생기는 건 결국 insert_free_blockremove_free_block다. 이 둘이 없으면 free list는 한 번도 일관되게 유지되지 않는다. 반대로 말하면, 2편의 핵심은 “allocator 전체를 갈아엎었다”가 아니라 기존 흐름 사이사이에 free list 연결/해제를 끼워 넣었다는 데 있다.

static void insert_free_block(void *bp)
{
    PRED(bp) = NULL;
    SUCC(bp) = free_listp;

    if (free_listp != NULL) {
        PRED(free_listp) = bp;
    }
    free_listp = bp;
}

static void remove_free_block(void *bp)
{
    if (PRED(bp) != NULL)
        SUCC(PRED(bp)) = SUCC(bp);
    else
        free_listp = SUCC(bp);

    if (SUCC(bp) != NULL)
        PRED(SUCC(bp)) = PRED(bp);
}
현재 구현이 택한 정책: LIFO head insertion
새 free block
기존 head
나머지 free blocks
삽입은 빠르다. 하지만 free list 순서가 “주소 순”이 아니라 “가장 최근에 free된 블록 순”이 된다. 이건 현재 구현의 선택이지 explicit free list의 유일한 정답은 아니다.

mm_init과 extend_heap은 free list의 출발점을 같이 만든다

기본 힙 경계를 세우는 역할은 1편과 같다. 다만 이제는 free_listp = NULL이 초기화에 들어간다. 또 extend_heap이 만든 새 free block은 그냥 반환되는 게 아니라, 뒤에서 coalesce를 거쳐 free list에 연결된다.

1편 implicit에서 초기화할 때 감각
heap_listp = mem_sbrk(...);
...
extend_heap(...);
2편 explicit에서 꼭 추가되는 줄
heap_listp = mem_sbrk(...);
free_listp = NULL;
...
extend_heap(...);
if ((heap_listp = mem_sbrk(4 * WSIZE)) == (void *)-1)
    return -1;

free_listp = NULL;

PUT(heap_listp, 0);
PUT(heap_listp + (1 * WSIZE), PACK(DSIZE, 1));
PUT(heap_listp + (2 * WSIZE), PACK(DSIZE, 1));
PUT(heap_listp + (3 * WSIZE), PACK(0, 1));
초기화 직후 시점
heap 경계 생성
+
free_listp = NULL
첫 free block 연결 준비
즉 explicit 구조에서 초기화는 “힙만 만든다”에서 끝나지 않고, “free list가 비어 있는 상태도 명시한다”까지 포함한다.

find_fit은 이제 힙이 아니라 free list만 훑는다

이 변화가 explicit free list의 가장 직접적인 보상이다. 기존에는 에필로그까지 가면서 alloc/free를 모두 지나갔다. 이제는 free_listp에서 시작해 SUCC(bp)만 따라가면 된다.

변경 전 / 변경 후를 코드로 나란히 보면
1편 implicit
for (bp = heap_listp;
     GET_SIZE(HDRP(bp)) > 0;
     bp = NEXT_BLKP(bp)) {
    if (!GET_ALLOC(HDRP(bp)) &&
        asize <= GET_SIZE(HDRP(bp))) {
        return bp;
    }
}
2편 explicit
for (bp = free_listp;
     bp != NULL;
     bp = SUCC(bp)) {
    if (asize <= GET_SIZE(HDRP(bp))) {
        return bp;
    }
}
여기서 가장 중요한 변경은 `NEXT_BLKP`가 사라지고 `SUCC(bp)`가 들어온 점이다. 이 한 줄 때문에 allocator는 힙 전체가 아니라 free block 체인만 훑게 된다.
static void *find_fit(size_t asize) {
    void *bp;

    for (bp = free_listp; bp != NULL; bp = SUCC(bp)) {
        if (asize <= GET_SIZE(HDRP(bp))) {
            return bp;
        }
    }
    return NULL;
}
탐색 경로 비교
implicit
프롤로그 뒤부터 에필로그 전까지 계속 NEXT_BLKP로 이동
explicit
free_listp에서 시작해서 free block끼리만 SUCC로 이동

다만 여기서도 중요한 건 “first-fit이 사라졌다”가 아니다. 현재 구현은 여전히 first-fit이다. 단지 first-fit을 적용하는 집합이 힙 전체에서 free list로 줄었을 뿐이다.

place는 이제 배치 함수이면서 리스트 정리 함수다

1편에서는 place가 “헤더/풋터를 할당으로 바꾸고 split할지 말지 결정하는 함수”였다. 지금은 그 앞에 한 줄이 더 붙는다. 배치 대상 free block을 먼저 free list에서 떼야 한다. 그리고 split이 일어나면 새로 남은 free block을 다시 free list에 꽂아야 한다.

place에서 실제로 추가된 줄은 이것들이다
1편 implicit
PUT(HDRP(bp), PACK(asize, 1));
PUT(FTRP(bp), PACK(asize, 1));

next_bp = NEXT_BLKP(bp);
PUT(HDRP(next_bp), PACK(csize - asize, 0));
PUT(FTRP(next_bp), PACK(csize - asize, 0));
2편 explicit
remove_free_block(bp);

PUT(HDRP(bp), PACK(asize, 1));
PUT(FTRP(bp), PACK(asize, 1));

next_bp = NEXT_BLKP(bp);
PUT(HDRP(next_bp), PACK(csize - asize, 0));
PUT(FTRP(next_bp), PACK(csize - asize, 0));
insert_free_block(next_bp);
즉 2편의 `place`는 메모리 블록을 쪼개는 함수에서 끝나지 않는다. `remove_free_block`과 `insert_free_block`이 붙으면서 free list의 연결 상태까지 같이 책임지는 함수로 바뀐다.
static void place(void *bp, size_t asize) {
    size_t csize = GET_SIZE(HDRP(bp));

    remove_free_block(bp);

    if ((csize - asize) >= MINBLOCKSIZE) {
        void *next_bp;

        PUT(HDRP(bp), PACK(asize, 1));
        PUT(FTRP(bp), PACK(asize, 1));

        next_bp = NEXT_BLKP(bp);
        PUT(HDRP(next_bp), PACK(csize - asize, 0));
        PUT(FTRP(next_bp), PACK(csize - asize, 0));
        insert_free_block(next_bp);
    } else {
        PUT(HDRP(bp), PACK(csize, 1));
        PUT(FTRP(bp), PACK(csize, 1));
    }
}
배치 순서를 그림으로 보면
free block
리스트에서 제거
앞부분 할당
남은 free는 재삽입
explicit free list에서는 split이 단순 메모리 조작으로 끝나지 않는다. “남은 free 조각을 리스트에 어떻게 다시 걸 것인가”까지가 배치 함수의 책임이 된다.

coalesce는 이제 병합 함수이면서 링크 복구 함수다

explicit free list로 바뀐 뒤 가장 중요해진 함수는 coalesce다. 1편에서는 크기와 경계만 맞추면 됐다. 이제는 다르다. 병합 대상인 이웃 free block이 free list 안에 이미 들어가 있으므로, 먼저 체인에서 떼고 병합해야 한다. 그 뒤 병합 결과 블록을 다시 free list에 넣어야 일관성이 유지된다.

1편과 2편의 차이가 제일 크게 드러나는 코드
1편 implicit coalesce
size += ...;
PUT(HDRP(...), PACK(size, 0));
PUT(FTRP(...), PACK(size, 0));
bp = PREV_BLKP(bp);
2편 explicit coalesce
remove_free_block(PREV_BLKP(bp));
remove_free_block(NEXT_BLKP(bp));

size += ...;
PUT(HDRP(...), PACK(size, 0));
PUT(FTRP(...), PACK(size, 0));
bp = PREV_BLKP(bp);

insert_free_block(bp);
병합 수식은 익숙해 보여도, 앞뒤에 `remove`와 `insert`가 붙는 순간 함수의 책임이 완전히 달라진다. 이제는 “크기를 합친다”만으로 끝나지 않고 “free list 체인도 같은 현실을 가리키게 만든다”까지 해야 한다.
if (prev_alloc && next_alloc) {
    insert_free_block(bp);
    return bp;
}
else if (prev_alloc && !next_alloc) {
    remove_free_block(NEXT_BLKP(bp));
    ...
}
else if (!prev_alloc && next_alloc) {
    remove_free_block(PREV_BLKP(bp));
    ...
}
else {
    remove_free_block(PREV_BLKP(bp));
    remove_free_block(NEXT_BLKP(bp));
    ...
}

insert_free_block(bp);
Case 4를 장면으로 보면
prev free
|
curr free
|
next free
↓ remove prev/next from free list, then merge
하나의 merged free block으로 재삽입
여기서 제일 흔한 오류
병합은 잘했는데 free list에서는 이웃 노드를 떼지 않거나, 병합 결과를 다시 넣지 않는 경우다. 그러면 힙 메타데이터와 free list 체인이 서로 다른 현실을 가리키게 된다. explicit free list는 바로 이 “이중 일관성” 때문에 까다롭다.

mm_malloc은 실제로 크게 안 바뀌지만, 한 줄이 allocator의 성격을 바꾼다

겉으로 보면 mm_malloc은 1편과 비슷하다. 요청 크기를 조정하고, 맞는 블록을 찾고, 없으면 확장한다. 그런데 여기엔 두 가지 명확한 변화가 있다. 하나는 MINBLOCKSIZE를 기준으로 split 여부를 판단한다는 점, 다른 하나는 find_fit가 더 이상 힙 전체를 보지 않는다는 점이다.

asize = MAX(ALIGN(size + DSIZE), MINBLOCKSIZE);

if ((bp = find_fit(asize)) != NULL) {
    place(bp, asize);
    return bp;
}
항목 1편 implicit 2편 explicit
탐색 대상 힙 전체 블록 free block 체인
최소 블록 크기 기준 2 * DSIZE 수준 MINBLOCKSIZE를 따로 둠
split 후 남은 조각 헤더/풋터만 맞추면 됨 헤더/풋터 + free list 재삽입 필요

이 구현은 “최소 수정”이라는 장점과 한계를 함께 가진다

지금 코드의 장점은 분명하다. implicit 구조의 블록 문법을 거의 그대로 유지한 채 explicit free list만 붙였다. 그래서 변화 지점을 추적하기 쉽고, 디버깅도 덜 복잡하다. 반대로 한계도 분명하다. free list 정렬 정책, best-fit 여부, realloc 최적화, 더 세밀한 split 정책은 아직 손대지 않았다. 즉 이 구현은 “완성형 explicit allocator”라기보다 implicit 기반 코드를 explicit로 옮기는 첫 안정 단계에 가깝다.

좋은 점
기존 implicit 코드에서 바뀐 줄을 손으로 추적하기 쉽다. allocator 일반 원리와 구현 수정 지점을 연결해 보기 좋다.
남는 숙제
free list 순서 정책, 성능 측정, realloc의 더 나은 확장 전략, heap checker까지 가야 “실전형 explicit allocator”에 가까워진다.

직접 해볼 실습

실습 1. free list를 손으로 그려 보기
힙 안에 free block이 세 개 있다고 가정하고, 삽입 순서가 다음과 같다고 적어 본다: A free → B free → C free.
  • LIFO insertion이면 head가 어떻게 바뀌는지 적는다.
  • PRED, SUCC가 각 블록에서 어떤 값을 가리키는지 적는다.
  • B를 remove할 때 어느 링크 두 개가 바뀌는지 적는다.
관찰 포인트
explicit free list는 결국 “힙 메타데이터 + 포인터 체인” 두 구조를 동시에 맞춰야 한다는 점을 직접 느껴 본다.
실습 2. split 후 남은 free block을 다시 연결해 보기
예를 들어 64바이트 free block에서 32바이트를 할당한다고 가정하고, 남는 32바이트 조각을 free list 어디에 넣을지 적어 본다.
  • 먼저 할당 대상 블록을 리스트에서 제거한다.
  • 앞부분을 allocated block으로 바꾼다.
  • 남은 조각이 MINBLOCKSIZE 이상이면 새 free block으로 만든다.
  • 그 free block을 다시 list head에 꽂는다.
관찰 포인트
split은 메모리 절약 문제가 아니라, free list 일관성을 다시 세우는 문제라는 점이 보이면 제대로 읽은 것이다.
이번 글에서 기억할 것
  • explicit free list는 allocator 문법을 새로 만드는 게 아니라, free block만 따로 연결하는 관리 층을 추가하는 구조다.
  • free block payload 안에 포인터 둘을 심기 때문에 최소 블록 크기가 커진다.
  • find_fit는 여전히 first-fit일 수 있지만, 탐색 대상은 힙 전체가 아니라 free list다.
  • place는 배치 함수이면서 동시에 free list 정리 함수가 된다.
  • coalesce는 병합 전에 이웃을 떼고, 병합 후 결과를 다시 넣어야 한다.
스스로 점검
1. explicit free list로 바뀌어도 boundary tag가 여전히 필요한 이유는 무엇인가?
2. 왜 free block만 포인터를 들고 있고, allocated block에는 같은 포인터가 필요 없는가?
3. coalesce에서 이웃 블록을 먼저 remove하지 않으면 어떤 종류의 버그가 생기는가?
4. 현재 구현의 LIFO insertion은 어떤 장점을 주고, 어떤 탐색/파편화 문제를 남길 수 있는가?