CSAPP 9장 파트 5: 결정 ④ 병합, boundary tag

2026. 10. 2. 14:43ㆍ크래프톤 정글/포스팅

지금 위치: 네 가지 결정 중 마지막 ④ 병합입니다.

이번 파트의 질문:
블록을 free하고 난 후, free된 블록의 이웃이 free라면 어떻게 할까?
그리고 앞 뒤 이웃의 상태와 위치를 어떻게 빨리 알아낼까?

 

1. 문제: 이웃한 빈 블록이 "가짜 단편화"를 만든다

블록을 free하면 그 이웃도 free일 수 있습니다. 합치지 않고 두면 이런 일이 생깁니다.

[ free 3워드 ][ free 3워드 ]     ← 합치면 6워드

이 상태에서 payload 4워드를 요청하면(블록 크기 6워드 필요?) 각각은 3워드라 둘 다 작아서 실패합니다.

합계는 충분한데 쪼개져 있어서 못 쓰는 상태, 이것이 가짜 단편화(false fragmentation)입니다. 

 

해결은 간단합니다. 인접한 빈 블록은 하나로 합칩니다(coalescing). 책은 이를 필수라고 봅니다.

2. 언제 합칠까?

방식 내용 특징
Immediate (즉시) free할 때마다 바로 합침 단순하고 상수 시간.
다만 "합쳤다가 바로 다시 쪼개는" 낭비(thrashing)가 생길 수 있음
Deferred (지연) 나중에 한꺼번에 합침
(예: 할당 실패 시) 
빠른 allocator가 선호

 

예를 들어 같은 크기의 할당/해제를 반복하면, 즉시 병합은 합쳤다가 다시 쪼개기를 반복합니다.

책의 allocator와 과제는 immediate coalescing입니다.

3. 다음 블록 합치기는 쉽다

현재 블록의 헤더로 다음 블록의 위치를 알 수 있습니다. (현재 블록 헤더 위치 + 현재 블록 사이즈) 

 

다음 블록 헤더를 보고 free인 경우 

현재 블록 헤더에 새 크기(현재 크기 + 다음 블록 크기)를 기록하면 끝이고, 상수 시간입니다.

합치기 전:  [ H(n) | ... ][ H(m2) | ... ]
합치기 후:  [ H(n+m2) | ............... ]
                          ↑ 옛 m2의 헤더가 있던 자리는 그냥 참조하지 않는 값으로 남아있음

4. 이전 블록 합치기는 어렵다 → boundary tag (푸터) 사용 

상황: 블록 n을 free했고, 앞 뒤 이웃이 free인지 알아야 한다

[ m1: ? ][ n: 방금 free ][ m2: ? ]

합치려면 m1과 m2가 free인지 알아야 합니다. 

m2와 합치는 것은 쉽습니다. n의 헤더에 n의 크기가 있으므로 m2 헤더로 이동할 수 있고

m2의 헤더를 읽으면 해당 블록이 free인지 바로 압니다. 

이전 블록(m1)은 갈 수가 없다

m1의 위치 = n의 위치 - (m1의 크기)

그런데 n의 헤더에는 n 자신의 크기만 있어서 m1의 크기를 모릅니다. 얼마나 뒤로 가야 할지 계산할 수 없습니다.

힙 처음부터 훑으며 n 직전 블록을 기억하는 방법은 있지만, free마다 힙 전체를 훑어야 해서 너무 느립니다.

해결: m1이 자기 끝에 정보를 남겨둔다 (푸터)

그래서 모든 블록 맨 끝에 헤더의 복사본(푸터)을 둡니다.

 [ m1: H | ...... | F ][ n: H | ...... | F ][ m2: H | ... | F ]
                    ↑  ↑
              m1의 푸터  n의 헤더

 

블록들은 빈틈없이 이어 붙어 있으므로

n의 헤더 바로 앞 4바이트에서 m1의 푸터에 접근하여 m1의 크기와 free/allocated 여부를 확인할 수 있게됩니다. 

 

n의 bp(payload 시작)를 기준으로 따라가보면.. 

  1. n의 헤더 = bp - 4
  2. 헤더의 앞 4바이트인 m1의 푸터 = bp - 8
  3. bp - 8을 읽는다 → m1의 크기와 상태를 알게 된다
  4. m1이 free면 합칠 대상이다
  5. m1의 bp = n의 bp - m1의 크기

숫자 예시: m1 크기 16, n의 bp = 140이면

  • 푸터 위치 = 140 - 8 = 132   → "크기 16, free" 읽음
  • m1의 bp  = 140 - 16 = 124

읽기 한 번과 뺄셈 한 번으로 끝나므로 상수 시간입니다

5. free시 4가지 Case 

Case 이전 블록 다음 블록 동작
1 allocated allocated 합칠 것 없음. 현재 블록만 free로 표시
2 allocated free 현재 + 다음 합침. 크기 n+m2. 현재 헤더, 다음 블록의 푸터를 갱신
3 free allocated 이전 + 현재 합침. 크기 m1+n. 이전 블록의 헤더, 현재 푸터를 갱신
4 free free 셋 모두 합침. 크기 n+m1+m2. 이전 블록 헤더, 다음 블록 푸터를 갱신

핵심 규칙: 합쳐진 블록의 "맨 앞 헤더"와 "맨 뒤 푸터"에 새 크기를 기록합니다.

Case 4: [ m1: free ][ n: 방금 free ][ m2: free ]
         ↓ 합침
        [ 헤더: m1+n+m2|f ...... 푸터: m1+n+m2|f ]

6. 푸터의 비용과 최적화 

문제: 푸터는 모든 블록에 4바이트를 더 쓴다

푸터 덕분에 이전 블록을 상수 시간에 찾지만, 모든 블록이 끝에 4바이트를 더 차지합니다.

┌────────┬─────────┬─────────┬────────┐
│ Header │ Payload │ Padding │ Footer │
│  4B    │         │         │  4B    │
└────────┴─────────┴─────────┴────────┘
└────────────── 블록 크기 ──────────────┘

작은 블록이 많으면 비율이 커집니다. 예를 들어 블록 크기 16에서 헤더+푸터가 8바이트이니 절반입니다.

 

푸터가 정말 항상 필요한가?

푸터는 블록 n을 free할 때 이전 블록 m1이 free인지 알아내려고 읽는 값이었습니다.

[ m1: ? ][ n: 방금 free ][ m2: ? ]
  • 알고 싶은 것: 이전 블록 m1이 free인가 allocated인가
  • m1이 allocated이면 합칠 일이 없으니 m1의 크기도 필요 없습니다. (m1이 "allocated다"라는 사실만 필요)
  • m1이 free일 때만 합쳐야 하므로 크기가 필요합니다.

즉 m1이 allocated인 동안은 한 비트 정보를 얻으려고 푸터 4바이트를 통째로 쓰고 있었던 겁니다.

m1의 상태 비트를 n의 헤더에 미리 적어둔다

 31                             3  2  1  0
 ┌──────────────────────────────┬──┬──┬──┐
 │      블록 크기를 나타내는 숫자     │ 0│ p│ a│
 └──────────────────────────────┴──┴──┴──┘
                                    │  └ a: 현재 블록 (1 = allocated, 0 = free)
                                    └─── p: 이전 블록 (1 = allocated, 0 = free)

헤더에서 크기 값은 언제나 8의 배수라 하위 3비트가 비어 있기 때문에 최하위 비트에 현재 블록의 상태를 적습니다. 

그런데 비트가 더 남아있으니, 그 옆의 남는 비트에 "이전 블록이 allocated?인지를 적습니다. 

그러면 n은 자기 헤더만 보고 이전 블록의 상태를 압니다. 이전 블록이 allocated이면 푸터를 읽으러 갈 필요가 없습니다.

 

따라서 현재 블록이 allocated인 경우 푸터를 가질 필요가 없다.

하지만 현재 블록이 free인 경우에는, 다음 블록이 free될 때 이 블록과 병합해야 하므로 푸터에서 이 블록의 크기를 읽어 시작 위치로 가야 한다. 그래서 free 블록에는 푸터가 필요하다.

최소 블록 계산 하기 

  • allocated 블록의 payload는 최소 1바이트, free 블록에는 payload 없음 
  • 블록 크기는 정렬 단위의 배수로 올림 (Single word = 4, Double word = 8)
정렬 단위 allocated 구성 최소 블록 크기 계산 free 구성 최소 블록 크기 계산 최종 최소 블록 크기 
4B 헤더+푸터 4+1+4=9 → 12 헤더+푸터
(payload 없음)
4+4=8 12
4B 헤더만 4+1=5 → 8 4+4=8 8
8B 헤더+푸터 4+1+4=9 → 16 4+4=8 16
8B 헤더만 4+1=5 → 8 4+4=8 8

 


7. epilogue와 prologue가 필요한 이유

문제: 힙의 양 끝 블록은 이웃이 없다

coalesce는 블록을 free할 때 항상 이웃 둘을 확인합니다.

prev_alloc = GET_ALLOC(FTRP(PREV_BLKP(bp)));   // 이전 블록의 푸터를 읽는다 → bp - 8 
next_alloc = GET_ALLOC(HDRP(NEXT_BLKP(bp)));   // 다음 블록의 헤더를 읽는다 → (bp - 4) + 현재 크기)

 

그런데 가장자리 블록은 이웃이 없습니다.

낮은 주소                        높은 주소
   ◀───────────────────────────▶ 
 ? [ 첫 블록 ][ ... ][ 마지막 블록 ] ?
 ↑                              ↑
 이전 블록이 없다                  다음 블록이 없다
  • 첫 블록을 free하면: 이전 푸터 자리(bp - 8)가 힙 바깥입니다. 그걸 읽으면 엉뚱한 값을 읽습니다.
  • 마지막 블록을 free하면: 다음 헤더 자리도 힙 바깥입니다.

이 문제를 그대로 두면 코드에 이런 if가 필요해집니다.

if (첫 블록이면)   → 이전 확인 건너뛰기
if (마지막 블록이면) → 다음 확인 건너뛰기

free할 때마다 이 검사를 하게 되어 코드가 지저분해지고, 실수하기 쉽고, 느려집니다. 그리고 case도 늘어납니다.

해결: 가짜 이웃을 미리 깔아둔다 - prologue와 epilogue

양 끝에 항상 allocated인 표식 블록을 둡니다. 그러면 가장자리 블록에게도 이웃이 "있는 것"이 되고, 그 이웃이 allocated이니 "합칠 대상이 아님"으로 자연스럽게 기존 코드 로직 그대로 처리됩니다. 예외 처리가 사라지고 4가지 case만으로 모든 경우가 처리됩니다.

[ 패딩 ][ prologue: H | F ][ 첫 진짜 블록 ] ... [ 마지막 진짜 블록 ][ epilogue: H ]
       └ 크기 8,allocated ┘                                  └ 크기 0,allocated ┘
  • prologue (맨 앞 블록, 맨 처음 블록의 이전 쪽 이웃)
    • 구성: 헤더 + 푸터(8바이트) payload는 0바이트인 특수한 블록입니다.
      → 헤더나 푸터 둘 중 하나만 있으면 안될까? 헤더+푸터를 다 두는 이유: prologue를 "크기 8짜리 진짜 블록"으로 만들기 위해. 블록의 모양은 헤더 + 푸터가 한 세트라서, 다른 코드들이 prologue를 평범한 블록처럼 다룰 수 있게 됩니다.
    • 적혀있는 값: 크기 8, 상태 allocated 
    • 블록들이 이전 블록을 확인을 할 때 읽는 bp - 8이 prologue의 푸터입니다. "allocated"가 읽히므로 합치지 않습니다.
  • epilogue (맨 뒤 블록, 마지막 블록의 다음 쪽 이웃)
    • 구성: 헤더만(4바이트)
      →
      헤더만 필요한 이유: 다음 블록 확인은 헤더만 읽기 때문입니다.
    • 적혀있는 값: 크기 0, 상태 allocated. 
    • 마지막 진짜 블록이 다음 확인을 할 때 읽는 것이 epilogue의 헤더입니다. "allocated"가 읽히므로 합치지 않습니다.
    • 크기 0이라는 값이 훑기(find_fit)의 종료 표식 역할도 합니다.
    • 힙이 늘어나면 extend_heap에서 새 free 블록이 옛 epilogue 자리를 덮어쓰고, 새 영역 끝에 새 epilogue를 다시 씁니다. 그래서 힙이 몇 번 늘어나도 "끝은 항상 allocated 헤더"라는 불변식이 유지됩니다.

예시: 첫 블록을 free할 때

[ 패딩 ][ prologue(a) ][ 첫 블록 n ][ m2 ]

n을 free하면:

  1. 이전 확인: bp - 8(= prologue의 푸터)을 읽는다 → allocated
  2. 다음 확인: m2의 헤더를 읽는다
  3. 결과: Case 1 (a/a) 또는 Case 2(a/f)가 됨. 가장자리라서 따로 처리할 것이 없다. 


인접한 빈 블록을 합치지 않으면 합계는 충분한데 못 쓰는 가짜 단편화(외부 단편화)가 생기므로, free할 때 즉시 합친다.

→ 다음 블록은 헤더의 크기로 쉽게 찾지만, 이전 블록은 알 방법이 없어서 블록 끝에 헤더의 복사본인 푸터(boundary tag)를 둔다.

현재 블록 바로 앞 4바이트가 이전 블록의 푸터라서 이전 블록의 상태와 위치를 상수 시간에 알 수 있다.

→ 합쳐진 블록의 맨 앞 헤더와 맨 뒤 푸터에 새 크기를 쓴다.

→ 양끝에 항상 allocated인 prologue/epilogue를 두어 가장자리 예외 처리를 없앤다.