이 글은 PHP 내부의 메모리 할당/관리 방식을 담당하는 Zend Memory Manager(ZMM)를 다룬다. opcache, Zend VM, GC 전반은 범위를 벗어나며, 특히 emalloc/efree 중심의 ZMM 경로를 소스 코드 레벨에서 확인하고, GDB로 실제 동작을 검증한다.
위 명령어의 결과가 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 */intchunks_count; /* number of allocated chunks */intpeak_chunks_count; /* peak number of allocated chunks for current request */intcached_chunks_count; /* number of cached chunks */doubleavg_chunks_count; /* average number of chunks allocated per request */intlast_chunks_delete_boundary; /* number of chunks after last deletion */intlast_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_tfree_pages; /* number of free pages */// 청크의 끝에서 연속된 free page가 시작되는 위치 uint32_tfree_tail; /* number of continuous free pages at the end of chunk */uint32_tnum;zend_mm_heapheap_slot; /* used only in main chunk */// chunk가 관리하는 page의 할당 여부를 비트 단위로 저장합니다. zend_mm_page_mapfree_map; /* 512 bits or 64 bytes */// chunk가 관리하는 page의 정보를 기록합니다. (작은 할당, 큰 할당, ...)zend_mm_page_infomap[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에 저장한다.
그럼 위의 구조가 형성된다. 첫 번째 페이지(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 함수를 호출한다.
해당 함수에서 요청 size를 확인한 후에 3KB 이하라면 zend_mm_alloc_small 함수를 호출한다. ZEND_MM_SMALL_SIZE_TO_BIN 매크로 함수를 통해 이전에 말한 size를 align해서 전달한다. 이때 align은 요청된 바이트가 55바이트일 때 56바이트로 변환을 의미한다.
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바이트 형태로 저장하는 용도이다.
내부적으로 zend_mm_alloc_pages 함수를 호출하여 실질적으로 필요한 페이지(4KB page)를 할당하는 로직을 수행한다. 해당 함수는 청크의 free_map의 필드를 확인하면서 필요한 pages_count와 비교하면서 best-fit 구조로 알맞은 공간을 찾아낸다.
기본적으로 하나의 chunk는 512개의 page를 소유하고 있으며 free_map은 64바이트로 비트연산으로 512개의 page의 할당 여부를 확인한다. (1은 할당, 0은 미할당) 다만 그림으로 512개를 모두 표현하기에는 다소 무리가 있기에 아래 가정을 한다. 다만 할당 알고리즘은 달라지지 않는다.
하나의 청크는 24개의 page를 관리한다.
free_map은 3바이트로 24개의 page의 할당 여부를 관리한다.
3개의 page를 필요로 하는 할당 요청이 들어온 상태이다.
현재 chunk의 page 사용량은 다음과 같다.
그리고 다음 zend_mm_alloc_pages 함수의 내부 구현한다. 다만 내용이 다소 복잡하기 때문에 확인 전에 이해에 필요한 개념이나 변수를 안내한다.
조금 복잡한 알고리즘이지만 요약하면 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를 호출하여 해제한다. 지금까지 할당과 해제 내용을 살펴보았고 다음은 위 내용이 실제로 맞는 내용인지 검증하는 내용이다.
1750 바이트를 2번 할당하고 해제하는 코드이다. 먼저 예상되는 결과로는 첫 번째 할당 시 이전에 할당된 적이 없는 크기이므로 zend_mm_alloc_pages 함수가 호출되어 페이지(pages)를 할당한다. 그리고 3KB 이하의 크기인 작은(small) 할당이기에 bin number가 26인 free_slot에 할당한 page를 분할하여 저장할 것이다.
phpizemakemaketest<?phptest1();?>
helloworld 함수를 추가한 이유는 해당 구간에 breakpoint를 걸어서 디버깅의 편의성을 추가하기 위해서이다.
zend_mm_alloc_pages 함수를 호출하면 best-fit 할당 방식을 사용하여 위에서 2번째 줄 000부터 3번째 줄 0000까지 사용할 것임을 예측할 수 있다. (7개를 사용할 것이라고 예상한 이유는 bin_pages를 보면 bin_number가 26일 때 page_count가 7개로 설정되어 있는 것을 볼 수 있다.)
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가 어떻게 동작하는지 이해하는데 도움이 되었으면 한다.