ARC 정리 - Low-level까지 딥다이브

Hashswim·2024년 8월 26일
0

ARC란

ARC에 대해 알고있는 내용을 정리해보자..

ARC(Automatic Reference Counting)는 Heap 영역에 저장된 동적 메모리의 reference count를 자동으로 관리해주는 swift의 메모리 관리 기법으로 컴파일 과정중 SIL 최적화 단계에서 rc를 관리하도록 실행된다.

ARC 이전에는 MRC(Manual reference counting)을 사용해 개발자가 직접 retainrelease등을 통해 rc를 관리했다.

일반적으로 객체를 참조하고 있는 수 만큼 rc를 증가시키는데 해당 객체의 rc가 0이 되면 메모리에서 해제 된다고 생각하면 된다.

둘 이상의 객체가 참조를 연쇄적으로 하면서 사이클을 생성하는 경우 강한 순환 참조가 발생하게 되는데 이를 방지하기 위해 swift에서는 객체의 참조 방식을 나누어놓았다.

1. Strong(Default)
: 참조당하는 객체의 rc를 증가시키는 방식으로 기본으로 적용
2. Weak
: 참조당하는 객체의 rc를 증가시키지 않으며 참조당하는 객체가 메모리에서 해제되었을 때 참조하는 객체에 nil을 할당함(참조하는 객체는 optional 타입..!)
3. Unowned
: 참조당하는 객체의 rc를 증가시키지 않으며 참조당하는 객체가 메모리에서 해제되었을 때 접근하려하면 런타임 에러를 발생시킴(swift 5.0+에서 optional 타입으로 선언 가능, 참조당하는 객체가 해제되어도 자동으로 nil을 할당하지는 않음)
-> 참조하는 객체의 수명이 참조당하는 객체의 수명보다 짧을거라고 생각될 때 사용하는게 일반적 (unowned로 참조당하는 객체가 먼저 해제되지 않도록)

왜 Unowned?

weak과 비교해서 unowned의 차이는 참조당하는 객체가 해제되었을 때 해당 객체에 대한 참조를 nil로 자동으로 할당하는지 하지 않는지의 차이...

당연히 자동으로 nil을 할당해서 런타임시에 옵셔널 처리를 해주는게 안전하지만, 퍼포먼스 적으로 속도가 느리기때문에 unowned를 사용하는 경우가 있다.

암시적으로 참조당하는 객체가 참조하는 객체보다 먼저 해제되지 않다고 알려주는 것이므로 협업시에 해당 객체에 대해 알려주는 장점도 있다.

물론 참조당하는 객체가 메모리에서 해제되었을 때 unowned로 선언했었다면 접근시 에러를 발생하므로 조심해서 사용해야한다.(optional로 선언했더라도 직접 nil을 할당하지 않았다면 에러)

내부 동작

그럼 내부적으로 weak과 unowned가 어떻게 처리하는지 알아보자..

지금까지 하나의 객체는 하나의 rc를 가지고 strong, weak, unowned모두 이 rc를 가감한다고 이야기했지만 내부적으로 3가지의 rc를 가지고 있다.

swift 4 이전

객체의 isa 필드 다음에 rc를 인라인으로 생성해 두며, 참조를 해제하면서 strong rc가 0이 되면 객체는 비활성화 상태가 됨(메모리에서 해제되지는 않음)

weak 참조를 통해서 weak rc를 1 증가시키고 strong rc가 0이지만 weak rc는 0이 아닐 때 weak 참조를 통해 비활성화된 메모리에 접근할 경우 weak rc를 1 감소시킴

실제 메모리 해제는 weak rc가 0이될 때 비로소 메모리 해제...

-> 어떤 객체가 weak으로만 참조당하고 있을 때 우리의 의도대로 메모리에서 해제되지 않고 메모리를 차지하고 있어 메모리 누수가 발생 (weak rc는 weak 참조를 통해 해당 객체에 접근하는 시점에 -1을 하므로) 실제 ARC 의도대로 메모리에서 해제하기 위해서는 weak 참조를 하고있는 모든 곳에서 비활성화된 객체에 접근해 weak rc를 0을 만들어야 했다...
또한 멀티 쓰레드 환경에서 하나의 객체를 참조할 경우 레이스 컨디션이 발생해 오버헤드가 큰 문제,,

swift 4.0+)
객체의 외부에 사이드 테이블을 두는 방식으로 추가되었다.
물론 rc에 접근하기 위해서 객체 내부에 인라인으로 저장하고 접근하는 방식이 더 빠르기 때문에
swift에서는 사이드 테이블을 선택적으로 둘 수 있는데, 사이드 테이블이 생성되면 객체는 사이드 테이블에 대한 포인터를 갖고 모든 rc는 side table에 저장한다.

strong 참조와 unowned 참조는 객체 내부에 존재하며 접근시 객체에 바로 접근하고 포인터를 통해 사이드 테이블에 접근, weak 참조는 객체의 사이드 테이블에 바로 접근하기 때문에 weak 참조만 남아 있더라도 실제 객체는 해제되고 사이드 테이블만 남아 있도록 할수 있다.

+) 객체의 사이드 테이블 생성은 weak 참조 생성 뿐만 아니라 strong rc와 unowned rc가 오버플로가 발생해 여유 메모리가 필요하거나 객체 정보에 대한 추가적인 저장소가 필요할 때도 생성 됨

레퍼런스 카운트의 종류

  1. Strong RC (Strong Reference Count): 객체에 대한 강한 참조를 관리합니다. 이 값이 0이 되면 객체의 deinit이 호출되고 객체가 제거됩니다. 이때, unowned 참조는 에러를 발생시키고 사이드 테이블이 있다면 순회하며 nil을 반환하는 weak 참조 제로화과정 진행.

  2. Unowned RC (Unowned Reference Count): 객체에 대한 Unowned 참조를 관리합니다. Strong RC의 제거가 완료된 후 이 값이 0이 되면 객체의 메모리 할당이 해제

  3. Weak RC (Weak Reference Count): 객체에 대한 약한 참조를 관리합니다. 이 값이 0이 되면 객체의 사이드 테이블 엔트리가 해제

그림과 같이 메모리해제가 이루어진다. Freed 상태는 weak 참조만 남아 있는 상태로 swift 4.0 이전의 문제가 해결되는 모습을 볼수 있다.

Freed 상태에서는 참조당하는 객체는 할당해제된 상태지만 사이드 테이블(모든 weak variable에 nil이 할당된)만 남아 있으며 weak 참조를 통해 접근하게 되면 nil을 반환 받고 weak rc를 감소시킨다.

즉 unowned의 존재와 사용이유는 사이드 테이블 생성과 사이드 테이블을 순회하며 nil을 할당하는 zeroing weak reference 과정 없이 참조당하는 객체의 메모리를 효율적으로 관리할 수 있기 때문..!

저수준 단계에서의 메모리 관리

지금까지는 swift complie structure의 semantic analysis 단계에서의 ARC과정을 알아보았다.. 실제 Canonical SIL 코드를 보며 좀더 저수준 언어에서 어떻게 처리되는지 알아보자..

class aClass{
    var value = 1
}

let object1 = aClass()
let object2 = aClass()
let object3 = aClass()


class bClass{
    var o1 = object1
    weak var o2 = object2
    unowned var o3 = object3
}

let b = bClass()
b.o1.value = 10
b.o2?.value = 10
b.o3.value = 10

위 코드를 변환하면 너무 긴 SIL 코드가 나오므로 관련있는 부분만 살펴보자..

SIL 문법 알아보기

  1. 변수 초기화 부분
// object1 초기화
alloc_global @$s4main7object1AA6aClassCvp       // 전역 변수 object1을 위한 메모리 할당
%3 = global_addr @$s4main7object1AA6aClassCvp : $*aClass
%5 = function_ref @$s4main6aClassCACycfC : $@convention(method) (@thick aClass.Type) -> @owned aClass
%6 = apply %5(%4) : $@convention(method) (@thick aClass.Type) -> @owned aClass  // aClass 인스턴스 생성
store %6 to %3 : $*aClass                       // 생성된 인스턴스를 object1에 저장

// object2 초기화 (object1과 유사)
alloc_global @$s4main7object2AA6aClassCvp
%9 = global_addr @$s4main7object2AA6aClassCvp : $*aClass
%12 = apply %11(%10) : $@convention(method) (@thick aClass.Type) -> @owned aClass
store %12 to %9 : $*aClass

// object3 초기화 (object1과 유사)
alloc_global @$s4main7object3AA6aClassCvp
%15 = global_addr @$s4main7object3AA6aClassCvp : $*aClass
%18 = apply %17(%16) : $@convention(method) (@thick aClass.Type) -> @owned aClass
store %18 to %15 : $*aClass

// b 초기화
alloc_global @$s4main1bAA6bClassCvp
%21 = global_addr @$s4main1bAA6bClassCvp : $*bClass
%24 = apply %23(%22) : $@convention(method) (@thick bClass.Type) -> @owned bClass
store %24 to %21 : $*bClass
  1. 참조 카운트 관리 부분(ref count 증감하는 부분만)
// o1(강한 참조)
// 강한 참조 증가
%28 = apply %27(%26) : $@convention(method) (@guaranteed bClass) -> @owned aClass
strong_retain %28 : $aClass

// 강한 참조 감소
strong_release %28 : $aClass


// o2(약한 참조)
// 약한 참조를 통한 객체 접근 (강한 참조 증가)
%48 = load %47 : $*aClass
strong_retain %48 : $aClass

// 강한 참조 감소
strong_release %48 : $aClass

// 약한 참조를 통한 메모리 관리
store_weak %11 to %7 : $*@sil_weak Optional<aClass>


// o3(소유되지 않은 참조)
// unowned 참조에서 strong 참조로 변환
%63 = apply %62(%61) : $@convention(method) (@guaranteed bClass) -> @owned aClass
%66 = class_method %63 : $aClass, #aClass.value!setter : (aClass) -> (Int) -> ()

// unowned 참조 증가
unowned_retain %18 : $@sil_unowned aClass

// unowned 참조 감소
unowned_release %9 : $@sil_unowned aClass

// 강한 참조 감소
strong_release %63 : $aClass
  1. 메모리 해제 부분
// bClass.deinit()
sil hidden @$s4main6bClassCfd : $@convention(method) (@guaranteed bClass) -> @owned Builtin.NativeObject {
  %2 = ref_element_addr %0 : $bClass, #bClass.o1  // o1에 대한 접근
  destroy_addr %2 : $*aClass                      // o1 인스턴스 해제
  %6 = ref_element_addr %0 : $bClass, #bClass.o2  // o2에 대한 접근
  destroy_addr %6 : $*@sil_weak Optional<aClass>  // o2 인스턴스 해제
  %10 = ref_element_addr %0 : $bClass, #bClass.o3 // o3에 대한 접근
  destroy_addr %10 : $*@sil_unowned aClass        // o3 인스턴스 해제
  
  %2 = ref_element_addr %0 : $bClass, #bClass.o1  // o1에 대한 접근
  %3 = begin_access [deinit] [static] %2 : $*aClass
  destroy_addr %3 : $*aClass                      // o1 인스턴스 해제
  end_access %3 : $*aClass                        // id: %5
  
  %6 = ref_element_addr %0 : $bClass, #bClass.o2  // o2에 대한 접근
  %7 = begin_access [deinit] [static] %6 : $*@sil_weak Optional<aClass> // users: %9, %8
  destroy_addr %7 : $*@sil_weak Optional<aClass>  // o2 인스턴스 해제
  end_access %7 : $*@sil_weak Optional<aClass>    // id: %9
  
  %10 = ref_element_addr %0 : $bClass, #bClass.o3 // o3에 대한 접근
  %11 = begin_access [deinit] [static] %10 : $*@sil_unowned aClass // users: %13, %12
  destroy_addr %11 : $*@sil_unowned aClass        // o3 인스턴스 해제
  end_access %11 : $*@sil_unowned aClass          // id: %13
}

// bClass.__deallocating_deinit()
sil hidden @$s4main6bClassCfD : $@convention(method) (@owned bClass) -> () {
  %2 = function_ref @$s4main6bClassCfd
  %3 = apply %2(%0)                               // deinit 호출
  dealloc_ref %4 : $bClass                        // 메모리 해제
}

SIl 코드를 통해 알게된 점

ref count를 관리하는 방식
Swift doc의 SIL - ref counting 부분에서 확인해보면

  • strong_retain: 객체의 강력한 유지 수를 늘립니다.

  • strong_release: 객체의 강력한 참조 카운트를 감소시킵니다. release 연산이 객체의 강력한 참조 카운트를 0으로 만들면 객체가 파괴되고 약한 참조가 지워집니다. 강력한 참조 카운트와 소유되지 않은 참조 카운트가 모두 0에 도달하면 객체의 메모리가 할당 해제됩니다.

  • unowned_retain: 힙 객체의 소유되지 않은 참조 횟수를 증가시킵니다.

  • unowned_release: 객체의 unowned 참조 카운트를 감소시킵니다. strong 참조 카운트와 unowned 참조 카운트가 모두 0에 도달하면 객체의 메모리가 할당 해제됩니다.

  • load_weak: 실제 ARC 호출은 아니지만 선택 사항에 의해 참조되는 객체의 강력한 참조 횟수를 증가시킵니다.

  • store_weak: 약한 참조를 초기화하거나 재할당합니다.


참조 방식과 상관없이 strong ref count를 참조해 연산하거나 변환하는 방식...
실제로 3가지의 참조방식 모두 컴파일 단계에서 ref count를 개념적으로 저장하는 변수가 있지만 최적화를 통해 Strong ref count를 가지고 다른 참조 카운트를 운용하는 것을 확인할 수 있다.

+) https://www.uraimo.com/2016/10/27/unowned-or-weak-lifetime-and-performance/ 에 따르면 내부적으로 Strong reference counter와 additional weak reference counter 두가지를 운용하며 내부적으로 ref count를 처리한다고 하지만, RefCount.h, HeapObject.h, HeapObject.cpp, Heap.cpp 등 SIL 문서 커밋로그까지 다 확인해도 해당 내용을 확인 할 수 없었다..

아마 개념적으로 32bit(+64bit도 해당) RefCount 객체가 StrongExtraRefCount와 UnownedRefCount 두가지를 가지고 연산하는 걸 개념적으로 설명한거라고 생각된다.. (너무 오래되기도 했고),,

참조가 생성되거나 해제 될 때 현재 객체의 참조관련 상태를 읽고 상태에 따라 다르게 동작한다는 정도만 알면 될듯 하다..
ex) strong_retain_unowned

자세하게 알고 싶다면 SIL ref counting과 swift 헤더 파일들을 확인해보는게 좋을 듯하다..

정리

(low-level에서의 참조 카운트 관리는 사실 몰라도 상관없다고 생각한다..)
객체는 strong, weak, unowned 각각 참조 카운터를 가지고 있으며, weak 참조를 통해 side-table을 생성하고 바로 접근할 수 있으며 strong과 unowned는 객체에 직접 접근한다.

메모리 해제 과정에 있어
side-table 을 순환하며 nil을 할당하는 zeroing weak, weak ref count의 strong ref count로의 변환에 있어 멀티쓰레드 환경에서의 레이스 컨디션 등의 과정이 추가적으로 필요해 weak 참조는 unowned 참조에 비해 오버헤드가 비교적 크다.

swift ARC 공식 문서
에서 확인할 수 있듯 퍼포먼스와 메모리 최적화를 위해 unowned를 사용할수 있는 상황일 때는 unowned 사용에 대한 런타임 에러, 복잡한 구조에서의 사이드 이펙트를 피하기위해 방어적으로 weak을 사용하기 보단 한번더 고민하고 적극적으로 도입해 보자..
ex) 클로저는 캡처된 객체와 동일한 수명을 가지므로 객체에 도달할 수 있을 때까지만 클로저에 도달할 수 있으며 외부 객체와 클로저는 동일한 수명을 갖는, 객체와 부모 간의 간단한 역참조 상황)


+) 여러 메모리 관리 도구들을 통해 의도적으로 weak 참조의 오버헤드를 증가시켜도 의미있는 차이를 확인하지 못함...
최적화 단계가 아닌 구현 개발 단계라면 그냥 weak을 사용하는게 맞다고 생각한다...

References

0개의 댓글