최종화자는 외부 리소스를 해제하는 안전망을 제공하기 위해 초기 Go 릴리스에서 도입되었으며, 특히 cgo를 통해 C 라이브러리와 연결될 때 사용됩니다. 자바의 유사 메커니즘을 모델링하여 runtime.SetFinalizer는 객체에 함수를 연결하는데, 이 함수는 가비지 수집기가 참조가 없다고 판단할 때 실행됩니다. 그러나 Go 팀은 비결정론적 실행 타이밍과 가비지 수집기의 단계와의 복잡한 상호작용으로 인해 이러한 사용을 지속적으로 권장하지 않았습니다.
최종화자는 GC가 객체를 도달할 수 없는 것으로 표시한 후에만 전용 goroutine에서 비동기적으로 실행되며, 이로 인해 리소스가 필요 이상으로 오랫동안 할당된 상태로 남겨지는 창이 만들어집니다. 중요한 문제는 최종화자가 전역 변수나 살아 있는 객체에 참조를 저장하여 자신의 객체를 복원함으로써 다시 접근할 수 있게 되는 경우에 발생합니다. 무한 최종화 반복 및 리소스 소진을 방지하기 위해 런타임은 최종화자가 이미 실행되었음을 추적하고, 후속 최종화가 발생하기 전에 필수 "냉각" 기간을 시행해야 합니다.
Go는 객체가 처음으로 GC 주기에서 도달할 수 없는 것으로 발견된 후 최종화자가 정확히 한 번 실행되도록 보장합니다. 그 프로그램이 미리 종료되지 않는 한, 복원이 발생할 때 런타임은 내부 스윕 버퍼에서 최종화자 연결을 제거하고, 재등록을 위해 runtime.SetFinalizer에 대한 명시적 새 호출이 필요합니다. 이 설계는 복원된 객체가 다음 최종화자가 스케줄되는 이전에 다시 실제로 도달할 수 없음을 증명하기 위해 추가적인 완전 GC 주기를 반드시 살아남아야 한다는 것을 보장합니다.
type Resource struct { ptr unsafe.Pointer // C 메모리 } func NewResource() *Resource { r := &Resource{ptr: C.malloc(1024)} // r이 도달할 수 없게 되면 최종화자가 실행됩니다. runtime.SetFinalizer(r, (*Resource).Finalize) return r } func (r *Resource) Finalize() { C.free(r.ptr) // 만약 우리가: global = r를 했다면, 우리는 r을 복원합니다. // 최종화자는 이제 분리되었으며; r은 다시 최종화되기 위해 또 다른 GC 주기와 새로운 SetFinalizer 호출이 필요합니다. }
실시간 분석 파이프라인을 구축하는 과정에서, 우리 팀은 cgo를 사용하여 하드웨어 가속 암호화를 위한 서드파티 C 라이브러리를 통합하였고, C 힙 메모리에 민감한 키 버퍼를 할당했습니다. 우리는 Go 래퍼 구조체에서 runtime.SetFinalizer를 사용하여 래퍼가 가비지 수집될 때 C free() 함수를 자동으로 호출하도록 했습니다. 지속적인 부하 테스트 동안, 우리는 Go 코드가 이미 해제된 C 메모리에 접근하려고 시도할 때 간헐적인 세그멘테이션 오류를 관찰했습니다. 이는 해당 Go 객체가 여전히 요청 핸들러에서 활성 상태임에도 불구하고 발생했습니다.
근본 원인 분석 결과, 최종화자 내에서 호출된 우리의 로깅 프레임워크가 오류 컨텍스트를 위해 Go 래퍼에 대한 포인터를 캡처하면서 글로벌 링 버퍼에 실수로 복원했습니다. Go의 최종화자는 애플리케이션과 동시에 실행되므로, 객체는 C 메모리가 해제된 후에 복원되었지만 요청 핸들러가 그것을 사용하는 동안에는 복원되었습니다. 이 레이스 조건은 복원된 객체가 문제의 C 포인터를 잡고 있어, 높은 동시성에서 예기치 않게 서비스가 충돌하게 만들었습니다.
우리는 io.Closer 의미를 가진 명시적 Close() 메서드를 구현하고 최종화자는 누수 감지를 위한 안전망으로만 유지하는 방법을 고려했습니다. 이 접근 방식은 결정론적인 리소스 관리와 Go의 모범 사례를 보장하며, 요청이 완료될 때 C 메모리를 즉시 해제합니다. 그러나 Close()와 최종화자가 동시에 실행될 경우의 이중 해제 위험이 존재하며, 개발자가 Close()를 호출하지 잊어버릴 경우 최종화자가 객체를 복원하면 여전히 충돌을 방지하지 못합니다.
또 다른 옵션은 uintptr 주소를 사용하여 미해결 할당을 추적하는 사용자 정의 레지스트리로 최종화자를 대체하는 것이었으며, 이 방법은 가비지 수집을 방지하지 않으면서 객체 생명 주기에 대한 명시적 제어를 가능합니다. 하지만 이는 복잡한 수동 동기화 및 레지스트리의 유효하지 않은 항목을 주기적으로 스캔할 필요가 있으며, 자체적으로 면밀하게 유지되지 않으면 메모리 누수 위험이 있어 상당한 운영 오버헤드를 추가합니다.
우리는 또한 최종화자가 C 메모리를 해제하기 전에 객체 포인터가 글로벌 캐시에 존재하는지 감지하도록 수정하는 방법을 평가했으며, 만약 감지되면 패닉을 발생시켰습니다. 비록 이것이 테스트 중에 버그를 즉시 드러내지만, 기본적인 리소스 관리 문제를 해결하지 않으며 우아한 저하가 아닌 생산 중단을 초래할 수 있습니다. 게다가 객체 상태를 확인하기 위해 비싼 글로벌 잠금에 의존하므로 고성능 파이프라인에 필요한 처리량에 심각한 영향을 미칩니다.
궁극적으로 우리는 최종화자를 전체적으로 프로덕션 코드에서 제거하고, 모든 코드 경로에서 defer 문을 통해 강제된 명시적 Close() 호출을 의무화했습니다. 마지막 사용과 Close() 호출 사이의 조기 GC를 방지하기 위해, 우리는 C 메모리를 사용하는 중요한 섹션 이후에 runtime.KeepAlive(obj) 호출을 추가했습니다. 이 전략은 비결정론적 동작을 제거하고 복원 위험을 없애며, Go의 명시적 리소스 관리 철학과 일치하지만, Close()가 항상 접근 가능하도록 코드베이스의 상당 부분을 리팩토링해야 했습니다.
이전을 완료한 후, 세그멘테이션 오류는 완전히 사라졌고, GPU 메모리 사용량은 요청량과 함께 예측 가능하고 선형적으로 변했습니다. 이러한 객체의 Close() 호출을 강제로 실행하는 정적 분석 린터가 추가되었으며, 컴파일 타임에 리소스 누수를 탐지했습니다. 시스템은 현재 초당 100k 이상의 요청을 처리하며 메모리 관련 충돌 없이, 명시적 생명 주기 관리가 미션 크리티컬 Go 서비스에서 최종화자 기반 접근 방식을 능가함을 보여줍니다.
최종화자가 실행되는 동안 최종화된 객체가 GC에 의해 회수될 수 있는 이유는 무엇이며, runtime.KeepAlive가 이를 어떻게 방지하나요?
후보자들은 종종 최종화자의 존재가 목표 객체를 최종화자가 완료될 때까지 살아있게 유지한다고 가정합니다. 하지만 실제로는, GC가 객체가 도달할 수 없다고 판단하면 즉시 수집 대상이 되며, 최종화자는 별도의 goroutine에서 실행되도록 스케줄됩니다. 다른 참조가 존재하지 않으면 객체는 최종화자가 완료되기 전에 회수될 수 있습니다. 이를 방지하기 위해, 객체의 최종 사용 이후에 **runtime.KeepAlive(obj)**를 호출해야 하며, 이는 컴파일러 수준에서 객체의 생명을 그 지점까지 연장하는 발생 순서의 엣지를 생성하여, 최종화자가 실행되는 동안 C 리소스나 기타 의존성이 유효하도록 보장합니다.
하나의 Go 객체가 runtime.SetFinalizer에 대한 연속 호출을 통해 여러 최종화자를 등록할 수 있으며, 만약 최종화자 함수 자체가 객체를 캡처하는 클로저인 경우에는 어떻게 되나요?
많은 후보자들은 여러 최종화자가 하나의 객체에 연쇄되거나 큐를 형성할 수 있다고 잘못 믿습니다. Go는 최종화자가 다시 호출될 때 기존의 최종화자를 명시적으로 덮어쓰며, 내부 런타임 해시 테이블에 가장 최근의 함수 포인터만을 유지합니다. 만약 최종화자가 객체를 캡처하는 클로저라면, 이는 객체를 영구적으로 도달 가능 상태로 유지하는 순환 참조를 생성하여 최종화자가 결코 실행되지 못하게 하고, 메모리 누수를 초래합니다. 이는 GC가 클로저 변수의 캡처된 참조를 보게 됩니다.
A가 B를 참조하고 두 객체 모두 최종화자가 등록된 객체 그래프의 최종화자 실행 순서를 GC가 어떻게 처리하나요?
후보자들은 종종 자식-부모 또는 LIFO 스택 동작과 같은 결정론적 순서를 기대합니다. Go는 GC가 모든 도달할 수 없는 객체의 최종화자를 동시에 전역 큐에 추가하고, 이 큐는 여러 백그라운드 goroutine에서 병렬로 처리되기 때문에 순서를 보장하지 않습니다. A의 최종화자가 B에 접근하는 경우, B의 최종화자가 이미 실행되어 리소스를 해제했을 경우, A의 최종화자는 손상된 상태를 겪거나 사용 후 해제 오류를 발생시키게 됩니다. 따라서 최종화자는 다른 최종화자가 있는 객체에 접근하지 않도록 하거나, 모든 정리 로직이 루트 객체에 대한 단일 최종화자에 집중되어야 합니다.