기어가더라도 제대로

[번역]UI는 왜 Main Thread 에서 업데이트 되어야 하는가?(feat. 데드락) 본문

생각정리/번역글(blog, WWDC)

[번역]UI는 왜 Main Thread 에서 업데이트 되어야 하는가?(feat. 데드락)

Damagucci-juice 2022. 9. 1. 12:11

목차

- UIKit 이 Thread-Safe 하지 않은 이유
- Run Loop 와 뷰 드로잉 사이클
- iOS 렌더링 과정을 이해하기
    - 렌더링 프레임워크
    - Core Animation Pipeline
    - Texture or ComponentKit
- 결론
- 역자의 말(소감, 추가 의문)

개발을 하다보면 background 스레드에서 UIKit 의 요소를 호출하는 경우가 있다.

background 에서 작업하는 네트워크 콜백에서 imageView.image = image 라고 하거나, UIApplication.sharedApplication 을 호출하는 등의 작업을 백그라운드 스레드에서 하는 경우말이다.

이럴 경우에, 우리는 런타임 에러를 얻게 되고 즉시 그것들을 고칠 것이다.

근데 생각을 해보면, 왜 UI 를 메인 스레드에서 업데이트 해야하나? 백그라운드 스레드에서 업데이트 되면 무슨 일이 일어나는가? 메인 스레드가 막히는 것을 피하기 위해서 백그라운드 스레드에서 진행하는 것이 더 낫지 않은가?

이 기사는 이런 질문들에서 시작되었다.

1. UIKit 이 Thread-Safe 하지 않은 이유

보시다시피, 대부분의 UIKit 구성요소(component) 는 nonatomic 하다고 서술되어있다. 이 말은 그들이 thread-safe 하지 않다는 뜻이다. 그리고 엄청나게 큰 UIKit 의 모든 속성들을 thread-safe 하게 처리하는 것은 비현실적이다. thread-safety 한 프레임워크를 디자인하는 것은 단지 nonatomic 에서 atomic 하게 바꾸는 것이나, NSLock 을 추가하는 것이 아니라, 많은 문제와 관련이 있다 .

  • 비동기적으로 뷰의 속성을 변경한다고 가정하면, 이러한 변경들은 동시에 효력을 갖는가, 혹은 각자의 런루프 스레드를 따라가는가?
  • UITableView 가 cell 을 백그라운드 스레드에서 제거한다고 하면, 또 다른 백그라운드 스레드에서 그 셀의 인덱스를 사용하면 이것은 충돌을 내는가?
  • 백그라운드 스레드에서 뷰를 제거하면서 이 스레드의 RunLoop 는 끝나지 않을때, 동시에 “이 뷰가 제거됩니다"를 사용자가 탭하면 앱은 이 터치 이벤트에 반응해야하는가? 여기서 또 어떤 스레드가 반응해야하나?

더 깊게 생각할 수록, 백그라운드 스레드에서 뷰를 업데이트 하는 것이 이득이 없어 보인다. 위의 문제를 풀다보면, 우리는 쉽게 “모든 작업을 순차 큐에서 처리하면 아무 문제도 나타나지 않을거야” 라는 결론에 도달한다. 이게 애플의 생각이였고, UI 는 메인 스레드에서 순차적으로 작동되게 되었다.

Thread-safe 클래스 디자인 도 같은 이야기를 한다.

UIKit 이 스레드 세이프 하지 않는 것은 애플의 의도된 디자인적 결정이다. 스레드 세이프하게 만들어도 성능 측면에서 큰 도움이 되지 않는다. 사실 다른 것들을 더 느리게 만든다. 또한 UIKit 이 메인 스레드에 결합되어있다는 사실이 동시성(Concurrent) 프로그래밍을 작성하고 UIKit 을 사용하기에 매우 쉽게 한다. 당신이 확실히 할 것은 UIKit 에 대한 호출은 전부 메인 스레드에서 하는 것이다.

자 이제 당신이 “요술"을 부려서 UIKit 를 리펙터링 하고, 이 “신비한" UIKit 이 위의 문제를 완벽하게 풀 수 있다.

이제 우리는 UI를 백그라운드 스레드에서 업데이트해도 될까?

미안하지만 그래도 여전히 그럴 수 없어요.

🤔 thread-safe

  • 복수의 스레드가 동시에 하나의 데이터를 사용하려 시도할 때,
    “정합성(Correctness)"을 보장하는 시스템의 능력

2. Run Loop 와 뷰 드로잉 사이클

아시다시피, UIApplication 은 여기서 Main RunLoop 를 호출하면서 RunLoop 를 메인 스레드에서 시작할 것이고, 앱의 생명주기동안 사용자 상호작용과 같은 대부분의 사용자의 이벤트를 처리할 것입니다. 메인 런루프는 가능한 빠르게 사용자 이벤트에 응답할 수 있도록 보장하는 동면(hibernation)과 끊임없는 이벤트 처리 과정의 반복 속에 있습니다. 스크린이 새로고침 될 수 있는 이유는 Main RunLoop 가 작동 중이여서 그렇습니다.

또한 모든 뷰의 변화가 즉시 변화하지 않습니다. 변화는 현재 런루프의 끝에 다시 그려질 것입니다. 이 점은 앱이 모든 뷰에 대한 모든 변화를 처리할 수 있음을 보장합니다. 그리고 모든 변화가 같은 시점에 효력을 얻습니다. 이것을 “View Drawing Cycle” 이라고 합니다.

우리의 “신비한” UIKit을 사용하고 UI를 백그라운드에서 업데이트 한다고 가정해봅시다. 문제는 우리의 기기를 회전해야하거나 레이아웃을 새로고침할 때 나타납니다. 왜냐면 각각의 스레드는 고유한 런루프를 가지고 있어서 모든 변화가 동시에 적용될 수가 없습니다. 이는 기기를 회전 후에 회전 되지 않은 뷰가 남아있는 경우를 야기합니다.

또한 그 “신비한" UIKit은 메인 스레드에 있지 않기 때문에, 메인 런루프 안의 유저 이벤트들은 화면과 동기화 되지 않습니다.

그래요, 근데 어떻게 잘 UIApplication 의 유저 이벤트 메커니즘을 조정해서 스레드의 동기화 문제를 해결한다고 해봅시다. 그럼 이제는 정말 UI 를 백그라운드 스레드에서 업데이트 해도 될까요?

미안하지만 여전히 그럴 수 없어요..

🤔 신비한 UIKit

  • thread-safety 한 가상의 UIKit 을 말합니다.
  • 실제 UIKit 은 Thread-safety 하지 않습니다.

3. iOS 렌더링 과정을 이해하기

3.1. 렌더링 프레임워크

  • UIKit
    • 모든 종류의 컴포넌트, 유저 이벤트 핸들링 코드를 포함합니다.
    • 어떠한 렌더링 코드도 포함하지 않습니다.
  • Core Animation
    • 그리기(Drawing) 에 대한 책임이 있고, 모든 뷰를 전시하고 움직이게 합니다.
  • OpenGL ES
    • 2D, 3D 렌더링 서버를 제공합니다.
  • Core Graphics
    • 2D 렌더링 서버를 제공합니다.
  • Graphics Hardware
    • GPU 를 이야기 합니다.

모든 뷰는 UIKit이 아니라 Core Animation Framework 에서 그려지고 동작합니다.

3.2. Core Animation Pipeline

Core Animation 은 렌더링을 위해 네 단계로 구분되는 Core Animation Pipeline 을 사용합니다.

  • Commit Transaction(커밋 교환)
    • 뷰 레이아웃, 이미지 디코딩 및 형식 변환 작업 처리, 뷰 레이어를 감싸서(패키징) 렌더 서버로 전송
  • Render Server
    • 렌더링 - 커밋 교환에서 온 패키징을 분석하고, 렌더링 트리에 역직렬화(deserialization) 합니다.
    • 뷰의 레이어 속성으로 그리기 지시를 생성할 것입니다.
    • 다음 VSync 신호가 올 때 화면을 렌더링하라고 OpenGL을 호출 합니다.
  • GPU
    • 화면의 VSync 신호를 기다릴 것입니다. 그 후 OpenGL 렌더링 파이프 라인을 사용해 랜더링 합니다.
    • 렌더링 후에 결과물을 버퍼에 보냅니다.
  • Display
    • 버퍼로 부터 데이터를 얻고 화면에 내보냅니다.

이 Core Animation Pipeline 에서, 우린 이 준비 작업이 60분의 1초안에 끝나고 렌더 서버로 데이터를 보내기를 원합니다. 그 후 60분의 1초안에 렌더링을 끝내고, 그 안에 어플리케이션이 막히지 않을 것입니다.

그러나, 만약 우리의 “신비한" UIKit을 사용해서, 다수의 백그라운드 스레드마다 있는 RunLoop의 끝 시점에 UI를 업데이트 한다면, 언제 화면이 렌더링 되어야하는지에 대한 문제가 나타납니다. 각각의 스레드는 다른 렌더 정보를 커밋하기 때문에, 우리는 더 많은 커밋 교환을 처리해야 하고, 그 결과 Core Animation Pipeline 는 항상 GPU 로 정보를 커밋할 것입니다. 그러나 렌더링은 실제로 시스템 자원의 매우 비싼 자원이며(비디오 메모리와 CPU 제어권을 얻어야함), 스레드 간에 잦은 문맥 교환과 많은 문맥 교환은 GPU 가 일을 못하도록 합니다. 결국 이는 성능 문제로 이어지며, 여러 stall(일시 정지)를 발생시키며, 60분의 1초마다 레이어 트리의 제출(그려짐)의 완성에 장애를 발생시킵니다.

그래도 UI를 백그라운드에서 업데이트 하고 싶은데 어떻게 하면 좋을까요?

그래요, 이제 몇가지 방법이 있겠네요.

3.3. Texture or ComponentKit

AsyncDisplayKit(Texture) 는 페이스북에서 iOS 앱을 부드럽게 하도록 개발한 프레임 워크입니다.

또 페이스북에서 개발된 뷰 프레임워크인 ComponentKit 은 리액트에 깊은 영감을 받았습니다. UI를 만들 때 함수형과 선언형의 접근을 취합니다.

다시 UI가 메인 스레드에서만 업데이트 될 수 있는 평범한 UIKit 으로 돌아와봅시다.

이 두가지 프레임워크는 실제로 백그라운드 스레드에서 UI를 업데이트 하는 것이 아니라, 비동기적으로 훨씬 더 똑똑하게 UI가 메인 스레드에서만 업데이트 되어야하는 한계를 우회하는 어떠한 시간 소비 작업(Time-consuming operations) 방식을 사용합니다.

Texture는 몇몇 Node 라는 클래스를 생성합니다. Node 는 UIView 를 포함하며, Node 그 자체로는 Thread-safe 합니다. 이제 우리는 노드를 백그라운드 스레드에서 작동시킬 수 있습니다. 그리고 노드의 속성이 변경되면 변화를 즉시 반영하지 않고, 적절한 시간에 메인 스레드에서 뷰에 변경점을 적용할 것입니다. 위에서 말한 View Drawing Cycle 과 비슷하게 들립니다.

스텐실 판

 

ComponentKit 는 UI를 “설명하기" 위해 Component 라는 클래스를 생성합니다. Component 도 thread-safe 합니다. 이 Component 를 스텐실 판이라고 상상해보면, 뷰는 스텐실 판 밑의 종이고, 렌더링은 잉크와 같습니다. 즉 Component 를 생성할 때, 뷰의 스텐실 판을 만든다는 말이고, 렌더링은 스텐실 판을 따라가기만 한다는 이야기고, 우리가 필요할 때 뷰를 렌더링 할 수 있게 됩니다.

4. 결론

아마 모든 iOS 개발자들이 “UI를 메인 스레드에서 업데이트 해야한다.” 는 것을 알고 있었을 것이다.
그 이유는 생각해본적이 있을까? 더 깊게 팔수록, 거기에 더 많은 지식을 발견할 것이다.
그리고 이런 지식들을 종종 못보고 넘어가는 경우가 있다.

코딩은 쉬웠던 적이 없다.

참조.

iOS: Why the UI need to be updated on Main Thread

 

iOS: Why the UI need to be updated on Main Thread

Do you ever think about why UI really MUST to be updated on main thread? What will happened if we turn UIKit into thread-safe design?

medium.com

https://swiftrocks.com/thread-safety-in-swift#:~:text=I personally define thread safety,it at the same time

 

Thread Safety in Swift

Concurrency is the entry point for the most complicated and bizarre bugs a programmer will ever experience. In this article, I'll share my favorite methods of ensuring thread safety, as well as analyzing the performance of the different mechanisms.

swiftrocks.com

 

Dispatching async or sync? The differences explained

 

Dispatching async or sync? The differences explained

When writing iOS apps, we regularly run into code that is asynchronous. Sometimes you know you're writing something that will run asynchronously and other times you're passing a completion handler to…

www.donnywals.com

 

 

역자의 말

결론에 원문으로는

“We can not update UI on main thread.”

라고 되어 있는데 이는 잘못된 것으로 “UI를 메인 스레드에서 업데이트 해야한다.” 로 번역하였다.

DispatchQueue.sync 언제 쓸까?

  • 프로퍼티나 값이 멀티 스레드에 의해서 변경될 때 정합성(correctness)를 유지하기 위해서 사용

DispatchQueue.main.sync 를 안쓰는 이유

iOS 나 안드에선 UI 를 업데이트 하기 위해 메인 스레드를 사용하는데,
sync적으로(어떤 작업이 끝날때까지 기다리는) 작업을 하게되면 데드락이 발생해서 앱이 멈춘 것 처럼 된다네요.
사용자 경험이 떨어지겠네요.

근데 왜 데드락이 걸리는 것 일까요?

  • 왜냐면 소스코드가 돌아가는 곳이 DispatchQueue.main.sync 인데,
    클로저로 DispatchQueue.main.sync를 부르면 본인 큐에 본인이 가서 서있는 형국이 된다.
  • 비유적으로 말을 하자면 아빠와 아이스크림 심부름 이야기가 제격이다.
    • 아버지가 아들에게 아이스크림을 사오라고 시켰다.
    • 아들이 아이스크림 가게에 가서 줄을 서서 기다리고 있다.
    • 근데 앞에 아빠가 아이스크림을 기다리고 있다.
    • 즉 순서가 손님 1 — 손님 2— 아빠 — 손님 3 — 손님 4 — 아들
    • 이런 순서가 되어서 아들의 경우엔 아빠의 일처리가 끝나기 전엔 실행될 수 없다.
    • 또 아빠의 경우엔 아들이 아이스크림을 사와야 일처리가 끝나는데,
    • 이러한 형국을 데드락이라고 부른다. 
  • 근데 아까도 말했지만 DispatchQueue.main.sync 는 프로세스의 코드를 처리하는 영역이여서
    • 클로저로 다시 위의 큐에 추가를 해도 모든 코드가 실행되기 전에는 클로저 안에 코드가 실행되지 않는다.
    • 근데 그 모든 코드 안에는 클로저가 포함된다. 
    • 즉, 클로저가 실행되기 전에는 클로저가 실행되지 않는다.
Comments