메뉴 바로가기 검색 및 카테고리 바로가기 본문 바로가기

한빛출판네트워크

한빛랩스 - 지식에 가능성을 머지하다 / 강의 콘텐츠 무료로 수강하시고 피드백을 남겨주세요. ▶︎

IT/모바일

리눅스에서 메모리가 부족할 때

한빛미디어

|

2007-01-03

|

by HANBIT

67,116

제공: 한빛 네트워크
저자: Mulyadi Santosa, 이정목 역
원문: When Linux Runs Out of Memory

아마도 여러분은 좀처럼 이 상황에 직면하지 않을 것이지만, 만약 그렇게 된다면 여러분은 무엇이 문제(free 메모리의 부족이나 Out of Memory (OOM))인지 확실히 알게 될 것입니다. 그 결과는 전형적인데, 여러분은 더 이상 더 많은 메모리를 할당할 수 없고 커널은 태스크(일반적으로 현재 동작하고 있는 것)를 제거(kill)할 것입니다. 대량의 스와핑(swapping)은 일반적으로 이러한 상황을 동반하게 되며, 따라서 화면과 디스크의 움직임이 이를 반영합니다.

이 문제의 기저에는 다른 문제들이 놓여져 있는데, 얼마만큼의 메모리를 할당하기를 원하는가? 운영체제(OS; Operating System)가 얼마나 할당해 주고 있는가? OOM의 기본적인 원인은 간단합니다. 즉, 여러분은 사용 가능한 가상 메모리 공간보다 더 많은 것을 요구했을 것입니다. 필자가 "가상"이라고 말한 이유는 RAM이 free 메모리로 계산되는 유일한 공간이 아니며 어떠한 스왑 영역도 해당되기 때문입니다.

OOM 조사하기

OOM 조사를 시작하기 위해 먼저 대량의 메모리 블록을 할당하는 아래의 코드를 입력하고 실행시킵니다:
#include 
#include 

#define MEGABYTE 1024*1024

int main(int argc, char *argv[])
{
        void *myblock = NULL;
        int count = 0;

        while (1)
        {
                myblock = (void *) malloc(MEGABYTE);
                if (!myblock) break;
                printf("Currently allocating %d MB\n", ++count);
        }
        
        exit(0);
}
프로그램을 컴파일하고 실행한 다음 잠시동안 기다립니다. 조만간 OOM 상태가 될 것입니다. 이제 대량의 블록을 할당하고 그곳을 1로 채우는 다음의 프로그램을 컴파일하십시오.
#include 
#include 

#define MEGABYTE 1024*1024

int main(int argc, char *argv[])
{
        void *myblock = NULL;
        int count = 0;

        while(1)
        {
                myblock = (void *) malloc(MEGABYTE);
                if (!myblock) break;
                memset(myblock,1, MEGABYTE);
                printf("Currently allocating %d MB\n",++count);
        }
        exit(0);
        
}
차이점을 발견하셨습니까? A 프로그램이 B 프로그램보다 많은 메모리 블록을 할당합니다. 여러분이 B 프로그램을 실행한 후에 얼마 지나지 않아서 "죽었음(Killed)" 라는 단어를 볼 것이라는 것은 명백합니다. 두 프로그램 모두 동일한 이유로 종료됩니다. 더 이상 사용 가능한 공간이 없기 때문입니다. 보다 구체적으로 말하자면, A 프로그램은 malloc()이 실패하기 때문에 얌전하게 종료됩니다. B 프로그램은 리눅스 커널의 소위 말하는 OOM Killer 때문에 종료됩니다.

관찰할 첫 번째 사실은 할당된 블록의 양입니다. 여러분이256MB의 RAM과 888MB의 스왑(현재 필자의 리눅스 설정)을 가지고 있다고 가정해 봅시다. B 프로그램은 다음에서 종료되었습니다:
Currently allocating 1081 MB
한편, A 프로그램은 다음에서 종료되었습니다:
Currently allocating 3056 MB
A 프로그램은 나머지 1095MB를 어디에서 가져왔습니까? 제가 속인 건 절대 아닙니다. 여러분이 두 가지 리스트를 자세히 들여다보면 B 프로그램은 할당된 메모리 공간을 1로 채운다는 것을 발견하게 될 것입니다. 반면 A 프로그램은 단지 단순히 할당만 할 뿐입니다. 이런 현상은 리눅스가 지연된 페이지 할당(Deferred page allocation)을 사용하기 때문에 발생합니다. 다시 말해, 여러분이 실제로 그것을 사용하기 전까지 할당은 실질적으로 일어나지 않습니다. 예를 들면, 블록에 데이터를 쓰기 전까지입니다. 그래서 여러분이 블록을 건드리지 않으면 여러분은 좀 더 많은 공간을 요구하는 것이 가능합니다. 이것을 전문적인 용어로 낙관적 메모리 할당(optimistic memory allocation)이라고 합니다.

두 프로그램에서 /proc//status 을 확인해보면 진상이 드러날 것입니다. 아래에 A 프로그램이 나타나 있습니다:
$ cat /proc//status
VmPeak:  3141876 kB
VmSize:  3141876 kB
VmLck:         0 kB
VmHWM:     12556 kB
VmRSS:     12556 kB
VmData:  3140564 kB
VmStk:        88 kB
VmExe:         4 kB
VmLib:      1204 kB
VmPTE:      3072 kB
다음은 B 프로그램입니다. OOM Killer가 해체하기 바로 전 내용입니다.
$ cat /proc//status 
VmPeak:  1072512 kB
VmSize:  1072512 kB
VmLck:         0 kB
VmHWM:    234636 kB
VmRSS:    204692 kB
VmData:  1071200 kB
VmStk:        88 kB
VmExe:         4 kB
VmLib:      1204 kB
VmPTE:      1064 kB
VmRSS는 추가적인 설명이 필요합니다. RSS는 "Resident Set Size"를 의미합니다. 이것은 태스크에 소유되어 할당된 블록이 현재 RAM에 얼마만큼 존재하는지 알려줍니다. B 프로그램이 OOM에 도달하기 전에 swap 사용이 거의 100%(대부분이 888MB임)입니다. 반면에 A 프로그램은 swap을 전혀 사용하지 않습니다. malloc() 자체는 메모리 영역으로 예약된 영역 이상에 대해서는 아무것도 하지 않는다는 것이 확실합니다.

다른 의문이 또 생깁니다. 페이지를 사용하지도 않았는데, 왜 할당 제한이 3056MB일까요? 이것은 눈에 보이지 않는 제약을 드러냅니다. 32비트 시스템에서는 모든 응용 프로그램에 대해 4GB의 사용가능한 주소 공간이 있습니다. 리눅스 커널은 일반적으로 선형 주소를 분할하여 0에서 3GB는 사용자 공간으로, 3GB에서 4GB는 커널 공간으로 제공합니다. 사용자 공간은 태스크가 원하는 것을 할 수 있는 장소이고, 반면에 커널 공간은 오로지 커널만을 위한 장소입니다. 여러분이 이 3GB 경계를 넘어가려 한다면 세그먼테이션 폴트(segmentation fault)가 발생할 것입니다.
사이드 노트: 문맥교환(context-switching)을 희생시켜 전제 4GB를 사용자 공간으로 부여하는 커널 패치도 있다.
결론은 2가지의 기술적인 이유로 인해 OOM이 발생한다는 것입니다:
  1. VM에서 더 이상 사용가능한 페이지가 없을 때
  2. 사용자 주소 공간이 더 이상 존재하지 않을 때
  3. 1번과 2번 둘 다 일 때
그러므로 이러한 상황을 방지하기 위한 방법으로는:
  1. 사용자 주소 공간의 크기가 얼마나 하는지 알아야 합니다.
  2. 얼마나 많은 페이지가 사용가능한지 알아야 합니다.
여러분이 메모리 블록을 요구할 때, 여러분은 일반적으로 malloc()을 사용하여 미리 할당된 블록이 사용가능한지 런타임 C 라이브러리를 사용하여 알아볼 것입니다. 이 블록의 크기는 최소한 사용자가 요청하는 것과 같아야 합니다. 이미 사용 가능한 메모리 블록이 있다면, malloc()은 사용자에게 이 블록을 할당할 것이고 "사용됨(used)"로 표시할 것입니다. 만약 그렇지 않으면, malloc()은 힙(heap)을 확장함으로써 더 많은 메모리를 할당해야 합니다. 모든 요청된 블록은 힙(heap)이라 불리는 영역으로 들어갑니다. 지역변수와 함수의 리턴 주소를 저장하는 스택과 혼동하지 마시기 바랍니다. 이 두 가지 영역은 서로 다른 역할을 하고 있습니다.

주소 공간에서 힙은 어디에 위치해 있을까요? 프로세스의 주소 맵을 보면 정확히 어디인지 알 수 있습니다:
$ cat /proc/self/maps
0039d000-003b2000 r-xp 00000000 16:41 1080084    /lib/ld-2.3.3.so
003b2000-003b3000 r-xp 00014000 16:41 1080084    /lib/ld-2.3.3.so
003b3000-003b4000 rwxp 00015000 16:41 1080084    /lib/ld-2.3.3.so
003b6000-004cb000 r-xp 00000000 16:41 1080085    /lib/tls/libc-2.3.3.so
004cb000-004cd000 r-xp 00115000 16:41 1080085    /lib/tls/libc-2.3.3.so
004cd000-004cf000 rwxp 00117000 16:41 1080085    /lib/tls/libc-2.3.3.so
004cf000-004d1000 rwxp 004cf000 00:00 0
08048000-0804c000 r-xp 00000000 16:41 130592     /bin/cat
0804c000-0804d000 rwxp 00003000 16:41 130592     /bin/cat
0804d000-0806e000 rwxp 0804d000 00:00 0          [heap]
b7d95000-b7f95000 r-xp 00000000 16:41 2239455    /usr/lib/locale/locale-archive
b7f95000-b7f96000 rwxp b7f95000 00:00 0
b7fa9000-b7faa000 r-xp b7fa9000 00:00 0          [vdso]
bfe96000-bfeab000 rw-p bfe96000 00:00 0          [stack]
이것은 cat에 대한 실제 주소 공간 레이아웃입니다. 하지만 여러분은 아마도 다른 결과를 볼 수 있을지도 모릅니다. 이것은 리눅스 커널과 그것들을 정렬하는 런타임 C 라이브러리에 달려 있습니다. 현재 리눅스 커널 버전(2.6.x) 은 친절하게 메모리 영역에 이름을 붙여놓았지만 그것에 전적으로 의지하지 않기 바랍니다.

힙은 기본적으로 이미 프로그램 매핑과 스택에 주어지지 않은 빈 공간입니다. 그러므로, 그것은 사용 가능한 주소 공간을 좁혀갑니다. 힙은 완전한 3GB가 아닌 3GB에서 매핑이 된 것을 제외한 것입니다. 여러분의 프로그램 코드 세그먼트가 크면 클수록, 힙을 위한 공간은 적어집니다. 여러분의 프로그램에 동적 라이브러리를 더 많이 링크할수록 여러분이 얻을 수 있는 힙 공간은 더 적어집니다. 이것은 기억해야 할 만큼 중요합니다.

더 이상 메모리 블록을 할당할 수 없을 때 프로그램 A에 대한 맵은 어떻게 보이겠습니까? 프로그램이 종료되기 바로 직전에 프로그램을 잠깐 멈추도록(loop.c와 loop-calloc.c를 참고하십시오) 조금만 수정하면 최종 맵은 다음과 같습니다:
0009a000-0039d000 rwxp 0009a000 00:00 0 ---------> (allocated block)
0039d000-003b2000 r-xp 00000000 16:41 1080084    /lib/ld-2.3.3.so
003b2000-003b3000 r-xp 00014000 16:41 1080084    /lib/ld-2.3.3.so
003b3000-003b4000 rwxp 00015000 16:41 1080084    /lib/ld-2.3.3.so
003b6000-004cb000 r-xp 00000000 16:41 1080085    /lib/tls/libc-2.3.3.so
004cb000-004cd000 r-xp 00115000 16:41 1080085    /lib/tls/libc-2.3.3.so
004cd000-004cf000 rwxp 00117000 16:41 1080085    /lib/tls/libc-2.3.3.so
004cf000-004d1000 rwxp 004cf000 00:00 0
005ce000-08048000 rwxp 005ce000 00:00 0 ---------> (allocated block)
08048000-08049000 r-xp 00000000 16:06 1267       /test-program/loop
08049000-0804a000 rwxp 00000000 16:06 1267       /test-program/loop
0806d000-b7f62000 rwxp 0806d000 00:00 0 ---------> (allocated block)
b7f73000-b7f75000 rwxp b7f73000 00:00 0 ---------> (allocated block)
b7f75000-b7f76000 r-xp b7f75000 00:00 0          [vdso]
b7f76000-bf7ee000 rwxp b7f76000 00:00 0 ---------> (allocated block)
bf80d000-bf822000 rw-p bf80d000 00:00 0          [stack]
bf822000-bff29000 rwxp bf822000 00:00 0 ---------> (allocated block)
6개의 가상 메모리 영역(VMA ; Virtual Memory Area )이 메모리 요청을 반영합니다. VMA는 페이지와 동일한 접근권한과(혹은) 동일한 보조파일을 묶는 메모리 영역입니다. VMA는 사용자 공간이 사용가능 한한 사용자 공간 내 어디에도 존재할 수 있습니다.

지금 여러분은 "왜 6개인가? 모든 블록을 포함하는 하나의 큰 VMA로 하면 안 되는가?" 라고 생각할 지도 모릅니다. 거기에는 2가지 이유가 있습니다. 첫 번째는 블록들을 하나의 VMA로 합칠만한 그렇게 큰 "틈(hole)"을 찾는 것이 종종 불가능하기 때문입니다. 두 번째로 프로그램은 대략 3GB 블록을 한번에 할당하는 것을 요청하지 않고 부분적으로 요청합니다. 그러므로 glibc 할당자는 얼마나 많은 것을 원하든지 메모리를 정렬하는데 완전히 자유롭습니다.

왜 필자가 사용 가능한 페이지를 언급하겠습니까? 메모리 할당은 페이지 크기 단위로 발생합니다. 이것은 운영체제의 영역이 아니고 "기억 관리 장치(MMU ; Memory Management Unit)" 자체의 특징입니다. 페이지는 다양한 크기가 가능하지만 x86에서의 일반적인 설정은 4K입니다. 여러분은 getpagesize()나 sysconf()(_SC_PAGESIZE 매개변수 사용) libc 함수를 사용하여 직접 페이지 크기를 알아낼 수 있습니다. libc 할당자는 각각의 페이지를 관리하는데 그것들을 더 작은 블록으로 나누고, 프로세스에 할당하고, 할당 해제시키는 것 등의 작업을 수행합니다. 예를 들어, 실제로는 할당자가 어딘가 4105에서 4109 바이트 사이의 영역을 주더라도 여러분의 프로그램이 전체 4097 바이트를 사용한다면 여러분은 2개의 페이지를 필요로 합니다.

256MB의 RAM에 swap을 하지 않는 경우 여러분은 65536개의 페이지를 사용할 수 있습니다. 맞습니까? 실제로는 그렇지 않습니다. 여러분이 보지 않는 것은 어떤 메모리 영역은 커널 코드와 데이터에 의해 사용 중이라서 다른 요구에 대해서도 사용할 수 없을 것입니다. 또한 긴급상황이나 높은 우선순위에 필요한 예약된 메모리 영역이 있습니다. dmesg는 이러한 수치들을 보여줍니다:
$ dmesg | grep -n kernel
36:Memory: 255716k/262080k available (2083k kernel code, 5772k reserved,
    637k data, 172k init, 0k highmem)
171:Freeing unused kernel memory: 172k freed
init은 단지 초기화 단계에서 필요한 커널 코드와 데이터를 가리킵니다. 그러므로 커널은 그것이 더 이상 필요하지 않을 경우 할당해제 시킵니다. 2083 + 5772 + 637 = 8492KB가 남습니다. 실질적으로 말하면, 2123 페이지가 사용자의 관점에서 보면 없어졌습니다. 여러분이 더 많은 커널 기능을 사용하거나 더 많은 커널 모듈을 삽입한다면 독점적인 커널 사용을 위해 더 많은 페이지를 사용하게 되므로 좀더 신중해야 합니다.

또 다른 커널 내부 데이터 구조는 페이지 캐시(page cache)입니다. 페이지 캐시는 최근에 블록 장치로부터 읽어온 데이터를 채워놓습니다. 캐시를 하면 할 수록 여러분은 실질적으로 더 적은 양의 남은 페이지를 가지게 됩니다만 메모리가 꽉 찼을 때 커널이 그것들을 회수할 것이므로 실제로 점유되어 있는 것은 아닙니다.

커널과 하드웨어 관점에서 보면 아래 사항들을 기억하는 것이 중요합니다:
  1. 할당된 메모리 영역이 물리적으로 인접한다고 보장할 수는 없습니다. 그것은 오직 가상적으로 인접할 뿐입니다. 이러한 "환영(illusion)"은 주소 변환이 이루어지는 방법에 기인합니다. 보호모드 환경에서 하드웨어는 물리적 주소를 가지고 작동하는 데 반해 사용자는 가상 주소를 가지고 작업을 하게 됩니다. 페이지 디렉터리와 페이지 테이블이 이러한 두 주소 사이의 변환을 수행합니다. 예를 들어, 가상주소가 0과 4096으로 시작하는 두 블록은 물리적 주소인 1024와 8192에 맵핑될 수 있습니다.

    이것은 할당을 용이하게 하는데, 왜냐하면 실제로 항상 연속된 블록을 얻기란 매우 일어나기 힘든 일이며, 특히 큰 요청(메가바이트나 심지어 기가바이트의 경우)의 경우 더욱 그러합니다. 커널은 단순히 인접한 남은 블록이 아닌 모든 곳을 요청을 채울 빈 페이지로 인식합니다. 그러나 그것들이 가상적으로 인접하도록 보여지기 위해서는 페이지 테이블을 정렬하는 등의 추가적인 작업을 좀 더 수행할 것입니다. 이렇게 하는 데에는 비용이 수반됩니다. 왜냐하면 메모리 블록이 인접하지 않을 수 있기 때문인데, 가끔 L1과 L2 캐시가 제대로 이용되지 않기도 합니다. 가상적으로 인접한 메모리 블록은 다른 물리적 캐시 선상에 걸쳐 펼쳐질지도 모르는데 이는 (순차적) 메모리 접근 속도의 저하를 의미합니다.

  2. 메모리 할당은 두 단계를 거칩니다. 먼저 메모리 영역의 길이를 확장한 다음 필요할 경우 페이지를 할당합니다. 이것은 페이징을 요구합니다. VMA를 확장하는 동안 커널은 단순히 요청이 기존의 VMA와 겹치는지와 요청 범위가 아직 사용자 공간에 존재하는지를 검사합니다. 기본적으로 실제 할당이 일어날 수 있는가에 대한 검사는 생략합니다.

    따라서 비록 실제로 여러분이 가진 메모리가 16MB의 램과 64MB의 스왑 공간을 가짐에도 불구하고 여러분의 프로그램이 1GB 블록을 요구하여 메모리를 획득하는 것이 그리 이상한 일은 아닙니다. 이러한 "낙관적"인 방식은 모든 이들을 만족시킬 수는 없을 지도 모릅니다. 왜냐하면 여러분이 아직도 사용 가능한 빈 페이지가 남아 있다고 생각하는 그릇된 희망사항을 갖게 될지도 모르기 때문입니다. 리눅스 커널은 이러한 수용 범위를 초과하는(overcommit) 행위를 통제하는 조정가능한 파라미터들을 제공합니다.

  3. 페이지에는 익명 페이지와 파일기반 페이지의 두 가지 형태가 있습니다. 익명 페이지가 여러분이 malloc()을 수행할 때 얻게 되는 것과 같은 것인데 반해, 파일 기반 페이지는 디스크내의 파일을 mmap() 시키는 것으로부터 발생합니다. 익명 페이지는 어떤 파일과도 관계를 맺지 않습니다. RAM이 꽉 차게 되면 커널은 익명 페이지를 스왑 공간으로 스왑 아웃(swap out)시키고 파일기반 페이지를 내보내(flush) 현재 요청에 필요한 공간을 확보해 줍니다. 다시 말해 파일기반 페이지가 스왑 영역을 소비하지 않는 것과는 달리 익명 페이지는 스왑 영역을 소비합니다. 이것의 유일한 예외는 MAP_PRIVATE 플래그를 사용하는 mmap()된 파일들뿐입니다. 이 경우 파일 수정은 RAM내에서만 일어나게 됩니다.

    이것이 RAM을 확장하는 의미에서의 스왑을 이해하는 것의 시작입니다. 확실히 페이지에 접근하는 것은 그것을 RAM으로 되가져오는 것을 필요로 합니다.
할당자(allocator)의 내부 살펴보기

실제 작업들은 glibc 메모리 할당자 내부에서 실질적으로 일어납니다. 할당자는 블록을 응용 프로그램으로 보내주고 (가끔씩) 커널로부터 오는 힙으로부터 블록들을 잘라냅니다(carving).

커널이 일꾼(worker)이라면 할당자는 관리자(manager)입니다. 이 사실을 염두에 두면 최대의 효율성은 커널이 아닌 적절한 할당자에 의해 이루어진다는 사실을 이해하는 것이 쉬워집니다.

glibc는 ptmalloc이라 불리는 할당자를 사용합니다. Wolfram Gloger는 Doug Lea가 만들었던 원형 malloc 라이브러리의 수정 버전으로 glibc을 만들었습니다. 할당자는 할당된 블록들을 "덩어리(chunks)"로 보고 관리합니다. 덩어리는 여러분이 실제로 요청한 메모리 블록을 나타내며 요청의 크기를 나타내는 것은 아닙니다. 사용자 데이터 외에 이 덩어리 내부에는 별도의 헤더가 추가되어 있습니다.

할당자는 두 개의 함수를 사용하여 커널로부터 메모리 덩어리를 가져옵니다:
  • brk()는 프로세스의 데이터 세그먼트가 끝나는 지점을 지정합니다.
  • mmap()는 새로운 VMA를 생성하여 할당자로 전달합니다.
물론 malloc()은 현재 풀 안에 더 이상의 빈 덩어리가 없을 경우에 한해 이 함수들을 사용합니다.

brk()를 사용할 것인지 mmap()를 사용할 것인지에 대한 결정은 하나만 간단히 확인해 보면 됩니다. 요청의 크기가 M_MMAP_THRESHOLD보다 크거나 동일하다면 할당자는 mmap()를 사용합니다. 만약 M_MMAP_THRESHOLD보다 더 작으면 할당자는 brk()를 호출합니다. 기본값으로 M_MMAP_THRESHOLD의 크기는 128KB이나 여러분은 mallopt()를 사용하여 자유로이 그 값을 변경할 수도 있습니다.

OOM 상황에서 ptmalloc이 메모리 블록을 해제하는 방법을 살펴보는 것은 재미있는 일입니다. mmap()로 할당된 블록은 unmap() 호출로 할당 해제되며, 그리고 나서 완전히 해제됩니다. brk()으로 할당된 블록을 해제하는 것은 그것들이 해제되었다고 표시하는 것을 의미하지만 그 블록들은 여전히 할당자의 통제하에 남아있게 됩니다. 그 블록들은 요청의 크기가 빈 덩어리의 크기보다 작거나 같을 경우 다른 malloc() 호출을 채우기 위하여 빈 덩어리로 재할당될 수 있습니다. 할당자는 다수의 빈 덩어리들을 그것들이 인접한 만큼 병합시킬 수 있습니다. 심지어 빈 덩어리를 좀 더 작은 크기의 덩어리로 쪼개어 차후의 작은 크기의 요청으로 채울 수도 있습니다.

이것은 할당자 내의 차후 요청의 크기를 맞출 수 없을 경우 빈 덩어리가 방치될 수도 있음을 의미합니다. 빈 덩어리에 대한 병합 실패 역시 신속한 OOM의 발단이 될 수도 있습니다. 이는 일반적으로 적절한 메모리 단편화에서 부적절한 메모리 단편화로 진행해 나가는 징조입니다.

복구

한번 OOM 상황이 발생하면 이제 어떻게 됩니까? 커널은 한 프로세스를 확실히 종료시킬 것입니다. 왜 프로세스를 제거하는 것입니까? 이것은 그 이상의 메모리 요청을 중지시키는 유일한 방법이기 때문입니다. 커널은 프로세스 내부에 차후 요청을 자동적으로 중지하는 복잡한 메커니즘이 있다고 가정할 수 없기 때문에 그 프로세스를 제거하는 방법 외에는 선택의 여지가 없는 것입니다.

그렇다면 어떻게 커널은 정확히 어느 프로세스를 제거할지 알 수 있습니까? 그 답은 리눅스 소스코드인 mm/oom_kill.c 안에 놓여져 있습니다. 이 파일안의 C 코드는 소위 말해서 리눅스 커널의 OOM 킬러를 나타냅니다. badness() 함수는 각각의 존재하고 있는 프로세스들에 점수를 부여합니다. 가장 높은 점수를 기록한 프로세스가 희생하게 되는데, 그 평가 기준은 아래와 같습니다.
  1. VM 크기. 이 수치는 할당된 모든 페이지의 합산이 아닌 프로세스가 소유하는 모든 VMA 크기의 합입니다. VM의 크기가 클수록 점수는 높아지게 됩니다.
  2. #1과 관련하여 프로세스 자식의 VM의 크기도 중요합니다. VM의 크기는 한 프로세스가 하나 혹은 그 이상의 자식을 가질 경우 누적됩니다.
  3. 작업 우선순위가 0보다 낮은(niced 프로세스) 프로세스의 경우 더 많은 점수를 얻게 됩니다.
  4. 가정상 슈퍼유저 프로세스는 중요한 위치에 있으므로 점수를 감산합니다.
  5. 프로세스 실행시간. 더 오랫동안 실행될수록 점수는 낮습니다.
  6. 직접적인 하드웨어 접근을 수행하는 프로세스는 좀 더 면제됩니다.
  7. 잠재적인 희생 프로세스 리스트로부터 면제된 커널 쓰레드 뿐만 아니라 swapper (pid 0)와 init(pid 1) 프로세스들
가장 높은 점수를 획득한 프로세스가 후보에서 "당첨"되며 OOM 킬러가 그 프로세스를 곧 제거할 것입니다.

휴리스틱은 완벽하지는 않지만 일반적으로 대부분의 상황에서 꽤 효과가 있습니다. 기준 #1과 #2는 중요한 것은 VMA 크기이며 실제 프로세스가 가지는 페이지의 개수가 아님을 명확하게 보여줍니다. 여러분은 VMA 크기를 측정하는 것이 잘못된 경고를 유발할 것이라 생각할지도 모르지만 운좋게도 그렇지는 않습니다. badness() 호출은 남아있는 빈 페이지가 거의 없고 페이지 프레임 교정(page frame reclamation)이 실패할 경우 페이지 할당 함수(page allocation function) 내부에서 발생하게 되며 따라서 VMA의 크기는 프로세스에 의해 점유된 페이지의 개수와 근접하게 맞아떨어집니다.

그렇다면 왜 단순히 실제 페이지 개수를 세지는 않는 것입니까? 그렇게 했을 경우 좀 더 시간을 소모하며 잠금(lock)을 사용해야 할 필요가 있기 때문입니다. 따라서 프로시저의 비용을 너무 높게 만들어 신속한 결정을 할 수 없게 됩니다. OOM 킬러가 완벽하지 않다는 것을 알고 있으므로 여러분은 잘못된 프로세스 제거에 대비해야만 합니다.

커널은 SIGTERM 신호를 사용하여 대상 프로세스가 중지되어야 함을 알려줍니다.

OOM 위험을 줄이는 방법

OOM 위험을 회피하는 한 가지 간단한 규칙은 실제로도 간단한데, "머신의 현재 빈 공간 이상으로 할당하지 말라"는 것입니다. 하지만 실제로는 많은 인자들이 발생하기 때문에 이 방법에 대한 좀 더 구체적인 개선안이 있습니다.

1. 적절히 순서화된 할당(Properly Ordering Allocation)을 통해 단편화를 감소시켜라

복잡한 할당자를 사용할 필요는 없습니다. 여러분은 적절히 순서화된 메모리 할당과 해제로 단편화를 감소시킬 수 있습니다. 틈은 쉽사리 발생할 수 있기 때문에 여러분이 마지막으로 할당한 항목을 여러분이 해제하고자 하는 첫 번째 항목으로 만드는 LIFO 기법을 이용해 보십시오. 예를 들면, 이렇게 하는 대신:
        void *a;
        void *b;
        void *c;
        ............
        a = malloc(1024);
        b = malloc(5678);
        c = malloc(4096);

        ......................

        free(b);
        b = malloc(12345);
이렇게 같이 하는 것이 더 낫습니다:
        a = malloc(1024);
        c = malloc(4096);
        b = malloc(5678);
        ......................

        free(b);
        b = malloc(12345);
이 방법을 이용하면 a와 c 덩어리간에는 아무런 틈이 생기지 않을 것입니다. 또한 여러분은 존재하는 malloc()된 블록의 크기를 조정하기 위하여 realloc()을 고려해 볼 수도 있습니다.

두 예제 프로그램(fragmented1.cfragmented2.c)은 할당 재배치의 효과를 보여줍니다. 두 프로그램 끝의 보고서는 시스템(커널과 glibc 할당자)에 의해 할당된 바이트 수와 실제로 사용된 바이트 수를 보여줍니다. 예를 들어 커널 2.6.11.1과 glibc 2.3.3-27에 명시적으로 파라미터를 아무것도 주지 않고 실행시키면 fragmented2가 2089200 바이트를 (약 2MB)를 소비하는 반면 fragmented1은 319858832 바이트(약 305 MB)를 소비하였습니다. 이것은 152배나 작은 수치입니다!

여러분은 프로그램 파라미터로 다양한 숫자들을 전달하여 그 밖의 실험들을 수행해볼 수 있습니다. 이 파라미터는 malloc() 호출에 대한 요청 크기의 역할을 합니다.

2. 커널의 Overcommit 동작을 조정하라

여러분은 리눅스 커널의 소스코드 안에 Documentation/vm/overcommit-accounting로 문서화되어 있는 것과 같이 /proc 파일시스템을 통해 리눅스 커널의 동작을 변경할 수 있습니다. 커널의 overcommit을 조정할 경우 /proc/sys/vm/overcommit_memory에 숫자로 나타나 있는 3가지 선택사항들이 있습니다:
  • 0은 커널이 이러한 overcommit을 허용할지를 결정할 때 이미 정의되어 있는 휴리스틱 방식을 사용할 것임을 의미하며 이것은 기본값입니다.
  • 1은 항상 overcommit을 수행합니다. 아마 여러분은 이제 이 모드의 위험성을 실감하게 될 것입니다.
  • 2는 overcommit이 특정 워터마크를 초과하는 것을 방지합니다. 이 워터마크 또한 /proc/sys/vm/overcommit_ratio를 통해 조정 가능합니다. 이 모드에서는 전체 commit은 스왑 공간 크기 + overcommit_ratio 비율 * RAM 크기를 초과할 수 없습니다. overcommit 비율의 기본값은 50입니다.
기본 모드는 일반적으로 대부분의 상황에서 거의 작동하지만 모드 #2는 overcommit에 대하여 좀더 나은 보호를 제공해 줍니다. 반면 모드 #2는 여러분이 실행되는 모든 응용 프로그램이 필요로 하는 공간의 양이 얼마인지에 대하여 신중하게 예측하도록 요구합니다. 확실히 여러분은 제한이 너무 엄격하여 여러분의 응용 프로그램이 더 많은 메모리를 얻지 못하는 것을 보고 싶어하지는 않을 것입니다. 그러나 모드 #2는 프로그램이 갑자기 죽는 것을 회피할 수 있는 최선의 방법이기도 합니다.

여러분이 256MB의 램과 256MB의 스왑공간을 가지고 있고 overcommit 제한을 384MB로 설정하고 싶다고 가정해 봅시다. 이는 256 + 50 퍼센트 * 256MB를 의미하며 따라서 /proc/sys/vm/overcommit_ratio에는 50을 입력하면 됩니다.

3. 메모리 누수에 대하여 메모리 할당과 감사 후에 NULL 포인터를 검사하라

이것은 매우 간단한 규칙이나 때때로 생략됩니다. NULL을 검사함으로써 적어도 여러분은 할당자가 비록 차후에 필요로 하는 페이지를 할당할 것에 대한 확실한 보장이 없음에도 불구하고 할당자가 메모리 영역을 확장할 수 있음을 알게 됩니다. 일반적으로 여러분은 계획상 잠시 동안 할당을 회피하거나 지연시킬 필요가 있을 수 있습니다. overcommit을 조정하는 것과 함께 여러분은 OOM을 예상하는 쓸만한 방법을 가지는데malloc()이 나중에 빈 페이지를 획득할 수 없을 것이라 판단하게 되면 NULL을 리턴하는 것 때문입니다.

메모리 누수 또한 불필요한 메모리 소비의 원인입니다. 누수된 메모리 블록은 응용 프로그램이 더 이상 찾지 않는 것인데도 불구하고 커널이 회수하지 않는 것을 말합니다. 왜냐하면 커널의 입장에서 보면 태스크가 여전히 커널의 통제하에 있기 때문입니다. Valgrind는 여러분의 코드에서 재코드화할 필요 없이 이러한 메모리 누수 발생을 찾아내는 멋진 도구입니다.

4. 항상 메모리 할당 통계를 참고하라

리눅스 커널은 메모리 상태에 관한 완전한 정보를 알아보기 위한 방법으로 /proc/meminfo를 제공합니다. 또한 /proc 항목은 top, free, vmstat와 같은 유틸리티를 위한 정보의 원천이기도 합니다.

여러분이 확인해 보아야 할 것은 비어있는(free) 메모리와 회수가능한(reclaimable) 메모리입니다. "비어있는"이라는 말은 더 이상 설명할 필요가 없지만 "회수가능한"은 무엇을 의미하는 것입니까? 이것은 버퍼와 페이지 캐시(디스크 캐시)를 가리킵니다. 그것들은 회수가능한데 왜냐하면 메모리가 꽉 찼을 경우 리눅스 커널은 단순히 그것들을 디스크로 도로 내보낼(flush) 수 있기 때문입니다. 이러한 것들이 바로 파일기반 페이지입니다. 필자는 이러한 메모리 통계의 예를 살짝 편집해 보았습니다:
   $ cat /proc/meminfo
   MemTotal:       255944 kB
   MemFree:          3668 kB
   Buffers:         13640 kB
   Cached:         171788 kB
   SwapCached:          0 kB
   HighTotal:           0 kB
   HighFree:            0 kB
   LowTotal:       255944 kB
   LowFree:          3668 kB
   SwapTotal:      909676 kB
   SwapFree:       909676 kB
위 출력물에 근거하면, 비어있는 가상 메모리는 MemFree + Buffers + Cached + SwapFree = 1098772kB가 됩니다.

필자는 빈 메모리 공간(회수가능한 것을 포함한)을 알아내는 정형화된 C(glibc) 함수를 찾지 못했습니다. 필자가 찾은 것 중 가장 근접한 것은 get_avphys_pages()이나 sysconf() (_SC_AVPHYS_PAGES 파라미터와 함께 사용)를 사용하여 알아낼 수 있는 것들입니다. 그것들은 단순히 빈 메모리의 양을 보여주며 빈 메모리 양과 회수가능한 메모리 양의 합을 보여주지는 않습니다.

이것은 정확한 정보를 얻기 위해서는 여러분이 직접 /proc/meminfo을 프로그램적으로 읽어들여 계산해야 한다는 것을 의미합니다. 만약 여러분이 게으르다면 그것을 어떻게 하는지에 대한 참고자료로 procps 소스 패키지를 사용하십시오. 이 패키지는 ps, top, free와 같은 도구들을 포함하고 있으며 GPL 라이선스하에서 사용 가능합니다.

5. 대체 메모리 할당자를 실험해 보라

서로 다른 할당자들은 메모리 덩어리들을 관리하고 가상 메모리 영역을 줄이고, 확장하고, 생성하는 데 있어 각기 다른 방법을 취합니다. Hoard가 한 예인데, University of Massachusetts의 Emery Berger는 이것을 고성능 메모리 할당자로서 제작하였습니다. Hoard는 멀티 쓰레드 응용 프로그램에서 가장 잘 작동할 것으로 보이며 per-CPU 힙의 개념을 도입하였습니다.

6. 64비트 플랫폼을 이용하라

대량의 주소공간을 필요로 하는 사용자는 64비트 플랫폼 사용을 고려해 볼 수도 있습니다. 리눅스 커널은 이러한 머신에서는 더 이상 3:1 VM 분할을 사용하지 않습니다. 다시 말해, 사용자 공간이 상당히 커진 것입니다. 이것은 4GB 이상의 램을 가진 머신과 잘 맞을 것입니다.

이것은 인텔 32비트 프로세서가 64GB크기의 램까지 주소를 부여할 수 있도록 하는 인텔 PAE(Physical Address Extension)와 같은 확장된 주소 스키마로의 연결이 없습니다. 이 주소체계는 물리 주소를 다루는 데, 가상 주소 상황에서도 사용자 공간 자체는 여전히 3GB(3:1 VM 분할을 가정하여)입니다. 이러한 여분 메모리는 도달가능하나 주소 공간으로 모두 맵핑가능한 것은 아닙니다. RAM의 맵핑불가능한 부분은 사용할 수 없습니다.

7. 구조체의 패킹된 타입을 고려하라

패킹된 속성은 구조체, 열거형, 유니온의 크기를 줄이는데 도움을 줄 수 있습니다. 이것은 구조체 배열의 경우 좀 더 바이트를 절약할 수 있는 방법입니다. 아래에 구조체 선언의 예가 나타나 있습니다:
struct test
   {
        char a;
        long b;
   } __attribute__ ((packed));
이렇게 하는 것을 반대하는 이유는 이렇게 하면 특정 필드를 정렬시키지 않게 되며 따라서 그 필드에 접근하고자 할 때 CPU 사이클을 더 사용하게 되기 때문입니다. 여기서 "정렬된(aligned)"이라는 것은 변수의 주소가 그 변수의 자료형 원래 크기의 배수라는 것을 의미합니다. 이 방법의 최종 결론은 데이터 접근 빈도(data access frequency)에 따라 실행시간은 상대적으로 느려질 수 있다는 것이나 페이지 정렬(page alignment)과 캐시 결합성(cache coherence)도 참작하십시오.

8. 사용자 프로세스에 ulimit()을 사용하라

ulimit -v으로 여러분은 프로세스가 mmap()로 할당할 수 있는 주소 공간을 제한할 수 있습니다. 한계에 도달하게 되면 모든 mmap(), 그리고 그러므로 malloc()의 호출은 0을 리턴할 것이며 커널의 OOM 킬러는 절대로 시작되지 않을 것입니다. 이는 여러분이 다른 모든 사용자를 신뢰할 수 없고 임의의 프로세스를 제거하는 것을 피하고자 하는 다중 사용자 환경에서 가장 유용합니다.

감사의 글

필자에게 도움을 준 몇몇 이들에게 공로를 돌립니다: Peter Ziljtra, Wolfram Gloger, Rene Hermant. Mr. Gloger도 마찬가지로 ulimit() 기법에 공헌을 해주셨습니다.

참고자료
  1. "Dynamic Storage Allocation: A Survey and Critical Review," by Paul R. Wilson, Mark S. Johnstone, Michael Neely, and David Boles. Proceeding 1995 International Workshop of Memory Management.
  2. Hoard: A Scalable Memory Allocator for Multithreaded Applications, by Emery D. Berger, Kathryn S. McKinley, Robert D. Blumofe, and Paul R. Wilson
  3. "Once upon a free()" by Anonymous, Phrack Volume 0x0b, Issue 0x39, Phile #0x09 of 0x12.
  4. "Vudo: An Object Superstitiously Believed to Embody Magical Powers," by Michel "MaXX" Kaempf. Phrack Volume 0x0b, Issue 0x39, Phile #0x08 of 0x12.
  5. "Policy-Based Memory Allocation," by Andrei Alexandrescu and Emery Berger. C/C++ Users Journal.
  6. "Security of memory allocators for C and C++," by Yves Younan, Wouter Joosen, Frank Piessens, and Hans Van den Eynden. Report CW419, July 2005
  7. Lecture notes (CS360) about malloc(), by Jim Plank, Dept. of Computer Science, University of Tennessee.
  8. "Inside Memory Management: The Choices, Tradeoffs, and Implementations of Dynamic Allocation," by Jonathan Bartlett
  9. "The Malloc Maleficarum," by Phantasmal Phantasmagoria
  10. Understanding The Linux Kernel, 3rd edition, by Daniel P. Bovet and Marco Cesati. O"Reilly Media, Inc.
Mulyadi Santosa 인도네시아에 살고 있는 자유기고가입니다.
TAG :
댓글 입력
자료실

최근 본 상품0