Automatic Reference Counting에 대하여


Swift에서는 자동 참조 카운팅(Automatic Reference Counting)을 활용하여 앱의 메모리를 관리합니다.
처음으로 ARC를 접했을 때는 메모리를 자동으로 관리해준다고 하니, 일종의 런루프나 스레드 같은 것이 아닐까 생각했던 적도 있었는데요..!
오늘은 이러한 ARC가 어떤 식으로 동작하는지 자세히 파헤쳐보도록 하겠습니다 😊



[참고]

[Apple 문서 - 자동 참조 카운팅 (Automatic Reference Counting)]

[WWDC2021 - ARC in Swift: Basics and beyond]

[WWDC16 - Understanding Swift Performance]

[Wikipedia - 정적 단일 할당 양식]



ARC란?

자동 참조 카운팅(ARC)은 클래스와 같은 참조 타입의 메모리를 관리해주는 방법입니다.
객체를 참조하면 reference count 횟수를 증가시키고, 해제하면 reference count를 감소시키며 이 값이 0이 될 때 객체를 메모리에서 완전히 해제하는 구조입니다.

힙에 할당된 인스턴스의 reference count는 인스턴스 자체에 저장됩니다.

이렇게 말이죠..?

그리고 컴파일러는 해당 객체가 사용되는 부분에 retain 및 release 연산을 삽입합니다.
즉 ARC는 기본적으로 컴파일러가 삽입된 코드에 의해 동작하는 것이라고 할 수 있겠네요!


ARC Deep Dive

이러한 retain / release는 실제로 어떻게 동작하는 것일까요?
직접 확인해 보겠습니다.

class Person {
    var name: String
    var age: Int
    
    init(name: String, age: Int) {
        self.name = name
        self.age = age
    }
}

let student = Person(name: "James", age: 20)

let copy = student

student.age += 1

오늘도 예시 코드를 구워왔습니다. 🍞
컴파일러가 ARC 코드를 삽입해주는 것이라고 하니 컴파일된 코드를 보면 되겠죠?

class Person {
  @_hasStorage var name: String { get set }
  @_hasStorage var age: Int { get set }
  init(name: String, age: Int)
  @objc deinit
}

@_hasStorage @_hasInitialValue let student: Person { get }

@_hasStorage @_hasInitialValue let copy: Person { get }

...

너무 길어서 일부는 생략했습니다.
이제 여기서 retain / release 동작을 어떻게 해주고 있는지 찾아보겠습니다.. 😇


%3 = global_addr @$s8Contents7studentAA6PersonCvp : $*Person // users: %21, %18, %15

...

%14 = apply %13(%10, %12, %4) : $@convention(method) (@owned String, Int, @thick Person.Type) -> @owned Person // user: %15
store %14 to %3 : $*Person

...

%17 = global_addr @$s8Contents4copyAA6PersonCvp : $*Person // user: %20
%18 = load %3 : $*Person                        // users: %20, %19
strong_retain %18 : $Person                     // id: %19
store %18 to %17 : $*Person                     // id: %20

이 부분입니다!
우선 %3에 %14에서 생성한 Person을 저장하고 있고, 다음으로 copy에서 기존의 student를 참조하고 있으므로 strong_retain으로 retain이 발생하는 것을 확인할 수 있습니다.

그런데 아무리 들여다봐도 release를 해주는 부분이 없더라구요….? 😭
좀 더 찾아보니 아무래도 Playground에서 전역 변수로 생성해버려서 일반적인 스코프 기반 해제 로직이 적용되지 않은 듯 합니다.

따라서 다음과 같이 코드를 수정해 주었습니다.

func myFunction() {
    let student = Person(name: "James", age: 20)

    let copy = student

    student.age += 1
}

myFunction()

이렇게 하면 일반적인 지역 변수 스코프대로 처리될 수 있을 것 같습니다. 😊

확인해보니 sil에도 변동이 있네요..!

변경 전

%18 = load %3 : $*Person                        // users: %20, %19
strong_retain %18 : $Person                     // id: %19
store %18 to %17 : $*Person                     // id: %20
%21 = load %3 : $*Person                        // users: %23, %24
%22 = integer_literal $Builtin.Int64, 1         // user: %29
%23 = class_method %21 : $Person, #Person.age!modify : (Person) -> () -> (), $@yield_once @convention(method) (@guaranteed Person) -> @yields @inout Int // user: %24
(%24, %25) = begin_apply %23(%21) : $@yield_once @convention(method) (@guaranteed Person) -> @yields @inout Int // users: %34, %26, %35
%26 = struct_element_addr %24 : $*Int, #Int._value // user: %27
%27 = load %26 : $*Builtin.Int64                // user: %29
%28 = integer_literal $Builtin.Int1, -1         // user: %29
%29 = builtin "sadd_with_overflow_Int64"(%27 : $Builtin.Int64, %22 : $Builtin.Int64, %28 : $Builtin.Int1) : $(Builtin.Int64, Builtin.Int1) // users: %31, %30
%30 = tuple_extract %29 : $(Builtin.Int64, Builtin.Int1), 0 // user: %33
%31 = tuple_extract %29 : $(Builtin.Int64, Builtin.Int1), 1 // user: %32
cond_fail %31 : $Builtin.Int1, "arithmetic overflow" // id: %32
%33 = struct $Int (%30 : $Builtin.Int64)        // user: %34
store %33 to %24 : $*Int                        // id: %34
end_apply %25                                   // id: %35
%36 = integer_literal $Builtin.Int32, 0         // user: %37
%37 = struct $Int32 (%36 : $Builtin.Int32)      // user: %38
return %37 : $Int32                             // id: %38
} // end sil function 'main'

변경 후

%9 = function_ref @$s8Contents6PersonC4name3ageACSS_SitcfC : $@convention(method) (@owned String, Int, @thick Person.Type) -> @owned Person // user: %10
%10 = apply %9(%6, %8, %0) : $@convention(method) (@owned String, Int, @thick Person.Type) -> @owned Person // users: %29, %28, %13, %12, %11, %16, %15
debug_value %10 : $Person, let, name "student"  // id: %11
strong_retain %10 : $Person                     // id: %12
debug_value %10 : $Person, let, name "copy"     // id: %13
%14 = integer_literal $Builtin.Int64, 1         // user: %21
%15 = class_method %10 : $Person, #Person.age!modify : (Person) -> () -> (), $@yield_once @convention(method) (@guaranteed Person) -> @yields @inout Int // user: %16
(%16, %17) = begin_apply %15(%10) : $@yield_once @convention(method) (@guaranteed Person) -> @yields @inout Int // users: %26, %18, %27
%18 = struct_element_addr %16 : $*Int, #Int._value // user: %19
%19 = load %18 : $*Builtin.Int64                // user: %21
%20 = integer_literal $Builtin.Int1, -1         // user: %21
%21 = builtin "sadd_with_overflow_Int64"(%19 : $Builtin.Int64, %14 : $Builtin.Int64, %20 : $Builtin.Int1) : $(Builtin.Int64, Builtin.Int1) // users: %23, %22
%22 = tuple_extract %21 : $(Builtin.Int64, Builtin.Int1), 0 // user: %25
%23 = tuple_extract %21 : $(Builtin.Int64, Builtin.Int1), 1 // user: %24
cond_fail %23 : $Builtin.Int1, "arithmetic overflow" // id: %24
%25 = struct $Int (%22 : $Builtin.Int64)        // user: %26
store %25 to %16 : $*Int                        // id: %26
end_apply %17                                   // id: %27
strong_release %10 : $Person                    // id: %28
strong_release %10 : $Person                    // id: %29
%30 = tuple ()                                  // user: %31
return %30 : $()                                // id: %31

코드를 변경하고 나니 strong_release가 두 번 발생했습니다.

하지만.. 뭔가 차이가 보입니다.
분명히 동일한 코드를 작성하였는데, 전역으로 작성했을 때와 차이가 있습니다.
이런 부분도 궁금하니 한 번 짚어보도록 하겠습니다! 😊

먼저 전역에 student를 선언하였을 때…

%18 = load %3 : $*Person                        // users: %20, %19
strong_retain %18 : $Person                     // id: %19
store %18 to %17 : $*Person                     // id: %20

%3에 있는 student를 load해서 retain count를 증가시키고 %17에 참조를 전달해 주었는데요,
함수 내에 해당 코드를 작성하자…

debug_value %10 : $Person, let, name "student"  // id: %11
strong_retain %10 : $Person                     // id: %12
debug_value %10 : $Person, let, name "copy"     // id: %13

동일하게 retain count는 증가시키지만 뭔가 동작이 달라보이네요..?!
새로운 debug_value라는 키워드가 등장했습니다.

document에서는 이 명령어들을 뭐라고 설명하고 있을까요..?

load와 store는 주소에서 값을 읽고 쓰는 명령어입니다.

debug_value는 값을 어떤 변수와 연결시켜 디버깅 정보에 표시하는 명령어라고 합니다.
즉 변수를 선언했을 때, 디버거에서 이를 이해할 수 있도록 하기 위해 변수 이름과 값을 연결하는 것입니다.

debug_value %0 : $Person, let, name "self", argno 1 // id: %1

좀 더 찾아보니 위 코드에서는 %0을 self라는 이름으로 할당하는 과정을 나타낸다고 합니다.

[출처: SIL(Swift Intermediate Language)을 통한 Swift debugging ]


왜 이런 차이가 발생할까요?
결국 지역 변수도 스택이라는 메모리에 올라가는 거니까, load / store와 유사하게 동작하는 것 아닌가..? 라고 생각해서 헷갈렸습니다.

조금 더 찾아보니… 우선 Swift 컴파일러는 SSA 형식으로 동작합니다.
여기서 SSA 형식이란 각 변수가 정확히 한 번만 할당되는 중간 표현(IR)의 한 유형을 말합니다.
이때 값이 변하면 새로운 SSA 값으로 분기합니다.
즉… 동일한 값에 대한 SSA 값은 하나 밖에 없는 것이죠!

그렇다면 함수 내에서 단순히 참조를 전달한다고 해서 새로운 SSA 값이 필요할까요? 아닙니다.
그냥 위에서처럼 동일한 %10 값을 사용하게 됩니다.
그래서 디버깅 정보에 표시하기 위해 변수 이름과 연결하는 동작 외에는 별도의 작업이 없는 것입니다.

하지만 전역 변수의 경우… 메모리에 저장된 전역적인 값을 프로그램 어디에서나 불러오게 됩니다.
그래서 꼭 메모리에서 load하고 store하는 동작을 통해 접근하게 되는 것이죠!!
따라서 컴파일러 입장에서 실행할 수 있는 형태로 바라볼 때 전역 변수는 서로 다른 메모리에 존재하는 별도의 값으로 인식된다는 것을 알 수 있습니다.
만약 기계어 수준으로 가게 된다면 둘 다 메모리에 접근하여 값을 저장하는 코드가 존재할 것 같네요!


중간에 이상한 곳으로 빠지기는 했는데… 어쨌든 retain과 release가 실제로 삽입되는 것을 확인할 수 있었습니다!

그런데 또 궁금한 점은…
분명히 WWDC에서는 마지막 사용 직후에 release 연산을 삽입한다고 하는데요, 왜 아까 sil에서 연달아 release가 실행되었을까요..?
생각해보면 copy의 마지막 사용 직후 해제되어도 될 것 같은데 말이죠.

이 이유는… 컴파일러의 최적화 때문이지 않을까 생각했습니다.
Apple에서도 객체의 보장된 생명 주기는 마지막 사용 시점까지이고, 실제로 컴파일러가 삽입하는 retain / release 연산에 의해 생명주기가 결정된다고 말합니다.

그렇다면 혹시 최적화 이전의 코드를 확인해보면 release가 정확한 시점에 들어가 있을까요?

  destroy_value %17 : $Person                     // id: %33
  destroy_value %13 : $Person                     // id: %34
  %35 = tuple ()                                  // user: %36
  return %35 : $()                                // id: %36
} // end sil function '$s8Contents10myFunctionyyF'

아니네요..?
코드가 조금 달라지긴 했는데 여전히 destroy_value를 연속으로 호출하고 있습니다.
(결국 최적화보다는 뭔가 내부적인 로직에 의해서 이렇게 작동하는 것 같은데 놓친 부분이 있다면 알려주세요 😭)


그렇다면 Apple에서 제공하는 예시로 바꿔서 테스트를 해보겠습니다!

func test() {
    let traveler1 = Traveler(name: "Lily")
    // retain
    let traveler2 = traveler1
    // release
    traveler2.destination = "Big Sur"
    // release
    print("Done traveling")
}

결과는 어떨까요?

  destroy_value %48 : $String                     // id: %51
  destroy_value %46 : $String                     // id: %52
  destroy_value %44 : $Array<Any>                 // id: %53
  destroy_value %14 : $Traveler                   // id: %54
  destroy_value %10 : $Traveler                   // id: %55
  %56 = tuple ()                                  // user: %57
  return %56 : $()                                // id: %57

이 결과로 봐서는… ARC의 동작 순서는 실제 코드 순서와는 차이가 있을 수도 있겠다는 생각이 듭니다.
결국 정확한 생명 주기가 무엇인지 중요하지 않다는 Apple의 말처럼 명확하게 보장된 범위를 벗어난 참조만 지양해야겠네요..!


마무리

지금까지 객체를 사용하면서 막연히 ARC가 메모리 관리를 도와준다는 말에 적당히 코드 블록이 닫히는 시점까지는 객체가 살아있겠거니 생각했었는데, 직접 확인해보니 꽤 유익했습니다. 😊
따라서 보장된 생명 주기가 아닌 관찰 가능한 생명 주기에 의존하지 않는 코드를 작성하는 것이 중요합니다.
이에 관해 Apple이 언급한 몇 가지 예시와 해결법이 존재하는데, 실제로 코드를 짤 때 이런 부분을 주의 깊게 살펴봐야겠다는 생각이 들었습니다. 🙂