우선 GC가 발생하는 공간을 시각화해보자.
웹 어플리케이션을 실행하면 chrome의 경우 tab 하나하나가 렌더링 프로세스를 가진다.
이 렌더링 프로세스 내에 있는 heap 영역 중에서도 렌더링 엔진이 관리하는 heap 영역이 따로 있고 자바스크립트 엔진이 관리하는 heap 영역이 따로 있다. 그리고 자바스크립트 엔진이 관리하는 heap 영역은 젊은 객체와 나이든 객체가 적재된 공간이 분리되어 있다.
이렇게 객체가 생긴지 얼마나 되었는지에 따라 공간을 분리하는 건 잔인해보이지만(?) generational GC라는 경험적 이론에 기반한 것이다.
생긴지 얼마 안된 객체일수록 GC에 의해 수거될 가능성이 높다는 경험적 이론이다. 그래서 young한 객체를 위주로 빈번하게 GC하는 것이 효율적이기 때문에 굳이 공간을 나눠놓는 것이다.
young한 객체를 GC하는 것을 minor GC라 하고, young or old인지와 무관히 자바스크립트 엔진이 관리하는 객체 전체를 대상으로 GC하는 것을 major GC라고 한다.
과거에는 컴퓨터 시스템이 주로 단일 CPU 코어를 가지고 있었어서 소프트웨어 알고리즘도 주로 싱글스레드 환경에서 동작하도록 구현되었다. GC 알고리즘도 원래는 싱글 스레드 환경에 최적화되어서 설계되었다가, 이후에 멀티스레드 환경이 보편화되면서 최적화를 위한 프로젝트가 다년간 이루어졌다. 먼저 이번 포스팅에서는 싱글 스레드 환경에서 어떤 알고리즘으로 GC가 동작했는지, 그리고 어떤 최적화가 이루어졌는지 알아보자.
minor GC의 경우 Scavenger라고도 부른다. 위에서 얘기한 것처럼 young 객체들이 적재되는 공간은 별도로 분리되어 있다. 사실 이 공간은 다시 한번 2개의 semispace로 나뉜다.
하나는 from-space이고 다른 하나는 to-space이다. 모든 새로운 객체들은 GC이전에 from-space에만 적재되고, to-space는 텅텅 비어있다.
만약 새로운 객체를 계속해서 from-space에 적재하다가 더 이상 from-space에 공간이 부족해지면 이제 to-space로 살아있는 객체들을 copy해서 옮기기 시작한다.
이 때 root set으로부터 시작하여 pointer를 따라 하나하나 추적하며 생존해야 하는 객체들을 to-space로 복사한다.
root set이란?
전역 객체, 콜스택에 있는 객체 참조, remember set 등을 root set으로 삼는다.
remember set은 말 그대로 기억할 만한 가치가 있는 객체들의 참조를 저장하는 곳이다. old 객체가 참조하고 있는 young 객체들의 리스트가 이에 포함된다.
그리고 pointer update이 이루어진다. 그리고 from space에 남은 객체들은 모조리 수거한다. 이 때 To-space에 객체들을 차곡차곡 순서대로 복사해서 compaction이 이루어지고, 파편화를 방지할 수 있는 효과도 있다.
이렇게 to-space로 복사하고 난 이후, 이제부터는 from space와 to space는 서로의 역할을 바꾼다. from space가 to space 역할을 하고 to space가 from space 역할을 하게 되는 것이다.
재미있는 사실은 to-space로 이전에 한 번 옮겨진 객체들은 그 다음 minorGC 때 to-space가 아니라 old 객체가 적재된 영역으로 이동한다. 이렇게 old 영역으로 이동하는 걸 promotion이라고 한다.
객체가 몇살이 되어야 old 영역으로 옮겨지는 지는 언어 별로 상이하지만, 자바스크립트의 경우는 to-space로 옮겨진 이후의 GC에서 즉시 old 영역으로 age가 2일 때 옮겨지니 다른 언어에 비해 짧은 편에 속하는 듯하다.
중요한 것은 싱글 스레드 환경에서는 이 모든 과정이 메인 스레드에서 전부 다 이루어졌다는 사실이다.
major GC에서는 marking이라는 중요한 과정이 존재한다. 객체들을 root set으로부터 탐색하면서 살아있는 객체들을 선별하는 과정이다. 이를 mark and sweep이라고 한다.
탐색을 기반으로 해서 그런지, v8 docs의 설명을 읽어보니 알고리즘을 풀면서 익숙해졌던 깊이 우선 탐색 알고리즘이나 너비 우선 탐색 알고리즘이 바로 떠올랐다. 물론 자바스크립트 엔진의 내부 구현은 C++로 이루어져있지만, 나에게 좀 더 익숙한 javascript로 DFS 알고리즘을 작성해보고 비교해보자.
const stack = [root];
while (stack.length){
// 검정색으로 마킹하는 부분이 떠오른 코드
const currentNode = stack.pop();
for (const nextNode of tree[currentNode]){
// 회색으로 마킹하는 부분
stack.push(nextNode);
visited[nextNode] = true;
//...
위의 코드는 평소에 깊이 우선 탐색 문제를 풀 때 기반이 되는 가장 기본적인 코드이다. root로부터 시작해서 인접 노드를 stack에 push하고, visited 배열을 이용하여 방문 처리 한 다음, 다시 stack에서 pop해오기를 반복하면서 탐색을 진행한다.
GC 마킹도 이와 비슷하다. 3가지 색을 이용한다는 점이 특징이다. 탐색 전의 객체 노드는 모두 흰색이다.
인접 노드일 경우 회색으로 바꾼 후 marking work list(stack의 역할을 하는 곳)라는 곳에 push한다.
그리고 marking worklist에서 pop해온 후에 검정색으로 바꾼다. 이 과정을 tri-color marking이라고 한다.
그런데 minor GC도 그렇고 major GC도 그렇고 모두 렌더링 프로세스의 메인스레드에서 발생하면 렌더링 도중에 화면에 일시정지가 생길 수도 있지 않을까? 그렇다. 스크롤을 내리거나 애니메이션을 보다가 갑자기 화면이 멈출 수도 있는 것이다. 이건 1초 이내의 일시정지이더라도 분명 유저 경험에 악영향을 주는 요소이다. 이러한 상황을 stop-the-world라고 부른다.
싱글 스레드 환경에서도 v8 팀은 이러한 stop-the-world 현상을 줄이기 위해 나름의 노력을 기울였다. 대표적으로 idle time GC와 incremental GC가 있다. 다음 포스팅에서는 이와 같은 싱글스레드 환경에서의 최적화 기법에 대해 알아보자.
참고:
v8 docs
https://v8.dev/blog/trash-talk
카카오엔터테인먼트 기술블로그
https://fe-developers.kakaoent.com/2022/220519-garbage-collection/