on
Swift ARC Deep Dive
Swift ARC Deep Dive
1. 계기
예전에 ARC 관련 코드가 컴파일 단계에서 어떻게 동작하는지 확인하려고 SIL로 컴파일해서 분석하는 글을 쓴 적이 있어요. 그런데 SIL로 변환된 코드를 직접 보니, 몇 가지 궁금한 점이 생겼어요.
class Foo { }
func bar() {
let a = Foo()
}
위 코드를 SIL로 컴파일하면 다음과 같은 코드가 나와요.
...
// bar()
// Isolation: unspecified
sil hidden @$s1b3baryyF : $@convention(thin) () -> () {
bb0:
%0 = metatype $@thick Foo.Type // user: %2
// function_ref Foo.__allocating_init()
%1 = function_ref @$s1b3FooCACycfC : $@convention(method) (@thick Foo.Type) -> @owned Foo // user: %2
%2 = apply %1(%0) : $@convention(method) (@thick Foo.Type) -> @owned Foo // users: %4, %3
debug_value %2 : $Foo, let, name "a" // id: %3
strong_release %2 : $Foo // id: %4
%5 = tuple () // user: %6
return %5 : $() // id: %6
} // end sil function '$s1b3baryyF'
...
여기서 strong_release는 나오는데, 왜 retain 관련 코드는 없을까? 이 부분이 궁금해서 ARC 동작 방식에 대해 더 깊이 알아보기로 했어요.
2. ARC 동작 개요
ARC에 관해서는 짧게 설명할게요.
Swift의 ARC(Automatic Reference Counting)는 참조 카운트 기반으로 메모리를 자동 관리해주는 시스템이에요. 각 객체가 몇 번 참조되고 있는지 컴파일러가 추적해서, strong 참조가 모두 사라지는 순간 메모리를 해제해줘요.
ARC는 strong, weak, unowned 세 가지 참조 방식을 지원해요.
- weak 참조는 대상 객체가 사라지면 자동으로 nil로 바뀌어서 안전하게 사용할 수 있어요.
- unowned 참조는 참조 대상이 사라진 뒤에도 남아 있다가, 접근하면 런타임에서 크래시가 발생해요. 그래서 객체의 생명주기가 명확할 때만 사용해야 해요.
3. Swift Runtime의 HeapObject / RefCount 구조
SIL 코드에서 retain 관련 코드가 보이지 않는 이유는 금방 알 수 있었어요.
The object has an initial retain count of 1
// Refcount of a new object is 1.
constexpr RefCounts(Initialized_t) : refCounts(RefCountBits(0, 1)) { }
객체가 생성될 때 retain count의 초기값이 1이기 때문이에요. 이렇게 이해하고 끝내려다가, Swift 컴파일러 소스코드를 살펴보면서 더 흥미로운 부분들을 발견했어요.
3-1. Physical field is 0?
Swift 런타임에서 strong reference count는 실제 메모리상으로는 0부터 시작하지만, 논리적으로는 1로 동작해요.
즉, 객체가 생성될 때 내부 refcount 비트 값은 0이지만, Swift ARC는 이걸 reference count 1로 간주해요.
예를 들어, 객체가 메모리에 올라오면 물리적으로는 0이지만 논리적으로는 1인 거예요.
+----------------------+
| Heap Object |
+----------------------+
| ... |
| RefCount bits: 0 | ← 내부 비트값(0) = 참조 1개!
+----------------------+
왜 이렇게 되어 있을까?
이렇게 설계한 이유는 underflow만 감지하면 deinit 처리가 가능하기 때문이에요.
즉, 참조 카운트가 1에서 0으로 줄어드는 순간을 따로 체크하지 않고, 비트 값이 음수가 되는 시점(underflow)에 객체 해제를 트리거하는 방식이에요.
또한 physical level에서 1로 저장하는 것보다 0으로 저장하는 게 계산이 더 효율적일 수 있어요.
예를 들어, MSB(최상위 비트) 하나만 보면 음수/양수를 바로 판단할 수 있어서 조건 체크가 빨라져요.
decrementStrongExtraRefCount(uint32_t dec) {
...
bits -= BitsType(dec) << Offsets::StrongExtraRefCountShift;
return (SignedBitsType(bits) >= 0);
}
3-2. Reference Count가 3개?
Swift ARC는 strong 참조만 관리하는 게 아니라, strong, unowned, weak 세 가지 카운트를 각각 독립적으로 관리해요.
이 부분은 swift/RefCount.h 소스 코드에서 확인할 수 있어요.
An object conceptually has three refcounts. These refcounts
are stored either “inline” in the field following the isa
or in a “side table entry” pointed to by the field following the isa.
Strong Reference Count
- 객체를 alive 상태로 유지하는 참조의 개수예요.
- strong RC가 0이 되는 순간 deinit이 호출돼요. 이때 객체의 메모리는 아직 해제되지 않았고, deinit이 끝나야 unowned/weak 등 추가 해제 단계로 넘어가요.
Unowned Reference Count
- unowned 참조의 개수를 셉니다.
- strong 참조가 남아있을 때는 unowned RC에도 +1이 더해져 있어요.
- deinit이 끝나면 unowned RC에서 -1을 하고, unowned RC가 0이 될 때 실제 메모리 해제가 일어나요.
- unowned 참조는 weak과 다르게, 참조 대상이 사라진 후 접근하면 크래시가 발생해요.
Weak Reference Count
- weak 참조의 개수를 관리해요.
- 객체가 deinit되면 weak 참조 변수는 자동으로 nil로 할당돼요.
- weak RC가 0이 되면 side table 메모리까지 완전히 해제돼요.
아래 코드에서 Reference Count의 초기값을 설정하는데, strong RC를 0(논리적으로 1), unowned RC를 1로 세팅하는 걸 볼 수 있어요.
constexpr RefCounts(Initialized_t)
: refCounts(RefCountBits(0, 1)) {}
3-3. Unowned Reference Count는 왜 1부터 시작할까?
이 부분에 대해서는 공식 문서에서 명확한 설명을 찾지 못했고, 아래는 개인적인 해석이에요.
unowned reference count를 0부터 시작해서 underflow 시 해제하는 구조로도 만들 수 있었을 것 같아요.
하지만 그렇게 되면 underflow가 발생했을 때 이게 진짜 메모리 해제가 필요한 상황인지, 이미 한 번 해제된 객체에 또 접근하는 건지 런타임에서 구분이 어려워질 수 있어요.
그래서 unowned RC가 0이 되는 순간을 “이 객체는 이제 정말로 끝났다”라고 런타임에서 확실히 판단할 수 있도록 1로 초기화해둔 것 같아요.
즉, “0”이라는 값이 “DEAD(or FREED) 상태”라는 의미로 동작하게 설계한 거죠.
정리하면,
- 0부터 시작하면 underflow 체크, double free 방지 로직이 더 복잡해질 수 있고
- 1부터 시작해서 0이 되는 순간 해제로 간주하면 훨씬 명확하게 처리할 수 있기 때문에 이렇게 설계한 것으로 보입니다.
decrementUnownedShouldFree의 구현부를 보면 실제로 unowned RC가 0이 될 때만 체크를 하고 있어요.
bool decrementUnownedShouldFree(uint32_t dec) {
...
newbits.decrementUnownedRefCount(dec);
if (newbits.getUnownedRefCount() == 0) {
// DEINITED -> FREED, or DEINITED -> DEAD
performFree = true;
} else {
performFree = false;
}
...
return performFree;
}
혹시 공식 설명을 찾으면 나중에 추가로 업데이트할게요!
4. Side Table
Swift ARC의 refcount는 일반적으로 HeapObject 헤더의 InlineRefCounts 안에서 비트로 관리돼요.
하지만 weak 참조가 생기거나, refcount가 overflow하는 특수 상황에서는 별도의 side table 구조체로 refcount 관리를 옮겨요.
Storage Layout
swift/RefCount.h에 구조가 잘 나와 있어요.
HeapObject {
isa
InlineRefCounts {
atomic<InlineRefCountBits> {
strong RC + unowned RC + flags
// 또는
HeapObjectSideTableEntry* // side table 포인터!
}
}
}
HeapObjectSideTableEntry {
SideTableRefCounts {
object pointer
atomic<SideTableRefCountBits> {
strong RC + unowned RC + weak RC + flags
}
}
}
- 평소에는 InlineRefCounts의 비트로 strong/unowned RC+flags까지 관리해요.
- weak 변수가 쓰이거나, 카운트가 비트 한계를 넘으면 HeapObjectSideTableEntry* 포인터로 전환돼요.
- 이후에는 HeapObject → side table 포인터가 연결되고, side table에서 strong/unowned/weak RC를 모두 관리해요.
Overflow가 발생했을 때 Side Table을 생성하는 코드
HeapObject *RefCounts<InlineRefCountBits>::incrementSlow(InlineRefCountBits oldbits,
uint32_t n) {
if (oldbits.isImmortal(false)) {
return getHeapObject();
}
else if (oldbits.hasSideTable()) {
// Out-of-line slow path.
auto side = oldbits.getSideTable();
side->incrementStrong(n);
}
else {
// Overflow into a new side table.
auto side = allocateSideTable(false);
side->incrementStrong(n);
}
return getHeapObject();
}
Weak Reference가 발생했을 때 Side Table을 생성하는 코드
HeapObjectSideTableEntry* RefCounts<InlineRefCountBits>::formWeakReference()
{
auto side = allocateSideTable(true);
if (side)
return side->incrementWeak();
else
return nullptr;
}
5. Object Life Cycle
Object Lifecycle State Machine ARC 객체의 상태 전이는 RefCount.h 주석에 잘 설명돼 있어요. 주요 단계만 요약하면 다음과 같아요.
LIVE without side table
- 객체가 살아있고, refcount는 HeapObject 헤더에 inline 비트로만 관리돼요.
- weak 참조는 없는 상태예요.
LIVE with side table
- weak 참조가 생기면 side table이 생성되고, weak RC 관리가 시작돼요.
- strong/unowned/weak 모두 side table에서 관리돼요.
DEINITING/DEINITED/FREED
- deinit, unowned release, weak release 등 해제 순서에 따라 side table만 남거나, side table까지 모두 해제되는 상태로 진입해요.
FREED with side table
- 객체 메모리는 이미 해제됐지만 side table에 weak 참조가 남아 있을 수 있어요.
- weak RC까지 0이 되면 side table entry도 완전히 해제(DEAD)돼요.
실제 동작 예시
- 객체 생성 → HeapObject 헤더에 inline refcount 비트로 strong/unowned 관리 (side table 없음)
- weak 변수에 저장 → side table 생성, strong RC + unowned RC가 side table 포인터로 바뀜
- 이후 모든 refcount 연산은 side table에서 처리됨
- unowned RC가 0이 되면 side table만 남고 객체는 deallocated(FREED)
- weak RC, unowned RC까지 모두 0 → side table entry 해제, 진짜 DEAD
여기서 한 가지 의문이 생겼어요.
“unowned reference가 남아 있으면 객체가 진짜로 deallocate되지 않는 건 아닐까?”
“weak, unowned reference를 쓰면 항상 메모리 누수를 막을 수 있다고 알고 있었는데, 정말 그럴까?”
6. unowned RC로 인한 메모리 릭
정말로 unowned reference가 남아 있으면 deallocate가 되지 않는지 직접 확인해보고 싶어서 간단한 프로젝트로 실험해봤어요.
final class FooObject {
init() {
print(
"init \(FooObject.self)\n",
"id: \(ObjectIdentifier(self))\n",
"size: \(MemoryLayout<Self>.size)"
)
}
deinit {
print(
"deinit \(FooObject.self)\n",
"id: \(ObjectIdentifier(self))\n",
"size: \(MemoryLayout<Self>.size)"
)
}
}
class ViewController: UIViewController {
...
var data: FooObject?
var weakRefs: [() -> FooObject?] = []
var unownedRefs: [() -> FooObject?] = []
...
@objc private func createUnownedReferences() {
data = FooObject()
for _ in 0 ..< 10 {
let unownedRef = { [unowned data = self.data] in
return data
}
unownedRefs.append(unownedRef)
}
updateStatus()
}
@objc private func removeStrongReferencesKeepUnowned() {
data = nil
updateStatus()
}
}
실험 목적은, unowned reference가 남아 있을 때 deinit은 호출되지만, 실제 메모리는 해제되지 않는지 확인하는 거예요.
실행 순서
- createUnownedReferences를 호출해서 data에 대한 unowned reference를 만든다.
- removeStrongReferencesKeepUnowned를 호출해서 data의 strong RC를 nil로 만든다.
- Xcode의 Debug Memory Graph로 메모리 상태를 확인한다.
결과
- 콘솔에서는 deinit이 정상적으로 호출되는 것을 볼 수 있어요.
- Xcode의 Debug Memory Graph에서는 FooObject가 실제로 deallocate되지 않고 남아 있는 걸 확인할 수 있어요.
이런 현상을 husk leaking이라고 불러요. 여기서 “husk(껍데기)“란, 객체의 deinit은 이미 호출되어 내부 리소스는 정리됐지만, unowned reference가 남아 있기 때문에 HeapObject의 껍데기 메모리는 해제되지 않고 남아 있는 상태를 말해요.
즉,
- 객체의 라이프사이클상 deinit이 호출돼도, 모든 unowned reference가 해제되지 않으면 실제 메모리 해제는 일어나지 않아요.
- unowned reference가 남아 있으면 객체의 껍데기만 남는 메모리 릭이 발생할 수 있어요.
- weak reference가 남아 있으면 side table도 deallocate되지 않고 남아 있을 수 있어요.
따라서, 객체가 메모리에서 완전히 해제(deallocate)됐는지 확인하려면 deinit 호출만으로는 부족하고, 모든 unowned/weak 참조까지 해제됐는지 꼭 확인해야 해요.
전체 코드는 Github에 올려두었어요.