Swift. GCD 기초 정리하기 2

Dispatch, DispatchQueue, DispatchItem, DispatchGroup, DispatchSemaphore, DispatchSource

Posted by MinJun on Thursday, May 3, 2018 Tags: Swift   11 minute read

A deep dive into Grand Central Dispatch in Swift의 내용을 의역 했습니다. 오역이나 매끄럽지 못한 부분은 피드백 주시면 감사하겠습니다.

A Deep dive into Grand Central Dispatch in Swift

Grand Central Dispatch(GCD)는 대부분의 스위프트 개발자가 수많은 시간동안 사용해온 기술중에 하나 입니다. 주로 동시성 큐(concurrent queues)에 작업을 보낼수(dispatch) 할수 있는것으로 알려져 있고, 대부분 다음 처럼 사용 합니다.

DispatchQueue.main.async {
	// Run async code on the main queue
}

조금더 깊이 들어가면 GCD에도 강력한 API와 기능들이 있습니다.


Delaying a cancellable task with DispatchWorkItem

GCD에 대한 일반적인 오해중 하나는 “작업(task)를 예약(schedule)하면 취소할수 없으므로 Operation API를 사용해야 합니다.” 입니다. IOS 8 및 MacOS 10.10에 DispatchWorkItem이 사용하기 쉬운 API로 도입되었습니다.

UI 검색창이 있고 사용자가 문자를 입력하면 서버에 요청한다고 가정합니다. 사용자가 빠르게 타이핑하고 우리는 네트워크 요청을 시작하기를 원하지 않습니다.(서버 용량와 많은 데이터의 낭비가 있을수 있음) 대신, 이러한 이벤트를 디바운스(debounce)라고 하고 사용자가 0.25초 동안 입력하지 않으면 서버에 요청을 수행합니다.

이것은 DispatchWorkItem이 들어오는 곳입니다. 작업아이템(work item)에 우리의 요청 코드를 캡슐화한것으로 부터 매우 쉽게 작업을 취소할수 있습니다.

class SearchViewController: UIViewController, UISearchBarDelegate {
    // We keep track of the pending work item as a property
    private var pendingRequestWorkItem: DispatchWorkItem?

    func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
        // Cancel the currently pending item
        pendingRequestWorkItem?.cancel()

        // Wrap our request in a work item
        let requestWorkItem = DispatchWorkItem { [weak self] in
            self?.resultsLoader.loadResults(forQuery: searchText)
        }

        // Save the new work item and execute it after 250 ms
        pendingRequestWorkItem = requestWorkItem
        DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250),
                                      execute: requestWorkItem)
    }
}

위에서 볼수 있듯이 DispatchWorkItem을 사용하는것은 후행 클로저 구문, GCD가 Swift로 잘 가져왔는지에 따라 Timer 나 Operation을 사용하는 것보다 간단하게 사용됩니다. @objc로 표시된 메소드나 #selector가 필요하지 않습니다.


Grouping and chaining tasks with DispatchGroup

때로는 논리로 이동하기 전에 어떤 작업 그룹을 수행해야합니다. 예를들어 모델을 만들기전에 데이터 소스의 그룹에서 로드된 데이터가 필요하다고 가정합니다. 모든 데이터 소스를 직접 추적하지 않고 작업을 DispatchGroup을 과 쉽게 동기화 할수 있습니다.

디스패치 그룹을 사용하면 작업을 별도의 큐에서 동시에 실행할수 있다는 큰 이점이 있습니다. 이것을 통해서 작업을 다시 작성할 필요없고, 원한다면 동시성을 쉽게 추가할수 있습니다. 디스패치 그룹에 enter()leave() 균형있게 호출만하면 작업을 동기화 할수 있습니다.

로컬 저장소, iCloud 드라이브 및 백엔드 시스템에서 메모를 로드 한 다음 모든 결과를 NoteCollection에 결합하는 예제를 살펴봅니다.

// First, we create a group to synchronize our tasks
let group = DispatchGroup()

// NoteCollection is a thread-safe collection class for storing notes
let collection = NoteCollection()

// The 'enter' method increments the group's task count…
group.enter()
localDataSource.load { notes in
    collection.add(notes)
    // …while the 'leave' methods decrements it
    group.leave()
}

group.enter()
iCloudDataSource.load { notes in
    collection.add(notes)
    group.leave()
}

group.enter()
backendDataSource.load { notes in
    collection.add(notes)
    group.leave()
}

// This closure will be called when the group's task count reaches 0
group.notify(queue: .main) { [weak self] in
    self?.render(collection)
}

위의 코드는 작동하지만 중복이 많습니다.. DataSource를 따르는 Array의 Element를 리펙토링 합니다.

extension Array where Element: DataSource {
    func load(completionHandler: @escaping (NoteCollection) -> Void) {
        let group = DispatchGroup()
        let collection = NoteCollection()

        // De-duplicate the synchronization code by using a loop
        for dataSource in self {
            group.enter()
            dataSource.load { notes in
                collection.add(notes)
                group.leave()
            }
        }

        group.notify(queue: .main) {
            completionHandler(collection)
        }
    }
}

위의 확장을 사용하면 이전 코드를 다음과 같이 변경할수 있습니다.

let dataSources = [localDataSource, iCloudDataSource, backendDataSource]

dataSources.load { [weak self] collection in
    self?.render(collection)
}

Waiting for asynchronous tasks with DispatchSemaphore

DispatchGroup는 비동기 상태를 유지하면서 비동기 작업 그룹을 쉽고 편리하게 동기화하는 방법을 제공합니다. DispatchSemaphore는 비동기작업 그룹을 동기적으로 대기하는 방법을 제공합니다. 이는 커맨드라인 툴과, 스크립트에서 매우 유용하며, 여기는 애플리케이션의 run loop이 없으며 완료될때까지 전역 컨텍스트에서 동기적으로 실행됩니다.

DispatchGroup과 마찬가지고 semaphore API는 waite() 또는 signal()을 호출하여 내부 카운터를 증가 또는 감소 시키는 점에서 매우 간단합니다. signal()을 호출하기전에 wait()를 호출하면 현재 큐가 signal을 받을때까지 차단됩니다.

이전에 우리가 만든 확장을 다른것으로 오버라이드 합니다. NoteCollection을 동기적으로 반환하거나 또는 에러를 발생 시킵니다. 이전의 DispatchGroup기반의 코드를 재사용 하지만 semaphore를 사용하여 작업을 재 조정합니다.

extension Array where Element: DataSource {
    func load() throws -> NoteCollection {
        let semaphore = DispatchSemaphore(value: 0)
        var loadedCollection: NoteCollection?

        // We create a new queue to do our work on, since calling wait() on
        // the semaphore will cause it to block the current queue
        let loadingQueue = DispatchQueue.global()

        loadingQueue.async {
            // We extend 'load' to perform its work on a specific queue
            self.load(onQueue: loadingQueue) { collection in
                loadedCollection = collection

                // Once we're done, we signal the semaphore to unblock its queue
                semaphore.signal()
            }
        }

        // Wait with a timeout of 5 seconds
        semaphore.wait(timeout: .now() + 5)

        guard let collection = loadedCollection else {
            throw NoteLoadingError.timedOut
        }

        return collection
    }
}

Array의 새로운 매소드를 사용하여, 아래 처럼 스크립트 또는 커맨드라인 툴에서 notes를 동기적으로 로드 할수 있습니다.

let dataSources = [localDataSource, iCloudDataSource, backendDataSource]

do {
    let collection = try dataSources.load()
    output(collection)
} catch {
    output(error)
}

Observing changes in a file with DispatchSource

마지막으로 덜 알려진 GCD의 기능 파일 시스템의 파일에서 변경 사항을 관찰하는 방법을 가져오길 원합니다. DispatchSemaphore 처럼 이것은 스크립트 또는 커맨드라인 툴에서 유용합니다.(왜지..?) 사용자가 편집한 파일에 자동으로 반응하길 원한다면 쉽게 실시간 편집(live editing) 도구를 만들수 있습니다.

Dispatch sources는 관찰하는 내용에 따라 몇가지 차이점이 있습니다. 이 경우 DispatchSourceFileSystemObject를 사용하여 파일 시스템의 이벤트를 관찰할수 있습니다.

fileDescriptorDispatchQueue를 사용하여 관찰하는 디스패치 소스를 생성합니다. 다음은 간단한 fileObserver의 예입니다. 파일이 변경되었을때 매 시간 클로저를 줍니다(파일 참조를 얻기위해 사용함)

class FileObserver {
    private let file: File
    private let queue: DispatchQueue
    private var source: DispatchSourceFileSystemObject?

    init(file: File) {
        self.file = file
        self.queue = DispatchQueue(label: "com.myapp.fileObserving")
    }

    func start(closure: @escaping () -> Void) {
        // We can only convert an NSString into a file system representation
        let path = (file.path as NSString)
        let fileSystemRepresentation = path.fileSystemRepresentation

        // Obtain a descriptor from the file system
        let fileDescriptor = open(fileSystemRepresentation, O_EVTONLY)

        // Create our dispatch source
        let source = DispatchSource.makeFileSystemObjectSource(fileDescriptor: fileDescriptor,
                                                               eventMask: .write,
                                                               queue: queue)

        // Assign the closure to it, and resume it to start observing
        source.setEventHandler(handler: closure)
        source.resume()
        self.source = source
    }
}

아래처럼 FileObserver를 사용할수 있습니다

let observer = try FileObserver(file: file)

observer.start {
    print("File was changed")
}

Conclusion

Grand Central Dispatch는 실제로 볼수 있는것보다 훨씬 많은것을 하는 강력한 프레임 워크입니다. 이 게시물은 당신의 일에 상상력을 불어넣어 주었고, Timer, OperationQueue 기반 코드는 GCD를 사용하여 실제 더 간단하게 만들수 있습니다.


Reference

A deep dive into Grand Central Dispatch in Swift

GCD GitHub
Dispatch Documents
DispatchQueue Documents
DispatchSource
GCD overview