본문 바로가기

Swift/GCD

WWDC 2017 - Modernizing Grand Central Dispatch Usage

Parallelism and concurrency

Parallelism - 밀접히 관련된 계산들의 동시 실행

Concurrency - 앱에서 동시에 실행할 독립적 요소의 구성

 

두 개념을 쉽게 설명하면 병렬처리(Parallelism)는 우리가 일반적으로 여러 코어를 활용하여 동시에 모두 사용하는 것이고, 동시성(Concurrency)은 단일 코어에서도 수행할 수 있다. 동시성은 애플리케이션의 다양한 작업들을 구성(개입시키는)하는 방법이다.

 

Parallelsim with GCD

concurrentPerform - 시스템의 모든 코어에서 계산을 자동으로 load balancing 하는 병렬 for loop

 

💡 Load balancing이란? 부하분산 또는 로드 밸런싱(load balancing)은 컴퓨터 네트워크 기술의 일종으로 둘 혹은 셋이상의 중앙처리장치 혹은 저장장치와 같은 컴퓨터 자원들에게 작업을 나누는 것을 의미한다.

 

이를 swift와 함께 사용하면, 모든 계산을 실행할 컨텍스트를 자동으로 올바르게 선택한다.

Swift 3.0 이전에 DISPATCH_APPLY_AUTO 키워드와 동일한 기능을 한다.

위 메서드의 파라미터인 iteration count(반복횟수)는 시스템전체에서 블록이 병렬로 호출되는 횟수이다.

 

그렇다면 반복횟수를 얼마나 설정해 주는 것이 좋을까?

 

코어 갯수에 맞게 해주는 것이 이상적이라고 생각할 수 있겠다.

 

그런데 만약 UI 렌더링으로 세 번째 코어를 사용하게 되는 순간, 세 번째 블록을 첫 번째로 옮기게 된다. 이렇게 되면 CPU Bubble이 남게 된다.

 

모든 CPU를 항상 사용할 수 있는 것은 아니고, 시스템에서 동시에 실행되는 많은 작업들이 있다.

 

결국 시스템의 사용 가능한 리소스를 최대한 활용할 수 있고, 각 concurrentPerform블록 내에 수행하는 작업의 균형을 유지하는 선에서 유연성을 갖도록 충분히 큰 반복 횟수를 사용하는 것이 바람직하다.

 

Concurrency

만약 간단한 뉴스 앱을 구성한다면, 독립적인 서브시스템들 (메인스레드에서 UI 렌더링하는 User Interface, 기사를 저장하는 Database, 네트워킹하여 기사를 불러오는 Networking)을 구성할 것이다.

 

가용할 수 있는 CPU는 하나이고 이것을 맨 위에 CPU트랙의 타임라인과 함께 확인해 보자.

유저가 기사를 새로고침하는 버튼을 눌렀다. interface는 버튼에 대한 응답을 렌더링 한 다음 database에 비동기식의 요청을 보낸다. 데이터 베이스는 기사를 새로 고쳐야 한다고 결정하고 networking 시스템에 또 다른 명령을 내릴 것이다.

 

하지만 그 순간에 유저가 앱을 다시 터치했다.

데이터베이스는 애플리케이션의 메인 스레드에서 실행되지 않기 때문에, OS가 즉각적으로 CPU에 UI스레드의 작업을 수행할 수 있게 하여 사용자에게 즉각적인 응답을 제공할 수 있게 한다.

이것이 메인 스레드에서 작업을 분리하는 이점이다.

이후 user interface가 응답을 마치면, CPU는 다시 database 스레드로 전환하여 네트워킹 작업을 완료한다.

그래서 동시성을 활용하면 반응성 있는 앱을 만들 수 있다.

 

위 CPU에서 흰 줄들은 서브 시스템 간 전환을 나타낸다. Instrument System Trace를 사용하여 이것을 시각화하여 확인할 수 있다. (WWDC 2016 - System Trace in Depth를 참고)

이러한 Context switching은 concurrency의 힘을 나타낸다.

그렇다면 context switch는 언제 왜 발생할까?

 

일단, 앞서 예를 들었던 UI스레드가 database스레드를 선점하는 → 우선순위가 높은 스레드가 CPU를 필요로 할 때이다.

또한 스레드가 현재 작업을 완료했거나, 비동기 요청이 완료되기를 대기하거나, 리소스를 얻기 위해 대기 중일 때도 발생할 수 있다.

하지만 이 Context Switching이 과도해지면, 문제가 발생할 수 있다.

 

흰색 막대(Context Switching)가 누적되고, context switching을 실행하는 데 소요되는 시간뿐만 아니라, 코어가 누적한 이전 작업이력을 되찾는 데 소요되는 시간도 포함되기 때문에 퍼포먼스의 손실이 발생한다.

 

또한 CPU에 접근하려는 다른 작업이 이미 기다리고 있을 수도 있다.

이러한 과도한 Context switching을 유발하는 세 가지 주원인에 대해 얘기할 것이다.

 

첫 번째, 독점적인 리소스에 과도한 접근을 위해 반복적으로 대기하는 것 

독립적인 작업 간에 반복적인 switching과 스레드 간 작업을 반복적으로 반송하는 것이다.

이를 너무 많이 반복하게 되면 비용이 발생하기 시작한다.

주로 많은 스레드가 lock을 획득하려 할 때 발생한다.

 

이것은 과도한 자원(CPU) 간 경합이 일어나는 상황이다. 시간에 따라 계단식으로 각 스레드가 짧은 시간 동안 실행되고 다른 스레드에 CPU를 양도하는 상황이 계속 반복되고 있다.

 

이상적인 상황은 위와 같은 CPU가 한 번에 하나의 작업에 집중하고, 그 작업을 완료한 다음 작업을 실행할 수 있도록 하는 것일 것이다.

 

여러 스레드가 하나의 자원에 접근하기 위해 경합하는 경우, 이 자원을 소유하는 스레드는 락(lock)을 획득하고 작업을 수행하며, 다른 스레드는 해당 자원을 사용할 수 없어서 락을 얻을 때까지 대기하게 된다. 이때, 각 스레드가 락을 얻기 위해 대기하는 시간이 길어지면 전체적인 처리 속도가 떨어지고, 스레드 간 Context Switching이 자주 일어나게 되어 CPU 자원을 낭비하게 되는 것이다. → Fair lock

 

Unfair lock은 락을 요청한 순서에 상관없이 경쟁에서 이긴 스레드만 락을 얻게 되어있기 때문에 특정 스레드가 락에 대한 기회를 더 많이 받거나 더 적게 받을 가능성이 있다. 그러나 Context Switching의 수를 줄일 수 있다.

 

일반적으로 low-level primitives(낮은 레벨의 동시성 기본 요소)들은 런타임에서 다음에 언락 할 스레드를 알고 있기 때문에 이를 통해 앱에서 발생하는 우선순위 역전을 자동으로 해결할 수 있다. 이 low-level primitive는 Single known owner(락의 소유권을 가진 스레드가 하나인 경우)를 가진 serial queue 나 unfair lock을 의미한다.

 

그러나 dispatch semaphore와 dispatch group과 같은 asymmetric primitive(비대칭적 기본요소)는, 런타임이 블록킹 된 스레드를 풀어줄 수 없기 때문에 위에서 언급한 single known owner의 권한을 가지지 못한다.

 

마지막으로, private, concurrent queue와 reader-writer locks 등의 multiple owner(락의 소유권을 가진 스레드가 하나 이상인 경우)는 하나의 락소유권이 없기 때문에 위 low-level primitives의 기능을 활용하지 않는다.

 

따라서 동기화 방법을 선택할 때 다른 우선순위를 가진 스레드가 상호작용하는 경우에 대해 고려해야 한다.

예를 들어 만약, 높은 우선순위인 UI 스레드와 낮은 우선순위인 background 스레드가 상호작용 한다면, UI 스레드가 background 스레드에서 대기하지 않도록 보장해야 한다.

이러한 비효율적인 동작은 코드에서 쉽게 찾을 수 없기 때문에 Instuments System Trace에서 앱의 실제 동작을 시각화 하고, 올바른 lock을 사용할 수 있도록 해야 한다.

 

두 번째, 독립적인 작업 간에 반복적으로 전환하는 문제가 있는 패턴

Serial queue는 FIFO(First In First Out) 방식으로 작업을 처리하며, 큐에 여러 개의 작업을 추가하는 것이 원자적(atomic)으로 이루어지기 때문에 다중 스레드에서 작업을 추가하더라도 안전하게 처리된다. 따라서 큐에 여러 개의 작업을 추가하는 것도 안전하고, 큐에서는 내부에서 먼저 추가된 작업부터 차례대로 처리된다. 이를 통해 동기화(mutual exclusion)를 구현할 수 있다.

 

Dispatch source - GCD에서의 이벤트 모니터링 방법

DispatchSource.makeRead를 통해 읽기 가능한 파일 디스크립터를 모니터링하도록 설정하고 소스의 대상 큐인 실행 대상 큐를 전달한다. 이곳에서 소스 이벤트 핸들러가 실행되며 여기서는 파일 디스크립터에서 읽는다. 이 대상 큐는 읽은 데이터를 처리하는 등이 이 작업과 직렬화되어야 하는 다른 작업을 넣을 수 있는 곳이다. 이후 무효화 패턴을 구현하는 setCancelHandler를 설정하고 activate을 호출하여 모니터링을 시작한다.

 

위 두 개념을 결합하여 target queue hierarchy를 얻을 수 있다.

두 개의 source인 S1과 S2, 두개의 queue인 Q1, Q2가 있고 이들을 동기화해 줄 serial queue인 EQ가 있다. 이를 구현하기 위해 DispatchQueue 생성자에 옵셔널 매개변수로 EQ를 전달하는 방법을 사용한다. 이를 통해 소스 중 하나 또는 한 번에 하나의 큐 만 실행할 수 있다.

 

여기에 만약 Q1, Q2를 특정 순서로 집어넣어도 EQ는 serial queue이기 때문에 동기화를 제공한다. 즉 하나의 항목만 한 번에 실행된다.

 

그러나 두 queue내부의 아이템은 원래 대기열의 개별 순서를 유지하면서 교대로 실행될 수 있다. 이 때문에 QoS의 개념이 필요해진다.

 

위와 같은 EQ queue는 utility QoS이고, EQ queue에 userinitiated QoS인 Q1의 작업이 제출되고, userInteractive QoS인 S2의 작업이 제출된 상태이다. → priority inversion(우선순위 역전) 상태이다.

 

이때 시스템은 현재 대기열에 있는 것보다 우선순위가 가장 높은 작업자 스레드를 불러와서 이 우선순위 역전을 해결한다.

 

 

다시 위 뉴스앱으로 돌아와 네트워킹 시스템을 구성할 때 당연히 하나의 네트워킹 subsystem으로 이루어지지 않을 것이다.

그러나 위와 같은 하나의 네트워킹 관련 이벤트 핸들러를 각각의 대상 queue에 넣는 방식은 바람직하지 않다. 이것이 동시에 활성화되면, 시스템은 각 이벤트 핸들러를 실행하기 위해 각각의 스레드를 생성할 것이다.

이는 앞서 본 것처럼 많은 연결을 생성하고 이는 과도한 Context Switching을 발생시킬 수 있다.

 

이러한 상황을 개선하는 방법으로 맨 아래에 추가적인 serial queue를 생성하여 모든 네트워크 연결에 대한 동기화 context를 얻도록 할 수 있다. 이후 동시에 발생하게 되면, EQ에 인큐 되고 순서대로 하나의 스레드가 작업을 실행하게 된다.

 

이러한 방법은 간단한 변경처럼 보이지만 일부 코드에서 1.3배의 성능 향상을 가져오기도 했다.

애플리케이션에서 올바른 동시성 개수를 선택하는 것은 스레드 폭발등의 위험한 패턴을 피할 수 있는 방법이다. 위에서 추천한 방법은 하나의 subsystem 마다 하나의 serial queue를 사용하여 동기화작업을 수행하게 하는 것이다.

그러나 이것은 복잡한 애플리케이션에 대해서 너무 단순한 패턴일 수 있다.

 

핵심은, 고정된 수의 serial queue 계층을 갖는 것이다. 이를 통해 subsystem에 제출된 작업을 세분화하여 subsystem 간 이동 시에 CPU가 효율적인 성능 상태를 유지할 수 있게 한다.

 

세 번째, 큐에 대한 우선순위를 적절하게 설정하여 작업이 일어나는 스레드를 제한하면 작업이 반복적으로 스레드 간에 전환되는 것을 방지

이전에는 백그라운드 작업을 요청하면, 애플리케이션이 익명의 스레드를 요청하고, 그 스레드가 할 일을 나중에 결정하는 방식으로 동작했다. 이는 각 작업 항목 간 우선순위를 비교하기 부적절할 뿐 아니라 백그라운드 작업이 실행되기까지 시간이 걸리거나 실행될 수도 없는 문제점이 있었다.

이것을 해결하기 위하여 각 queue에 고유한 identifier를 부여하였다. 그리고 백그라운드 작업을 요청하면 큐 ID를 사용하여 만약 큐에 더 높은 우선순위를 가진 작업이 들어왔을 때, 해당 큐의 identity를 통해 우선순위를 업데이트하여 시스템은 해당 큐에 더 높은 우선순위를 부여하게 되어, 해당 큐의 작업 항목이 우선적으로 처리될 수 있게 한다. 이를 통해 큐의 지연 시간을 최소화하고, 최적화된 스케줄링이 가능해졌다.

 

 

 

출처

https://developer.apple.com/videos/play/wwdc2017/706/