XV6 운영체제의 락 경합 최적화: 메모리 할당 및 버퍼 캐시 개선

멀티코어 시스템에서 운영체제의 성능을 높이기 위해서는 커널 내 자원에 대한 락 경합(Lock Contention)을 최소화해야 합니다. XV6의 기본 구현은 공유 자원에 대해 단일 전역 락을 사용하는 경우가 많아, 여러 CPU가 동시에 접근할 때 병목 현상이 발생합니다. 이 글에서는 메모리 할당기(kalloc)와 디스크 버퍼 캐시(bcache)의 구조를 변경하여 락 경합을 줄이는 방법을 다룹니다.

1. 물리 메모리 할당기(kalloc.c) 개선

기본 kalloc은 모든 CPU가 하나의 프리리스트(freelist)와 단일 락을 공유합니다. 이를 개선하기 위해 각 CPU마다 독립적인 프리리스트를 가지도록 구조를 변경합니다.

먼저 kmem 구조체를 CPU 개수만큼 배열로 선언합니다.

// kernel/kalloc.c

struct {
  struct spinlock lock;
  struct run *freelist;
} kmem_pool[NCPU]; // 각 CPU별로 관리되는 메모리 풀

그다음 초기화 루틴인 kinit에서 각 풀의 락을 초기화합니다.

void
kinit()
{
  for(int i = 0; i < NCPU; i++) {
    initlock(&kmem_pool[i].lock, "kmem_pool");
  }
  freerange(end, (void*)PHYSTOP);
}

kfree 함수는 현재 작업을 수행 중인 CPU의 전용 풀에 메모리를 반환하도록 수정합니다.

void
kfree(void *pa)
{
  struct run *r;
  if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
    panic("kfree");

  memset(pa, 1, PGSIZE);
  r = (struct run*)pa;

  push_off(); // 인터럽트 비활성화
  int id = cpuid();
  acquire(&kmem_pool[id].lock);
  r->next = kmem_pool[id].freelist;
  kmem_pool[id].freelist = r;
  release(&kmem_pool[id].lock);
  pop_off();
}

kalloc에서는 먼저 현재 CPU의 풀에서 메모리를 할당받으려 시도하고, 만약 부족하다면 다른 CPU의 풀에서 메모리를 가져오는(Stealing) 로직을 구현합니다.

void *
kalloc(void)
{
  struct run *r = 0;
  push_off();
  int start_id = cpuid();

  // 현재 CPU부터 시작하여 순환하며 빈 메모리 탐색
  for(int i = 0; i < NCPU; i++) {
    int target_id = (start_id + i) % NCPU;
    acquire(&kmem_pool[target_id].lock);
    r = kmem_pool[target_id].freelist;
    if(r) {
      kmem_pool[target_id].freelist = r->next;
    }
    release(&kmem_pool[target_id].lock);
    
    if(r) break;
  }
  pop_off();

  if(r)
    memset((char*)r, 5, PGSIZE);
  return (void*)r;
}

2. 디스크 버퍼 캐시(bio.c) 개선

버퍼 캐시 역시 단일 락으로 보호되는 LRU 리스트 구조에서 해시 테이블 기반의 다중 버킷 구조로 변경합니다. 각 버킷은 개별 락을 가져 병렬성을 높입니다.

먼저 buf.h 구조체에 접근 시간을 기록할 타임스탬프 필드를 추가합니다.

// kernel/buf.h
struct buf {
  int valid;
  int disk;
  uint dev;
  uint blockno;
  struct sleeplock lock;
  uint refcnt;
  // struct buf *prev; // 기존 리스트 필드 삭제 가능
  // struct buf *next;
  uint last_access;   // LRU 대체를 위한 타임스탬프
  uchar data[BSIZE];
};

bio.c에서 해시 버킷 구조를 정의하고 전역 타임스탬프 변수를 선언합니다.

// kernel/bio.c
#define BUCKET_SIZE 13

struct {
  struct spinlock lock;
  struct buf buffers[NBUF];
} bcache_pool;

struct bucket {
  struct spinlock lock;
  struct buf head; // 버킷 내 버퍼 관리를 위한 더미 헤드
} hash_table[BUCKET_SIZE];

uint ticks_counter; // 전역 시간 카운터

uint
get_hash(uint blockno) {
  return blockno % BUCKET_SIZE;
}

binit 함수에서 버킷별 락과 버퍼들을 초기화합니다.

void
binit(void)
{
  for(int i = 0; i < BUCKET_SIZE; i++) {
    initlock(&hash_table[i].lock, "bcache_bucket");
  }

  for(int i = 0; i < NBUF; i++) {
    struct buf *b = &bcache_pool.buffers[i];
    initsleeplock(&b->lock, "buffer_sleep");
    b->refcnt = 0;
    b->last_access = 0;
  }
}

버퍼를 해제하거나 참조 횟수를 조정하는 함수들은 해당 버킷의 락만 획득하도록 수정합니다.

void
brelse(struct buf *b)
{
  if(!holdingsleep(&b->lock))
    panic("brelse");

  uint h = get_hash(b->blockno);
  acquire(&hash_table[h].lock);
  b->refcnt--;
  if(b->refcnt == 0) {
    b->last_access = __sync_fetch_and_add(&ticks_counter, 1);
  }
  release(&hash_table[h].lock);
  releasesleep(&b->lock);
}

void
bpin(struct buf *b) {
  uint h = get_hash(b->blockno);
  acquire(&hash_table[h].lock);
  b->refcnt++;
  release(&hash_table[h].lock);
}

void
bunpin(struct buf *b) {
  uint h = get_hash(b->blockno);
  acquire(&hash_table[h].lock);
  b->refcnt--;
  release(&hash_table[h].lock);
}

가장 핵심인 bget 함수는 요청된 블록이 이미 버킷에 있는지 확인하고, 없다면 참조되지 않은(refcnt=0) 버퍼 중 가장 오래된 것을 찾아 교체합니다.

static struct buf*
bget(uint dev, uint blockno)
{
  uint h = get_hash(blockno);
  acquire(&hash_table[h].lock);

  // 1. 해당 버킷에 이미 캐싱되어 있는지 확인
  for(int i = 0; i < NBUF; i++) {
    struct buf *b = &bcache_pool.buffers[i];
    if(b->dev == dev && b->blockno == blockno) {
      b->refcnt++;
      release(&hash_table[h].lock);
      acquiresleep(&b->lock);
      return b;
    }
  }

  // 2. 캐시 미스 시 교체 대상 탐색 (전체 버퍼 중 refcnt 0이고 가장 오래된 것)
  struct buf *victim = 0;
  uint oldest_time = 0xffffffff;

  // 단순화를 위해 전체 풀에서 탐색 (실제 구현 시 버킷별 탐색이 효율적일 수 있음)
  for(int i = 0; i < NBUF; i++) {
    struct buf *b = &bcache_pool.buffers[i];
    if(b->refcnt == 0 && b->last_access < oldest_time) {
      oldest_time = b->last_access;
      victim = b;
    }
  }

  if(victim) {
    victim->dev = dev;
    victim->blockno = blockno;
    victim->valid = 0;
    victim->refcnt = 1;
    release(&hash_table[h].lock);
    acquiresleep(&victim->lock);
    return victim;
  }

  panic("bget: no buffers available");
}

마지막으로 필요한 유틸리티 함수와 전역 변수 증가 로직을 defs.h에 추가하여 외부에서 참조할 수 있도록 설정하면 최적화가 마무리됩니다. 이러한 구조 변경을 통해 멀티코어 환경에서 커널의 동시성 성능이 크게 향상됩니다.

태그: xv6 operating-system lock-contention kalloc buffer-cache

9월 4일 19:40에 게시됨