역사
Go의 메모리 할당기는 C++ 다중 스레드 서버를 위해 설계된 구글의 스레드 캐싱 malloc인 TCMalloc에서 파생되었습니다. 런타임은 동시 프로그램에서 잠금 경합을 제거하기 위해 다중 수준 캐시를 구현합니다. 이 설계는 작은 객체의 빠른 경로에서 메모리 효율성보다 처리량을 우선시합니다.
문제
높은 동시성을 요구하는 서비스에서는 모든 할당이 전역 힙 잠금을 획득해야 한다면 고루틴이 직렬화되어 처리량이 감소하게 됩니다. 일반적인 사례에 대한 O(1) 할당 대기 시간을 동기화 없이 제공하는 것이 과제이며, 안전성을 유지해야 합니다. 전통적인 malloc 구현은 여러 CPU가 동일한 잠금 단어를 위해 경쟁할 때 캐시 라인의 전환 문제로 어려움을 겪습니다.
해결책
런타임은 67개의 크기 클래스 각각에 대한 span을 포함하는 per-P 캐시(mcache)를 유지합니다. 고루틴이 작은 객체(≤32KB)를 할당할 때에는 경계 포인터를 증가시키거나 mcache 내의 스레드 지역 무료 목록에서 뽑습니다. 이 과정에서 원자적 작업이 필요하지 않습니다. 중요한 불변 조건은 mcache가 항상 하나의 P에 독점적으로 소속되어 있으며, 할당이 P 경계를 넘지 않는다는 것입니다. 이렇게 함으로써 공유 가변 상태를 피할 수 있습니다.
type PriceTick struct { Symbol uint32 Price float64 } func ProcessTick() { // mcache에서 잠금 없이 16 바이트 할당 tick := &PriceTick{} _ = tick }
고빈도 거래 플랫폼이 매초 500,000개의 시장 데이터 이벤트를 처리하며, 각 이벤트는 가격 정규화를 위해 임시 24바이트 구조체를 필요로 합니다. 초기 구현은 이러한 객체를 위해 전역 sync.Pool을 사용했으며, 이는 부하 하에서 심각한 경합 지점이 되어 원자적 작업 및 캐시 일치 트래픽에서 CPU 시간을 35% 소모하게 되었습니다.
해결책 A: 수동 풀 샤딩
팀은 풀을 고루틴 ID 해시로 선택된 256개의 내부 하위 풀로 수동 샤딩하는 방안을 고려했습니다. 장점: 캐시 라인 간의 경합 분산. 단점: 고립된 샤드에서 메모리 팽창을 초래하고, 한 로컬 샤드가 비어 있을 때 다른 샤드에 무료 객체가 있을 경우 복잡한 기아 처리가 필요합니다.
해결책 B: 작업자 전용 아레나
그들은 작업자 고루틴당 대규모 메모리 아레나를 사전 할당하고 bump-pointer 할당을 사용하는 것을 평가했습니다. 장점: 경합 없음 및 매우 빠른 할당 경로. 단점: 수동 메모리 관리가 필요하고 포인터가 잘못 처리되면 메모리 누수가 발생할 위험이 있으며, 비동기 경계를 넘는 객체 수명 추적이 복잡해집니다.
해결책 C: 스택 할당 및 배치
선택된 접근 방식은 이벤트 프로세서를 포인터 대신 값 구조체를 사용하도록 재구성하였으며, 가능한 한 스택에 데이터를 유지하고 1000개의 배치로 이벤트를 처리하여 할당을 완화하였습니다. 장점: 단기 데이터의 힙 압력을 전혀 제거하고 동기화 원시 타입이 필요하지 않습니다. 단점: 이전에 포인터 의미론을 기대했던 인터페이스의 상당한 리팩토링이 요구되며, 고루틴당 스택 사용량이 증가하였습니다.
결과
해결책 C를 구현함으로써, 서비스는 핫 경로에서 힙 할당의 99%를 제거했습니다. P99 대기 시간은 12밀리초에서 180마이크로초로 떨어졌으며, 가비지 수집 주기는 85% 감소하여 서비스가 서브 밀리초 SLA 요구사항을 충족할 수 있었습니다.
Go는 고정 크기 span에서 다양한 크기의 객체를 할당할 때 메모리 조각화를 어떻게 제한하나요?
Go는 특정 세분화(8바이트 단계 최대 512바이트, 그 이후 더 큰 간격)로 67개의 서로 다른 크기 클래스를 사용합니다. 객체는 가장 가까운 클래스 크기로 반올림되어 내부 조각화를 대략 12.5%로 제한합니다. 외부 조각화는 각 mspan이 정확히 하나의 크기 클래스의 객체만 포함하기 때문에 최소화되어, 작은 객체가 큰 메모리 블록을 고정하지 않도록 합니다.
런타임이 메모리 할당 중 사용자가 볼 수 있는 메모리 대신 힙 비트맵을 초기화하는 이유는 무엇인가요?
할당자는 객체 헤더가 아닌 heapArena 메타데이터 구조 내에서 타입 정보 및 포인터 비트맵을 유지합니다. 메모리가 할당될 때, 필요한 경우 포인터 슬롯을 나타내는 비트맵만 초기화되고, 데이터 메모리는 변형자 또는 동시 스위프 중에 요청 시 초기화됩니다. 이 접근 방식은 작업을 연기하고 캐시 지역성을 개선하며, 할당 중 필요한 메모리 대역폭을 줄입니다.
가비지 수집 중 mcache에서 mcentral로 span이 전환되는 이유는 무엇인가요?
GC 스위프 단계에서 런타임은 mcache 인스턴스에서 보유된 span을 검사합니다. span에 할당된 객체가 없으면(모든 슬롯이 해제됨) P는 mcentral로 반환하고 유지하지 않습니다. 이는 메모리 축적을 방지하고 프로세서 간 무료 메모리의 균형 잡힌 분포를 보장하지만, 중앙 잠금을 다시 획득하는 비용이 발생합니다.