언제나 내게는 숙제와도 같은 주제다.
이 발표에서는 hang 의 정의, hang 이 발생하는 원인, 이를 잡아내는 Instruments 에 대해 다룬다.
Instruments 에 대해 익숙하지 않다면 WWDC19 의 'Getting Started with Instruments' 를 보고 오라고 한다. 필자는 Instruments 를 써보긴 해서 사용방법은 알지만 활용방법에 대해서는 잘 모르고 있다고 판단하고 있다.
이 발표를 쭉 보고 위의 발표까지 볼 필요가 있는지 판단해보려 한다.
해당 발표에서는 hang 을 찾는 것에 대해서는 다루지 않는다 WWDC22 의 'Track down hangs with Xcode and on-device detection' 에서 다루었다고 한다.
선행조건이 많은 발표라 느낌이 좋지 않다...
hang 을 설명하기 위해 한 가지 예시를 든다.
전구가 있다. 전구에는 케이블을 꽂으면 불이 들어오고, 케이블을 빼면 불이 꺼진다. 이 과정은 바로바로 일어났으면 좋겠다.
근데 케이블을 꽂고 약 500ms 이후에 불이 켜진다고 해보자. 케이블을 뺐을 때도 500ms 이후에 불이 꺼진다고 해보자.
다른 예시도 들어보자. 대화를 하는 두 주체가 있다. 한 주체가 대화를 시도하고 한 주체가 대답을 한다. 이 사이에는 1초 간의 간격이 있다.
무엇이 hang 인가? 시간으로 이를 구별할 수 있을까? 전구의 예시는 hang 인가? 대화의 예시는 hang 인가?
발표에서 제시하는 기준은 모두에게 다를 수 있다. 하지만 내가 보기에도(애플에 설득당함) 아래와 같은 기준을 통해 hang 을 구별할 수 있을 것 같다.
여기서 또 다른 한가지를 더 구별해야 하는데 우리가 하는 상호작용은 2 가지로 구별할 수 있다.
당연히 Request-Response 가 Instant 보다 느릴 수 밖에 없다. 그러므로 Instant 즉, '바로 실행되었다' 의 기준은 다음과 같다.
위에서 말한 내용을 종합해보면 상호작용할 때 가장 좋은 것은 UI 가 'Instant' 하게 작동해야 한다는 것이다.
이를 위해서는? non-UI 작업을 Main thread 로부터 완전 분리해야 한다.
작업을 분리하는 것은 컴파일러에서 미리 걸러주거나, 앱을 테스트하는 도중 발견되므로 상관이 없다. 충분히 예상 가능한 범위에서 일어나는 일이라는 것이다.
하지만 여기서는 2 개의 예상치 못한 경우가 발생할 수 있다.
Main Thread 의 작업 자체가 길어져서 Hang 이 발생한다.
Main Thread 의 작업이 실행되기 전 선행되어야 하는 작업을 실행해야 하는데, 다른 작업을 실행해야 해서 Hang 이 발생한다.
그러므로 우리가 목표로 해야 하는 것은 유저 상호작용이 발생한 이후(시작) 100ms 가 지난 시점에 Main Thread 의 작업이 시작되는 것을 목표로 해야 하는 것이다.
이제부터 Xcode 에서 Profile 을 실행한다. Xcode 의 Product 메뉴에서 Profile 을 실행하면 앱이 빌드되고 설치된다. 실행은 되지 않는다.
그리고 Instruments 창이 열린다. 설정은 Xcode 의 배포 타겟과 기기 설정을 그대로 가져오게 된다.
발표에서 좋은 시작점이면서 뭐부터 시작해야할지 잘 모르겠을 때는 Time Profiler template
를 이용해보라고 한다.
Time Profile template 를 통한 Instruments 창을 확인해보면 Time Profiler instrument 와 Hangs instruments 가 존재한다. 아주 유용한 것들이다.
여기서 빨간 동그라미가 겹쳐있는 Record 버튼을 누르면 앱이 실행된다.
이 발표에서 보여준 Hang 은 'Choose Background' 버튼을 탭 하고나서 sheet 를 보여주는 화면의 Hang 을 보여주고 있다. 약 4초 정도 걸린 것 같다. 발표자의 말을 빌리자면 Severe Hang
이다.
그리고 Instruments 에도 이와 같은 사항이 기록 되었다.
위의 Hang 은 Main Thread 작업 자체가 오래걸려서 발생하였다. 다른 하나인 Main Thread 에 선행되어야 할 작업이 실행되지 않는 Hang 이 걸렸을 경우를 Instruments 에서는 아래와 같이 표현한다.
Main Thread 에서 어떤 작업이 이루어지고 있을까? 다시 Instruments 로 돌아가보자.
각 Instruments 에는 여러 스레드의 작업을 기록해 놓은 Call Stack 을 확인할 수 있다. 지금은 Main Thread 의 어떤 작업이 문제인지 알아보기 위해 Track 에서 Main Thread 를 선택하여 Call Stack 을 확인한다. 시간이 얼마나 걸렸는지 알 수 있기 때문에 어떤 함수가 문제인지 찾기 편리하다.
Severe Hang(빨간 부분) 에서 우클릭 한 뒤 option 키를 누른 상태에서 'Set Inspection Range' 를 클릭하면 자동으로 줌이 되어 Hang 부분만 화면에 들어오도록 한다.
Main Thread 의 CPU Usage 를 그래프로 표현하고 있는데, 60~90% 정도라면 상당히 높은 Usage 로 판단한다고 한다.
이는 빼도박도 못하는 Busy Main Thread, 즉 Main Thread 작업 자체 시간이 길었던 것으로 판단할 수 있는 것이다.
그럼 도대체 어떤 메소드가 문제란 말인가? 로그 한줄한줄 다 찾아봐야 하나? 오른쪽 하단에 'Heaviest Stack Trace' 가 도움이 될 것이다. 여기서 우리가 작성한 코드를 가리키고 있는 항목을 클릭해 본다. 'BackgroundThumbnailView.body.getter' 는 SwiftUI 의 뷰 내 body 프로퍼티를 가리키는 것으로 판단된다.
여기에 맨 아래 'Call Tree' 버튼을 클릭하면 Call Tree 에 나오는 항목들에 필터를 걸 수 있는데, 'system libraries' 항목을 클릭하여 작업자가 작성한 부분만을 볼 수 있도록 도움을 줄 수 있다.
마지막 스크린 샷의 선택된 항목을 오른쪽 클릭한 뒤 'Reveal In Xcode' 를 선택하면 실제 프로젝트의 소스코드를 바로 보여준다.
이제 우측 상단의 라이브러리를 추가해서 원인을 개선하는 것을 시연하는 영상을 보여주었다. 그것까지 포스팅에 넣으면 너무 길어지니 방법을 간단히 소개하는 것으로 마치겠다.
위의 노력으로 화면 자체의 hang 은 없는데 Instruments 는 여전히 Hang 을 감지한다. 이는 sheet 화면이 표시되기 이전에 발생하는 Hang 이었다.
그래서 창을 바로 닫고 싶어도 닫을 수 없었다. Hang 때문에 상호작용이 먹히지 않는 것이다.
Hang 에 의해 상호작용이 불가해지는 현상은 2 개의 원인이 있다.
여기서 말하는 Synchronous/Direct Hang 은 User Input 에 대한 반응이 오래 걸리는 것을 말한다. Asynchronous/Delayed Hang 은 User input 에 반응은 하였으나 이를 처리하는 과정이 오래걸리는 것을 말한다.
참고로 Hang detection 은 위의 작업 시간을 계산하는 것으로 시작된다. 각 작업이 100ms 가 넘어가는지 조사하는 것이다. 100ms 가 넘어가면 Circumstantial, Micro Hang, Hang 등으로 구분 되는 것이다.
여기서도 문제를 해결해 나가는 과정을 글로 적어나가도록 하겠다. 사용한 도구들은 이미 위에 다 소개되었으니 이로 대체하도록 하겠다.
보이는 것처럼 Main Thread 에서 처음에는 CPU Usage 가 보이지만 한동안 CPU 를 사용하지 않고 있다.
Main Thread 자체가 작동하지 않아 생기는 Hang 을 체크하는 방법은 무엇일까? 우측 상단의 라이브러리 추가 버튼을 통해 'Thread State Trace' 를 추가해보도록 하자.
이를 통해 수집한 정보를 보자. 5초 이후 조금 있다가 CPU Usage 가 반응이 없다. 그리고 Thread State Trace 덕분에 여러가지 지표를 확인할 수 있다.
파란 선 이후의 타임라인을 선택하고 아래를 보자. 아래 목록의 특정 항목이 선택된다.
아까와는 다른 화면이다. Heaviest Stack Trace 등이 존재하지 않는다. Narrative view 는 스레드의 작업을 언제 그리고 왜 수행했는지 알려준다.
Blocked for 6.64 s (100.0% of mach_msg2_trap's duration) starting at priority 47.
잘 보이지 않을 수 있어 직접 적었다. 내용은 6.64s 동안 정지되었고, mach_msg2_trap 때문이라고 한다.
오른쪽의 backtrace 화면만 가져와 봤다. mach_msg2_trap 은 MLModel 에 존재하는 시스템에서 실행하는 함수인 것으로 추측된다.
MLModel 의 실행은 ColorizingService 라는 객체에 의해 실행된 것으로 보이며, 위에 shared 를 보아 싱글턴 객체로서 호출되었다. 이를 통해 하나의 스레드를 많은 작업이 공유할 수 있겠다는 생각이 든다.
그리고 바로 위, BackgroundThumbnailView.body.getter 의 첫번째 클로저에서부터 이 모든 작업이 시작됨을 알 수 있다.
'closure #1 in backgroundThumbnailView.body.getter' 항목을 더블 클릭하면 소스 뷰가 표시된다. 실제 코드는 아래와 같다.
struct ImageTile: View {
// [...]
// implicit @MainActor
var body: some View {
mainContent
.task() { // inherits @MainActor isolation
// [...]
let colorizer = ColorizingService.shared // 싱글턴 객체를 task 안에서 참조하고 있음.
result = try await colorizer.colorize(image)
}
}
}
여기서 발표자는 shared 로 싱글턴 객체를 받아오는 작업을 async 하게 만들되 lock 혹은 semaphore 를 조심하라고 한다.