Ractor 잠금 해제: object_id

Unlocking Ractors: object_id | byroot’s blog

3줄 요약

  • Ruby Ractor의 성능은 VM 잠금 경합으로 제약받으며, `#object_id`가 주요 경합 지점 중 하나로 파악되었습니다.
  • 과거 메모리 주소 기반이었던 `object_id`는 GC Compaction 도입 후 안정성 확보를 위해 전역 해시 테이블을 사용하게 되며 비용이 증가했습니다.
  • 저자는 Shapes 메커니즘을 활용하여 `object_id`를 객체 내부에 저장함으로써 Ractor 간 잠금 경합을 줄이는 개선 작업을 진행 중입니다.

이전 글에서 Ractor가 애플리케이션 전체를 실행하기는 어렵지만, CPU 바운드 작업을 메인 스레드에서 분리하고 병렬 알고리즘을 구현하는 데 유용하다고 설명했습니다. 하지만 Ractor는 여전히 많은 구현 버그와 VM의 전역 잠금으로 인해 성능 제약이 있습니다. 최근 `fstring_table`이 잠금 없는 구조로 개선되어 JSON 벤치마크에서 Ractor 버전이 3배 느렸던 것에서 2배 빨라지는 등 긍정적인 변화가 있었습니다. 그럼에도 불구하고 여전히 제거하거나 줄여야 할 경합 지점이 많으며, 그중 예상치 못한 하나가 바로 `#object_id` 메서드입니다. 이 글은 `#object_id`가 어떻게 경합 지점이 되었는지 그 역사를 살펴보고, 이를 해결하기 위한 제안된 개선 방안을 설명합니다.

#object_id의 역사를 살펴보면, Ruby 2.6까지는 객체의 메모리 주소(나누기 2)를 반환하는 매우 간단한 구현이었습니다. 이로 인해 ObjectSpace._id2ref 또한 간단하게 구현되었지만, GC가 객체 슬롯을 재활용하거나 이동시킬 때 object_id가 불안정해지는 심각한 문제가 있었습니다. 이러한 결함은 Ruby 2.7에서 Aaron Patterson이 GC Compaction을 구현할 때 걸림돌이 되었습니다. Compaction은 객체를 다른 메모리 위치로 이동시키므로, object_id는 더 이상 메모리 주소 기반일 수 없었습니다. 해결책으로 Ruby는 객체와 ID의 매핑을 저장하기 위한 두 개의 내부 해시 테이블(OBJ_TO_ID_TABLE, ID_TO_OBJ_TABLE)을 도입했습니다. object_id에 처음 접근할 때 고유 ID를 생성하고 이 매핑을 두 테이블에 저장합니다. 이 변경으로 object_id는 안정화되었지만, 각 ID 저장에 약 48B의 메모리가 필요하고 해시 탐색이 필요해져 이전보다 훨씬 비용이 많이 들게 되었습니다. 이후 Koichi Sasada가 Ractor를 구현하면서, 여러 Ractor가 이 해시 테이블에 동시에 접근할 수 있게 되자 #object_id_id2ref 메서드에 전역 VM 잠금(RubyVM.synchronize)을 추가할 수밖에 없었고, 이로 인해 #object_id가 Ractor의 경합 지점이 되었습니다. #object_id는 디버깅 외 실제 코드에서는 자주 사용되지 않는다고 생각할 수 있지만, Object#hash 메서드가 기본적으로 #object_id에 의존하며, Class 객체와 같이 기본 해시 구현을 사용하는 객체는 #object_id를 호출하게 되어 결국 VM 잠금을 유발합니다. 이러한 경합을 줄이기 위해 제안된 첫 번째 개선은 ObjectSpace._id2ref가 매우 드물게 사용된다는 점을 이용해 ID_TO_OBJ_TABLE을 필요할 때까지 지연 생성하는 것입니다. 이는 잠금 내부에서 수행되는 작업을 줄이고 메모리 및 GC 오버헤드를 약간 감소시킵니다. 더 나아가, object_id를 중앙 집중식 해시 테이블 대신 객체 자체 내부에 저장하는 방안이 모색되고 있습니다. 이는 Ruby 3.2부터 도입된 Shapes 메커니즘을 활용하는 것으로, Shapes는 객체의 인스턴스 변수 레이아웃, 크기, 상태(frozen 여부) 등을 추적합니다. object_id를 인스턴스 변수처럼 Shapes를 통해 객체 내부에 저장하면, 특히 Shapes가 주로 불변이라는 특성 덕분에 첫 접근 이후에는 잠금 없이 object_id에 접근할 수 있게 됩니다. 이를 위해 frozen된 객체도 object_id를 저장할 수 있도록 Shapes 구조를 수정하는 작업이 필요했습니다. SHAPE_OBJ_ID라는 새로운 Shape 타입을 도입하여 객체 내부에 object_id를 위한 공간을 확보할 수 있습니다. 하지만 이 방식에도 몇 가지 제약이 있습니다. 첫째, Shapes의 자식을 찾거나 생성하는 작업은 여전히 VM 동기화가 필요하므로 object_id에 처음 접근할 때는 잠금이 발생합니다. 둘째, Ractor 간에 공유될 수 있는 객체의 경우 초기 ID 저장 시 경쟁 상태를 피하기 위해 여전히 잠금이 필요합니다. 셋째, 모든 객체가 인스턴스 변수를 동일한 방식으로 저장하지 않습니다. T_OBJECT, T_CLASS, T_MODULE 외의 객체(T_STRING, T_ARRAY, T_HASH 등)는 인스턴스 변수를 인라인으로 저장하지 않고 별도의 전역 해시 테이블(GENERIC_STORAGE)에 저장합니다. 따라서 이러한 객체의 경우 object_id를 Shapes/인라인으로 저장하더라도 결국 다른 전역 해시 테이블 접근으로 인한 잠금이 발생할 수 있습니다.

현재 진행 중인 이 패치는 아직 완성되지 않았으며, 특히 "generic" 객체들을 어떻게 처리할지에 대한 고민이 남아있습니다. 이 패치가 최종적으로 병합될지 여부는 불확실합니다. 하지만 이 작업을 공유하는 이유는 문제에 대해 더 깊이 생각하는 데 도움이 되고, `#object_id`가 현재 Ractor의 가장 큰 병목은 아닐지라도 Ractor를 더 병렬적으로 만들기 위해 필요한 저수준 작업의 유형을 잘 보여주기 때문입니다. 유사한 작업은 심볼 테이블, 다양한 메서드 테이블 등 다른 내부 테이블에서도 필요할 것입니다. 이러한 지속적인 노력을 통해 Ruby Ractor의 잠재력을 완전히 끌어낼 수 있을 것입니다.