Chapter 4: Groups & Semaphores
때로는 작업을 단순히 대기열(queue)에 넣는 대신, 작업의 그룹(group)을 처리해야 하는 경우가 있다. 모두 동시에(same time) 실행할 필요는 없지만, 모든 작업이 완료(completed)된 시점은 알아야 한다. Apple은 이런 경우를 위해 Dispatch Groups을 제공한다.
DispatchGroup
DispatchGroup는 작업 그룹(group)의 완료(completion)를 추적(track)할 때 사용하는 클래스(class)이다.
먼저 DispatchGroup을 초기화(initializing)한다. 작업을 해당 그룹(group)의 일부로 추적하려는 경우, dispatch queue의 비동기(async) 메서드(method)의 인수로 그룹(group)을 제공할 수 있다:
let group = DispatchGroup()
someQueue.async(group: group) { /* ... your work ... */ }
someQueue.async(group: group) { /* ... more work .... */ }
someOtherQueue.async(group: group) { /* ... other work ... */ }
group.notify(queue: DispatchQueue.main) { [weak self] in
self?.textLabel.text = "All jobs have completed"
}
위의 예제 코드에서 볼 수 있듯이, 그룹(groups)은 단일(single) dispatch queue에 고정되지 않는다. 실행해야 하는 작업의 우선 순위(priority)에 따라 단일 그룹을 사용하면서 여러 대기열(multiple queues)에 작업을 제출(submit)할 수 있다. DispatchGroups은 notify(queue :) 메서드(method)를 제공하며, 제출된 모든 작업이 완료(finished)되는 즉시 통지 받을 수 있다.
알림(notification) 자체는 비동기(asynchronous)이므로 이전에 제출한 작업이 아직 완료(completed)되지 않은 경우, 알림을 호출한 후 그룹(group)에 더 많은 작업을 제출할 수 있다.
notify 메서드(method)는 dispatch queue를 매개 변수로 사용한다. 작업이 모두 완료되면 지정한 dispatch queue에서 클로저(closure)가 실행된다. 위의 알림 호출(call)은 가장 흔하게 사용하는 구현이지만, 서비스 품질(quality of service)을 지정할 수 있는 다른 방법도 있다.
Synchronous waiting
어떤 이유로 그룹(group)의 완료(completion) 알림(notification)에 비동기(asynchronously)로 응답 할 수 없는 경우, dispatch group의 wait 메서드(method)를 사용할 수 있다. 이는 모든 작업이 완료 될 때까지, 현재 대기열(queue)을 차단하는 동기(synchronous) 메서드(method)이다. 작업이 완료될 때까지 기다리는 시간을 지정하는 선택적(optional) 매개 변수(parameter)가 필요하다. 지정하지 않으면, 대기 시간이 무한(infinite)으로 된다:
let group = DispatchGroup()
someQueue.async(group: group) { /* ... your work ... */ }
someQueue.async(group: group) { /* ... more work .... */ }
someOtherQueue.async(group: group) { /* ... other work ... */ }
if group.wait(timeout: .now() + 60) == .timedOut {
print("The jobs didn’t finish in 60 seconds")
}
이렇게 하면 현재 스레드(thread)가 차단되므로, 절대(never ever) 기본 대기열(main queue)에서 wait를 호출해선 안 된다.
위의 예에서는 wait가 반환(returns)되기 전에, 최대 60초 동안 작업을 완료해야 한다.
시간이 초과된 후에도 작업이 계속 실행된다. 이 장의 시작(starter) 프로젝트(project)로 이동하여 DispatchGroup.playground를 연다.
playground에서 두 개의 작업을 dispatch group에 추가한다. 하나는 10초(작업 1)가 걸리고, 다른 하나는 완료하는 데 2초가 걸린다:
let group = DispatchGroup()
let queue = DispatchQueue.global(qos: .userInitiated)
queue.async(group: group) {
print("Start job 1")
Thread.sleep(until: Date().addingTimeInterval(10))
print("End job 1")
}
queue.async(group: group) {
print("Start job 2")
Thread.sleep(until: Date().addingTimeInterval(2))
print("End job 2")
}
그런 다음, group이 완료(complete)될 때까지 동기적(synchronously)으로 기다린다(waits):
if group.wait(timeout: .now() + 5) == .timedOut {
print("I got tired of waiting")
} else {
print("All the jobs have completed")
}
playground를 실행(run)하고 Xcode 창의 오른쪽의 출력을 확인한다. 작업 1과 2가 시작되었다는 메시지가 즉시(immediately) 표시된다. 2초 후에 작업 2가 완료되었다는 메시지가 표시되고, 3 초 후에 "I got tired of waiting."라는 메시지가 표시된다.
작업 2는 2 초 동안만 sleeps 하기 때문에 완료(complete)될 수 있음을 알 수 있다. 총 5 초의 시간을 wait로 지정했으며, 작업 1을 완료(complete)하기에 충분하지 않아 제한 시간 초과(timeout) 메시지가 출력된다.
그러나 5 초를 더 기다리면, (이미 5 초를 기다렸고, 작업 1은 완료에 10 초가 소요된다) 작업 1에 대한 완료(completion) 메시지가 표시된다.
이 시점에서 이와 같은 동기식(synchronous) 대기(wait) 메서드(method)를 호출하는 것은 아키텍처(architecture)의 잠재적(potentially) 문제를 유발하는 코드 냄새(code smell)가 난다. 물론 동기적(synchronously)으로 구현하는 것이 훨씬 쉽지만, 이 책의 목적은 앱이 최대한 빠르게 작동(perform)하는 방법을 배우는 것이다. 스레드(thread)가 실행되면서 "Is it done yet?"이라고 계속 묻는 것은 시스템 리소스(system resources)를 잘 사용하는 것이 아니다.
Wrapping asynchronous methods
dispatch queue는 기본적으로 dispatch groups에 대한 작업 방법을 알고 있으며, 작업이 완료(completed)되면 시스템(system)에 알리는 신호(signaling)를 처리한다. 이 경우 완료(completed)는 코드 블록이 예정대로 실행(run)되었음을 의미한다. 클로저(closure) 내부에서 비동기(asynchronous) 메서드(method)를 호출하면, 내부의 비동기(asynchronous) 메서드(method)가 완료(completed)되기 전에 클로저(closure)가 완료(complete)되기 때문에 중요하다.
내부(internal) 호출(calls)이 완료(completed)되기 전에, 작업이 아직 완료되지 않았음을 알려야 한다. 이 경우에는 DispatchGroup에서 제공되는 enter 및 leave 메서드(method)를 호출(call)할 수 있다. 이것들을 단순히 실행 중인 작업의 총계(count)로 생각할 수 있다. enter 마다 count가 1 씩 증가하고, leave 하면 count가 1 씩 감소한다:
queue.dispatch(group: group) {
// count is 1
group.enter()
// count is 2
someAsyncMethod {
defer { group.leave() }
// Perform your work here,
// count goes back to 1 once complete
}
}
group.enter()를 호출하여 dispatch group에 그룹(group)의 전체(overall) 완료(completion) 상태(status)를 계산(counted)하는 다른 코드 블록이 실행 중임을 알려야 한다. 물론 이를 group.leave() 호출(call)과 짝(pair)지어야 한다. 그렇지 않으면 결코 완료(completion) 신호(signaled)를 받지 못할 것이다. 오류 조건(error conditions)에서도 leave를 호출해야 하므로, 일반적으로 위와 같이 defer 문을 사용하여 클로저(closure)를 종료하는 방식에 관계없이 group.leave() 코드가 실행되도록 한다.
위 예제와 같이 간단한 코드의 경우, enter / leave 쌍(pairs)을 직접(directly) 호출하면 된다. dispatch groups과 함께 someAsyncMethod를 자주 사용하는 경우에는 필요한 호출(call)을 잊지 않도록 메서드(method)를 랩핑(wrap)해야 한다:
func myAsyncAdd(
lhs: Int,
rhs: Int,
completion: @escaping (Int) -> Void) {
// Lots of cool code here
completion(lhs + rhs)
}
func myAsyncAddForGroups(
group: DispatchGroup,
lhs: Int,
rhs: Int,
completion: @escaping (Int) -> Void) {
group.enter()
myAsyncAdd(lhs: lhs, rhs: rhs) { result in
defer { group.leave() }
completion(result)
}
}
래퍼(wrapper) 메서드(method)는 count를 사용할 그룹(group)을 매개 변수(parameter)로 사용하고, 나머지 인수(arguments)는 랩핑하는 메서드(method)의 인수와 정확히 동일해야 한다. 비동기(async) 메서드(method)를 래핑(wrapping)하는 것은 그룹(group)의 enter / leave 메서드(method)가 적절하게 처리되었는지 100% 확신하는 것 외에는 특별한 것이 없다.
랩퍼(wrapper) 메서드(method)를 작성하면 그 다음에 테스트를 해볼 수 있다. 모든 활용(utilizations)에서 적절한 enter / leave 쌍(pairing)을 검증(validate)하기 위해, 단순화되었다.
Downloading images
네트워크 다운로드(network download)는 항상 비동기(asynchronous) 작업(operation)이어야 한다. 사용자에게 플레이어 목록(list)과 이미지(images)를 제시하기 전에 플레이어 아바타(avatars)를 모두 다운로드(download)해야 하는 예제가 있다. dispatch group은 해당 작업을 위한 완벽한 해결책(solution)이다.
이 장의 시작(starter) 폴더(folder)에서 Images.playground를 연다. 제공된 배열(array)을 사용해 각 이미지를 비동기(asynchronous) 방식으로 다운로드한다. 완료(complete)되면, 적어도 하나 이상의 이미지를 표시하고 playground가 종료되어야 한다. 계속하기 전에 코드를 직접 작성해 본다.
URL을 생성하기 위해 이미지를 반복(loop)해야 하므로 다음과 같이 시작할 수 있다:
for id in ids {
guard let url = URL(string: "\(base)\(id)-jpeg.jpg") else { continue }
유효한 URL을 가져왔으므로, URLSession의 dataTask 메서드(method)를 호출한다. 이는 이미 비동기(asynchronous)식이므로 그룹(group)의 출입(entry and exit)을 처리해야 한다:
group.enter()
let task = URLSession.shared.dataTask(with: url) { data, _, error in
비동기(asynchronous) 코드를 사용할 때, defer 문을 함께 사용하는 것이 좋다. 이제 비동기(asynchronous) 작업을 시작했으므로 비동기(asynchronous) 작업이 어떻게 종료되든 간에, 작업이 완료(completed)되었음을 dispatch group에 알려야 한다. 그렇지 않으면, 앱은 완료(completion)를 영원히 기다리게(waiting) 된다:
defer { group.leave() }
그런 다음 이미지를 변환하여 배열(array)에 추가하기만 하면 된다.
if error == nil, let data = data, let image = UIImage(data: data) {
images.append(image)
}
}
task.resume()
enter와 leave 쌍(pairs)을 적절하게 처리하기 때문에, 더 이상 그룹(groups)이 동기적(synchronously)으로 기다릴 필요가 없다. 모든 이미지의 다운로드(downloads)가 완료(completed)되면, notify(queue :) 콜백(callback) 메서드(method)를 사용한다. for 반복문(loop) 외부(outside)에 아래 코드를 추가한다:
group.notify(queue: queue) {
images[0]
PlaygroundPage.current.finishExecution()
}
playground 실행(run)하고 사이드 바(sidebar)를 확인해 본다. 각 작업이 시작되고 이미지가 다운로드(downloading)되며, 결국에는 첫 번째 이미지로 알림(notification)을 실행(triggering)하게 된다.
Semaphores
공유 리소스(shared resource)에 접근(access)할 수 있는 스레드(threads)의 개수를 제어(control)해야 하는 경우가 있다. 단일(single) 스레드(thread)에 대한 접근(access)을 제한하는 읽기(read) / 쓰기(write) 패턴을 이미 확인했지만, 전체 스레드(thread)의 개수를 계속 제어(control)하면서 한 번에 더 많은 리소스(resources)를 사용할 수 있도록 허용(allow)해야 하는 경우가 있다.
예를 들어 네트워크(network)에서 데이터를 다운로드(downloads)하는 경우, 한 번에 얼마나 많은 수의 다운로드(downloads)를 허용할 지 제한(limit)할 수 있다.
dispatch queue을 사용하여 작업을 종료(offload)하고, 모든 다운로드(downloads)가 완료(completed)된 시기를 알 수 있도록 dispatch groups을 사용한다. 그러나 수신하는 데이터가 상당히 크고 처리하기에 무겁기(resource-heavy to process) 때문에 한 번에 네 개의 다운로드(downloads)만 허용하려고 한다.
DispatchSemaphore를 사용하면 해당 사례(case)를 정확하게 처리(handle)할 수 있다. 원하는 리소스(resource)를 사용하기 전에, 단순하게 동기(synchronous) 함수(function)인 wait 메소드(method)를 호출하면 리소스(resource)를 사용할(available) 수 있을 때까지 스레드(thread)가 실행(execution)을 일시 정지(pause)한다. 아직 소유권(ownership)을 주장하는 것이 없으면, 즉시 접근(access)할 수 있다. 이미 사용 중이라면, 그것이 끝났다는 신호(signal)를 보낼 때까지 기다린다.
세마포어(semaphore)를 작성할 때, 리소스에 대해 허용할 동시 접근(accesses) 수를 지정한다. 한 번에 4개의 네트워크(network) 다운로드(downloads)를 허용하도록 하려면 4를 전달한다. 리소스(resource)에 단일 접근(access)만 허용하려면 1을 지정하면 된다.
Semaphores.playground를 열고, 그룹(group)과 대기열(queue)을 설정하는 간단한 상용구(boilerplate) 코드를 확인할 수 있다. dispatch queue를 할당(assigns)하는 행(line) 다음에 네 개의 동시(concurrent) 액세스(accesses)를 허용하는 세마포어(semaphore)를 작성한다:
let semaphore = DispatchSemaphore(value: 4)
10개의 네트워크(network) 다운로드(downloads) 수행을 가정(simulate)하고, 그룹(group)을 사용하여 대기열(queue) 반복문을 작성한다. 세마포어(semaphore)를 생성한 후, 반복문을 구현한다:
for i in 1...10 {
queue.async(group: group) {
}
}
이전과 유사한 코드이므로 새로울 것은 없다. 이제 각 다운로드(download) 스레드(thread)에서 리소스(resource) 사용 권한을 요청한다. 네트워크 다운로드를 가장(simulate)하기 위해 3초 동안 스레드(thread)를 휴면(sleep) 상태로 만든다. 아래 코드를 비동기(async) 블록(block) 안에 삽입한다:
semaphore.wait()
defer { semaphore.signal() }
print("Downloading image \(i)")
// Simulate a network wait
Thread.sleep(forTimeInterval: 3)
print("Downloaded image \(i)")
dispatch group에서 leave를 호출해야 했던 것처럼, 리소스(resource) 사용이 완료되면 신호(signal)를 보내야 한다. 리소스(resource)를 해제하지 않고는 벗어날 수 없도록 defer 블록(block)을 사용하는 것이 가장 좋다.
실행(run)하면 4번의 다운로드(downloads)가 즉시(immediately) 발생하고, 3초 후에 또 다른 4번의 다운로드(downloads)가 발생한다. 그리고 3초 후에 마지막으로 2개가 완료(complete)된다.
이는 세마포어(semaphores)가 네트워크에 대한 접근(access)를 제한(limiting)하는 것을 확인하는 유용한 예시이다. 이번에는 실제로 무언가를 다운로드(download)하시 예시를 확인해 본다.
세마포어(semaphore) 변수를 생성한 후, playground의 모든 것을 제거한 다음 Images.playground에서 let base 로 시작하는 코드를 복사하여 뒤에 붙여 넣는다. 제어하려는 리소스(resource)는 네트워크(network)이므로 URL을 for 반복문(loop)에서 생성할 수 있지만, 그룹(group) enter 전에 사용가능한 세마포어(semaphore)를 기다려야(wait) 하므로 group.enter() 전에 세마포어(semaphore) 호출(call)을 추가한다:
semaphore.wait()
세마포어(semaphore)가 리소스(resource)에 대한 접근(access)을 제어(controls)하므로, dispatch group이 완료(completion)를 추적(tracking)하는 방법으로 두 가지 요소를 모두 사용해야 한다. 세마포어(semaphore) 해제를 처리하도록 defer 문을 수정한다:
defer {
group.leave()
semaphore.signal()
}
순서는 실제로 중요하지 않다. 단지 세마포어(semaphore)가 작업을 시작하고, 끝내는 외부(outer) 요소(element)가 된다. DispatchSemaphore를 4 대신 2의 값으로 업데이트 한 후 다시 실행한다. 한 번에 두 개의 다운로드(downloads)로 제한(limitation)되기 때문에 속도가 느리긴하지만, 이전과 동일하게 동작해야 한다.
세마포어(semaphore) 자체를 어떤 유형(type)의 리소스(resource)로 생각해 볼 수도 있다. 3개의 망치(hammers)와 4개의 톱(saws)을 사용할 수있는 경우, 두 개의 세마포어(semaphores)를 만들어 이를 나타낸다:
let hammer = DispatchSemaphore(value: 3)
let saw = DispatchSemaphore(value: 4)