이전 글에서 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의 잠재력을 완전히 끌어낼 수 있을 것입니다.