tech

PHP 개발자를 위한 Zend Memory Manager 심화 분석

이 글은 PHP 내부의 메모리 할당/관리 방식을 담당하는 Zend Memory Manager(ZMM)를 다룬다. opcache, Zend VM, GC 전반은 범위를 벗어나며, 특히 emalloc/efree 중심의 ZMM 경로를 소스 코드 레벨에서 확인하고, GDB로 실제 동작을 검증한다.

주의 및 준비

  • 기준 버전: PHP 8.4.10. 다른 버전은 일부 구현이 다를 수 있다.
  • 가비지 콜렉터의 동작 원리는 다루지 않는다.
  • 실습을 권장한다. 아래처럼 소스를 직접 받아 컴파일한다.
git clone --depth 1 --branch php-8.4.10 https://github.com/php/php-src.git

cd php-src
./buildconf --force
./configure --enable-debug --enable-fpm --disable-cgi --with-openssl --enable-phpdbg --enable-phpdbg-debug --enable-opcache
./config.nice 
make -j $(nproc)
make test
sudo make install

php -v

위 명령어의 결과가 php-8.4.10으로 나오면 성공이다. 위 과정에서 필요한 의존성 패키지는 실습자의 환경에 맞게 추가 설치가 필요하다.

기본 정보: Heap, Chunk, Page, Bin

이번 글에서 소개할 내용은 크게 초기화, 할당, 해제로 나눌 수 있다. 또 할당은 3KB 미만의 작은(small) 할당, 2MB 이하의 큰(large) 할당, 2MB 이상의 거대(huge) 할당으로 나눌 수 있다. 이러한 할당들은 하나의 전역변수인 zend_mm_heap 구조체에서 관리된다.

/* zend_alloc.c */
// 설명에 필요하지 않는 부분은 제거했습니다.
struct _zend_mm_heap {
    // 3KB 이하의 해제된 메모리 공간을 링크드 리스트 형태로 가리킵니다. 
	zend_mm_free_slot *free_slot[ZEND_MM_BINS]; /* free lists for small sizes */

	zend_mm_huge_list *huge_list;               /* list of huge allocated blocks */

	zend_mm_chunk     *main_chunk;
	zend_mm_chunk     *cached_chunks;			/* list of unused chunks */
	int                chunks_count;			/* number of allocated chunks */
	int                peak_chunks_count;		/* peak number of allocated chunks for current request */
	int                cached_chunks_count;		/* number of cached chunks */
	double             avg_chunks_count;		/* average number of chunks allocated per request */
	int                last_chunks_delete_boundary; /* number of chunks after last deletion */
	int                last_chunks_delete_count;    /* number of deletion over the last boundary */
};

heap은 여러 개의 2MB 크기의 청크를 관리한다. 중요한 필드는 free_slot이다. free_slot은 3KB 이하의 페이지를 할당한 후 해당 page를 bin의 size만큼 분할한 후 free_slot에서 링크드 리스트(linked list) 형태로 관리한다. 예를 들어 56바이트를 요청하면 먼저 4KB의 page를 할당한다. 4KB에서 56바이트만 필요한 것이기에 4KB를 56바이트로 분할시켜 73개의 slot을 만들고 하나를 할당시킨다. 나머지 72개는 free_slot에 넣어 다음 할당 요청 때 바로 할당할 수 있게 관리한다. 이러한 free_slot의 개수는 30개이다.

/* zend_alloc.c */
struct _zend_mm_chunk {
	zend_mm_heap      *heap;
	zend_mm_chunk     *next;
	zend_mm_chunk     *prev;
    // 현재 청크에서 해제된(미할당) 페이지 개수
	uint32_t           free_pages;				/* number of free pages */
    // 청크의 끝에서 연속된 free page가 시작되는 위치 
	uint32_t           free_tail;               /* number of continuous free pages at the end of chunk */
	uint32_t           num;
	zend_mm_heap       heap_slot;               /* used only in main chunk */
    // chunk가 관리하는 page의 할당 여부를 비트 단위로 저장합니다. 
	zend_mm_page_map   free_map;                /* 512 bits or 64 bytes */
    // chunk가 관리하는 page의 정보를 기록합니다. (작은 할당, 큰 할당, ...)
	zend_mm_page_info  map[ZEND_MM_PAGES];      /* 2 KB = 512 * 4 */
};

하나의 2MB 청크는 512개의 Page(4KB)로 구성되며 각 페이지의 할당 여부 체크, 할당 타입 등의 정보를 저장하고 다음 청크와 연결 리스트 형태로 관리된다. 여기까지 내용을 정리하자면 하나의 heap이 존재하고 heap은 2MB 크기의 여러 chunk를 연결 리스트 형태로 관리하며, 하나의 청크는 512개의 4KB 크기의 페이지(Page)를 관리하고 3KB 이하의 메모리 할당의 경우 Page를 작은 크기로 분할하여 heap의 free_slot에 넣고 관리한다. 청크가 관리하는 512개의 Page 중 첫 번째 Page는 Chunk의 메타데이터를 관리하는 용도로 쓰인다.

청크(Chunk)에서 가장 중요한 필드는 free_map, map, free_tail이다. page는 512개지만 free_map의 크기는 64바이트다. 기록의 단위가 바이트라면 64개만 기록할 수 있지만 비트(bit) 단위로 기록한다면 최대 512개의 기록을 저장할 수 있다. 추후 비트연산으로 인하여 free_map에서 기록의 단위는 비트 단위이며 비트가 1로 세팅되면 해당 위치의 page는 사용 중, 0이면 page는 미사용 중임을 인지해야 한다.

free_tail은 청크의 끝에서 해제된(free) 페이지(page)가 시작되는 지점을 의미한다. 표현이 모호할 수 있기 때문에 직접 예시를 들자면 아래와 같다.

이럴 경우 free_tail의 값은 6이다.

초기화: ZMM 라이프사이클

우선 PHP 모듈의 Lifecycle부터 확인할 필요가 있다.

PHP의 MINIT – MSHUTDOWN이라는 큰 패턴 안에 RINIT – RSHUTDOWN의 작은 패턴이 존재한다. zend_startup -> start_memory_manager 함수를 호출하여 Zend Memory Allocator를 초기화한다. 초기화된 Zend Memory Allocator는 전역변수인 alloc_globals에 저장한다.

typedef struct _zend_alloc_globals {
	zend_mm_heap *mm_heap;
} zend_alloc_globals;

# define AG(v) (alloc_globals.v)
static zend_alloc_globals alloc_globals;
[...]
ZEND_API void start_memory_manager(void)
{
    alloc_globals_ctor(&alloc_globals);
}

static void alloc_globals_ctor(zend_alloc_globals *alloc_globals)
{
[...]
	alloc_globals->mm_heap = zend_mm_init();
}

zend_mm_init 함수를 통해 전역변수인 heap을 초기화한다.

그럼 위의 구조가 형성된다. 첫 번째 페이지(page)에 ZEND_MM_LRUN이라는 값을 대입했다. 이는 할당된 주소가 어떤 타입을 가지는지 저장한다. ZEND_MM_LRUN은 다음에 언급할 3KB < SIZE < 2MB 구간의 경우를 의미하고 ZEND_MM_SRUN 16 <= size <= 3072 구간을 의미한다. 마지막으로 ZEND_MM_FRUN은 현재 메모리 주소가 해제된 상태임을 의미한다.

할당 부분을 넘어가기 전에 이전에 아래 정의 구간을 언급한 적 있다. 이는 16 <= size <= 3072 사이에는 무수히 많은 값이 존재하는데 해당 값 전부를 관리하게 되면 오히려 성능상의 이슈가 발생하기 때문에 해당 구간을 30개 즉, ZEND_MM_BINS만큼 나눠서 관리한다. 이를 bins라고 한다. 대표적인 사이즈 표는 아래와 같다.

zend_mm_free_slot *free_slot[ZEND_MM_BINS];

위 값은 각각 요청 size 범위 – bin number를 의미한다. 예를 들어 요청한 size가 17 <= size <= 24인 경우 bin number가 2임을 의미한다. 이런 식으로 30개의 bin을 관리한다. 또한 0은 제외한다.

추가적으로 각 bin_number는 대표하는 size가 존재하는데 아래 표와 같다.

bin_data_size는 주어진 bin_number가 대표하는 size가 몇인지 관리하는 테이블이다. bin_pages는 주어진 bin_number가 몇 개의 page를 필요로 하는지 관리하는 테이블이다. bin_elements는 주어진 bin_number가 대표하는 size 기반으로 4KB 페이지(page)를 얼마만큼 분할해야 할지 결정하는 테이블이다. 예를 들어 사용자가 56바이트를 요청했다면 bin_number는 6이며 4KB PAGE를 73개로 분할할 수 있다. bin_elements[bin_number:6] -> 73이다.

사용자가 56 사이즈의 메모리를 할당 요청했을 때 56이면 어떤 bin_number인지 확인하고 해당 free_slot을 살펴보고 메모리가 있다면 반환하는 형식이다. 반환 시 링크드 리스트가 서로 다시 연결될 수 있도록 구조화되어 있다.

할당 경로 개요

지금까지의 이야기는 zend memory manager의 구조화된 데이터들과 기본적인 흐름을 설명했다. 다음으로 할당 과정을 조금 더 자세히 살펴본다.

libc에서 제공되는 메모리 할당과 해제 API는 malloc과 free이지만 zend memory manager에서 제공하는 API는 emalloc, efree이다. 자세히 살펴보도록 한다.

작은 할당

사용자가 emalloc 함수를 호출하여 메모리 할당을 요청하면 zend_mm_alloc_heap 함수를 호출한다.

void *zend_mm_alloc_heap(zend_mm_heap *heap, size_t size)
{
	if (size <= ZEND_MM_MAX_SMALL_SIZE) {
		ptr = zend_mm_alloc_small(heap, ZEND_MM_SMALL_SIZE_TO_BIN(size));
		return ptr;
	} 
    
    else if (size <= ZEND_MM_MAX_LARGE_SIZE) {
		ptr = zend_mm_alloc_large(heap, size);
		return ptr;
	} 
    
    else {
		return zend_mm_alloc_huge(heap, size);
	}
}

해당 함수에서 요청 size를 확인한 후에 3KB 이하라면 zend_mm_alloc_small 함수를 호출한다. ZEND_MM_SMALL_SIZE_TO_BIN 매크로 함수를 통해 이전에 말한 size를 align해서 전달한다. 이때 align은 요청된 바이트가 55바이트일 때 56바이트로 변환을 의미한다.

void *zend_mm_alloc_small(zend_mm_heap *heap, int bin_num)
{
	if (heap->free_slot[bin_num] != NULL) {
		zend_mm_free_slot *p = heap->free_slot[bin_num];
		heap->free_slot[bin_num] = zend_mm_get_next_free_slot(heap, bin_num, p);
		return p;
	} 
    
    else {
		return zend_mm_alloc_small_slow(heap, bin_num);
	}
}

free_slot을 살펴보고 해당 bin_number에 해당하는 메모리 주소가 존재한다면 바로 반환한다. 만약 없다면 zend_mm_alloc_small_slow 함수를 호출한다.

해당 함수는 zend_mm_alloc_pages 함수를 호출하여 하나의 4KB 페이지(page)를 할당한다. 그리고 ZEND_MM_SRUN 값을 chunk->map[page_num]에 저장한다. (map은 페이지의 메타데이터를 저장하는 필드이다.) 요청한 size는 56바이트이므로 4KB는 73개로 분할될 수 있다. (이전에 말한 bin_elements이다.) 분할된 조각들은 free_slot에 링크드 리스트(linked list) 형태로 저장한다. 사용자의 다음 56바이트의 요청은 free_slot에서 반환된다.

ZEND_MM_SRUN, ZEND_MM_LRUN같이 chunk->map에 저장하는 매크로는 해당 페이지의 service information을 4바이트 형태로 저장하는 용도이다.

# 2 bits
FUN     (free pages)
LRUN    (first page or large allocation)
SRUN    (first page or small allocation)

# 10 bits
lrun_pages  (allocated pages의 개수)

# 5 bits
srun_bin_num    (bin number)

내부적으로 zend_mm_alloc_pages 함수를 호출하여 실질적으로 필요한 페이지(4KB page)를 할당하는 로직을 수행한다. 해당 함수는 청크의 free_map의 필드를 확인하면서 필요한 pages_count와 비교하면서 best-fit 구조로 알맞은 공간을 찾아낸다.

(best-fit의 개념은 다음 위키피디아 참조 https://en.wikipedia.org/wiki/Best-first_search)

기본적으로 하나의 chunk는 512개의 page를 소유하고 있으며 free_map은 64바이트로 비트연산으로 512개의 page의 할당 여부를 확인한다. (1은 할당, 0은 미할당) 다만 그림으로 512개를 모두 표현하기에는 다소 무리가 있기에 아래 가정을 한다. 다만 할당 알고리즘은 달라지지 않는다.

  1. 하나의 청크는 24개의 page를 관리한다.
  2. free_map은 3바이트로 24개의 page의 할당 여부를 관리한다.
  3. 3개의 page를 필요로 하는 할당 요청이 들어온 상태이다.
  4. 현재 chunk의 page 사용량은 다음과 같다.

그리고 다음 zend_mm_alloc_pages 함수의 내부 구현한다. 다만 내용이 다소 복잡하기 때문에 확인 전에 이해에 필요한 개념이나 변수를 안내한다.

# 필요한 개념  변수 안내
# ex) 블록 구조가 001100 001110  free_tail은 10입니다. 
free_tail :: 청크의 끝에서 free pages가 시작되는 위치입니다. 

pages_count :: 요청한 page 개수

# 이는 필요 page count가 3  가장 최적의 길이는 3 찾습니다. 없다면  다음 사이즈가 됩니다.
best_len :: 최적의 길이

조금 복잡한 알고리즘이지만 요약하면 free_map을 보고 요청된 pages_count에 최적의 공간을 찾아서 청크 내 최적의 page 위치(넘버)를 반환한다고 생각하면 된다.

그럼 위에서 가정한 부분을 가지고 어떤 로직을 타는지 확인한다.

위 사진은 위에서 가정한 예시를 기반으로 커버리지되지 않는 부분은 제거했다.

위의 로직을 거치고 나면 A Chunk의 내부 page 할당 여부는 위와 같이 변경된다.

큰 할당

똑같이 위 로직을 따르지만 small bins와 달리 free_slot에 저장하지 않고 주어진 size를 page 크기만큼 나눠서 필요한 page_count를 지정한다. 그리고 zend_mm_alloc_pages 함수를 호출하여 페이지를 할당한다.

해제 경로

메모리 해제는 efree -> zend_mm_free_heap 함수를 호출하면서 이루어진다.

작은 할당 사이즈의 경우 free_slot에 해제 주소를 삽입하고 linked list의 가장 앞에 설정한다. large size의 경우 해제 page의 free_map에 할당 해제 설정을 진행한다. 또한 청크(chunk) 해제 가능하면 해당 청크를 unmap(2) API를 호출하여 해제한다. 지금까지 할당과 해제 내용을 살펴보았고 다음은 위 내용이 실제로 맞는 내용인지 검증하는 내용이다.

테스트 및 디버깅

테스트는 먼저 확장 모듈을 기반으로 직접 디버깅을 진행한다.

cd php-src/ext
# example php ext_skel.php --ext heap
php ext_skel.php --ext [확장모듈 이름] 

원하는 확장 모듈 이름을 넣고 실행하면 (예를 들어 heap) heap이라는 이름의 폴더가 생기게 된다. 사용자는 다음 파일을 수정하면 된다.

디버깅 시 gdb라는 도구를 사용하며 확장 플러그인 사용자의 편의에 맞게 다양한 플러그인 설치가 가능하다.

  1. heap.c # source code here!
  2. heap_arginfo.h
  3. heap_stub.php

확장 모듈 컴파일하기

cd php-src/ext/heap
./configure
make

확장 모듈 테스트하기

확장 모듈을 만들면 기본적으로 test1 함수가 있다. 그대로 사용한다.

/* heap.c */

// for pending breakpoint
void helloworld(){
    return;
}

PHP_FUNCTION(test1)
{
	ZEND_PARSE_PARAMETERS_NONE();
    helloworld();
	int *a = emalloc(1750);
    int *b = emalloc(1750);

    efree(a);
    efree(b);
}

1750 바이트를 2번 할당하고 해제하는 코드이다. 먼저 예상되는 결과로는 첫 번째 할당 시 이전에 할당된 적이 없는 크기이므로 zend_mm_alloc_pages 함수가 호출되어 페이지(pages)를 할당한다. 그리고 3KB 이하의 크기인 작은(small) 할당이기에 bin number가 26인 free_slot에 할당한 page를 분할하여 저장할 것이다.

phpize
make
make test

<?php
test1();
?>

helloworld 함수를 추가한 이유는 해당 구간에 breakpoint를 걸어서 디버깅의 편의성을 추가하기 위해서이다.

gdb php
pwndbg> set break pending on
pwndbg> b helloworld
Function "helloworld" not defined.
Breakpoint 1 (helloworld) pending.

pwndbg> run test.php

helloworld 함수는 모듈에 정의되어 있으므로 이전에 본 PHP Lifecycle에 따르면 아직 로드되지 않은 함수이다. 그렇기에 pending break를 설정하고 실행한다. 이는 언젠가 발견되면 break 걸 수 있도록 하는 옵션이다.

In file: /home/tuuna/php-src/ext/heap/heap.c:28
   23 /* {{{ void test1() */
   24 PHP_FUNCTION(test1)
   25 {
   26         ZEND_PARSE_PARAMETERS_NONE();
   27         helloworld();
28         int *a = emalloc(1750);
   29         int *b = emalloc(1750);
   30
   31         efree(a);
   32         efree(b);
   33 }

pwndbg> p alloc_globals->mm_heap->free_slot[26]
$4 = (zend_mm_free_slot *) 0x0

현재 bin number가 26인 free_slot은 아직 비어져 있는 상태이다.

pwndbg> p alloc_globals->mm_heap->main_chunk->free_map
$8 = {18446744073709551615, 2305843009213693951, 65472, 0, 0, 0, 0, 0}

free_map을 살펴보면 위와 같다. 이를 이진수로 표현하면 다음과 같다.

1111111111111111111111111111111111111111111111111111111111111111
0001111111111111111111111111111111111111111111111111111111111111
0000000000000000000000000000000000000000000000001111111111000000
0000000000000000000000000000000000000000000000000000000000000000

0000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000

zend_mm_alloc_pages 함수를 호출하면 best-fit 할당 방식을 사용하여 위에서 2번째 줄 000부터 3번째 줄 0000까지 사용할 것임을 예측할 수 있다. (7개를 사용할 것이라고 예상한 이유는 bin_pages를 보면 bin_number가 26일 때 page_count가 7개로 설정되어 있는 것을 볼 수 있다.)

pwndbg> n # 진행
pwndbg> p alloc_globals->mm_heap->main_chunk->free_map
$9 = {18446744073709551615, 18446744073709551615, 65487, 0, 0, 0, 0, 0}

1111111111111111111111111111111111111111111111111111111111111111
(111)1111111111111111111111111111111111111111111111111111111111111
000000000000000000000000000000000000000000000000111111111100(1111)
0000000000000000000000000000000000000000000000000000000000000000

0000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000

예상된 값으로 바뀐 것을 볼 수 있다. 다음은 free_slot을 살펴본다.

pwndbg> p alloc_globals->mm_heap->free_slot[26]
$10 = (zend_mm_free_slot *) 0xfffff4e7d700

위 주소로 설정되어 있음을 확인할 수 있다. 해당 주소가 어떻게 연결되어 있는지 확인해본다.

pwndbg> set $p = (void **)0xfffff4e7d700
pwndbg> while ($p != 0)
 >printf "ptr = %p\n", $p
 >set $p = *(void **)$p
 >end
ptr = 0xfffff4e7d700
ptr = 0xfffff4e7de00
ptr = 0xfffff4e7e500
ptr = 0xfffff4e7ec00
ptr = 0xfffff4e7f300
ptr = 0xfffff4e7fa00
ptr = 0xfffff4e80100
ptr = 0xfffff4e80800
ptr = 0xfffff4e80f00
ptr = 0xfffff4e81600
ptr = 0xfffff4e81d00
ptr = 0xfffff4e82400
ptr = 0xfffff4e82b00
ptr = 0xfffff4e83200
ptr = 0xfffff4e83900

위 스크립트를 통해 해당 주소에 연결된 next_free_slot을 호출하는데 NULL일 때까지 출력한다. 가장 앞 주소인 0xfffff4e7d700이 다음 할당에 쓰일 것으로 예상된다. 한번 확인해보자.

pwndbg> n
pwndbg> p b
$11 = (int *) 0xfffff4e7d700

그럼 다음과 같이 free_slot이 변경된다.

pwndbg> set $p = (void **)0xfffff4e7de00
pwndbg> while ($p != 0)
 >printf "ptr = %p\n", $p
 >set $p = *(void **)$p
 >end
ptr = 0xfffff4e7de00
ptr = 0xfffff4e7e500
ptr = 0xfffff4e7ec00
ptr = 0xfffff4e7f300
ptr = 0xfffff4e7fa00
ptr = 0xfffff4e80100
ptr = 0xfffff4e80800
ptr = 0xfffff4e80f00
ptr = 0xfffff4e81600
ptr = 0xfffff4e81d00
ptr = 0xfffff4e82400
ptr = 0xfffff4e82b00
ptr = 0xfffff4e83200
ptr = 0xfffff4e83900

여기까지가 앞서 설명한 내용을 기반으로 실제 확장 모듈을 디버깅하여 확인하는 과정이다.

요약

PHP의 메모리를 관리하는 Zend Memory Allocator는 하나의 Heap Manager와 2MB 크기의 Chunk, 각 청크는 512개의 4KB의 Pages를 관리한다. 이때 청크의 첫 번째 Page는 청크의 헤더 즉, 메타데이터를 표시하는데 사용된다. 그리고 청크의 page를 관리하기 위해서는 64바이트 크기의 free_map을 비트연산을 통해 관리한다. map은 각 페이지가 어떤 할당 또는 해제로 이루어졌는지 표시하며 SRUN, LRUN, FRUN이 존재한다. 사용자는 emalloc API를 사용해서 할당 요청이 오면 사이즈를 확인하고 3KB 이하라면 small bin으로 판단하고 heap에서 관리 중인 해제 링크드 리스트인 free_slot에서 이전에 할당 해제된 페이지(주소)를 반환한다. 그 외의 경우(2MB 이하) page 크기만큼 나눠 필요한 page 개수만큼 구한 뒤 best-fit 알고리즘에 따라 최적의 공간을 찾아내고 할당한다. 없다면 새로운 chunk를 할당받는다. 만약 사용자가 이전에 정한 최대 메모리 값을 넘어가게 된다면 할당 불가능 오류 메시지를 내뱉게 된다.

마무리

PHP 언어에서 객체를 할당하게 되면 이는 사용자의 관리 책임에서 벗어나 가비지 콜렉터의 영역으로 넘어가게 되기에 개발자의 관리 책임에서 벗어나게 된다. 하지만 C 언어로 구성된 확장 모듈(pdo, opcache, phpredis … )을 개발하다 보면 앞서 말한 emalloc, efree 등의 Zend Memory Manager의 API를 사용할 때가 온다. 물론 어느 정도 모두 아는 내용이겠지만 아는 내용이 실제로 맞는지 소스코드 또는 디버깅 레벨에서 확인하여 이해하는 영역 또한 필자는 중요하다고 생각했다.

아쉽게도 핵심 영역인 가비지 콜렉터를 다루지 않기 때문에 내용이 조금 지루할 수 있지만 가비지 콜렉터의 기반이기 때문에 아는 것도 중요하다.

김수창 기자

소스코드 또는 디버깅 레벨에서의 분석은 우리가 알고 있던 지식에 자신감을 심어준다. 이 글이 PHP의 zend memory manager가 어떻게 동작하는지 이해하는데 도움이 되었으면 한다.


TOP