Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ARC in Swift: Basics and beyond #3

Open
ffalswo2 opened this issue Apr 12, 2023 · 0 comments
Open

ARC in Swift: Basics and beyond #3

ffalswo2 opened this issue Apr 12, 2023 · 0 comments
Assignees
Labels
Swift 주제 WWDC21 WWDC 21년영상 민재

Comments

@ffalswo2
Copy link

ffalswo2 commented Apr 12, 2023

Start

reference type(참조 타입)의 의도치 않은 공유의 위험을 피하고 싶다면 ! 가능하다면 struct와 enum과 같은 value types을 더 선호해야한다.

참조 타입인 class를 사용한다면, Swift는 이것의 메모리를 Automatic Reference Counting(ARC)를 통해서 관리해준다.

효율적인 Swift 코드를 쓰기 위해서는, ARC가 어떻게 동작하는지 이해하는 것이 매우 중요하다.

Object lifetimes and ARC

Swift에서 객체는 initialization으로 시작하고 마지막 사용 시 종료됩니다. (use-based)

ARC가 객체의 생명이 끝나면 메모리에서 해당 객체를 할당 해제하면서 자동으로 메모리를 관리합니다.

객체의 수명이 끝났는지 뭘로 판단할까? 바로 해당 객체의 reference count이 0인지 아닌지.

ARC는 주로 retain(Ref count += 1) 및 release(Ref count -= 1) 작업을 삽입하는 Swift 컴파일러에 의해 구동됩니다.

Example Code

Traveler1

import Foundation

class Traveler {
var name: String = ""
var destination: String?

init(name: String) {
    self.name = name
}

}

func test() {
let traveler1 = Traveler(name: "Lily") // Traveler1 참조 시작 !
let traveler2 = traveler1 // Traveler1 참조 끝 !
release // 컴파일러가 참조 끝나자마자 release 추가
traveler2.destination = "Big Sur"
print("Done traveling")
}

test()

Swift 컴파일러는 참조가 시작할 때 retain operation을 삽입한다. 그리고 참조의 마지막 사용 이후에 release operation을 삽입한다.

위의 코드를 보면 Traveler1의 참조가 끝나는 직후 바로 release를 넣어주었는데, 참조가 시작할 때에 retain부분이 안보이는데 그 이유는 initialization이 reference count를 1로 설정해주기 때문이다.

Traveler2

import Foundation

class Traveler {
var name: String = ""
var destination: String?

init(name: String) {
    self.name = name
}

}

func test() {
let traveler1 = Traveler(name: "Lily")
retain
let traveler2 = traveler1 // Traveler2 참조 시작 !
release
traveler2.destination = "Big Sur" // Traveler2 참조 끝 !
release
print("Done traveling")
}

test()

Traveler2의 참조 시작과 끝은 위와 같다. Traveler2는 init에서 컴파일러가 reference count를 1로 설정해주지 않기 때문에, 참조가 시작되기 이전에 retain operation이 삽입된다. 그 후에는 동일하게 참조가 끝나는 직후 release operation도 삽입된다.

Traveler1 ref count deallocate ?
init (힙에 저장되면서) 1 X
retain(traveler2(new ref) 시작할 때) 2 X
release(traveler1 참조 끝) 1 X
release(traveler2 dest 업데이트 후) 0 O

1

2

객체는 마지막 사용 이후에 바로 deallocate된다. 이런게 use-based라는 뜻.

Observable object lifetimes

Reference Cycle (순환 참조)

weak과 unowned는 reference count를 증가시키지 않는다. 이러한 이유로, 우리가 **Reference Cycle(순환 참조)**를 해결하기 위해서 weak, unowned 키워드를 사용한다.

3

traveler.account = account 에서 account 참조는 끝이 난다.

그러면 Account의 ref_count는 1이 된다.

4

그 후에 마지막 줄의 traveler.printSummary()함수가 끝난 후에 traveler 참조도 끝이 난다. 그러면 Traveler의 ref_count도 1이 된다.

최종 결과

5

모든 참조의 사용이 끝나고 이제 메모리에서 deallocate될 일만 남았는데, 해당 객체들은 절대 메모리에서 deallocate되지 않고 memory leak(메모리 누수)를 일으킬 것이다. 왜?

앞서 말했듯이, ARC는 메모리에서 deallocate판단을 하기 위한 기준으로 해당 객체의 ref_count가 0인지 아닌지를 본다.

위의 상황을 보면 모든 참조가 끝났음에도 ref_count가 모두 1로 남아있다. 그렇기에 ARC는 당연히 이들을 deallocate하지 않을 것이다.

이런 상황을 우리는 순환 참조, reference cycle이라고 한다.

해결해보자

이런 상황을 우리는 weak, unowned를 통해서 해결할 수 있다. 어떻게?

앞서 말했듯이 weak와 unowned는 ref_count를 증가시키지 않기 때문에 !

weak나 unowned 객체가 사용중일 때, deallocate될 수도 있다.

이런 상황이 발생한다면 Swift 런타임에서 weak reference는 nil로, unowned reference는 Trap으로 엑세스를 돌린다. (Trap → Trap handler → kernel SW)

예제 적용

6

한눈에 보기 쉽게 weak를 적용하기 전과 후를 비교해보면…

Ref Cycle

  • weak 전, reference cycle 상황
    Break Cycle

  • weak 적용 후, reference cycle이 깨진 상황 👍

weak를 통한 참조가 의도치 않은 다른 동작을 일으킬 수도 있다

class Traveler {
    var name: String
    var account: Account?
}

class Account {
    weak var traveler: Traveler?
    var points: Int
    func printSummary() {
        print("\(traveler!.name) has \(points) points")
    }
}

func test() {
    let traveler = Traveler(name: "Lily")
    let account = Account(traveler: traveler, points: 1000)
    traveler.account = account
    account.printSummary()
}

이 예제에서는 account가 printSummary()를 호출하고 있다.

printSummary함수 안에서 traveler의 정보에 엑세스를 하고 호출하고 있다.

우리가 위에서 살펴봤듯이 traveler.account = account에서 traveler의 참조는 끝난다. 컴파일러가 다음 명령어인 printSummary를 실행시키기 전에 바로 release를 해서 traveler의 ref_count를 1 줄인다면 traveler는 deallocate되고 nil이 될 수도 있다. 물론 바로 release되지 않고 printSummary가 의도한대로 잘 나올 수도 있지만 그것은 정말 우연이다. 이러한 잠재적 버그를 굳이 안고 갈 필요는 없다 !

결국 traveler가 nil이 되면, force unwrapping에 걸려 앱은 죽게 된다.

“?? 아니 그러면 ! 가 그럼 원인이 아니냐!” 라고 말할 수 있겠지만, 만약 옵셔널 바인딩으로 !를 제거해준다쳐도 이번에는 어디서 에러가 나는지 절대 알 수 없는 silent bug가 남는다.

해결방안1: withExtendedLifetime

이를 해결하기 위한 방법으로 withExtendedLifetime() 가 있다.

withExtendedLifetime(::) | Apple Developer Documentation

withExtended정의

Return Value

확장되는 x의 수명에 따라 달라지는 실행할 클로저입니다. body에 반환값이 있는 경우, 해당 값은 withExtendedLifetime(::) 메서드의 반환값으로도 사용됩니다.

func test() {
    let traveler = Traveler(name: "Lily")
    let account = Account(traveler: traveler, points: 1000)
    traveler.account = account
    withExtendedLifetime(traveler) {
        account.printSummary()
    }
}

func test() {
    let traveler = Traveler(name: "Lily")
    let account = Account(traveler: traveler, points: 1000)
    traveler.account = account
    account.printSummary()
    withExtendedLifetime(traveler) {}
}

func test() {
    let traveler = Traveler(name: "Lily")
    let account = Account(traveler: traveler, points: 1000)
    defer {withExtendedLifetime(traveler) {}}
    traveler.account = account
    account.printSummary()
}
  • 3가지 모두 같은 기능

  • defer ?

    작성된 위치와 상관없이 함수 종료 직전에 실행되는 구문.
    자기 자신의 실행을 맨 마지막으로 미루는 느낌. (defer: 미루다)

하지만 이것도 그닥..

이렇게 하면 결국 내가 정확성에 대한 책임을 져야 한다. 그 말은 즉슨, 모든 버그가 생길 수 있는 참조 지점에 withExtendedLifetime을 통하여 정확성을 보장해주어야한다는 말이다. 이건 말이 안되죠?

가장 확실한 해결방안

그냥 애초부터 클래스들의 디자인을 참조 문제가 발생하지 않게 만들면 됩니다. (아하…)
확실1

객체에 대한 엑세스를 강한 참조에서만 가능하게 하면 된다.

Traveler가 account를 강하게 참조하고 있고 Account는 Traveler를 약하게 참조하고 있다. 딱 아래 그림과 같은 상황.

Break Cycle

그렇다면 위의 문제 코드처럼 Account에서 Traveler의 정보에 접근하는 것이 아니라 !

Traveler에서 Account에서 접근하게끔 디자인 하면 된다는 뜻이다.

추가로, Account내에 약한 참조 앞에 private 접근 제한자를 통해 숨겨버린다.

(이제 Account.traveler.printSummary() 이렇게 못하게 할려고)

이제 printSummary함수를 호출하는 방법은 강한 참조인 Traveler를 통해서 호출하는 방법밖에 남지 않게 되었다.

잠시 멈춰서 생각해 볼 필요가 있습니다.

왜 weak, unowned references가 필요한가요?

reference cycle를 깨기 위해서만 사용되나요?

애초에 reference cycle를 만들지 않는다면 어떨까요?

reference cycle는 알고리즘을 수정하거나 순환 클래스 관계를 트리 구조로 변환하면 피할 수 있는 경우가 있다.

(cyclic class relationships → tree structures)

7

weak를 쓰고 있기 때문에 문제는 없는 코드.

우선 이 클래스의 디자인을 redesign하기 앞서 가정된 상황을 살펴보면..

  • Traveler는 Account를 가지고 있다. (Traveler는 Account의 모든 정보를 필요로 한다)
  • Account는 Traveler의 정보 중 개인정보에 대한 것들만 필요하다.

이런 상황으로 볼 수 있다. 현재 이름 밖에 없어서 그렇지 아래처럼 정보들이 조금 더 많다면? 그런데 성별은 또 개인정보가 아니라서 굳이 Account가 가질 필요가 없다고 한다면 어떻게 할까?

굳이 Traveler 전체를 가지고 있을 필요가 있을까?

class Traveler {
    var name: String = ""
    var age: Int
    var sex: Int // 성별은 딱히 개인정보까지는 아님!
    var account: Account?
}

class Account {
    weak var traveler: Traveler?
    var points: Int
}

Account는 Traveler의 개인 정보만 필요로 하기 때문에 PersonalInfo 클래스를 만들어줌으로써 Traveler와 Account의 cyclic reference를 끊을 수 있게 된다. 아래와 같이 말이다. 필요한 정보만 엑세스하는 건 덤이다.

class PersonalInfo {
    var name: String = "김민재"
    var age: Int = 26
}

class Traveler {
    var personalInfo: PersonalInfo
    var sex: Int
    var account: Account?

    init(personalInfo: PersonalInfo, sex: Int) {
        self.personalInfo = personalInfo
        self.sex = sex
    }
}

class Account {
    var info: PersonalInfo
    var points: Int

    init(info: PersonalInfo, points: Int) {
        self.info = info
        self.points = points
    }
}

8

이렇게 디자인하는 것은 시간이 더 소요되는 작업일 수 있지만, 잠재적인 객체 생명주기 버그를 없앨 수 있는 확실한 방법이다. (weak, unowned키워드를 쓸 필요조차 없는 상황이니까)

deinitializer side effect

class Traveler {
  var name: String
  var destination: String?
  deinit {
    print("\(name) is deinitializing")
  }
}

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

Traveler가 deallocate되기 전에 deinit안에 print문이 실행될 것이다.

해당 print문은 ARC의 최적화에 따라서 “Done traveling” 이 뜨기 전에 실행될 수도 있고 그 이후에 실행될 수도 있다.

복잡한 예제.

class Traveler {
    var name: String
    var id: UInt
    var destination: String?
    var travelMetrics: TravelMetrics
    // Update destination and record travelMetrics
    func updateDestination(_ destination: String) {
        self.destination = destination
        travelMetrics.destinations.append(self.destination)
    }
    // Publish computed metrics
    deinit {
        travelMetrics.publish()
    }
}

class TravelMetrics {
    let id: UInt
    var destinations = [String]()
    var category: String?
    // Finds the most interested travel category based on recorded destinations
    func computeTravelInterest()
    // Publishes id, destinations.count and travel interest category
    func publish()
}

func test() {
    let traveler = Traveler(name: "Lily", id: 1) // traveler참조 시작
    let metrics = traveler.travelMetrics
    ...
    traveler.updateDestination("Big Sur")
    ...
    traveler.updateDestination("Catalina") // traveler 참조 끝
    metrics.computeTravelInterest()
}

verifyGlobalTravelMetrics()

test함수를 보면 traveler의 참조가 시작되는 부분과 끝나는 부분을 주석으로 표시했다.

deinit이 metrics.computeTravelInterest() 뒤에 실행된다면 computeTravelInterest 메서드가 올바르게 실행된 이후의 기대한 결과값을 얻을 수 있을 것이다. 하지만 위의 상황에서 봤듯이 ARC의 최적화에 따라서 traveler의 참조가 끝나고 곧바로 release가 된다면 어떻게 될까? Traveler가 강한 참조로 가지고 있는 Metrics또한 compute함수가 실행되기도 전에 사라지게 되기 때문에 nil이 표시되고 원하는 기대값을 얻을 수 없게 된다.

deinit 해결방안

  • withExtendedLifetime()
func test() {
    let traveler = Traveler(name: "Lily", id: 1)
    let metrics = traveler.travelMetrics
    ...
    traveler.updateDestination("Big Sur")
    ...
    traveler.updateDestination("Catalina")
    withExtendedLifetime(traveler) {
        metrics.computeTravelInterest()
    }
}

위에서 언급한 정확성에 대한 책임을 모두 내가 가져야한다는 문제는 동일.

  • Redesign to limit visibility of internal class details
class Traveler {
    ...
    private var travelMetrics: TravelMetrics
    deinit {
        travelMetrics.computeTravelInterest()
        travelMetrics.publish()
    }
}

func test() {
    let traveler = Traveler(name: "Lily", id: 1)
    ...
    traveler.updateDestination("Big Sur")
    ...
    traveler.updateDestination("Catalina")
}

compute함수를 Traveler가 deinit될 때 local에서 해주면 deinit side effect는 생기지 않는다. private 을 통해 외부에서 접근을 막고 local에서 작업을 하도록 만들면 deallocate되기전에 deinit이 실행되기 때문에 compute함수를 실행할 때 metrics가 값을 들고 있는 것이 보장된다.

이것도 괜찮지만 deinit side effect를 완전히 없애는 것이 더 좋다.

  • Redesign to avoid deinitializer side-effects
class Traveler {
    ...
    private var travelMetrics: TravelMetrics
     
    func publishAllMetrics() {
        travelMetrics.computeTravelInterest()
        travelMetrics.publish()
    }

    deinit {
				assert(travelMetrics.published)
    }
}

class TravelMetrics {
    ...
    var published: Bool
    ...
}

func test() {
    let traveler = Traveler(name: "Lily", id: 1)
    defer { traveler.publishAllMetrics() }
    ...
    traveler.updateDestination("Big Sur")
    ...
    traveler.updateDestination("Catalina")
}

deinit 대신에 defer로 함수를 호출하고 deinit에서 assert로 확인만 해주고 있다.

각 솔루션은 초기 구현 비용과 지속적인 유지 관리 비용의 정도가 다르다.


세션 마지막에 Xcode에서 Optimize Object Lifetimes를 통해서 이런 release최적화를 할 수 있다고 했지만, 보니까 없더라고요..? 찾아보니까 Xcode14부터는 그냥 기본적으로 일관되게 최적화해준다고 하네요.

9

[Xcode 14 Release Notes | Apple Developer Documentation](https://developer.apple.com/documentation/xcode-release-notes/xcode-14-release-notes)

ref


https://babbab2.tistory.com/80

@ffalswo2 ffalswo2 added WWDC21 WWDC 21년영상 Swift 주제 민재 labels Apr 12, 2023
@ffalswo2 ffalswo2 self-assigned this Apr 12, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Swift 주제 WWDC21 WWDC 21년영상 민재
Projects
None yet
Development

No branches or pull requests

1 participant