메모리 맵 파일

프로그래밍은 데이터를 입력받아 처리하고 출력하는 작업이고, 이 데이터는 파일로써 디스크에 존재합니다. 내가 만약 '회원명단.csv'란 엑셀 파일을 가지고 여러 데이터 처리 작업을 해야한다고 가정해봅시다. 그렇다면 해당 파일에서 '읽기', '쓰기'를 할 때마다 디스크까지 접근해야할텐데 이러면 속도가 너무 느리지 않을까요? 만약 그 파일이 디스크가 아닌 메모리에 있다면 접근 속도는 엄청나게 향상되지않을까요? 이러한 발상은 최소한의 디스크 접근을 위한 버퍼 입출력에서 한 단계 더 나아간 발상입니다. '버퍼'란 개념이 아닌 디스크-메모리간의 '페이지(page)' 개념을 활용한 방식으로, 프로세스의 가상 메모리 주소 공간에 파일을 매핑한 뒤 가상 메모리 주소에 직접 접근하는 것으로 파일 읽기/쓰기를 수행합니다. 이 때, 가상메모리-디스크 간의 동기화 문제는 어떤 식으로 해결할 수 있는지도 알아보겠습니다.

 

들어가기에 앞서

메모리 맵 파일의 장점과 단점을 살펴보겠습니다.

 

장점

  1. 버퍼나 파일 처리를 위한 추가적인 자료구조가 필요없습니다. OS에서 페이징 기법을 사용하여 파일의 내용을 관리하며, 페이지 크기(일반적으로 4KB)만큼 '읽기/쓰기' 작업이 가능합니다.
  2. 대용량의 데이터를 처리할 때 매우 효율적입니다. 파일의 크기가 매우 크더라도 필요한 부분만 페이지로 불러와 작업할 수 있습니다.
  3. 전통적인 파일 입출력 API보다 속도가 빠릅니다. API는 내부적으로 시스템콜을 이용하기 때문에 작업을 수행하는 동안 유저모드-커널모드간의 전환을 위한 인터럽트가 오버헤드로 작용합니다. 메모리 맵 파일은 페이지 단위로 자료를 불러올때 발생하는 '페이지 폴트' 외에 어떠한 비용도 없습니다.

단점

  1. 메모리 맵 파일은 항상 페이지 크기의 정수배만 가능합니다. 즉, 크기가 작은 파일이라면 메모리 맵 파일을 이용하는 것이 메모리 공간의 낭비로 이어질 수 있습니다.
  2. 메모리 맵 파일의 오버헤드는 크기가 큰 파일에서 '페이지 폴트'로 인해 페이지를 새로 불러와야할 때입니다. 이 비용이 모든 경우에 가장 저렴하지는 않습니다.

 


리눅스에선 mmap() 시스템콜을 사용해서 객체를 메모리에 맵핑할 수 있습니다.

#include <sys/mman.h>

void* mmap(void* addr, // 어느 주소에 맵핑되길 원하는지 커널에 알려줍니다.(보통 0을 넘김)
    size_t len,
    int prot,       // 메모리 보호 정책을 설정합니다.
    int flags,      // 맵핑의 유형과 그 동작에 관한 요소를 명시합니다.
    int fd,
    off_t offset);  // 해당 offset 위치에서 len바이트만큼 메모리에 맵핑하도록 요청합니다.

 

prot 매개변수는 '메모리 보호 정책'을 설정하기위한 것으로, 다음에 있는 플래그 중에서 하나 이상을 OR 연산으로 묶을 수 있습니다.

  • PROT_NONE: 접근이 불가능한 페이지(거의 사용되지 않습니다.)
  • PROT_READ: '읽기'가 가능한 페이지
  • PROT_WRITE: '쓰기'가 가능한 페이지
  • PROT_EXEC: '실행'이 가능한 페이지

flags 매개변수는 매핑할 메모리의 유형과 그 동작에 관한 몇 가지 요소를 명시합니다. 마찬가지로 OR 연산으로 묶을 수 있습니다.

  • MAP_FIXED: mmap() 시스템콜의 addr 매개변수를 원하는 메모리 주소 공간을 알려준다는 목적을 넘어 해당 주소의 메모리 공간이 아니면 호출이 실패하게끔 확고히 커널에 요청합니다.
  • MAP_PRIVATE: 매핑된 메모리 공간의 '쓰기'가 발생하더라도 실제 파일과 해당 메모리 공간을 공유하고있는 다른 프로세스에 반영하지 않습니다. 원본을 훼손하지 않고, 수정된 복사본이 생깁니다. (copy-on-write) 
  • MAP_SHARED: 같은 파일을 메모리에 매핑한 모든 프로세스와 매핑된 메모리 영역을 공유합니다. 당연히 다른 프로세스에 의해 수정됬다면 해당 영역에 '읽기'를 할때 반영됩니다.

 

다음 예제는 fd가 가리키는 파일의 첫 바이트부터 len 바이트까지를 '읽기' 전용으로 메모리에 매핑합니다.

void* p;
p = mmap(0, len, PROT_READ, MAP_SHARED, fd, 0);
if (p == MAP_FAILED)
    perror("mmap");

 

mmap() 시스템콜은 호출이 성공하면 매핑된 메모리 주소를 반환합니다. 실패시 'MAP_FAILED'를 반환하고 errno를 적절한 값으로 설정합니다.

  • EACCESS: 주어진 파일 디스크립터가 일반 파일이 아니거나 파일이 prot이나 flags 매개변수와 충돌을 일으키는 모드로 오픈되었을 때
  • EAGAIN: 파일 락으로 파일이 잠긴 상태일 때
  • EBADF: 주어진 파일 디스크립터가 유효하지 않을 때
  • EINVAL: addr, len, off 중 하나 이상의 매개변수가 유효하지 않을 때
  • ENFILE: 시스템에서 오픈할 수 있는 파일 개수를 초과했을 때
  • ENODEV: 파일시스템에서 해당 파일에 대한 메모리 매핑을 지원하지 않을 때 
  • ENOMEM: 프로세스에 사용 가능한 메모리가 부족할 때
  • EOVERFLOW: addr+len 값이 주소 공간의 크기를 초과했을 때
  • EPERM: PROT_EXEC가 설정되었지만 파일시스템이 noexec 모드로 마운트되었을 때

 

 

mmap() 시스템콜로 생성한 매핑된 메모리 영역을 해제하고 싶다면 munmap() 시스템콜을 사용합니다.

#include <sys/mman.h>

int munmap(void* addr, size_t len);

 

다음은 mmap() 시스템콜을 이용해서 표준 출력으로 사용자가 선택한 파일의 내용을 출력하는 예제입니다.

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>

int main(int argc, char* argv[]) {
    struct stat sb;
    off_t len;
    char* p;
    int fd;
    
    if (argc < 2) {
        fprintf(stderr, "usage: %s [file] \n", argv[0]);
        return 1;
    }
    
    // 이하 에러 처리문 생략합니다.
    fd = open(argv[1], O_RDONLY);
    
    fstat(fd, &sb);
    if (!S_ISREG(sb.st_mode)) return 1;
    
    p = mmap(0, sb.st_size, PROT_READ, MAP_SHARED, fd, 0);
    
    for (len = 0; len < sb.st_size; ++len)
        putchar(p[len]);
    
    close(fd);
    munmap(p, sb.st_size); // 매핑된 메모리 영역을 해제합니다.
}