Skip to content

메모리 관리와 캐시

1. 페이지 (Page) & 구역 (Zone)

  • 프로세서가 메모리에 접근할 때 가장 작은 단위는 byte 또는 word지만, MMU와 커널은 메모리 관리를 페이지 단위로 처리한다.
  • 페이지 크기는 아키텍처 별로 다르며 보통 32-bit 시스템에선 4KB, 64-bit 시스템에선 8KB다.
  • 커널은 하나의 페이지를 여러 구역(zone)으로 나눠 관리한다. (<linux/mmzone.h>에 정의)
    • ZONE_DMA: DMA를 수행할 수 있는 메모리 구역
    • ZONE_DMA32: 32-bit 장치들만 DMA를 수행할 수 있는 메모리 구역
    • ZONE_NORMAL: 통상적인 페이지가 할당되는 메모리 구역
    • ZONE_HIGHMEM: 커널 주소 공간에 포함되지 않는 ‘상위 메모리’ 구역
  • 메모리 구역의 실제 사용 방식과 배치는 아키텍처에 따라 다르며 없는 구역도 있다.

2. 페이지 할당 & 반환

  • 커널은 메모리 할당을 위한 저수준 방법 1개 + 할당받은 메모리에 접근하는 몇 가지 인터페이스를 제공한다.

  • 모두 <linux/gfp.h> 파일에 정의돼있으며 기본 단위는 ‘페이지’다.

    // 방법 1 - alloc_pages
    struct page* alloc_pages(gfp_t gfp_mask, unsigned int order);
    // 방법 2 - __get_free_pages
    unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order);
    // 방법 3 - get_zeroed_page
    unsigned long get_zeroed_page(unsigned int gfp_mask, unsigned int order);
  • 위 세 방법 중 하나를 선택해서 원하는 크기(2 * order 페이지)만큼 메모리를 할당받을 수 있다.

  • 차이점은 반환값이다. 첫 번째 함수는 page 구조체를 얻을 수 있고, 두 번째 함수는 할당받은 첫 번째 페이지의 논리적 주소를 반환하며 마지막 함수는 0으로 초기화된 페이지를 얻을 수 있다.

  • 특히, 커널 영역에서 메모리를 할당하는 것은 실패할 가능성이 있으며 반드시 오류를 검사하는 과정이 필요하다. 또한, 오류가 발생했다면 원위치 시켜야 하는 과정도 필요하다.

  • 할당받은 페이지는 반드시 반환해야 하며, 할당받은 페이지만 반환해야 한다.

  • 페이지 반환에는 void __free_pages (struct page *page, unsigned int order);를 사용한다.

  • C의 malloc()free() 함수의 관계와 주의점을 생각하면 된다. ​

3. 메모리 할당 (kmalloc(), vmalloc())

  • kmalloc()gfp_mask 플래그라는 추가 인자가 있다는 점을 제외하면 C의 malloc()과 비슷하게 동작한다.
  • 즉, 바이트 단위로 메모리를 할당할 때 사용하며 커널에서도 메모리를 할당할 때 대부분 이 함수를 사용한다.
  • gfp_mask 플래그는 동작 지정자, 구역 지정자로 나뉘며 둘을 조합한 형식 플래그도 제공된다.
  • 커널에서 자주 사용되는 대표적인 플래그는 아래와 같다.
    • GFP_KERNEL: 중단 가능한 프로세스 컨텍스트에서 사용하는 일반적인 메모리 할당 플래그다.
    • GFP_ATOMIC: 중단 불가능한 softirq, 태스크릿, 인터럽트 컨텍스트에서 사용하는 메모리 할당 플래그다.
    • GFP_DMA: ZONE_DMA 구역에서 할당 작업을 처리해야 할 경우 사용한다.
  • 메모리를 해제할 때는 <linux/slab.h>에 정의된 kfree() 함수를 사용한다.
  • vmalloc() 함수는 할당된 메모리가 물리적으로 연속됨을 보장하지 않는다는 것을 제외하면 kmalloc()과 동일하게 동작한다.
    • 물리적으로 연속되지 않은 메모리를 연속된 가상 주소 공간으로 만들기 위해 상당량의 페이지 테이블을 조정하는 부가 작업이 필요하므로 kmalloc()의 성능이 훨씬 좋다.
    • 큰 영역의 메모리를 할당하는 경우에만 vmalloc()을 사용하자.
    • 메모리를 해제할 때는 vfree()를 사용한다.

4. 슬랩 계층 (Slab layer)

  • 사용이 빈번한 자료구조는 사용할 때마다 메모리를 할당하고 초기화하고 사용한 뒤 메모리를 반환하는 것보다 풀(pool) 방식을 사용하는 것이 성능면에서 효율적이다.
  • 슬랩 계층은 자료구조를 위한 캐시 계층이며 사용 종료 시 메모리를 반환하지 않고 해제 리스트에 넣어두고 다음 번에 재활용한다.
  • 슬랩의 크기는 페이지 1개 크기와 같고, 1개 슬랩에는 캐시할 자료구조 객체가 여러개 들어간다.
  • 빈번하게 할당 및 해제되는 자료구조일수록 슬랩을 이용해서 관리하는 것이 합리적이다.

5. 스택 할당

  • 사용자 공간은 동적으로 확장되는 커다란 스택 공간을 사용할 수 있지만, 커널은 고정된 작은 크기의 스택을 사용한다.
  • 커널 스택은 컴파일 시점의 옵션에 따라 하나 또는 두 개의 페이지(4KB ~ 16KB)로 구성된다.
  • 인터럽트 핸들러는 커널 스택을 사용하지 않고 프로세서별로 존재하는 1-page 짜리 스택을 사용한다.
  • 특정 함수의 지역변수 크기는 1KB 이하로 유지하는 것이 좋다. 스택 오버플로우는 조용히 발생하며 확실하게 문제를 일으키며 가장 먼저 thread_info 구조체가 먹혀버린 뒤 모든 종류의 커널 데이터가 오염될 여지가 있다.
  • 따라서 대량의 메모리를 사용할 때는 앞서 살펴본 동적 할당을 사용해야 한다.

6. 상위 메모리 연결

  • 1번 항목에서 설명했듯, 상위 메모리에 있는 페이지는 커널 주소 공간에 포함되지 않을 수도 있다. (대부분의 64-bit 시스템은 포함된다.)
  • alloc_pages() 함수를 호출해서 얻은 페이지에 가상주소가 없을 수 있으므로, 페이지를 할당한 다음에 커널 주소 공간에 수동으로 연결하는 작업이 필요하다.
  • 이를 위해 <linux/highmem.h> 파일에 kmap(struct page* page); 함수를 사용한다.
    • 페이지가 하위 메모리에 속해 있다면, 그냥 페이지의 가상주소를 반환한다.
    • 페이지가 상위 메모리에 속해 있다면, 메모리를 맵핑한 뒤 그 주소를 반환한다.
  • 프로세스 컨텍스트에서만 동작한다.

7. CPU별 할당 - percpu 인터페이스

  • SMP를 지원하는 2.6 커널에는 특정 프로세서 고유 데이터인 CPU별 데이터를 생성하고 관리하는 percpu라는 새로운 인터페이스를 도입했다.

  • <linux/percpu.h> 헤더파일과 <mm/slab.c>, <asm/percpu.c> 파일에 정의돼있다.

    // 컴파일 타임의 CPU별 데이터
    DEFINE_PER_CPU(var, name); // type형 변수 var 생성
    get_cpu_var(var)++; // 현재 프로세서의 var 증감
    // 여기부터 선점이 비활성화 된다.
    put_cpu_var(var); // 다시 선점 활성화
    // 런타임의 CPU별 데이터
    void *alloc_percpu(size_t size, size_t align);
    void free_percpu(const void *);
  • CPU별 데이터를 사용하면 세 가지 장점이 있다.

    • 락(스핀락, 세마포어)을 사용할 필요가 줄어든다.
    • 캐시 무효화(invalidation) 위험을 줄여준다.
    • 선점 자동 비활성화-활성화로 인터럽트 컨텍스트 & 프로세스 컨텍스트에서 안전하게 사용할 수 있다.

캐시 (Page Cache & Page Writeback)

1. 캐시 정의와 사용 방식

  • 리눅스는 캐시를 ‘페이지 캐시(Page cache)’라고 부르며 디스크 접근 횟수를 최소화 하기 위해 사용한다.
    • 프로세스가 read() 시스템콜 등으로 읽기 요청을 할 때, 커널은 가장 먼저 페이지 캐시를 확인한다.
    • 만약 있다면, 메모리 또는 디스크 접근을 하지 않고 캐시에서 데이터를 바로 읽는다.
    • 만약 없다면, 메모리 또는 디스크에 접근해 읽은 뒤 데이터를 캐시에 채워 넣는다.
  • 리눅스는 write policy로 지연 기록(write-back)을 채택하고 있다.
    • 프로세스의 쓰기 동작은 캐시에 바로 적용된다. (메모리 or 디스크에 적용 X)
    • 해당 캐시 라인에 dirty 표시를 한다.
    • 적당한 때에 주기적으로 캐시의 dirty 표시된 내용이 메모리 or 디스크에 갱신되고 지워진다.
    • 통합해서 한꺼번에 처리하므로 성능이 우수하지만 복잡도가 높다.
  • 캐시의 갱신된 페이지 내용을 메모리 or 디스크로 반영하는 작업을 ‘플러시(Flush)’라고 한다.
    • 리눅스는 이 작업을 ‘플러시 스레드(Flush thread)’라는 커널 스레드가 담당한다.
    • 페이지 캐시 가용 메모리가 특정 임계치 이하로 내려갈 때 dirty 캐시 라인을 플러시 한다.
    • 페이지 케시 dirty 상태가 특정 한계 시간을 지나면 플러시 한다.
    • 사용자가 sync(), fsync() 시스템콜을 호출하면 즉시 플러시 한다.
  • 리눅스는 replacement policy로 ‘이중 리스트 전략’(Two-list) 라는 개량 LRU(Least Recently Used) 알고리즘을 사용한다.
    • 페이지 캐시가 가득찼을 때 어떤 데이터를 제거할 것인지 선택하는 과정이다.
    • 언제 각 페이지에 접근했는지 타임스탬프를 기록해둔 뒤 가장 오래된 페이지를 교체하는 방법이다.
    • 이중 리스트 전략은 ‘활성 리스트’와 ‘비활성 리스트’ 두 가지 리스트를 활용한다.
      • 최근에 접근한 캐시 라인은 활성 리스트에 들어가서 교체 대상에서 제외한다.
      • 두 리스트는 큐처럼 앞부분에서 제거하고 끝부분에 추가한다.
      • 두 리스트는 균형 상태를 유지한다. 활성 리스트가 커지면 앞쪽 항목들을 비활성 리스트로 넘긴다.

2. 리눅스 페이지 캐시 구조체 - address_space

  • 다양한 형태의 파일과 객체를 올바르게 캐시하는 것을 목표로 <linux/fs.h>address_space 객체가 만들어졌다.
  • 하나의 address_space 객체는 하나의 파일(inode)을 나타내고 1개 이상의 vm_area_struct가 포함된다. 단일 파일이 메모리상에서 여러 개의 가상 주소를 가질 수 있다는 걸 생각하면 된다.
  • 특히, 페이지 캐시는 원하는 페이지를 빨리 찾을 수 있어야 하기 때문에 address_space에는 page_tree라는 이름의 기수 트리(radix tree)가 들어 있다.

참고