[바미] Node - GC
GC?
가비지 컬렉션은 프로그램이 동적으로 할당한 메모리 중에서 더 이상 사용되지 않는 부분을 자동으로 찾아서 해제하는 프로세스입니다. 이는 메모리 누수를 방지하고, 사용 가능한 메모리 공간을 최대화하여 프로그램의 안정성과 성능을 향상시키는 데 필요합니다.
Node.js에서는 프로세스 메모리 관리를 개발자가 직접하지 않고 자동으로 수행합니다.
그렇기 때문에 가비지 컬렉션(GC)은 Node.js의 메모리 관리의 핵심이며 성능에 많은 영향을 끼치죠
오늘은 Node.js의 V8 engine이 어떻게 가비지 컬렉션을 수행하는지 알아보겠습니다.
프로세스 메모리 관리 - C와 Node.js 비교
메모리 관리란?
프로그래머가 요청할 때 동적으로 Heap 영역에 메모리 청크를 할당해주고, 더 이상 필요하지 않을 때 메모리를 반환해 재사용이 가능하게 하는 것을 의미합니다.
C에서 메모리 관리
C는 manual memory management 방식으로 프로그래머가 직접 malloc() 명령어를 통해 메모리를 확보한 후, free()함수를 통해 할당한 메모리를 해제해야하는 책임을 가집니다.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main() {
char name[20];
char *description;
strcpy(name, "RisingStack");
// memory allocation
description = malloc( 30 * sizeof(char) );
if( description == NULL ) {
fprintf(stderr, "Error - unable to allocate required memory\\n");
} else {
strcpy( description, "Trace by RisingStack is an APM.");
}
printf("Company name = %s\\n", name );
printf("Description: %s\\n", description );
// release memory
free(description);
}
- manual memory management의 문제점
- Memory leak: 할당된 메모리가 다시 free되지 않아 사용되지 못하는 것
- Wild/dangling pointers: 삭제된 객체의 포인터가 재사용되는 것, 보안 문제가 발생할 수 있음
Node.js의 V8 engine 메모리 관리
V8 engine은 C와 달리 automatic memory management 방식으로 개발자가 메모리를 직접 할당 및 확보할 필요가 없다. 대신 필연적으로 프로그래머 대신 사용되지 않는 메모리를 수거해주는 가비지 컬렉션이 필요합니다.
function Engine (power) {
this.power = power
}
function Car (opts) {
this.name = opts.name
this.engine = new Engine(opts.power)
}
let LightningMcQueen = new Car({name: 'Lightning McQueen', power: 900})
let SallyCarrera = new Car({name: 'Sally Carrera', power: 500})
let Mater = new Car({name: 'Mater', power: 100})
위 코드를 시행시키면 아래 그림과 같이 root에서 각각 객체를 참조하고 있습니다. 따라서 이 상황에서 가비지 컬렉션이 작동해도 아무것도 삭제 하지 않게 됩니다.
root 객체는 global objects, DOM elements 또는 local variables가 됩니다.
Mater = undefined
하지만 Mater에 undefined를 참조시키면 위 그림처럼 Mater 객체는 root 객체로 부터 참조 연결이 끊어지게 됩니다.
이후 가비지 컬렉션이 작동하면 Mater 객체는 삭제 됩니다.
가비지 콜렉터 단점
가비지 콜렉터를 사용하면 메모리를 더 이상 프로그래머가 관리할 필요 없어 언어가 크게 단순화 되고, 메모리 누수 문제를 해결할 수 있지만 아래와 같은 단점도 존재합니다.
- 메모리에 대한 제어권 포기 - 메모리 관리가 중요한 모바일에는 문제가 될수도 있습니다.
- stop-the-world - 가비지 콜렉션을 작동하는 동안 프로그램을 잠시 멈추기 때문에 가비지 콜렉터는 비싼편입니다.
- 2번과 비슷한 맥략인데 가비지 콜렉터도 결국 컴퓨팅 파워를 사용하는 것이죠.
V8 engine 콜렉터 동작 방식
Heap 영역 구성
- New-space - 대부분의 객체가 할당되는 곳, 매우 빠르고 자주 가비지 콜렉션이 작동합니다.
semi-spaces(to-space 및 from-space)으로 또 나뉘죠. - Old-pointer-space - 다른 객체에 대한 포인터를 가질 수 있는 객체가 포함됩니다. new-space에서 살아남아야 할당될 수 있습니다.
- Old-data-space - 다른 객체에 대한 포인터를 가지지 않은 원시 데이터만 포함하는 객체가 포함됩니다.
new-space에서 살아남아야 할당될 수 있습니다. - Large-object-space - 다른 메모리 공간의 크기 제한을 초과하는 크기의 객체들을 저장하는 곳입니다. 일반적으로 이 공간은 대형 배열이나 대형 문자열과 같이 크기가 큰 단일 객체를 포함합니다. 이 공간에 저장된 각 객체는 고유한 mmap()을 통해 할당된 메모리 영역을 갖습니다.
- Code-space - 실행 가능한 코드, 즉 컴파일된 JavaScript 코드의 객체를 저장하는 메모리 영역입니다. 이 공간은 코드를 실행하기 위해 필요한 바이트코드나 기계어 코드 등을 포함합니다.
- Cell-space, property-cell-space and map-space - "셀"이라고 하는 작은 데이터 조각을 저장하는 곳입니다. 이 셀들은 주로 JavaScript에서 사용되는 작은 정수 값이나 참조 값을 저장하는 데 사용됩니다. 여기서 셀은 JavaScript 엔진에서 매우 기본적인 데이터 단위로, 변수의 값을 담을 수 있는 컨테이너 역할을 합니다. Cell-space는 이러한 셀들을 효율적으로 관리하기 위한 메모리 영역
포인터 발견
가비지 콜렉터는 살아있는 객체를 구별하기 위해서 포인터를 따라야합니다. 따라서 포인터와 데이터를 구별하는건 가비지 콜렉터가 가장 먼저 해야하는 일이죠. 크게 Conservative, Compiler hints, Tagged pointers 방법이 있는데 V8은 Tagged pointers를 이용합니다.
- Tagged pointers - 포인터인지 데이터인지 각 단어 끝 비트를 활용합니다. 컴파일러 개입이 필요하지만 효율적이면서 구현이 간단하죠. V8 engine은 32비트를 사용하는데 포인터는 하위비트 01을 가진다.
세대별 콜렉션
프로그램에서 대부분의 객체는 빠르게 죽는 반면 소수의 객체는 훨씬 길게 사는 경향이 존재합니다. 따라서 V8은 이러한 경향을 반영해 heap의 영역을 new-space와 old-space 로 나눴습니다.
new-space는 대부분의 객체가 할당되는 곳으로, 빠르고 자주 Minor GC가 작동합니다.
old-space는 new-space에서 살아남은 객체가 이동하며(minor GC에서 2번 살아남은) pointer와 data 영역으로 나뉘고, Major GC가 작동합니다.
Minor GC
new-space 영역에서 작동하는 가비지 콜렉션입니다.
new-space에서 GC 동작 방식
from-space, to-space로 나눠지며 to-space가 가득차면 minor GC인 Scavenge가 실행됩니다.
이때 객체들이 to-space와 from-space가 교체되고 from-space에서 Minor GC가 실행 후 살아남은 객체가 다시 to-space로 이동합니다(from-space는 비웁니다). to-space로 다시 이동하면서 공간이 압축되어 메모리 단편화를 해결합니다.
시간이 흘러 to-space가 가득차면 다시 Minor GC가 작동하고 Minor GC에서 2번 살아남은 객체는 old-space 로 이동합니다.
Write barriers
그런데 생각해보면 뭔가 이상함을 알 수 있습니다. Minor GC는 new-space만 검사를하고 GC를 진행하기 때문에 old-space에서 new-space 객체를 참조하는 경우를 고려할 수 없죠. 이를 해결하기 위해 storage buffer를 두어 old-space에서 new-space로의 포인터 목록을 유지 및 관리하는데 이러한 프로세스를 write barrier라고 합니다.
Major GC
Scavenge는 빠르지만 효율적이지만 to-space, from-space라는 2개의 메모리 영역을 두기 때문에 메모리 오버헤드가 존재합니다.
따라서 old-space같은 큰 메모리에는 Mark-Sweep-Compact방식을 사용한 Major GC를 진행합니다.
Major GC는 Marking, Sweep, Compact 3단계로 이뤄집니다.
Marking
마킹 상태는 다음 3가지를 가집니다
- white - 아직 GC가 탐색하지 못한 상태
- grey - GC가 탐색했으나 해당 객체가 참조하는 객체는 탐색하지 않은 상태
- black - GC가 해당 객체가 참조하는 객체까지 탐색을 완료한 상태
탐색은 DFS로 수행되며 deque 자료구조를 스택 형태로 활용합니다.
처음에는 모든 객체가 white로 마킹되어 있으며 root 객체를 회색으로 마킹하고 deque에 넣는 것 부터 시작한 뒤, 아래 과정을 반복합니다.
- deque에서 pop_front()해 객체를 꺼냅니다.
- 꺼낸 객체를 black으로 마킹합니다.
- 해당 객체가 참조하는 객체를 grey로 마킹한 후 deque에 push_front()로 넣습니다.
- 객체가 여러 곳에서 참조되는 경우 이미 grey 또는 black인 경우가 있는데 이러한 객체는 처리하지 않고 흰색 객체만 처리합니다.
deque가 비면 종료되고(DFS이기 때문에) 살아있는 모든 객체는 black으로 죽은 객체는 white로 표시됩니다.
deque가 오버플로우되는 경우 deque를 비우고 heap을 탐색하여 grey표시된 객체를 찾아 다시 deque에 넣어 탐색을 수행합니다.
Sweep
각 페이지는 free_list를 가지는데, 페이지별로 죽은 객체를 탐색하여 사용가능한 공간으로 전환하고 free_list에 해당 메모리를 추가한다.
Compact
조각난 page에서 다른 page의 여유 공간으로 객체를 마이그레이션하여 메모리 단편화를 줄이는 것을 의미하는데 이러한 과정이 복잡하기에 간단히 말하면 아래와 같습니다.
- 다른 곳으로 옮겨진 후보 page의 객체의 메모리는 목적지 page의 free_list에서 할당됩니다.
- 객체를 새로운 공간에 할당합니다.
- page를 비우는 과정에서 옮겨진 객체에 대한 포인터를 기록합니다.
- page가 비워지면 기록한 포인터들이 옮긴 객체를 가리키도록 업데이트 합니다.
Major GC 개선 사항
mark-sweep와 mark-compact는 대형 heap에서는 시간이 오래 걸리는 단점이 있을 수 있다. Major GC는 stop-the-world이 발생하기 떄문에 stop-the-world 시간을 줄이는 방법이 필요했고 구글은 2012년 2가지 개선사항 incremental marking과 lazy sweeping을 도입했다.
incremental marking
Incremental GC는 GC를 한번에 진행하지 않고 쪼개서 수행하는 것입니다.
위 그림과 같이 GC를 쪼개서 수행하게되는데 그림처럼 간단하지만은 않습니다.
heap객체 그래프가 GC완료전에 변경될 수 있기 때문이죠. 즉 검은색 객체가 흰색 객체를 가르키는 경우가 생길 수 있는 것입니다.
이를 해결하기 위해 Write Barrier를 다시 활용합니다. old-space → new-space로의 포인터 정보만 기록하는 것이 아니라 검은색 객체가 흰색 객체를 참조하는 것도 감지합니다. 검은색 → 흰색 포인터가 감지되면 검은색을 회색으로 다시 바꾸고 marking 큐로 다시 push하는 것이죠.
lazy sweeping
모든 페이지를 한번에 sweep 하는 것이 아니라 필요에 따라서 sweep 합니다.
마치며
오늘은 가비지 컬렉션에 대해 알아보았습니다. 매 언어마다 GC는 꼭 넣었던 것 같네요.
가비지 컬렉션은 명확한 원리와 알고리즘에 기반을 둔 섬세하게 조율된 프로세스라는 것을 잊지 마셨으면 좋겠습니다.
V8 엔진은 이러한 중요한 작업을 자동으로 처리함으로써, Node.js 개발자가 더 안정적이고 효율적인 애플리케이션을 만들 수 있도록 지원하죠. 이 글을 통해, 가비지 컬렉션의 원리와 V8 엔진의 작동 방식에 대한 이해가 깊어졌기를 바랍니다.
참고
https://blog.risingstack.com/node-js-at-scale-node-js-garbage-collection/#theheap
https://blog.risingstack.com/finding-a-memory-leak-in-node-js/
https://jayconrod.com/posts/55/a-tour-of-v8-garbage-collection
http://egloos.zum.com/sweeper/v/3196058
https://jayconrod.com/posts/55/a-tour-of-v8-garbage-collection