C++를 위한 고성능 가비지 컬렉션
과거에는 이미 여러 번 게시물에 JavaScript의 가비지 컬렉션, 문서 객체 모델(DOM), 그리고 이것이 V8에서 구현되고 최적화되는 방식에 대해 작성한 적이 있습니다. 하지만 Chromium의 모든 요소가 JavaScript로 작성된 것은 아닙니다. 대부분의 브라우저와 V8이 내장된 Blink 렌더링 엔진은 C++로 작성되었습니다. JavaScript는 DOM과 상호 작용할 수 있으며, 이는 이후 렌더링 파이프라인에 의해 처리됩니다.
DOM 주변의 C++ 객체 그래프가 JavaScript 객체와 깊게 얽혀 있기 때문에 Chromium 팀은 몇 년 전부터 이와 같은 메모리를 관리하기 위해 Oilpan이라는 가비지 컬렉터를 사용하기 시작했습니다. Oilpan은 C++로 작성된 가비지 컬렉터로, C++ 메모리를 관리하며, 크로스 컴포넌트 트레이싱을 통해 V8과 연결하여 얽혀 있는 C++/JavaScript 객체 그래프를 하나의 힙으로 처리합니다.
이 게시물은 Oilpan 블로그 게시물 시리즈의 첫 번째 글로, Oilpan의 핵심 원칙과 C++ API에 대한 개요를 제공합니다. 이번 게시물에서는 지원되는 일부 기능을 다루고, 이들이 가비지 컬렉터의 다양한 하위 시스템들과 어떻게 상호 작용하는지 설명하며, 스위퍼에서 객체를 동시에 회수하는 방식에 대해 깊이 탐구합니다.
가장 흥미로운 점은 Oilpan이 현재 Blink에 구현되어 있지만, V8에서는 하나의 가비지 컬렉션 라이브러리 형태로 이동하고 있다는 것입니다. 목표는 모든 V8 임베더와 일반적인 더 많은 C++ 개발자에게 C++ 가비지 컬렉션을 쉽게 사용할 수 있도록 하는 것입니다.
배경
Oilpan은 Mark-Sweep 가비지 컬렉터를 구현하며, 여기서 가비지 컬렉션은 두 단계로 나뉩니다: 관리 힙에서 살아 있는 객체를 스캔하는 마킹 단계와, 관리 힙에서 죽은 객체를 회수하는 스위핑 단계입니다.
V8에서의 동시 마킹을 소개할 때 이미 마킹의 기본 사항을 다루었습니다. 다시 요약하면, 살아 있는 객체를 스캔하는 것은 객체가 노드이고 객체 사이의 포인터가 엣지인 그래프 탐색으로 볼 수 있습니다. 탐색은 여기서 언급된 대로 루트, 즉 레지스터, 네이티브 실행 스택(이제부터는 스택이라고 부르겠습니다), 및 기타 글로벌 요소에서 시작됩니다.
이 점에서 C++은 JavaScript와 다르지 않습니다. 그러나 JavaScript와는 달리 C++ 객체는 정적으로 타입이 지정되어 있으며 런타임에 표현을 변경할 수 없습니다. Oilpan을 사용하여 관리되는 C++ 객체는 이 사실을 활용하여 방문자 패턴을 통해 그래프의 엣지(다른 객체에 대한 포인터 기술)를 제공합니다. 다음은 Oilpan 객체를 설명하는 기본 패턴입니다:
class LinkedNode final : public GarbageCollected<LinkedNode> {
public:
LinkedNode(LinkedNode* next, int value) : next_(next), value_(value) {}
void Trace(Visitor* visitor) const {
visitor->Trace(next_);
}
private:
Member<LinkedNode> next_;
int value_;
};
LinkedNode* CreateNodes() {
LinkedNode* first_node = MakeGarbageCollected<LinkedNode>(nullptr, 1);
LinkedNode* second_node = MakeGarbageCollected<LinkedNode>(first_node, 2);
return second_node;
}
위 예제에서 LinkedNode
는 GarbageCollected<LinkedNode>
를 상속받음으로써 Oilpan에 의해 관리됩니다. 가비지 컬렉터가 객체를 처리할 때 객체의 Trace
메서드를 호출하여 아웃고잉 포인터를 발견합니다. Member
타입은 std::shared_ptr
등과 문법적으로 유사한 스마트 포인터로, Oilpan이 제공합니다. 이것은 그래프 탐색 동안 일관된 상태를 유지하도록 사용됩니다. 이 모든 것 덕분에 Oilpan은 관리 객체 내에서 포인터가 어디에 있는지 정확히 알 수 있습니다.
열렬한 독자들은 아마도 그리고 아마 두려워할지도 모릅니다 위 예에서 first_node
와 second_node
가 스택 위에서 Raw C++ 포인터로 유지된다는 사실을 깨달았을 것입니다. Oilpan은 스택 작업을 위한 추상화를 추가하지 않고 뿌리를 처리할 때 보수적인 스택 스캐닝만을 사용하여 관리되는 힙으로의 포인터를 찾습니다. 이것은 스택을 단어별로 반복하면서 해당 단어들을 관리되는 힙의 포인터로 해석하는 방식으로 작동합니다. 따라서 Oilpan은 스택에 할당된 객체를 액세스하는 데 성능 저하를 초래하지 않습니다. 대신, 스택을 보수적으로 스캔할 때 쓰레기 수집 시간으로 비용을 이동시킵니다. 렌더러에 통합된 Oilpan은 흥미로운 스택이 없다고 보장되는 상태에 도달할 때까지 쓰레기 수집을 지연시키려고 합니다. 웹은 이벤트 기반이며 이벤트 루프에서 작업을 처리함으로써 실행이 주도되므로 이러한 기회는 많이 존재합니다.
Oilpan은 Blink에서 사용되며, 이는 성숙한 코드가 많은 대규모 C++ 코드베이스로 다음을 지원합니다:
- 믹스인 및 해당 믹스인에 대한 참조를 통한 다중 상속(내부 포인터).
- 생성자 실행 중 쓰레기 수집을 트리거.
- 루트로 처리되는
Persistent
스마트 포인터를 통해 비관리 메모리에서 객체를 유지. - 수집 백업 축소가 포함된 순차적(예: 벡터) 및 연관(예: 세트 및 맵) 컨테이너를 포함한 컬렉션.
- 약한 참조, 약한 콜백 및 에페메론.
- 개별 객체를 복구하기 전에 실행되는 종료 콜백.
C++를 위한 스위핑
Oilpan에서 마킹이 어떻게 작동하는지 자세히 설명하는 별도의 블로그 게시글을 기대해주세요. 이 기사에서는 마킹이 완료되고 Oilpan이 Trace
메서드의 도움으로 모든 접근 가능한 객체를 찾았다고 가정합니다. 마킹 후 접근 가능한 모든 객체는 마크 비트가 설정됩니다.
스위핑은 이제 죽은 객체(마킹 중 접근할 수 없는 객체)가 복구되고 해당 기본 메모리가 운영 체제로 반환되거나 후속 할당을 위해 사용 가능하게 되는 단계입니다. 아래에서는 사용 및 제약 관점에서 Oilpan의 스위퍼가 작동하는 방식과 높은 복구 처리량을 달성하는 방법을 보여줍니다.
스위퍼는 힙 메모리를 반복하면서 마크 비트를 확인하여 죽은 객체를 찾습니다. C++의 의미를 보존하기 위해 스위퍼는 메모리를 비우기 전에 각 죽은 객체의 소멸자를 호출해야 합니다. 복잡한 소멸자는 종료자로 구현됩니다.
프로그래머 관점에서 소멸자가 실행되는 순서는 정의되어 있지 않습니다. 스위퍼가 사용하는 반복은 생성 순서를 고려하지 않기 때문입니다. 이는 종료자가 다른 힙 객체를 만지는 것을 허용하지 않는다는 제약을 부과합니다. 이는 관리 언어가 일반적으로 종료 의미에서 순서를 지원하지 않기 때문에 종료 순서를 요구하는 사용자 코드 작성에 일반적인 문제입니다(예: Java). Oilpan은 Clang 플러그인을 사용하여 객체 파괴 중에 힙 객체에 접근하지 않는지 등을 고정적으로 검증합니다:
class GCed : public GarbageCollected<GCed> {
public:
void DoSomething();
void Trace(Visitor* visitor) {
visitor->Trace(other_);
}
~GCed() {
other_->DoSomething(); // error: 종료자 '~GCed'가 잠재적으로
// 종료된 필드 'other_'에 접근합니다.
}
private:
Member<GCed> other_;
};
호기심 있는 여러분을 위해: Oilpan은 객체가 소멸되기 전에 힙에 접근해야 하는 복잡한 사용 사례를 위해 사전 종료 콜백을 제공합니다. 이러한 콜백은 각 쓰레기 수집 주기에 소멸자보다 더 많은 오버헤드를 초래하지만 Blink에서는 드물게 사용됩니다.
점진적 및 동시 스위핑
관리되는 C++ 환경에서 소멸자의 제약 조건을 다룬 후 이제 Oilpan이 스위핑 단계를 구현하고 최적화하는 방법을 더 자세히 살펴볼 때입니다.
상세 내용을 다루기 전에 일반적으로 웹에서 프로그램이 어떻게 실행되는지를 상기하는 것이 중요합니다. 모든 실행, 예를 들어 JavaScript 프로그램뿐만 아니라 쓰레기 수집도, 이벤트 루프에서 작업을 디스패치함으로써 메인 스레드에서 주도됩니다. 렌더러는 다른 애플리케이션 환경과 마찬가지로 메인 스레드 작업을 처리하는 데 도움이 되는 백그라운드 작업을 지원합니다.
간단히 시작하면, Oilpan은 원래 스위핑을 중단하고 세계를 멈추는 방식으로 구현했으며 이는 애플리케이션의 실행을 메인 스레드에서 중단하는 쓰레기 수집 종료 중단 일시 중지의 일부로 실행되었습니다:
소프트 실시간 제약 조건이 있는 애플리케이션의 경우 쓰레기 수집을 처리할 때 결정적인 요소는 지연 시간입니다. 세계를 멈추는 스위핑은 상당한 일시 중지 시간을 초래하여 사용자에게 보이는 애플리케이션 지연 시간을 초래할 수 있습니다. 지연 시간을 줄이기 위한 다음 단계로, 스위핑을 점진적으로 만들었습니다:
점진적 접근 방식에서는 스위핑이 분리되어 추가 주 스레드 작업에 위임됩니다. 최상의 경우, 이러한 작업은 유휴 시간에 완전히 실행되어 일반 애플리케이션 실행에 방해가 되지 않습니다. 내부적으로, 스위퍼는 페이지라는 개념에 기반하여 작업을 더 작은 단위로 나눕니다. 페이지는 두 가지 흥미로운 상태를 가질 수 있습니다: 스위퍼가 처리할 필요가 있는 페이지와 이미 처리된 페이지. 할당은 이미 처리된 페이지만 고려하며, 사용 가능한 메모리 청크의 목록을 유지하는 자유 목록에서 로컬 할당 버퍼(LAB)를 보충합니다. 애플리케이션이 자유 목록에서 메모리를 가져올 때, 먼저 이미 처리된 페이지에서 메모리를 찾으려고 시도한 다음 할당 작업 내에서 스위핑 알고리즘을 인라인하여 처리할 페이지를 처리하는 데 도움을 준 후, 메모리가 없을 경우 OS로부터 새 메모리를 요청합니다.
Oilpan은 수년 동안 점진적 스위핑을 사용해 왔지만 애플리케이션과 그 결과로 생성된 객체 그래프가 점점 더 커짐에 따라 스위핑이 애플리케이션 성능에 영향을 미치기 시작했습니다. 점진적 스위핑을 개선하기 위해 우리는 메모리 병행 회수를 위해 백그라운드 작업을 활용하기 시작했습니다. 다음 두 가지 기본 불변성은 스위퍼를 실행하는 백그라운드 작업과 새 객체를 할당하는 애플리케이션 간의 데이터 레이스를 배제합니다:
- 스위퍼는 정의상 애플리케이션에서 접근할 수 없는 죽은 메모리만 처리합니다.
- 애플리케이션은 정의상 스위퍼가 더 이상 처리하지 않는 이미 처리된 페이지에서만 할당합니다.
이 두 불변성은 객체 및 그 메모리에 경쟁자가 없어야 함을 보장합니다. 불행히도, C++는 파괴자(소멸자)로 구현된 파이널라이저에 크게 의존합니다. Oilpan은 파이널라이저가 애플리케이션 코드 자체 내에서 데이터 레이스를 배제하도록 돕기 위해 주 스레드에서 실행되도록 강제합니다. 이 문제를 해결하기 위해 Oilpan은 객체 파이널라이저의 실행을 주 스레드로 연기합니다. 보다 구체적으로 말하면, 병행 스위퍼가 파이널라이저(소멸자)를 가진 객체를 만나면, 해당 객체를 주 스레드에서 애플리케이션과 함께 실행되는 별도 파이널라이저 단계에서 처리될 파이널라이저 큐로 푸시합니다. 병행 스위핑의 전체 작업 흐름은 다음과 같습니다:
파이널라이저는 객체의 페이로드 전체를 접근해야 할 수 있으므로, 관련 메모리를 자유 목록에 추가하는 작업은 파이널라이저 실행 후까지 지연됩니다. 파이널라이저가 실행되지 않는 경우, 백그라운드 스레드에서 실행되는 스위퍼는 회수된 메모리를 즉시 자유 목록에 추가합니다.
결과
백그라운드 스위핑은 Chrome M78에 도입되었습니다. 우리의 실제 성능 벤치마킹 프레임워크에 따르면 주 스레드 스위핑 시간이 25%-50% 감소(평균 42%)한 것으로 나타났습니다. 아래에 선택된 데이터 항목을 확인하세요.
주 스레드에서 소비되는 나머지 시간은 파이널라이저 실행입니다. Blink에서 객체 타입이 많이 인스턴스화되는 경우를 줄이기 위한 작업이 진행 중입니다. 여기에서 흥미로운 점은 모든 이러한 최적화가 애플리케이션 코드에서 이루어지며, 파이널라이저가 없는 경우 스위핑이 자동으로 조정된다는 점입니다.
V8의 모든 사용자들이 사용할 수 있는 출시 버전에 가까워지면서 C++ 가비지 컬렉션과 Oilpan 라이브러리 업데이트에 대한 더 많은 게시물이 나올 예정이니 계속 지켜봐 주세요.