스트리밍은 대부분의 브라우저와
Developer 앱에서 사용할 수 있습니다.
-
힙 메모리 분석하기
앱의 동적 메모리인 힙 메모리에 대해 자세히 알아보세요. Instruments와 Xcode를 사용하여 일반적인 힙 메모리 문제를 판단하고, 분석하고, 해결하는 방법을 살펴보세요. 앱에서 일시적 또는 지속적인 메모리 사용량 증가, 메모리 누수를 진단하는 기술 및 모범 사례도 소개합니다.
챕터
- 0:00 - Introduction
- 1:05 - Heap memory overview
- 3:45 - Tools for inspecting heap memory issues
- 7:40 - Transient memory growth overview
- 10:34 - Managing autorelease pool growth in Swift
- 13:57 - Persistent memory growth overview
- 16:00 - How the Xcode memory graph debugger works
- 20:15 - Reachability and ensuring memory is deallocated appropriately
- 21:54 - Resolving leaks of Swift closure contexts
- 24:13 - Leaks FAQ
- 26:51 - Comparing performance of weak and unowned
- 30:44 - Reducing reference counting overhead
- 32:06 - Cost of measurement
- 32:30 - Wrap up
리소스
관련 비디오
WWDC24
WWDC22
WWDC21
WWDC18
-
다운로드
안녕하세요, 힙 메모리 분석에 오신 것을 환영합니다! -이쪽은 Ben -이쪽은 Daniel입니다! 오늘 이야기할 주제는 힙 메모리와 앱입니다 힙 메모리는 앱에서 직접 또는 간접적으로 사용되며 개발자가 제어하고 최적화할 수 있는 부분입니다 앱의 참조 타입이 저장되는 곳이며 쓰기 작업이 자주 발생하고 수정되기 때문에 중요합니다 이렇게 수정된 메모리는 앱의 메모리 한도에 포함되어 계산됩니다 이러한 이유로 본 세션에서는 힙 메모리 측정과 감소 방법을 주로 다룰 것입니다
그래픽 메모리와 같은 다른 메모리나 메모리 한도에 대해 더 자세히 알고 싶다면 그것들을 더 자세히 다루는 다른 훌륭한 세션들이 있습니다 따라서 앱이 메모리를 너무 많이 사용하고 있거나 저처럼 단순히 호기심이 생겨 안을 들여다보고 싶으시다면 함께 살펴보겠습니다! 오늘 다룰 다섯 가지 주제는 힙 측정하기 일시적 증가에 대응하기 지속적 증가 추적하기 메모리 누수 수정하기 런타임 성능 개선하기입니다 그럼 이 질문으로 시작해 보겠습니다! 힙 메모리란 무엇이고 앱이 이를 얼마나 사용하고 있는지 어떤 도구로 측정할 수 있을까요? 힙을 이해하려면 앱의 전체 가상 메모리 내에서 어떤 맥락에 있는지 알아야 합니다 앱이 시작되면 자체적인 빈 가상 메모리 주소 공간을 받습니다 앱이 실행될 때 시스템은 메인 실행 파일, 연결된 라이브러리 프레임워크를 로드하고 디스크에서 읽기 전용 리소스 영역을 매핑합니다 실행 중에 앱은 각 스레드의 로컬 및 임시 변수를 위한 스택 영역을 사용하며 동적이고 수명이 긴 메모리는 집합적으로 힙이라고 부르는 메모리 영역에 배치됩니다 오늘 집중할 부분이 바로 여기입니다 자세히 보겠습니다 힙은 하나의 메모리 블록이 아니라 여러 가상 메모리 영역으로 구성됩니다 영역 수준으로 더 자세히 들여다보면 각 영역은 개별 힙 할당으로 나뉩니다 내부적으로 이러한 각 영역은 운영 체제의 16KB 메모리 페이지로 구성되지만 각 할당은 더 크거나 작을 수 있습니다 이러한 메모리 페이지 상태는 세 가지 중 하나 클린, 더티, 또는 스왑일 수 있습니다 클린페이지는 쓰기 작업이 이루어지지 않은 메모리입니다 할당되었지만 사용되지 않은 공간이거나 디스크에서 읽기 전용으로 매핑된 파일을 나타내는 페이지일 수 있습니다 시스템이 언제든 이러한 페이지를 폐기하고 다시 폴트할 수 있어 비용이 저렴합니다 더티 페이지는 최근에 애플리케이션이 쓰기 작업을 한 메모리입니다 한동안 사용되지 않은 더티 페이지는 버릴 수 없습니다 메모리가 부족하면 시스템은 이들을 스왑, 압축하거나 디스크에 쓸 수 있습니다 이렇게 하면 필요할 때 메모리를 압축 해제하거나 디스크에서 폴트할 수 있습니다 이 세 가지 중 더티와 스왑만 애플리케이션의 메모리 공간에 포함되며 대부분의 애플리케이션에서는 힙이 메모리 공간의 대부분을 차지합니다
힙 영역은 malloc 함수 또는 calloc이나 realloc과 같은 유사한 할당 프리미티브로 생성되는 메모리입니다 대부분의 경우 이러한 함수를 직접 호출하지는 않지만 컴파일러와 런타임은 예를 들어 Swift, Objective-C 클래스 인스턴스를 생성할 때 이를 많이 사용합니다 malloc으로 앱에서 장기 메모리를 동적으로 할당할 수 있습니다 할당은 명시적으로 해제될 때까지 유지되며 즉 해당 할당을 생성한 코드의 범위를 넘어설 수 있습니다 이러한 함수는 규칙을 몇 가지 적용하는데 예를 들어 최소 할당 크기와 정렬은 16바이트이므로 4바이트를 요청하면 요청이 16으로 반올림됩니다 또한 보안 기능으로 대부분의 작은 할당은 해제될 때 0이 됩니다 언어 런타임은 힙을 사용하여 장기 메모리를 할당합니다 예를 들어 Swift는 이 클래스 이니셜라이저를 확장해 일련의 Swift 런타임 함수를 호출하고 함수는 결국 malloc을 호출합니다
malloc에는 디버깅 기능도 있습니다 그 중 하나는 MallocStackLogging으로 각 할당의 호출 스택과 타임스탬프를 기록합니다 MallocStackLogging을 활성화하면 메모리가 할당된 위치와 시기를 훨씬 쉽게 추적할 수 있습니다
Xcode의 스킴 진단 탭에서 체크박스를 사용해 MallocStackLogging을 활성화할 수 있습니다 오늘 보여드릴 모든 데모에서 이와 같이 malloc 스택 로깅을 활성화했습니다 메모리 사용량을 추적하기 위한 첫 번째 도구는 애플리케이션의 시간 경과에 따른 풋프린트를 보여주는 Xcode 메모리 보고서입니다 애플리케이션 풋프린트는 힙 외에도 많은 것들로 구성되지만 메모리 보고서는 대규모 메모리 문제와 일부 최근 기록을 보여줄 수 있습니다 아쉽게도 메모리 사용량이 증가하는 이유는 알려 주지 못합니다 이를 이해하려면 다른 도구가 필요합니다 오늘 다룰 또 다른 도구도 Xcode의 일부입니다 메모리 그래프 디버거는 모든 할당과 그 사이의 참조에 대한 스냅샷인 메모리 그래프를 캡처할 수 있습니다 MallocStackLogging을 사용하면 각 할당의 역추적이 포함됩니다 특정 할당에 집중해야 할 때 유용한 도구로 Xcode의 디버그 막대에서 바로 액세스할 수 있습니다
Xcode에는 메모리 분석을 위한 강력한 명령어 라인 도구도 있습니다 Leaks, heap, vmmap malloc_history는 macOS 및 시뮬레이터 프로세스를 직접 분석하거나 이미 캡처된 메모리 그래프로 문제를 조사할 수 있습니다 이러한 도구의 매뉴얼 페이지를 살펴보시고 고급 기능을 자세히 알아보세요
시간 경과에 따른 메모리 사용량 프로파일링을 위해 Instruments 애플리케이션에는 여러 템플릿이 제공됩니다 Allocations 도구는 시간 경과에 따른 모든 할당과 여유 이벤트 이력을 기록하고 통계와 콜 트리를 집계해 다시 코드로 추적할 수 있게 합니다 Leaks 도구는 앱의 메모리를 주기적으로 스냅샷해 메모리 누수를 감지합니다 Allocations를 사용해 DestinationVideo 예시 앱에서 문제를 조사하는 방법을 살펴보겠습니다
다니엘과 제가 개발 중인 DestinationVideo 앱의 새 기능에서 메모리 문제가 발생하고 있습니다 비디오의 새 배경 이미지를 선택할 수 있는 기능인데요 배경 이미지 갤러리를 몇 번 열었다가 닫았는데 메모리가 너무 많이 사용되어 앱이 충돌하는 것을 보았습니다 Xcode 메모리 보고서에 의하면 갤러리를 열 때마다 메모리 사용량이 거의 1GB까지 급증했습니다 Allocations 도구를 사용해 이를 분석할 수 있습니다 그리고 제 기기에서 테스트해 보겠습니다
Instruments에서 프로파일링하기 위해 Product, Profile 메뉴 항목을 사용합니다 이렇게 하면 앱의 릴리즈 빌드가 수행되고 앱이 대상으로 선택된 상태에서 Instruments가 열립니다 열리면 프로파일링할 템플릿을 선택하라는 메시지가 표시됩니다 앱의 힙을 프로파일링해야 하니 Allocations를 선택하겠습니다 Allocations 템플릿에는 Allocations와 VM Tracker라는 도구가 있습니다 Allocations는 힙과 VM 이벤트를 실시간으로 기록하여 활동을 실시간으로 확인 가능합니다 VM Tracker는 주기적으로 스냅샷을 생성해 모든 가상 메모리를 측정할 수 있습니다 오늘은 힙에 집중해야 하니 이 기능은 활성화하지 않겠습니다 추적을 시작하기 위해 추적 문서의 왼쪽 상단에 있는 Record 버튼을 클릭합니다 추적이 시작되면 앱에 대한 데이터가 스트리밍되기 시작합니다 트랙 뷰를 보면 앱이 로드된 후에도 메모리 사용량이 안정적으로 유지되고 있습니다 갤러리 보기를 열었다 닫아 메모리 사용량이 급증할 때 어떤 일이 발생하는지 보겠습니다
지난번보다 조금 느리지만 예상했던 결과입니다 모든 malloc과 free에 대한 스택 추적을 받고 있습니다 이 데이터는 곧 아주 유용해질 것입니다
왼쪽 상단의 Stop 버튼을 클릭해 추적을 중지하겠습니다 Allocations 트랙에서 급증 패턴이 재현되는 것이 확실히 보입니다 이제 추적 파일이 생겼으니 메모리 버그를 좋아하는 제 파트너에게 보낼 수 있습니다 File, Save 메뉴 항목을 사용해 추적을 저장하면 Daniel에게 넘길 준비가 됐습니다
Daniel, 일시적 메모리 증가 진단에 대해 얘기해 보시겠어요? 물론이죠, Ben! Ben이 기록한 메모리 급증을 자세히 살펴보고 이에 대해 할 수 있는 일이 있는지 살펴봅시다 앱의 메모리 급증은 일시적인 메모리 증가의 한 유형이며 이런 증가가 좋지 않은 세 가지 이유가 있습니다
메모리 급증은 메모리 압박을 유발하고 시스템이 이에 반응합니다 더티 메모리를 교체 및 압축하고 읽기 전용 메모리를 폐기하고 백그라운드 작업을 종료할 수도 있습니다 최악의 경우 메모리 급증은 앱을 종료시킬 수도 있습니다 메모리 급증의 장기적인 영향은 힙 메모리 영역을 조각화하거나 구멍을 내므로 좋지 않습니다 메모리 급증 추적에는 두 가지 방법이 있습니다 특정 스파이크를 살펴보고 최저점에서 최고점에 있는 Created & Still Living 할당을 찾습니다 또는 전체적으로 큰 범위를 선택해 해당 범위에서 Created & Destroyed된 모든 할당을 찾을 수 있습니다 이제 Ben이 보내 준 추적 내역을 사용하겠습니다
타임라인의 스파이크 구간 중 하나를 선택합니다 트랙 뷰에서 스파이크의 최저점에서 최상단까지 클릭하고 드래그하면 아래의 통계 세부 정보에 원인에 대한 몇 가지 정보가 표시될 것입니다 이 행을 총 바이트 수로 정렬해 상위 원인을 찾아보겠습니다 이 강연에서는 힙 메모리가 주제이지만 상위 카테고리를 보니 IOSurface 가상 메모리인 것 같군요 이는 임시 메모리 문제가 배경 이미지 처리 방식과 관련이 있다는 것을 암시하는 좋은 힌트입니다 퍼시스턴트 기준으로 정렬하면 이 경우 스파이크의 상단에 있는 객체를 기준으로 정렬하면 눈에 띄는 노드가 하나 있습니다 @autoreleasepool content입니다 autoreleasepools에 대해서 너무 많이 생성되었습니다 잠시 후에 다시 설명하겠습니다 임시 메모리 문제를 찾는 다른 방법은 더 넓은 범위에서 생성, 소멸되는 객체를 담당하는 코드를 찾는 것입니다 윈도우 하단에서 Lifespan 필터를 Created & Destroyed로 변경합니다 그리고 타임라인에서 스파이크 세 개를 모두 선택합니다 이제 가운데에 있는 점프 바로 세부 정보 뷰를 콜 트리로 전환할 수 있습니다 콜 트리는 할당을 역추적으로 분석해 가장 많은 메모리를 할당하는 코드를 볼 수 있게 해줍니다 총계를 살펴보면 8GB의 임시 할당이 있네요 세상에 이게 어디서 오는 걸까요? 오른쪽의 가장 무거운 스택 추적이 단서를 제공합니다 조금 더 넓혀 보겠습니다
코드의 프레임이 강조되어 있고 목록을 살펴보니 makeThumbnail() 코드에서 시작하면 좋을 것 같습니다 한 번 클릭하면 콜 트리가 열리고 이중 클릭하면 소스를 볼 수 있습니다
좋아요, 이게 우리가 적용한 이미지 필터고 한 줄에서 기가바이트 단위의 메모리가 생성되고 파괴되고 있습니다 임시 메모리여야 하는 부분인데 스파이크 정점까지 증가하다가 한 번에 해제됩니다 먼저 점프 바의 호출 트리를 다시 클릭하여 몇 프레임을 올라가 보겠습니다 몇 프레임을 올려 보니 ThumbnailLoader의 loadThumbnails 코드를 살펴봐야 할 것 같습니다
루프에서 썸네일을 폴트하고 있고 루프가 실행되는 동안 메모리가 증가하다 끝에서 떨어집니다 앞서의 autoreleasepool 단서와 함께 합쳐 보니 무슨 일인지 알 것 같습니다 제가 사용하는 Swift에는 자동 참조 카운팅이 있지만 자동 릴리즈 풀은 임시 메모리 증가의 흔한 원인입니다 Objective-C는 이 풀을 사용해 함수 반환값의 객체 수명을 연장합니다 자동 릴리즈 풀은 해제를 지연시켜 이 반환값들을 유지합니다 이는 Swift가 Objective-C API를 사용하는 프레임워크를 호출할 때 자동 릴리즈된 객체를 생성할 수 있다는 의미입니다 이 간단한 예시는 현재 날짜를 출력하지만 자동 릴리즈된 문자열도 생성합니다 이 문자열은 현재 자동 릴리즈 범위가 끝날 때까지 힙에 남아있게 되며 이는 꽤 오래 걸릴 수 있습니다
스레드에는 일반적으로 최상위 수준 자동 릴리즈 풀이 있지만 자주 정리되지는 않습니다 이는 코드가 객체로 풀을 채울 때 매우 중요할 수 있으며 이는 루프에서 쉽게 발생합니다
모든 반복 객체는 같은 풀에 자동 릴리즈되며 필요 이상으로 오래 존재할 수 있습니다 이 경우 루프가 모두 완료될 때까지 기다립니다 자동 릴리즈 풀은 객체 참조를 위해 내부적으로 콘텐츠 페이지를 할당합니다 이는 Allocations 도구에서 볼 수 있으므로 이러한 문제를 알아차리는 좋은 방법이 될 수 있습니다 나중에 자동 릴리즈 풀이 비워지면 풀에서 지연된 릴리즈를 보내고 많은 객체가 한꺼번에 풀려날 수 있습니다
이 문제를 해결하는 방법은 일반적으로 중첩된 로컬 자동 릴리즈 풀 범위를 정의해 이러한 수명을 좁히는 것입니다 이 예에서는 자동 릴리즈된 객체가 내부 루프별 풀에 보관되고 각 반복마다 릴리즈됩니다 즉, 누적되는 객체 수가 줄어들고 참조를 추적하는 데 필요한 콘텐츠 페이지가 줄어듭니다 다시 돌아가서 문제를 해결할 수 있는지 살펴봅시다 Instruments에서 소스 뷰의 오른쪽 상단 메뉴를 사용해 Xcode에서 이 파일을 열어 보겠습니다
문제 해결을 위해 루프 본문에 자동 릴리즈 풀 범위를 추가해 각 반복 후에 객체를 비우도록 하겠습니다
잘 작동하는지 볼까요
앗, 개발용 휴대폰이 없네요 Ben, 수정 사항 테스트에 당신 휴대폰을 사용해도 될까요? 안 돼요! 제 휴대폰이에요 시뮬레이터를 사용하는 게 어때요? 좋아요, 좋은 지적이네요 대부분의 프로파일링에서는 정확한 타이밍을 위해 릴리즈 빌드를 실제 기기에서 실행하는 것이 중요합니다 하지만 힙 분석의 경우 시뮬레이터 환경이 동작에 더 가까워서 메모리 프로파일링에 사용해도 괜찮습니다 메모리 게이지로 다시 전환해서 Ben이 보여준 기능을 사용해 보겠습니다
시뮬레이터에서 갤러리를 한 번 열고 닫아 보겠습니다
게이지를 보니 메모리가 올라도 큰 폭으로는 상승하지 않았습니다
두 번째도 동일하지만 마음에 들지 않는 다른 패턴이 보이기 시작했습니다
시트를 세 번 불러오고 나니 메모리 급증이 사라진 것을 확인할 수 있었습니다! 기가바이트까지 올라가지 않았어요 하지만 이제 문제는 매번 메모리가 계단식 패턴으로 올라간다는 것입니다 이상하죠, 썸네일을 만드는 데 비용이 많이 들기는 하지만 갤러리를 처음 열었을 때만 증가해야 하는데 말입니다 잠시 후에 자동 릴리즈 풀 수정을 푸시할 예정이지만 오늘의 재미는 남겨둬야겠죠 메모리 그래프 디버거를 Xcode의 디버그 바에서 일시 중지하겠습니다 이렇게 하면 애플리케이션 힙의 모든 할당이 캡처되며 증가의 원인이 된 유형을 이미 알고 있다면 지금 검색할 수 있습니다 또는 오른쪽에서 공유할 수도 있습니다 이 메모리 그래프를 Instruments에서 바로 가져올 수도 있지만 그냥 Ben에게 Airdrop하는 게 더 나을 것 같아요 모래 속에서 바늘을 찾다가 아이디어를 얻을 수도 있겠죠 행운을 빌어요, Ben! 시도는 좋았어요, Daniel 하지만 또 속지는 않겠어요! 제 메모리 그래프를 이용해 이 지속적 증가를 살펴보겠습니다 영구 메모리는 할당이 해제되지 않는 메모리입니다 영구적 증가는 일반적으로 다음과 같이 나타납니다 시간이 지남에 따라 메모리가 증가합니다 이 증가는 여러 번의 할당을 통해 이루어집니다 Allocations 도구의 Mark Generation 기능을 사용하면 시간대별로 증가를 세분화할 수 있습니다 Mark Generation 버튼을 클릭하면 Instruments가 할당을 위한 새 그룹을 만듭니다 이 세대는 이 시점 이전에 생긴 모든 할당을 수집합니다 이는 추적이 끝날 때까지도 유지됩니다 이후 시간을 선택하고 Mark Generation을 다시 클릭하면 Instruments가 새 그룹을 만듭니다 다음 세대는 이전 세대 이후와 새 타임스탬프 이전에 만들어진 모든 지속적 할당을 수집합니다 Xcode에서 자체 메모리 그래프를 생성하고
Instruments에서 가져왔습니다 Instruments에는 Allocations, Leaks VM Tracker 도구의 데이터가 표시됩니다 지금은 Allocations 트랙에 집중하겠습니다 Daniel이 지적한 것과 동일한 계단 패턴을 트랙 뷰의 Xcode 메모리 보고서에서 볼 수 있습니다 세대 표시 기능을 사용하여 증가 기간 동안 생성된 영구 할당을 분리해 보겠습니다 증가 기간 사이를 여러 번 선택하고 Mark Generation 버튼을 누릅니다
이제 Instruments에 세 세대가 표시됩니다 Generations B와 C는 갤러리를 열어서 발생한 지속적인 증가를 보여 줍니다 이 세대 중 하나를 확장하여 할당량을 확인하고 증가 규모별로 정렬해 증가가 가장 많이 발생하는 유형을 찾아볼 수 있습니다 대부분의 증가는 데이터용 스토리지에서 발생한 것 같습니다 이 유형에 대한 항목을 확장하여 개별 할당량과 주소를 볼 수 있습니다 하! 바늘을 찾았어요!
모든 데이터 스토리지 할당을 생성한 것이 ThumbnailLoader 코드인 것 같습니다 그렇다면 무엇이 데이터를 차지하고 있을까요? Instruments에서 이 주소 중 하나를 메모리 그래프 디버거에 넣어 참조하는 항목을 확인하면 갤러리가 닫힌 후에도 데이터가 잔존하는 이유를 알 수 있습니다 펼쳐진 상세 보기에서 주소를 복사하여 메모리 그래프 디버거의 필터 막대에 넣고 할당을 선택하겠습니다
메모리 그래프 디버거의 내용을 더욱 잘 이해하기 위해 작동 원리에 대해 잠깐 이야기해 보겠습니다
메모리 증가 조사의 목적은 다음 질문에 답하는 것입니다 이 할당이 왜 아직 존재하는가? 할당에 무엇이 들어있나? 메모리 그래프 디버거가 이 질문에 답할 수 있게 도움을 줍니다 이 도구를 최대한 활용하려면 타입 정보와 참조 스캔에 대해 이해해야 합니다 참조에는 네 가지 주요 타입이 있습니다 강한 참조 ARC 관리 위치의 명시적 소유권이 보장되는 확실한 포인터입니다 약한 및 무소유 참조 명시적 비소유권이 보장되는 확실한 포인터입니다 비관리형 참조, 런타임이 알지만 자동으로 관리하지 않는 위치의 포인터입니다 이 포인터는 수동 소유 참조일 수도 아닐 수도 있습니다 그리고 불확실하거나 보수적인 참조 이는 스캔하는 메모리 타입을 도구가 알지 못하고 원시 메모리만 확인할 때 기록됩니다 값이 포인터처럼 보인다면 포인터일 수도 있지만 타입 정보가 없으면 확인할 방법이 없습니다 도구가 프로세스의 힙을 스캔할 때 각 할당에 대해 사용 가능한 최상의 타입 정보를 사용합니다 Swift Swallow 예시에서 첫 두 필드는 표준이며 참조 스캔을 할 중요한 내용이 없습니다 그 다음에 스캔할 coconut 참조가 있습니다! 이 필드에는 힙 할당에 대한 포인터가 있으며 Coconut 객체에 대한 강한 참조입니다! Swift와 Objective-C의 타입 정보는 우수하지만 C와 C++에는 참조 소유권 정보가 없어 보수적 참조만 볼 수 있습니다 도구의 최선은 가상 메서드가 있는 C++ 타입의 이름을 찾는 것입니다 이 클래스의 인스턴스는 Coconut으로 보일 것입니다 가상 메서드나 다른 할당이 없는 타입의 경우 스택 추적으로 이름을 제공할 수 있습니다 MallocStackLogging 데이터의 경우 이 클래스의 인스턴스는 PalmTree::growCoconut()에서 malloc이라고 레이블링될 수 있으며 이는 해당 클래스가 무엇인지 알 수 있는 좋은 힌트입니다 타입 정보와 참조에 대해 이야기했으니 이제 돌아가서 데이터 저장소가 영원히 지속되는 이유를 살펴봅시다 메모리 그래프 디버거에서 선택된 할당을 __DataStorage 객체가 보유하고 있는 것을 볼 수 있습니다 PhotoThumbnail이 보유하고 있습니다 PhotoThumbnail은 차례로 딕셔너리가 보유하고 있습니다 그리고 끝까지 거슬러 올라가면 이 정적 속성이 보유하고 있는 것처럼 보입니다 ThumbnailLoader.globalImageCache요 MallocStackLogging을 활성화한 상태로 실행 중이므로 오른쪽의 Inspector에서 할당 역추적을 볼 수 있습니다 Inspector를 사용해 할당을 담당하는 소스로 이동하겠습니다 데이터를 갖고 있는 PhotoThumbnail을 선택합니다 제 코드의 클로저 중 하나가 이것을 할당하는 것 같군요 스택 추적을 사용해 해당 코드로 이동하겠습니다
이 faultThumbnail 메서드가 썸네일을 캐싱하고 캐시 미스 시 새 썸네일을 생성하는 것 같습니다 아마 조금 전에 보았던 globalImageCache에 저장하고 있을 것입니다 주석을 보면 URL과 creationDate를 기반으로 캐싱하는 것 같은데 합리적으로 보이는군요 하지만 버그가 있어요! 분명히 해당 파일의 생성 타임스탬프가 아니에요! 현재 시간이네요 즉, 우리는 캐시에서 아무것도 찾을 수 없으며 이 메서드가 호출될 때마다 항상 새로운 PhotoThumbnail을 캐시하게 될 겁니다 이걸로 썸네일이 계속 증가하는 이유가 설명되네요! 실제 파일 생성 날짜를 기준으로 캐싱해 문제를 고쳐 보겠습니다 잘못된 타임스탬프를 사용하는 코드를 삭제하고 이제 파일의 생성 타임스탬프를 가져와야 합니다 좋아요, Xcode가 제가 원하는 코드를 제안하네요 Tab 키를 눌러 수락합니다 앱을 다시 실행해서 문제가 고쳐졌는지 확인하고 Xcode 메모리 보고서에서 계단 패턴이 보이지 않는지도 확인합니다
이 기능을 다시 사용해 보겠습니다
좋아요, 썸네일을 생성했고 다시 시도해 보겠습니다
좋네요, 증가하지 않습니다 확인차 한 번만 더 해보겠습니다
메모리 그래프 디버거에서 멈춰 보고 다른 문제가 없는지 확인해 보겠습니다
메모리 누수가 발견되었습니다 옆에 노란색 삼각형 아이콘이 있는 할당 영역에서요 Daniel, 우리 코드에 또 누수를 일으킨건가요? 네! 다음에 다룰 내용이 메모리 누수라서 다행이네요 누수된 메모리를 이해하고 해결하려면 먼저 도달 가능성을 알아야 합니다 프로그램의 모든 메모리는 나중에 사용될 어딘가에서 약한 참조가 아닌 참조로 도달할 수 있어야 합니다 힙에는 세 가지 종류의 메모리가 있습니다 첫째, 유용한 메모리 프로그램에서 도달할 수 있고 나중에 다시 사용할 메모리입니다 둘째, 버려진 메모리 도달하고 사용할 수 있지만 실제로는 다시는 사용하지 않습니다 앱의 메모리 사용량에 포함되지만 실제로는 그저 낭비되고 있습니다 캐시에 데이터를 너무 많이 저장하거나 싱글톤에 큰 용량의 데이터를 유지해서 발생할 수 있습니다 앱의 세 번째 타입의 메모리에서 누수가 발생하는데 재사용이 절대 불가능한 도달 불가능한 메모리입니다 일반적으로 이는 수동으로 관리되는 할당이나 객체의 참조 순환으로 인해 마지막 포인터가 손실될 때 발생합니다 대부분의 누수의 경우 한 순환에서 하나의 참조를 찾아 수정하는 것이 목표입니다 실수로 누출된 참조를 제거하거나 소유권 한정자를 강함에서 약함 또는 비소유로 바꾸는 방법이 사용될 수 있습니다 누출을 더 쉽게 조사할 수 있도록 필터 막대의 Show only leaked allocations 버튼을 사용합니다 삼각형 아이콘이 있죠
앱에서 여러 바이너리별로 그룹화된 타입이 내비게이터에 보입니다 코드가 시스템 바이너리에서 타입을 누출할 수도 있지만 일반적으로 누출은 프로젝트의 문제로 인해 직접 발생합니다 다른 필터 막대 버튼을 클릭해 제 프로젝트의 타입으로만 필터링해 보겠습니다 더 보기 쉬워졌네요! ThumbnailLoader 클래스에서 누출이 세 곳 ThumbnailRenderer에서 세 곳 있습니다 이 중 하나를 선택하겠습니다 ThumbnailRenderer, ThumbnailLoader 클로저 컨텍스트 사이의 작은 참조 순환처럼 보입니다 그런데 이 클로저 컨텍스트는 뭘까요? 잠시 이야기해 보겠습니다 Swift 클로저가 값을 캡처해야 할 때 힙에 메모리를 할당해 캡처를 저장합니다 메모리 그래프 디버거는 이러한 할당을 클로저 컨텍스트로 표시합니다 앱 힙의 각 클로저 컨텍스트는 라이브 클로저와 1:1 대응됩니다 클로저는 기본적으로 참조를 강하게 캡처해서 참조 순환을 만들 수 있습니다 이러한 순환은 약한 또는 비소유 캡처로 깰 수 있습니다 Swallow 객체에 완료 핸들러가 있다고 가정해 봅시다 Swallow가 coconut을 전달할 때 호출되는 핸들러입니다 주의하지 않으면 Swallow 자체를 강하게 캡처해 참조 순환이 생길 수 있습니다 메모리 그래프 디버거는 이 참조를 강한 캡처로 표시하지만 클로저 메타데이터에는 변수 이름이 포함되지 않습니다 클로저 컨텍스트의 모든 참조는 단순히 ‘capture’로 레이블링됩니다 다시 돌아가서 누수를 해결할 수 있는지 봅시다 Inspector를 열고 참조 몇 개를 클릭해 보겠습니다
ThumbnailRenderer에는 Loader에 대한 cacheProvider 참조가 있습니다 Loader에는 클로저 컨텍스트를 참조하는 completionHandler가 있습니다 렌더러로 돌아가는 캡처를 선택하면 Inspector는 이 참조가 강한 참조임을 보여 줍니다 이 참조 순환을 깨려면 클로저를 생성한 코드를 찾아야 합니다 클로저 컨택스트의 스택 추적에서 PhotosView의 코드로 이동하겠습니다
이 코드는 ThumbnailLoader 객체를 생성하고 이를 완료 핸들러에 할당한 다음 로딩을 시작하라고 지시합니다 하지만 방금 확인한 문제는 클로저가 ThumbnailRenderer를 강하게 캡처하고 있으며 참조 순환을 발생시키고 있습니다 어떻게 해결할 수 있을까요? 이 코드가 완료 클로저 대신 Swift 동시성을 사용하도록 변경해야 할 것 같습니다 일단 지금은 캡처 목록을 지정할 수 있습니다 weak 또는 unowned로 순환을 깰 수 있을 것입니다 렌더러 캡처를 weak로 설정합니다
이제 옵셔널 weak 참조가 있으니 이 guard let을 추가해 이 렌더러를 사용할 때 렌더러의 목적지가 아직 주변에 있도록 할 수 있습니다 방금 3 노드 순환을 수정했지만 이러한 작은 변화가 큰 효과를 낼 수 있습니다 기능을 다시 사용해 보고 메모리 그래프 디버거를 일시 정지하니
누수되는 타입이 보이지 않는군요 타입 필터를 끄니 깜짝 놀랄 일이 또 있습니다 다른 누수도 해결되었습니다! 이러한 다른 타입은 방금 수정한 누수가 참조하던 것이며 이제는 할당도 취소되고 있습니다 이 예시에서는 누출을 찾고 수정하는 것이 매우 쉬웠습니다 하지만 코드에서 누출이 생기는 이유에는 여러 가지가 있으며 누출을 찾으면서 질문을 가장 많이 하게 될 것입니다 몇 가지를 살펴보겠습니다 먼저 왜 누출 검사로 모든 것을 찾아내지 못할까요? 의도적으로 누수를 만들었다고 해 보겠습니다 왜 도구로 항상 찾을 수 없는 걸까요? 도구에 타입 정보가 없는 메모리가 많고 C와 같은 언어는 비관리형 포인터를 허용합니다 이는 도구가 포인터인 것처럼 보여도 그렇지 않을 수도 있는 것을 허용해야 한다는 의미입니다 도구가 보수적으로 스캔하면 포인터를 바이트 단위로 찾고 참조로 보이는 값을 찾은 다음 할당 목록과 비교하여 확인합니다 값이 일치하면 도구는 블록에 대한 불확실하거나 보수적인 참조를 기록합니다 그러나 값은 숫자 값, 플래그 또는 유효한 포인터처럼 보이는 임의의 바이트일 수 있음을 기억하세요 따라서 질문의 답변으로 보수적인 참조로 인해 실제 누수를 놓칠 수 있습니다 의도적인 누수를 만들어서 발견되는 것을 보고 싶다면 100번 반복하는 루프에 넣는 것도 나쁘지 않습니다 실제 앱에서 누수되는 코드는 일반적으로 여러 번 실행되기 때문에 도구가 모든 누수를 찾지는 못해도 원인이 되는 버그는 잡아낼 수 있습니다 또 다른 관련 질문은 보고된 누출 횟수가 시간이 지나며 들쭉날쭉해지는 이유가 뭘까요? 시간이 지나며 버그가 누출을 증가시키지만 힙은 노이즈가 많고 무작위일 수 있습니다 이 노이즈는 보수적 참조를 비결정적으로 만들기 때문에 나타나거나 사라질 수 있습니다 따라서 프로그램이 시작될 때 5개의 객체가 누수되더라도 처음에는 5개가 발견되고 나중에는 4개만 발견될 수 있습니다 또 다른 일반적인 질문은 반환되지 않는 함수가 메모리를 누수하는 것처럼 보이는 이유입니다 이는 noreturn 속성이 있는 C 함수 또는 Never 타입을 반환하는 Swift 함수일 수 있습니다 이러한 함수는 절대 반환되지 않기 때문에 컴파일러는 로컬 할당이나 생성된 참조를 해제하는 등 일반적으로 수행해야 하는 정리를 최적화할 수 있습니다 이러한 종류의 함수가 치명적인 어설션에 사용되었어도 프로그램은 어쨌거나 충돌할 테니 걱정 안 하셔도 됩니다 하지만 때로는 스레드를 영원히 파킹하는 데 사용되기도 합니다 혹시라도 이 예시의 Server 객체와 같이 noreturn 함수에 대한 호출에서 로컬 상태가 누출된 것으로 보고되는 경우 한 가지 해결책은 명시적으로 전역에 저장하는 것입니다 객체를 로컬 함수 범위 바깥에 저장하면 도구에서 볼 수 있는 곳에 참조가 저장됩니다 그리고 도구가 이를 볼 수 있으니 객체가 누출된 것이 아니라 도달 가능한 것으로 간주됩니다 로컬 변수가 컴파일러에 의해 보존되지 않더라도 말이죠 누수에 대해 다뤘으니 이제 런타임 속도와 코코넛을 위해 Ben에게 자리를 넘기겠습니다 고마워요, Daniel 메모리 감소는 앱 성능을 크게 향상시킬 수 있으며 추가 개선을 위해 주의해야 할 런타임 세부 사항도 있습니다
weak와 unowned는 Swift에서 강한 참조 순환을 피하기 위해 주로 사용하는 도구입니다 이들의 차이점과 사용 시기를 알아봅시다 약한 참조는 항상 옵셔널 타입이며 대상이 소멸되면 nil이 됩니다 소스와 대상의 수명과 관계없이 약한 참조를 사용할 수 있습니다 제비와 코코넛의 경우를 생각해봅시다 코코넛은 제비가 운반할 수 있지만 제비를 소유하지는 않습니다 코코넛이 제비를 참조하게 하려면 strong 참조를 사용하지 말아야 합니다 대신 weak 참조를 사용할 수 있습니다 하지만 오버헤드도 발생합니다 weak 참조를 구현하기 위해 Swift는 처음 weak 참조가 생길 때 대상 객체에 대한 weak 참조 저장소를 할당합니다 이 할당은 Swallow와 수신되는 모든 weak 참조 사이에 위치합니다 이는 Swallow가 사라진 후 weak 참조를 천천히 nil로 만들 수 있게 합니다 unowned 참조는 weak 참조와 달리 대상을 직접 보유합니다 이는 추가 메모리를 사용하지 않고 weak 참조보다 접근 시간이 짧음을 의미합니다 옵셔널이 아닐 수도 있고 상수일 수도 있습니다 그러나 unowned 참조 사용이 항상 유효한 것은 아닙니다 Coconut의 ‘holder’ 참조를 weak 대신 unowned 참조로 만든다고 가정해 봅시다 Swallow가 참조보다 먼저 사라지면 어떻게 될까요? Swallow는 제거되지만 할당이 해제되지는 않습니다 이러한 이유 때문에 unowned 참조가 안전한 것입니다 unowned 참조는 반드시 무언가를 가리켜야 하므로 런타임은 전 앵무새, 아니 제비를 유지합니다. 비유가 헷갈리네요 이 시점에서 Coconut의 unowned 참조를 사용해 Swallow에 접근하려고 하면 결정론적 충돌이 발생합니다 이렇게 unowned 참조는 weak 참조를 강제로 언래핑하는 것과 비슷합니다 unowned 참조에 액세스하지 않더라도 그대로 두는 것은 좋지 않습니다 unowned 참조가 존재하는 한 참조 대상은 할당 해제될 수 없고 메모리를 낭비하게 됩니다 대상의 수명이 얼마나 될지 모른다면 weak 함수의 약간의 오버헤드는 감수할 가치가 있습니다
메모리 그래프에 weak 참조 또는 unowned 참조가 보이지 않으면 Xcode에서 프로젝트의 Reflection Metadata Level 빌드 설정을 확인해야 할 수 있습니다 되도록 기본값인 All level을 사용하는 것이 좋습니다 이 설정에는 도구가 원하는 모든 메타데이터가 있으며 도구가 Swift에 훨씬 더 나은 정확도를 제공할 수 있게 합니다
구체적인 예를 살펴보겠습니다 이 ByteProducer 클래스에는 defaultAction 메서드에 할당된 클로저라는 생성기 속성이 있습니다 문제는 defaultAction 메서드가 암묵적으로 self를 사용하기 때문에 strong 참조 순환이 생성된다는 것입니다 메서드를 클로저로 사용할 때는 매우 조심하세요
이를 해결하려면 defaultAction()을 호출하는 클로저를 정의합니다 여전히 self 캡처를 수행하지만 이제 캡처가 명시적이며 캡처 목록을 사용하여 strong이 되지 않게 할 수 있습니다 참조 한정자를 지정해야 하며 여기서는 weak가 확실히 좋은 기본값이 됩니다 이 경우 Unowned도 괜찮습니다 생성기 클로저는 대상인 ByteProducer 인스턴스와 수명이 동일하기 때문입니다 클로저는 다른 코드로 할당되거나 비동기적으로 디스패치되지 않으므로 캡처된 자신보다 오래 지속될 수 없습니다 이러한 선택의 성능 차이는 때때로 누적되어 나타납니다 만약 이러한 ByteProducer 백만 개를 할당하고 메모리 그래프를 내보내면 힙 명령어 라인 도구로 비용을 빠르게 요약할 수 있습니다 각 ByteProducer마다 weak 참조 스토리지 할당이 하나씩 있는데 ByteProducer 자체만큼이나 많은 메모리를 사용합니다! unowned의 경우 이 메모리는 필요하지 않습니다 요점은 weak 참조는 좋은 기본값이며 unowned 참조는 참조가 대상보다 오래 지속되지 않음을 보장할 수 있을 때 메모리와 시간을 절약할 수 있다는 것입니다 CPU 오버헤드를 유발하는 영역을 찾으려면 프로파일링을 하고 swift_weakLoadStrong()과 같은 런타임 함수에 대한 호출을 찾으세요
Swift의 참조 카운팅에 대해서는 Automatic Reference Counting의 ‘Swift 프로그래밍 언어’ 챕터를 참고하세요
weak와 unowned 외에도 자동 retain과 release 호출이 프로파일링 핫스팟으로 나타날 수 있습니다 하고 싶다는 생각이 들어도 ARC를 우회해서는 안 됩니다 비관리 포인터를 사용하거나 성능에 민감한 코드를 메모리 불안전 언어로 옮기는 것보다 더 나은 해결책이 있습니다 -whole-module-optimization을 활성화해 두세요 인라이닝을 더 많이 허용해 오버헤드를 줄일 수 있습니다 명시적 특수화가 필요한 제네릭도 프로파일링하고 찾아보세요
가장 많이 복사되는 구조체 필드를 단순하게 유지하는 것도 좋습니다 프로파일링은 비용이 많이 드는 구조체 복사 식별에 도움이 됩니다 이러한 구조체에서는 참조 타입과 copy-on-write 타입 any 사용을 최소화하세요
더 많은 Swift 성능 관련 팁은 ‘Swift의 성능 살펴보기’ 세션과 ‘Swift에서 noncopyable 유형 소비하기’ 세션을 참조하세요 Objective-C 코드의 경우 retain과 release 오버헤드를 줄이는 몇 가지 방법이 있습니다
다시 말씀드리지만 ARC를 우회하지 마세요 수동 참조 카운팅으로 인한 누수는 디버깅이 매우 어렵습니다 objc_direct로 메서드를 표시하면 Objective-C 메서드 호출을 인라인화해 retain과 release 트래픽을 줄일 수 있습니다 인라인화가 불가능한 경우 objc_externally_retained 속성을 사용해 매개변수 수명이 보장될 때 컴파일러에 알려 주면 retain과 release를 제거할 수 있습니다
관찰 비용을 인식하는 것은 성능의 일부입니다 MallocStackLogging과 Allocations는 실시간 데이터를 추적하므로 모든 할당에 대한 정보 기록에 메모리와 CPU가 필요합니다 Leaks, VM Tracker, 메모리 그래프는 스냅샷 기반이므로 분석 중에 대상 앱을 일시 중단해야 합니다 이로 인해 스냅샷 과정에서 앱이 잠시 끊기거나 멈출 수 있습니다 정리하면, 오늘 저희는 Instruments로 힙을 측정하고 일시적 및 지속적 증가 패턴을 찾는 방법을 보여드렸습니다 개별 할당에 문제가 있는 것을 발견했다면 Xcode의 메모리 그래프 디버거와 MallocStackLogging을 사용해 앱의 힙에 왜 아직 존재하는지 알아보세요 가장 중요한 건 선제 조치입니다! 앱의 힙 메모리를 분석하고 최적화하세요 메모리 누수와 지속적 증가를 찾아내면 사용자가 앱을 더 오래 즐길 수 있습니다 시청해 주셔서 감사합니다
-
-
10:01 - ThumbnailLoader.makeThumbnail(from:) implementation
func makeThumbnail(from photoURL: URL) -> PhotoThumbnail { validate(url: photoURL) var coreImage = CIImage(contentsOf: photoURL)! let sepiaTone = CIFilter.sepiaTone() sepiaTone.inputImage = coreImage sepiaTone.intensity = 0.4 coreImage = sepiaTone.outputImage! let squareSize = min(coreImage.extent.width, coreImage.extent.height) coreImage = coreImage.cropped(to: CGRect(x: 0, y: 0, width: squareSize, height: squareSize)) let targetSize = CGSize(width:64, height:64) let scalingFilter = CIFilter.lanczosScaleTransform() scalingFilter.inputImage = coreImage scalingFilter.scale = Float(targetSize.height / coreImage.extent.height) scalingFilter.aspectRatio = Float(Double(coreImage.extent.width) / Double(coreImage.extent.height)) coreImage = scalingFilter.outputImage! let imageData = context.generateImageData(of: coreImage) return PhotoThumbnail(size: targetSize, data: imageData, url: photoURL) }
-
10:23 - ThumbnailLoader.loadThumbnails(with:), with autorelease pool growth issues
func loadThumbnails(with renderer: ThumbnailRenderer) { for photoURL in urls { renderer.faultThumbnail(from: photoURL) } }
-
10:33 - Simple autorelease example
print("Now is \(Date.now)") // Produces autoreleased .description String
-
11:08 - Autorelease pool growth in loop
autoreleasepool { // ... for _ in 1...1000 { // Autoreleases into single pool, causing growth as loop runs print("Now is \(Date.now)") } // ... }
-
11:50 - Autorelease pool growth in loop, managed by nested pool
autoreleasepool { // ... for _ in 1...1000 { autoreleasepool { // Autoreleases into nested pool, preventing outer pool from bloating print("Now is \(Date.now)") } } // ... }
-
12:16 - ThumbnailLoader.loadThumbnails(with:), with nested autorelease pool growth issues fixed
func loadThumbnails(with renderer: ThumbnailRenderer) { for photoURL in urls { autoreleasepool { renderer.faultThumbnail(from: photoURL) } } }
-
17:27 - C++ class with virtual method
class Coconut { Swallow *swallow; virtual void virtualMethod() {} };
-
17:40 - C++ class without virtual method
class Coconut { Swallow *swallow; };
-
18:41 - ThumbnailRenderer.faultThumbnail(from:), caching thumbnails incorrectly
func faultThumbnail(from photoURL: URL) { // Cache the thumbnail based on url + creationDate let timestamp = UInt64(Date.now.timeIntervalSince1970) // Bad - caching with wrong timestamp let cacheKey = CacheKey(url: photoURL, timestamp: timestamp) let thumbnail = cacheProvider.thumbnail(for: cacheKey) { return makeThumbnail(from: photoURL) } images.append(thumbnail.image) }
-
19:28 - ThumbnailRenderer.faultThumbnail(from:), caching thumbnails correctly
func faultThumbnail(from photoURL: URL) { // Cache the thumbnail based on url + creationDate let timestamp = cacheKeyTimestamp(for: photoURL) // Fixed - caching with correct timestamp let cacheKey = CacheKey(url: photoURL, timestamp: timestamp) let thumbnail = cacheProvider.thumbnail(for: cacheKey) { return makeThumbnail(from: photoURL) } images.append(thumbnail.image) }
-
22:19 - Code creating reference cycle with closure context
let swallow = Swallow() swallow.completion = { print("\(swallow) finished carrying a coconut") }
-
23:11 - PhotosView image loading code, with leak
// ... let renderer = ThumbnailRenderer(style: .vibrant) let loader = ThumbnailLoader(bundle: .main, completionQueue: .main) loader.completionHandler = { self.thumbnails = renderer.images // implicit strong capture of renderer causes strong reference cycle } loader.beginLoading(with: renderer) // ...
-
23:40 - PhotosView image loading code, with leak fixed
// ... let renderer = ThumbnailRenderer(style: .vibrant) let loader = ThumbnailLoader(bundle: .main, completionQueue: .main) loader.completionHandler = { [weak renderer] in guard let renderer else { return } self.thumbnails = renderer.images } loader.beginLoading(with: renderer) // ...
-
24:24 - Intentional leak of manually-managed allocation
let oops = UnsafeMutablePointer<Int>.allocate(capacity: 16) // intentional mistake: missing `oops.deallocate()`
-
25:12 - Loop over intentional leak of manually-managed allocations
for _ in 0..<100 { let oops = UnsafeMutablePointer<Int>.allocate(capacity: 16) // intentional mistake: missing `oops.deallocate()` }
-
26:11 - Nonreturning function which can see leaks of allocations owned by local variables
func beginServer() { let singleton = Server(delegate: self) dispatchMain() // __attribute__((noreturn)) }
-
26:22 - Fix for reported leak in nonreturning function
static var singleton: Server? func beginServer() { Self.singleton = Server(delegate: self) dispatchMain() }
-
27:21 - Weak reference example
weak var holder: Swallow?
-
27:43 - Unowned reference example
unowned let holder: Swallow
-
29:07 - Implicit use of self by method causes reference cycle
class ByteProducer { let data: Data private var generator: ((Data) -> UInt8)? = nil init(data: Data) { self.data = data generator = defaultAction // Implicitly uses `self` } func defaultAction(_ data: Data) -> UInt8 { // ... } }
-
29:25 - Break reference cycle cause day implicit use of self by method, using weak
class ByteProducer { let data: Data private var generator: ((Data) -> UInt8)? = nil init(data: Data) { self.data = data generator = { [weak self] data in return self?.defaultAction(data) } } func defaultAction(_ data: Data) -> UInt8 { // ... } }
-
29:41 - Break reference cycle cause day implicit use of self by method, using unowned
class ByteProducer { let data: Data private var generator: ((Data) -> UInt8)? = nil init(data: Data) { self.data = data generator = { [unowned self] data in return self.defaultAction(data) } } func defaultAction(_ data: Data) -> UInt8 { // ... } }
-
31:14 - Struct with non-trivial init/copy/deinit
struct Nontrivial { var number: Int64 var simple: CGPoint? var complex: String // Copy-on-write, requires non-trivial struct init/copy/destroy }
-
-
찾고 계신 콘텐츠가 있나요? 위에 주제를 입력하고 원하는 내용을 바로 검색해 보세요.
쿼리를 제출하는 중에 오류가 발생했습니다. 인터넷 연결을 확인하고 다시 시도해 주세요.