본문 바로가기

Swift/GCD

WWDC 2015 - Building Responsive and Efficient Apps with GCD

시작에 앞서 WWDC 2015에서 소개된 GCD관련 API는 대부분 Swift3.0 이전 버전이기 때문에 현재는 사용되지 않는 것들이 대부분이라 전체적인 흐름과 각 내부 메서드의 역할을 파악하는데 집중했습니다. 

 

  • OS X Snow Leopard에서 소개된 기술
  • 그 당시 최신 기기는 듀얼 코어인 맥북 프로였는데 애플리케이션의 서로 다른 부분을 동시에 실행하면서 두 코어를 모두 활용하고 스레딩을 쉽게 만들 수 있다는 장점이었다.

QoS는 시스템에 어떤 종류의 작업을 수행하는지 알려주는 방법. 이를 통해 시스템은 코드를 가장 효과적으로 실행하기 위한 다양한 resource control을 제공한다.

 

resource control이란?

  • 어떤 스레드를 실행할지, 어떤 순서로 실행할지를 결정 - CPU Sheduling priority
  • 다른 I/O 작업에 대한 우선순위를 고려하여 I/O 작업을 실행하는 방법을 결정 - I/O priority
  • 절전 기능 중 하나 - Timer coalescing
  • CPU를 가장 최적의 성능을 위한 처리량 지향 모드로 실행할지 아니면 최대한 에너지 효율을 높이는 효율성 지향 모드로 실행할지 결정

등의 구성 값을 조정해 준다. 이러한 구성값을 각 플랫폼 또는 장치 및 실행 코드마다 조정해야 하지만, 사실상 맞춤 설정하기 힘들고 비효율적이다. → QoS를 통해 작업의 의도와 분류를 시스템에 전달함

 

QoS의 자세한 설명은 앞선 글에서 했기 때문에 생략 (https://kswift.tistory.com/17 참조)

 

기기 자체에서 에너지를 소모하고 있는 경우, 덜 중요한 QoS의 순으로 작업을 줄여나가면서 기기 내에 에너지를 효율적으로 관리할 수 있도록 한다.

 

또한, 멀티테스크 작업에서도 각 스레드에 적절한 QoS를 부여함으로써 사용 가능한 자원을 효율적으로 배치한다.

 

 

Automatically propagetes QoS (QoS 자동전파)

위 예제는 메인 스레드에서 GCD 큐에서 비동기 작업을 수행하고 I/O를 수행한다. 좌측에는 메인 스레드가 있고 여기서 UI 렌더링 및 이벤트 처리가 발생한다. 적절한 QoS는 user interactive일 것이다.

그리고 오른쪽 부분은 메인 스레드에서 발생하지 않는 비동기 작업이기 때문에 이것은 userInteractive에서 실행되면 안 된다.

UI rendering을 하는 것이 아니지만, 어떤 문서를 탭 하고 열리기를 기다리는 경우가 있을 수 있을 것이다. 이때에 userInitiated 가 적합할 것이다.

흥미로운 점은 위 userInteractive에서 userInitiated로 QoS가 변경되는 과정을 구현할 필요가 없다는 것이다.

dispatch async는 main thread에서 비동기 작업을 처리하는데, 작업을 실행할 큐로 제출하는 스레드에서 QoS를 자동 전파한다. 이를 통해 userInteractive에서 userInitiated로 자동 변환하여 전파한다.

이를 통해 메인스레드와 UI 렌더링에만 관여해야 하는 QoS(userInteractive)가 과도하게 전파되지 않도록 한다.

또한 받아온 결과를 다시 메인 스레드에 업데이트하는 경우에도 이 자동 전파가 발생한다.

메인 스레드는 QoS가 userInteractive 이기 때문에, 메인 스레드와 같은 QoS가 할당된 스레드로 이동하더라도 우선순위가 낮아지지 않는다. 전파된 QoS를 무시하고 userInteractive QoS 클래스에서 UI 업데이트를 실행할 것이다.

 

⇒ Qos Propagation with dispatch_async

큐에 블록이 제출되는 타이밍에 캡처된 QoS를 특별한 규칙으로 userInteractive를 userInitiated로 변환했다. 이 propagated QoS는 블록이 제출되는 곳에 자체 QoS가 설정되어 있지 않은 경우에 사용되며, 자체적으로 높은 QoS가 설정되어 있는 메인 스레드로 이동하더라도 QoS가 낮아지지 않는다.

 

특별한 규칙이란?
https://kswift.tistory.com/20
- QoS 전파 또는 상속 - 특별한 규칙? 참고

 

 

다음 예시는 자동으로 열리지 않는 긴 작업이다. 이는 메인 스레드에 떨어진 상태에서 긴 계산 작업을 실행하여 메인 스레드를 방해하지 않도록 한다. 업데이트 시엔 비동기적인 back block을 통하여 UI를 업데이트한다.

이를 구현하기 위한 적절한 QoS는 왼쪽은 userInteractive, 오른쪽은 utility이다. 이를 통해 사용자는 계산 결과를 즉시 기다리지 않고 진행 상황을 확인하거나 UI를 계속 사용할 수 있을 것이다.

 

QoS를 명시적으로 utility로 설정한 뒤 실행하고자 하는 블록을 dispatch async를 통해 전달하면 해당 블록 객체는 지정된 QoS Class에 따라 실행된다. 이때, 해당 블록에서 QoS Class 가 변경된 경우, 자동 전파가 발생한다. 이후 해당 블록에서 비동기적으로 생성된 작업들도 자동으로 올바른 Qos Class로 실행된다.

 

Swift 3.0 이후

실행하고자 하는 블록(dispatch block)과 같은 역할을 하는 workItem을 생성

let utilityWorkItem = DispatchWorkItem(qos: .utility) {
	// utility QoS에서 실행하고자 하는 블록
}

DispatchQueue.global().async(execute: utilityWorkItem)

 

또한, 블록 내에서 QoS Class를 캡처하여 저장하고, 다른 스레드나 큐에서 나중에 해당 블록을 실행할 때 저장된 QoS Class 를 사용할 수 있다. dispatch block assign current flag 는 현재 QoS Class 를 콜백 블록에 저장하고 나중에 다른 스레드나 큐에 제출됐을 때, 알맞은 QoS로 전파하여 실행하도록 한다.

 

Swift 3.0 이후

let assignCurrentContextWorkItem = DispatchWorkItem(flags: .assignCurrentContext) {
		// 현재 컨텍스트의 스레드, 큐, QoS를 모두 상속받을 수 있다.
}

DispatchQueue.global().async(execute: assignCurrentContextWorkItem)

 

또 다른 예는 데이터베이스에 많은 느슨한 객체가 있다면, 압축, 정리 작업과 같은 background queue에서 실행되어야 하는 유지보수와 같은 작업이 필요하다. 이 작업은 userInteractive 작업이 필요하지 않다.

그렇다면 이 작업을 background QoS class에서 실행해야 한다. 이때 dispatch queue attr make with QoS class를 사용해 할당된 QoS와 함께 queue를 만든다. 이후 생성된 background queue에 작업을 전달한다.

Queue에 해당된 QoS class가 있다면 dispatch async를 통해 자동 전파를 얻을 수 있다. 메인 스레드에서 전파받은 userInitiated를 무시하고 큐가 지정한 QoS class를 사용하는 것이다.

 

Swift 3.0 이후

label, qos, attribute 등을 설정한 사용자 지정 큐 를 생성한 다음 큐에서 작업을 처리

let backgroundQueue = DispatchQueue(label: "com.example.backgroundQueue", qos: .background)

backgroundQueue.async {
		// background 작업
}

// or

DispatchQueue.global(qos: .background).async {
		// background 작업
}

 

이와 같은 실행 흐름과 관련이 없는 유지 관리 작업의 경우 dispatch block detached 플래그를 사용하는 것이 적절할 수 있다. 이는 운영 체제에게 수행하는 작업이 실행 흐름과 관련이 없음을 알린다.

 

Swift 3.0 이후

dispatchWorkItem의 flag중 .detached 를 사용

let detachedWorkItem = DispatchWorkItem(qos: .userInitiated, flags: .detached) {
		// QOS의 자동 전파를 무시 할 수 있으며 실행 컨텍스트의 다른 속성들을 버리고 독립적인 실행 컨텍스트에서 실행, 즉 userInitiated QoS의 독립적인 실행 컨텍스트에서 위 wortItem은 실행된다.
}

 

만약 background QoS로 실행돼야 할 작업이 있는 상황에서 로그아웃을 한다면 이는 QoS의 변경이 필요하다.

이때 큐의 값인 background 대신에 블록에 있는 값을 사용하기 위해서 block create와 함께 dispatch block enforce QoS class flag를 사용한다. 그러나 만약 serial queue에 높은 QoS의 작업이 제출되기 전에 낮은 QoS의 작업이 제출돼 있다면 proirity inversion(우선순위 역전) 이 발생한다.

GCD는 serial queue를 사용하여 이미 실행 중인 작업이나 대기 중인 작업을 높은 우선순위 블록에 도달할 때까지 우선순위를 높일 수 있도록 지원한다. 이는 QoS 오버라이드로 내부적으로 자동으로 처리된다. ⇒ Qos override

따라서 queue QoS는 앱에서 단일 목적으로 사용되는 큐나 제출된 작업들의 QoS보다 큐의 QoS가 더 중요한 경우에 적합하다. 따라서 유지보수와 같은 background 작업에 대해서는 detached block API를 사용하여 queue를 생성하고 해당 queue의 QoS를 background로 설정하는 것이 좋을 것이다.

또한 queue에 QoS를 사용하면 다른 async block들의 QoS가 무시되지만, enforce 플래그를 사용하는 경우 예외적으로 block의 QoS가 무시되지 않는다.

 

Swift 3.0 이후

dispatchWorkItem의 flag중 .enforceQoS를 사용

let enforceQoSWorkItem = DispatchWorkItem(qos: .userInitiated, flags: .enforceQoS) {
		// work item의 QoS가 현재 실행 컨텍스트의 QoS보다 낮지 않은 경우 work item의 QoS를 강제로 적용.
}

 

DispatchWorkItem의 flags에 대한 정리

https://kswift.tistory.com/20- DispatchWorkItemFlags 참고

 

마지막 예시는 GCD의 직렬(serial) 큐를 lock(잠금기능)으로 사용하는 것이다. 앱에서 공유 데이터 구조를 가지고 있다면, 이에 대한 동시 접근을 제어하기 위하여 잠금 접근을 원할 것이다. 이 경우 dispatch serial queue를 생성하여 dispatch sync를 사용하여 큐에 작업을 추가하는 스레드가 큐에 대한 잠금을 얻어 데이터 구조에 독점적으로 접근할 수 있도록 보장한다.

dispatch sync는 lock을 얻을 때 호출하는 스레드에서 코드(block)를 실행하고, block이 return 될 때, 스레드를 해제한다. 이 경우 추가적인 스레드가 필요하지 않으며, dispatch sync가 호출되는 스레드의 QoS가 사용된다. ⇒ 호출 스레드의 우선순위가 유지된다 ⇒ 따라서 블록이 끝날 때까지 호출 스레드가 블록 되고 다른 스레드가 호출되지 않기 때문에, 우선순위 역전이 발생하지 않는다. 이를 통해 보다 안정적인 동시성 프로그래밍이 가능해진다.

다른 큐들도 이 데이터 구조에 액세스 할 수 있기 때문에 동기화가 필요하다. 만약 utility 스레드에서 dispatch sync를 호출하여 데이터 구조에 대한 접근을 얻을 수 있다. 이 상황에서, main 스레드가 해당 데이터 구조에 대한 접근 권한을 얻으려고 시도하면, main 스레드는 utility 스레드가 작업을 완료할 때까지 대기하게 된다. ⇒ priority inversion (우선순위 역전) 이 발생된다.

이것을 다시 QoS상속 기능을 사용해 우선순위 역전을 방지한다. 위 경우에서 대기하는 작업의 QoS를 높여줌으로써, 해당 작업이 utility 스레드보다 우선순위가 높아진다. 이러한 QoS상속 기능은 serial queue에서 dispatch sync나 dispatch block wait API를 사용하는 경우에 발생한다.

또한 pthread mutex lock 또는 이를 기반으로 하는 API인 NSLock을 사용하는 경우에도 마찬가지이다.

중요한 점은 이것이 동작하지 않는 API도 있다. Dispatch semaphores는 소유 개념을 허용하지 않으므로 스레드가 자원을 사용할 수 없다. ⇒ semaphore을 사용하는 경우 wait을 사용해 준다

 

메인 스레드에서 어떠한 queue에 async 코드를 실행한다면, 이 코드는 새로운 스레드에서 실행될 것이다.

코드에서 performSelector withObject afterDelay을 실행했다면, 이는 타이머를 현재 스레드의 run loop에 추가한다. 몇 초 뒤 해당 블록이 완료되면 해당 스레드는 사라질 것이다. 이러한 스레드들은 일시적인 스레드 풀에서 가져온 것이다. 따라서 보장된 수명이 없고, 스레드가 유지되더라도 런루프는 실행되지 않는다.

이는 런 루프와 dispatch queue 기반 API를 혼합하여 사용하는 경우 발생할 수 있다. 간단히 런 루프와 직렬 큐간 차이점을 설명하자면, 런 루프는 특정 스레드에 바인딩되어, delegate 메서드의 콜백을 받는다. 또한 각 루프 반복 후 자동으로 autorelease 객체를 해제한다. 그리고 재진입이 가능하다. 이는 런 루프에서 호출된 외부 코드에서 다시 런 루프를 실행할 수 있도록 한다.

반면에 serial queue 또는 dispatch queue는 Grand Central Dispatch 스레드 풀에서 가져온 순간적인 스레드를 사용한다. 이들은 대부분 블록이나 API를 콜백으로 처리한다. 또한 serial queue의 autorelease는 스레드가 완전히 유휴 상태가 될 때만 팝 된다. 이는 앱이 계속 바쁘다면 해제되지 않는다. 또한 serial queue는 재진입이 불가능하다.

run loop은 main queue와 함께 묶인다 = run loop 은 main queue에 노출된다. 이를 통해 main thread는 두 세계 (run loop, main queue)를 쉽게 이동할 수 있다.

 

Dispatch World

여러 dispatch async를 수행할 것이다. 이들을 global queue에 넣는다. 시스템은 스레드 풀에서 스레드를 가져와 첫 번째와 두 번째 블록을 실행한다. 만약 2 코어의 장치일 경우, 스레드가 각각 실행중일 것이다. (이상적인 경우 코어 하나당 하나의 스레드가 있다.)

 

첫 번째 블록 실행이 끝나면 스레드를 가져와 다음 블록에 제공하고 이를 반복한다. 이 방법은 잘 작동하나, 블록 중 하나가 리소스 접근이 필요한 경우 대기(waiting)가 발생한다. 스레드는 I/O 또는 잠금과 같은 리소스가 필요할 때 대기하고 실행을 일시 중단한다.

GCD의 관점에서 기기의 각 코어마다 하나의 블록 또는 하나의 스레드를 활성화하여 실행되도록 한다. 그래서 스레드가 대기상태일 때, 한계치 내에서 스레드 풀에서 다른 스레드를 새로운 스레드를 생성하여 각 코어당 하나의 스레드를 활성화되도록 한다.

 

만약, 4개의 블록이 있다고 할 때, 첫 번째와 두번째 스레드에서 실행중이다. 그리고 첫번째 블록이 I/O를 요청하며 대기상태가 되었다. 이제 이 블록은 I/O가 반환될 때까지 기다려야 한다. 이후 다음 다른 스레드를 생성하여, 다음 블록을 실행한다. 이와 같은 스레드가 대기상태가 되고 스레드를 추가로 생성하는 상황이 반복되면 스레드 폭발 (thread esplosion) 이 발생할 수 있다.

많은 스레드들이 리소스를 공유하고 있기 때문에 많은 스레드들이 동시에 대기한다면 많은 이슈가 발생한다. 또한 가져올 수 있는 스레드의 개수도 한정적이기 때문에 이러한 문제가 발생하면 교착상태(deadlock)가 발생할 수 있다.

 

다음 볼 예시는 메인 스레드에서 많은 작업들을 수행하고자 하는 상황으로 메인 스레드에서 병렬 큐 (concurrent queue)로 dipatch async를 사용해 많은 작업을 처리하려는 상황이다. 위 예시처럼 4개의 스레드가 사용할 수 있는 전부라고 가정하고 메인 스레드로 다시 콜백을 해줄 것이다.

그렇게 하기 위해 메인스레드를 다시 사용 가능하게 만들어서 블록들이 블록들을 얻고 작업 후 다시 콜백해 줄 수 있도록 할 것이다. 만약 메인 스레드가 serial queue에 dispatch async 하게 작업을 처리한다고 가정해 보자.

이때 블록은 추가로 가용할 수 있는 스레드가 없기 때문에, 이 블록은 대기 상태에 진입하게 된다. 하지만 그다음 메인 스레드가 같은 serial queue에 dispatch sync 작업을 처리하려고 할 때, 문제가 발생한다.

문제는 현재 serial queue에 가용 가능한 스레드가 없다. 이 말은 dispatch sync call 은 영원히 막히게 된다.

이것이 데드락이 발생하는 일반적인 상황이다. 메인 스레드는 자원(가용가능한 스레드)을 기다리고 있고, 위 상황에서 스레드풀에서 가져온 스레드들은 메인스레드를 기다리고 있다. 그들은 서로 대기(wating) 상태에서 리소스를 포기하지 않기 때문에 때문에 데드락이 발생한다.

 

여러 애플리케이션의 부분에서, 다른 모듈, 프레임워크 등이 동시에 작업을 수행할 때 이러한 충돌이 발생할 수 있고 지금보다 훨씬 복잡할 것이다. 따라서 GCD를 사용할 때, 교착 상태를 피할 수 있게끔 애플리케이션을 설계하는 방법을 염두에 두어야 한다. 이를 위한 기본적인 방법은 가능한 asynchronous API를 사용하는 것이 좋다.

특히 I/O 작업에서는 대기(wait) 상태를 방지할 수 있어 더 많은 스레드를 실행시킬 필요가 없어지므로 효율성이 향상된다.

또한 serial queue를 사용하는 것이다. 만약, 모든 작업을 serial queue에서 실행하면 한 번에 하나의 블록만 실행하기 때문에 thread explosion 은 발생하지 않을 것이다. 애플리케이션 전체를 직렬화하는 것은 추천하지 않지만, 병렬적으로 실행해야 하는 작업을 명확하게 구성하고 알지 못하는 한 serial queue를 사용하여 잘 관리된 방식을 추천한다.

그리고 NSOperation queue 도 사용할 수 있다. 그리고 작업을 무제한으로 생성하지 말아야 한다. 작업의 블록 수를 제한적으로 설계할 수 있다면, 스레드 폭발을 방지할 수 있다.

 

문제가 된 구체적인 코드의 예시이다. 여기서 sync와 async를 혼합하여 사용했다. 만약 큐에 dispatch sync만 사용했다면 이는 lock을 취한 것이기 때문에 매우 빠르게 실행된다.

또한 하나의 dispatch async를 사용했다면, 원자적인(atomic) 대기열 큐이기 때문에 매우 빠를 것이다.

이것들 중 하나의 큐만 사용했다면, 성능은 기본적인 원시 타입과 비슷할 것이다. 그러나 이 둘을 혼합하면 문제가 발생한다. 예를 들어, dispatch async로 큐에 블록을 추가한 후 dispatch sync를 수행하면, 새로운 스레드를 생성하고 해당 블록을 실행하고 완료될 때까지 대기해야 한다.

이 때문에 스레드 생성 시간이 추가된다. 이러한 원시 타입을 혼합하여 사용하는 것은 안전하지만, 애플리케이션을 설계할 때 고려해야 한다. 특히 메인 스레드에서 이러한 원시 타입을 혼합하여 사용할 때는 특히 조심해야 한다.

 

다음 문제는 많은 블록들을 concurrent queue에 한 번에 dispatch 하는 것이다. 이 경우 메인 스레드 이외의 스레드에서 계속 실행하려고 할 것이다. 그러나 async를 실행하고 난 다음 barrier sync를 실행하는 경우와 같이 유사한 작업을 수행하는 경우에도 dead lock과 thread explosion을 유발한다.

하지만 dispatch apply라는 원시 타입이 있다. 이것은 GCD가 병렬성을 관리하고 위 문제를 피할 수 있다.

물론 dispatch semaphore를 사용할 수도 있다. 이는 많은 사람들이 lock으로 사용하는 방법에 익숙할 것이다.

counting semaphore을 사용하여 실행할 동시 작업 수를 초기화 한 다음, 블록이 완료될 때마다 semaphore을 sinal 한다. 매번 제출하면, semaphore에서 대기한다. 그 결과, 스레드는 4개의 제출을 수행하고 그중 하나가 완료되고 semaphore을 signal 하기 전까지 대기한다.

이 패턴은 여러 곳에서 제출하는 경우에 좋다.

 

출처:

Building Responsive and Efficient Apps with GCD - WWDC15 - Videos - Apple Developer