본문 바로가기

iOS/Swift - UIKit

[Swift] Modern Concurrency Swift ( 2편 - GCD와 Operation의 특징과 문제점 요약 )

[Swift] Modern Concurrency Swift ( 1편 - Thread Class로 Thread 문제 파헤치기 )


Dispatch : Grand Central Dispatch(GCD)

Grand Central Dispatch(GCD)로 알려진 Dispatch 프레임워크는, 시스템에 의해 관리되는 dispatch queues에 작업을 제출하여 멀티코어 하드웨어에서 동시성 작업을 수행하도록 하는 프레임워크입니다. C에 존재하는 libdispath library의 apple implments이며, 다음에 설명할 Operation QueueGCD의 위에서 구현되었습니다.
 
GCD가 다루는 모든 작업들은 GCD가 관리하는 FIFO(First in First Out) Queues에 의해 배치됩니다. 그리고 이러한 작업들은 시스템이 자체적으로 관리하고 최적화하는 threads pool에서 실행됩니다. 이 사실 자체만으로 앞서 살펴봤던 Thread Explosion 문제가 해결됩니다.

GCD의 Queue

GCD에서 Queue는 task를 받아서 적절한 스레드로 분배합니다.
이때 Queue가 task를 몇 개의 스레드에 분배하느냐에 따라 Serial Queue와 Concurrent Queue로 나눠집니다.
 
-Serial Queue: task들을 하나의 스레드에만 분배합니다. 따라서 queue에 미리 들어 있던 일들이 처리되어야 보낸 task가 실행됩니다. 이러한 성격은 특정 값이나 리소스에 대한 액세스를 제한하여 Data Race를 막는데 도움이 됩니다.
-Concurrent Queue: task들을 여러 개의 스레드로 분배합니다. 따라서 queue에 미리 들어 있던 일들이 끝나지 않아도 task가 실행됩니다. 또한 queue로 보내진 작업들이 어떤 순서로 완료될지 알 수 없습니다. 개발자 문서에는 Concurrent Dispatch Queue에 의해 스케줄링된 task가 queue를 block시킨다면, 시스템이 추가적인 스레드를 만들어서 concurrent한 task들을 실행시킨다고 나와 있습니다.(When a task scheduled by a concurrent dispatch queue blocks a thread, the system creates additional threads to run other queued concurrent tasks.)

Queue 생성하기

아래와 같은 방법으로 Serial 및 Concurrent Queue를 생성할 수 있습니다. label은 queue를 식별하기 위한 고유한 값이며, reverse DNS 패턴으로 짓는 경우가 많다고 합니다.

let serialQueue = DispatchQueue(label: "com.playground.serial")
let concurrentQueue = DispatchQueue(label: "com.playground.concurrent", attributes: .concurrent)

sync와 async로 queue로 작업을 스케줄링시키기

queue를 만들었으니 이제 각 queue로 원하는 작업을 스케줄링시킬 수 있습니다. 

serialQueue.sync {
	// task
}
serialQueue.async {
	// task
}

sync: queue에 보낸 task가 끝날 때까지 현재 스레드를 차단합니다. 예를 들어 UI 작업이 진행되는 main thread에서 sync로 task를 보낸다면 UI 작업이 멈추게 됩니다.
async: queue에 task를 보낸 순간 해당 task의 완료와는 관계 없이 현재 스레드를 계속 실행합니다.
 
즉 sync와 async는 작업의 대상이 되는 스레드가 차단되는지의 여부로 구분됩니다.

Dispatch.main, 메인 스레드를 담당하는 serial queue

비동기 작업 후 UI에 결과를 반영하기 위해 많이 사용했던 DIspatch.main은 메인 스레드를 담당하는 serial queue입니다. 따라서 다른 스레드에서 이 queue를 이용하면 메인 스레드로 작업을 보낼 수 있습니다.
 
Dead Lock: 만약 DIspatchQueue.main.sync를 메인 스레드에서 호출하면 sync이기 때문에 현재 작업을 보내는 스레드인 메인 스레드가 일시적으로 멈추게 되고, task를 받아서 처리해야할 메인 스레드는 멈춰있기 때문에 아무 작업도 하지 못하고 thread가 멈춰있는 상황이 됩니다.
 
그러나 background thread에서 작업 중에 UI에 먼저 결과를 반영해야 하는 경우가 있다면, DIspatchQueue.main.sync를 사용해서 수정사항을 반영할 수 있습니다. 따라서 적절한 상황에 적절한 queue를 사용하는 것이 중요합니다.

과도한 스레드 생성으로 인한 문제 방지하기

개발자 문서에서는 동시 대기열에서 thread를 block할 수 있는 Thread.sleep()과 같은 메서드를 사용하면 thread가 새로 생성되기에, 될 수 있으면 그러한 메서드의 실행을 자제하도록 권장합니다. 1편에서 보셨다시피 너무 많은 스레드는 시스템의 성능을 저하시킵니다.
 
비슷한 문제는 private concurrent dispatch queue가 많을 때에도 일어납니다. 너무 많은 동시 대기열이 각자의 작업을 thread pool에 배정하려고 하다보면 자연스레 스레드 생성도 많아질 것입니다. 이러한 문제를 해결하기 위해 가능하면 애플에서 제공하는 Concurrent Queue인 DispatchQueue.Global()을통해 동시 작업을 스케줄링할 수 있습니다. 단일 큐를 사용하게 되면 보다 적은 스레드 생성을 보장받을 수 있습니다.

Thread Class에서 발견된 문제들을 해결할 수 있을까?

Thread Class를 알아보며 발견된 몇 가지 문제점이 있었습니다. GCD와 비교해 보겠습니다.
 
- Thread Explosion: GCD가 Thread pool을 내부적으로 이용하기에 일부 해결되었습니다. 그러나 너무 많은 작업을 실행하면 thread가 제한되어 있기에 새로운 작업이 아예 실행되지 않을 수도 있다는 문제가 있습니다.
- Race Condition: 시리얼 큐를 이용하거나 NSLock을 이용할 수 있습니다. 또는 Semaphore를 이용하여 자원을 관리합니다.
- Thread 점유 문제: GCD에서도 Threa.sleep()과 같은 상황에서 작업에 반응하지 못합니다. 해당 시간 동안 스레드는 점유됩니다.
- Thread의 계층적 관리 불가능: 이는 특정 작업을 tracking하고 취소하는 것에 대한 문제였습니다. GCD에서는 DispatchWorkItem을 통해 cancel이 가능하지만, 여전히 Nesting된 작업을 cancel하는 작업을 수행할 수 없습니다.

GCD의 추가적인 특징

DispatchGroup
- synchronous waiting: group.wait를 통해 동기적으로 원하는 기간 동안 group에 속한 queue의 작업들이 완료되는 것을 기다릴 수 있습니다.
- asynchronous waiting: group.notify를 통해 group에 속한 queue의 작업들이 완료되는 것을 비동기적으로 확인할 수 있습니다.
 
Dispatch Barrier
아래와 같이 barrier를 이용하여 task에 들어가는 자원을 경쟁 상태로부터 지킬 수 있습니다. 그러나 NSLock보다 성능이 낮습니다.

queue.sync(flags: .barrier) {
	// task
}

DispatchSemaphore

let countSemaphore = DispatchSemaphore(value: 3)
countSemaphore.wait() // 작업 시작
countSemaphore.signal() // 작업 종료

Semaphore의 방식으로 동시 접근 자원의 수를 제한할 수 있습니다. 위와 같은 semaphore는 3개의 동시 작업까지 허용합니다.

Operation Queue, NSOperation

Operation은 GCD위에서 만들어진 추상화 계층으로, GCD에 비해 더 복잡한 시나리오를 제어 가능합니다. 예를 들어 background thread에서 실행될 수 있는 작업을 reusable한 객체로 다루거나, 하나의 작업이 다른 작업에 의존(계층)하거나, 작업이 시작하거나 끝날 때 특정 작업을 취소하는 등의 작업이 있습니다.
 
GCD와 비교하여 resuable한 기능을 구현할 때 유용한데, GCD는 한 번 실행하고 끝나는 block인 것과 대조됩니다. Operation은 class이기 때문에 이를 subclassing하여 재사용 할 수 있습니다.
 
또한 class이기에 변수를 가질 수 있어, 이에 대한 KVO가 가능합니다. 기본 제공되는 프로퍼티에는 isReady, isExcuting, isCancelled, isFinished가 있습니다. 이러한 프로퍼티를 활용하여 각 작업의 상태를 확인할 수 있습니다. 또한 completion block에 접근할 수 있어 작업 완료 시점에 실행할 클로저를 전달할 수 있습니다.

Block Operation

GCD와 같이 간단하게 사용할 수 있는 Operation class를 제공합니다.

let operation = BlockOperation {
  // task
}

동기와 비동기적 실행

operation은 start()로 실행할 경우 현재 스레드에서 동기적으로 실행되고, queue에 넣어 실행할 경우 현재 스레드에서 비동기적으로 실행됩니다.

let operation = BlockOperation {
  // task
}
operation.start() // sync

let queue = operationQueue()
queue.addOperation(op) // async

dependency

dependency를 통해 작업의 순서를 결정할 수 있습니다. 아래 예제는 A -> B의 순서로 실행됩니다. B는 A에게 의존하기 때문에 A가 완료되어야 실행할 수 있습니다.

let operationA = BlockOperation { }
let operationB = BlockOperation { }

operationB.addDependency(operationA) // 의존성 부여

queue.addOperation(operationA)
queue.addOperation(operationB)

dependency를 통해 작업의 계층을 만들 수 있지만, 의존성이 복잡하거나 순환이 발생할 수 있다는 문제점이 있어 프로그래머가 체크해야 합니다.

Thread Class에서 발견된 문제들을 해결할 수 있을까?

앞서 GCD를 Thread Class를 알아보며 발견된 문제점을 통해 비교해 보았는데, Operation도 동일하게 비교해보겠습니다.
 
- Thread Explosion : Operation도 thread pool을 사용하기에 어느정도 해결되었습니다. 그러나 너무 많은 작업을 실행하면 thread가 제한되어 있기에 새로운 작업이 아예 실행되지 않을 수도 있다는 문제는 동일합니다.
- Race Condition : GCD 또는 NSLock을 이용해서 해결해야 합니다.
- Thread 점유 문제 : Opration에서도 마찬가지로 Thread가 특정 작업을 기다리는 동안 점유됩니다.
- Thread의 계층적 관리 불가능 : Operation의 dependency를 사용하여도 의존 관계가 있는 두 작업을 동시에 취소할 수 없습니다.

Swift Concurrency의 필요성

결론적으로 OperationGCD위의 추상화 계층이기 때문에 내부적인 문제들을 공유합니다. 따라서 GCD와 조금 다른 맥락의 스레드 관리 시스템이 필요할 것 같습니다. 그것이 Swift concurrency가 나온 배경이며, 위의 문제들을 성공적으로 해결하고 있으며, 컴파일 타임에 안전한 멀티스레딩이 가능하게 해줍니다. 다음 편에서는 본격적으로 Swift Concurrency에 대해 알아보겠습니다.