TECH 으로 돌아가기
TECH HACKER NEWS 오늘 7분 읽기 26 READS

C++에도 가비지 컬렉터를: 크롬 렌더링 엔진을 떠받치는 Oilpan의 스위핑 최적화

웹 브라우저를 자바스크립트 실행 엔진 정도로 여기기 쉽지만, 실제로 크로미움과 그 렌더링 엔진인 Blink의 대부분은 C++로 작성돼 있다. 자바스크립트는 DOM을 조작하고, 그 DOM은 다시 C++로 구현된 렌더링 파이프라인이 처리한다. 문제는 DOM 주변의 C++ 객체 그래프가 자바스크립트 객체와 촘촘하게 얽혀 있다는 점이다. 서로 다른 언어의 객체가 상대를 참조하는 상황에서 각자의 메모리를 따로 관리하면 순환 참조나 조기 해제 같은 문제가 생기기 쉽다. 크로미움 팀이 몇 해 전 Oilpan이라는 C++ 전용 가비지 컬렉터를 도입한 배경이 여기에 있다.

Oilpan은 C++로 작성돼 C++ 메모리를 관리하는 가비지 컬렉터이며, 이른바 교차 컴포넌트 트레이싱(cross-component tracing)을 통해 V8과 연결된다. 이 방식은 뒤엉킨 C++/자바스크립트 객체 그래프를 하나의 힙처럼 취급해, 두 언어에 걸친 참조 관계를 함께 추적한다. 현재 Oilpan은 Blink 안에 구현돼 있지만, V8의 가비지 컬렉션 라이브러리 형태로 이전하는 작업이 진행 중이다. 목표는 C++ 가비지 컬렉션을 모든 V8 임베더와 일반 C++ 개발자가 손쉽게 쓸 수 있게 만드는 것이다. 이는 브라우저 내부 사정을 넘어, C++ 진영에서 수동 메모리 관리의 대안을 넓히려는 시도로 읽을 수 있다.

정적 타입을 활용한 정밀 추적

Oilpan은 마크-스위프(Mark-Sweep) 방식을 쓴다. 마킹 단계에서 힙을 훑어 살아 있는 객체를 찾고, 스위핑 단계에서 도달 불가능한 죽은 객체의 메모리를 회수한다. C++ 객체는 런타임에 표현이 바뀌지 않는 정적 타입이라는 특성을 갖는데, Oilpan은 이 점을 활용한다. 관리 대상 클래스는 GarbageCollected를 상속하고, 다른 객체를 가리키는 포인터는 Member라는 스마트 포인터로 보관하며, Trace 메서드로 자신의 외부 참조를 기술한다. 덕분에 컬렉터는 관리 객체 어디에 포인터가 있는지 정확히 안다.

다만 스택에 놓인 원시 포인터는 이런 기술의 대상이 아니다. Oilpan은 스택에 별도 추상화를 두지 않고 보수적 스택 스캐닝에 의존한다. 스택을 한 워드씩 읽어 관리 힙을 가리키는 포인터로 해석하는 방식이다. 이렇게 하면 스택 할당 객체에 접근할 때 성능 손해가 없는 대신, 비용이 가비지 컬렉션 시점으로 옮겨간다. 렌더러는 이벤트 루프에서 태스크를 처리하는 구조여서 '흥미로운 스택이 없는' 안전한 시점이 자주 찾아오며, Oilpan은 그때까지 컬렉션을 미루도록 설계돼 있다.

소멸자라는 제약과 순서 없는 파이널라이제이션

C++ 가비지 컬렉션의 까다로움은 소멸자에서 드러난다. 스위퍼는 죽은 객체의 메모리를 해제하기 전에 소멸자를 호출해야 C++ 의미를 지킬 수 있고, 자명하지 않은 소멸자는 파이널라이저로 구현된다. 그런데 스위퍼의 힙 순회는 객체 생성 순서를 고려하지 않으므로 소멸자 실행 순서가 정해져 있지 않다. 이 때문에 파이널라이저는 다른 힙 객체를 건드릴 수 없다는 제약이 붙는다. 자바 같은 관리 언어도 파이널라이제이션 순서를 보장하지 않는 것과 같은 맥락이다. Oilpan은 Clang 플러그인으로 소멸 중 힙 객체 접근을 정적으로 검증하며, 힙 접근이 꼭 필요한 복잡한 경우를 위해 사전 파이널라이제이션 콜백을 두되 오버헤드가 커 제한적으로만 쓴다.

멈춤에서 동시 실행으로, 스위핑의 진화

스위핑의 개선 과정은 지연 시간(latency)과의 싸움이었다. 초기 Oilpan은 메인 스레드의 실행을 멈추고 회수를 끝내는 정지형(stop-the-world) 스위핑을 썼는데, 이는 눈에 띄는 멈춤을 유발했다. 다음 단계는 증분(incremental) 스위핑으로, 작업을 페이지 단위로 쪼개 메인 스레드의 유휴 시간에 나눠 처리했다. 할당은 이미 스위핑된 페이지의 프리 리스트에서 메모리를 찾고, 없으면 아직 스위핑되지 않은 페이지의 처리를 할당 과정에 끼워 돕고, 그래도 없을 때만 OS에 새 메모리를 요청한다.

객체 그래프가 커지면서 증분 방식마저 성능에 부담이 되자, 팀은 백그라운드 태스크를 활용한 동시(concurrent) 스위핑으로 나아갔다. 관건은 백그라운드 스위퍼와 애플리케이션의 할당 사이에서 데이터 경쟁을 없애는 것이었다. 특히 소멸자는 반드시 메인 스레드에서 실행돼야 하므로, 동시 스위퍼가 파이널라이저를 가진 객체를 만나면 이를 파이널라이제이션 큐에 넣어 메인 스레드의 별도 단계에서 처리한다. 소멸자가 객체 전체 페이로드에 접근할 수 있는 만큼, 해당 메모리를 프리 리스트에 되돌리는 일도 파이널라이저 실행 이후로 미룬다. 파이널라이저가 없는 객체라면 백그라운드 스레드가 곧바로 메모리를 회수한다.

남은 병목과 실무적 함의

백그라운드 스위핑은 크롬 M78에 탑재됐고, 실사용 벤치마크에서 메인 스레드 스위핑 시간이 25~50%, 평균 42% 줄었다. 남는 시간의 상당 부분은 메인 스레드에서 실행되는 파이널라이저가 차지한다. 그래서 Blink에서는 대량으로 생성되는 객체 타입의 파이널라이저를 줄이는 작업이 이어지고 있다. 여기서 눈여겨볼 대목은 이 최적화가 컬렉터가 아니라 애플리케이션 코드 쪽에서 이뤄진다는 점이다. 소멸자가 없으면 스위핑이 자동으로 그 이점을 반영하기 때문이다. C++ 관리 메모리를 다루는 개발자에게 이 사실은 실용적 지침이 된다. 비용이 큰 소멸자를 남발하지 않고, 자명한 소멸이 가능하도록 타입을 설계하며, 파이널라이제이션 순서에 의존하지 않는 코드를 작성하는 것이 곧 회수 성능으로 직결된다는 뜻이다.

SOURCE · HACKER NEWS
원문 전체 보기 → https://v8.dev/blog/high-performance-cpp-gc
SHARE
NEXT · CHOOSE

변화를 읽었다면,
내가 만들 수익 구조를 고릅니다.

정보를 더 모으는 데서 멈추지 않고, 광고·외주·판매·중개·구독 중 내 상황에 맞는 출발점을 정해보세요.

21가지 수익 구조 살펴보기
처리 중...