본문 바로가기

iOS/iOS

iOS) GCD에 대한 고찰 - 2(DispatchWorkItem, DispatchGroup, DispatchSemaphore, Concurrency Problems)

 

iOS) GCD에 대한 고찰 - 1(Dispatch Queue)

 

iOS) GCD에 대한 고찰 - 1(Dispatch Queue)

iOS 개발을 하면서 멀티스레딩 작업이 필요할 때 사용하는 GCD에 대해 알아보려 합니다. 멀티스레딩 작업은 Operation으로도 할 수 있지만 이 글에선 GCD에 대해 다뤄보려 합니다. (Operation은 다른 글

demian-develop.tistory.com

이전에 썼던 글에 이어서 GCD에 대해서 글을 좀 더 써보려 합니다. 이 글에서는 DispatchWorkItem, DispatchGroup,

DispatchSemaphore, Concurrency Problems에 대해 다뤄보겠습니다.

 

DispatchWorkItem

DispatchWorkItem는 Dispatch Queue에 Closure를 태울 때, DispatchWorkItem로 Closure를 감싸 Closure에 identifier를 만드는 느낌입니다.

기존에 Closure를 태울 때,

let queue = DispatchQueue(label: "dochoi")
queue.async {
  print("The block of code ran!")
}

이런 식으로 하였다면,

let queue = DispatchQueue(label: "dochoi")
let workItem = DispatchWorkItem {
  print("The block of code ran!")
}
queue.async(execute: workItem)

이렇게 DispatchWorkItem로 이를 감쌀 수 있습니다. 이로써 Task를 취소할 수 있습니다.

let queue = DispatchQueue(label: "dochoi")
let workItem = DispatchWorkItem {
  print("The block of code ran!")
}
queue.async(execute: workItem)
workItem.cancel()

cancel() 함수를 호출할 때 workItem은 2가지 상태가 있고 각 상태에 따라 다른 결과가 나타납니다.

1. workItem의 Task가 Queue에서 대기 중인 경우, workitem이 제거됩니다.

2. workitem의 Task가 진행 중인 경우, workitem의 isCanceled property가 true로 설정됩니다. 

 

notify(queue: execute:)를 이용해 Serial Queue와 유사하게, 현재 작업이 끝나고 다음 작업을 예약할 수 있습니다.

let queue = DispatchQueue(label: "dochoi")
let firstWorkItem = DispatchWorkItem { print("first") }
let secondWorkItem = DispatchWorkItem { print("second") }
let thirdWorkItem = DispatchWorkItem { print("third") }
firstWorkItem.notify(queue: DispatchQueue.main,
execute: secondWorkItem)
secondWorkItem.notify(queue: DispatchQueue.main, execute: thirdWorkItem)
queue.async(execute: firstWorkItem)
RunLoop.main.run()

결과

하지만 이렇게 캡슐화하여 Task를 취소하거나, 의존성을 넣고 싶을 때는 Operation을 사용하는 것이 더 좋습니다. (Operation은 나중에 다뤄보겠습니다.)

 

DispatchGroup

DispatchGroup은 이름처럼 Task 그룹의 Completion을 다루고 싶을 때 사용합니다.

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"
}

코드에서와 같이 DispatchGroup은 여러 Queue에 있는 Task들과 연결될 수 있습니다. 이 모든 Task들에 대한 completion을 notify를 통해 다룰 수 있습니다. notification은 비동기적이므로, 그룹 내의 Task가 완료되지 않은 경우 Task를 계속 추가할 수 있습니다.

만약 notification을 동기적으로 하고 싶다면 wait를 사용할 수 있습니다.

import Foundation
let group = DispatchGroup()
let queue = DispatchQueue(label: "queue")
let queue2 = DispatchQueue(label: "queue2")
let queue3 = DispatchQueue(label: "queue3")
queue.async(group: group){  print("1") }
queue2.async(group: group) { print("2")  }
queue3.async(group: group) { print("3"); sleep(2) }
if group.wait(timeout: .now() + 1) == .timedOut {
  print("The jobs didn’t finish in 1 seconds")
}
print("complete")
RunLoop.main.run()

Task가 모두 끝날 때까지 현재 Thread를 차단하고 기다립니다. 또한, timeout이 되어도 Task는 취소되지 않고 진행됩니다.

 

Wrapping asynchronous methods

notification을 이용해서 Completion을 다뤘습니다. 하지만 Closure 내부에 또 다른 비동기 Closure가 있다면, 코드의 실행이 끝났음만 알 수 있기 때문에 이 비동기 Closure는 완료되지 않음에도 Completion이 호출될 것입니다. 이때 사용할 수 있는 게 enter(), levae() 함수입니다.  

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
     }
 }

enter()와 leave()는 항상 짝을 이뤄야 합니다. 그리고 일반적으로 defer문에 leave()를 삽입합니다. 이유는 에러를 핸들링하기 위함입니다. defer문은 함수가 어떤 경우에 리턴되든 호출됩니다. DispatchGroup은 언제 사용하면 좋을까요? 그중 한 예는 이미지를 로드할 때 하나라도 누락되지 않고 전부 보여주고 싶을 때입니다.

Semaphores

공유 자원에 대해 접근하는 Thread 수를 제한해야 할 때가 있습니다. 예를 들어 데이터를 다운로드하는 작업입니다. 다운로드 작업은 많은 리소스를 소모합니다. 따라서 어느 정도 제한을 줄 필요가 있습니다. 이때 사용할 수 있는 방법이 DispatchSemaphore입니다. 작업을 4개로 제한하고 싶을 때 Semaphore는 이렇게 생성할 수 있습니다.

 let semaphore = DispatchSemaphore(value: 4)

이미지 받아오는 함수를 동시에 4번으로 제한하고, 이미지 다운로드 작업은 가상으로 Thread 3초간 sleep함으로 구현하겠습니다. 

let group = DispatchGroup()
let semaphore = DispatchSemaphore(value: 4)
for i in 1...10  {
    DispatchQueue.global().async(group: group) {
        semaphore.wait()
        defer { semaphore.signal() }
        print("image \(i) 다운로드 시작" )
        // Simulate a network wait
        Thread.sleep(forTimeInterval: 3)
        print("image \(i) 다운로드 완료")
}
}
group.notify(queue: DispatchQueue.main) {
    print("모든 image 다운로드 완료")
}
RunLoop.main.run()

한번에 실행되는 작업 제한

결과입니다.

Concurrency Problems

멀티스레딩에서 일어날 수 있는 문제점입니다.  크게 3가지가 있습니다.

1. Race conditions

2. Deadlock

3. Priority inversion

Race conditions

동일한 프로세스를 공유하는 Thread의 경우, 같은 메모리 주소를 공유합니다. 즉 각각의 Thread가 동일한 공유자원을 Read 하고 Write 합니다. 이는 의도하지 않은 결과를 발생시킬 수 있는데 이를 race conditions이라 합니다.

count += 1

Thread 2개가 이와 같은 코드를 동시에 작동한다고 하면 count는 2가 될까요? 여기서는 코드 한 줄이지만 어셈블리로 내려가면 코드가 여러 줄이 됩니다.

1. count 변수를 메모리에 로드합니다. (Read)

2. 메모리에 로드된 count 값을 1 증가시킵니다

3. 증가된 count 값을 원래 메모리 주소에 업데이트합니다. (Write)

이해하기 쉽게 Swift에서 이를 설명하자면, 저 코드 한 줄 한 줄을 Global Queue에서 실행시킨다고 상상해보시면 됩니다.

1을 10번 더했는데 5가 나왔습니다.

Race condition은 디버깅하기 굉장히 복잡합니다. 이경우에 10이 나오게 하려면 어떻게 해야 할까요? serial queue를 이용합니다.

Serial Queue로 Race condition을 해결

이렇듯 Serial Queue를 이용해 Race condition을 해결할 수 있습니다. (Semaphores로도 해결할 수 있습니다.)

semaphore로 Race condition을 해결 

iOS에선 lazy variables에 처음 접근하는 코드를 Serial Queue를 이용해야 합니다. (혹은 Semaphore) 그렇지 않으면 멀티스레딩 환경에서 첫 번째 Thread가 접근하여 초기화를 시작하고, 두 번째 Thread가 lazy variables가 초기화가 완료되지 않음에도 이를 읽어올 수 있습니다.

 

Thread barrier

작업이 간단한 경우 Serial Queue를 이용해 해결할 수 있지만 종종 복잡한 논리가 필요한 경우가 있습니다. Semaphores를 제대로 구현하기 위해선 굉장히 많은 생각을 해야 합니다. 이때 barrier를 사용하면 좋습니다. barrier를 넣은 작업은, Serial Queue처럼 동작하며, Task가 완료되면 다시 Concurrent Queue로 동작합니다. 그런데 재미있는 점이 있습니다.

barrier는 Serial Queue처럼 동작하지 않았습니다.

barrier를 넣은 Concurrent Queue와 Serial Queue의 차이입니다. Serial Queue는 작업의 순서를 보장하지만, barrier를 넣은 Concurrent Queue는 Queue 내에서 Task가 단일로 이뤄짐만 보장될 뿐, 이후 대기하고 있는 Task에 대해선 순서를 보장하지 않습니다.

 

Deadlock

서로가 서로의 Task가 끝나기를 기다리고 있어 작업이 중단되는 현상이 Deadlock입니다.

Swift에서 Deadlock이 발생하는 경우는 대부분 현재 Queue에 Sync로 Task를 Dispatch 하는 경우에 발생하나, Semaphore나 Lock을 잘못 사용할 경우에도 발생합니다.

 

Priority inversion

Priority inversion은  Qos가 낮은 Task가 높은 Qos를 가진 Task보다 시스템에서 먼저 실행되는 것입니다. 주로 Low Qos Task와 High Qos Task가 의존성이 있을 경우 발생합니다. (자원을 공유함) Low Qos Task에서 공유자원을 잠가버리면 High Qos Task는 Low Qos Task가 완료될 때까지 아무것도 하지 못합니다.

import Foundation

let high = DispatchQueue.global(qos: .userInteractive)
let medium = DispatchQueue.global(qos: .userInitiated)
let low = DispatchQueue.global(qos: .background)
let semaphore = DispatchSemaphore(value: 1)
high.async {
    // Wait 2 seconds just to be sure all the other tasks have enqueued
    Thread.sleep(forTimeInterval: 2)
    semaphore.wait()
    defer { semaphore.signal() }
    print("High priority task is now running")
}
for i in 1 ... 10 {
    medium.async {
        let waitTime = Double(exactly: arc4random_uniform(7))!
        print("Running medium task \(i)")
        Thread.sleep(forTimeInterval: waitTime)
} }
low.async {
    semaphore.wait()
    defer { semaphore.signal() }
    print("Running long, lowest priority task")
    Thread.sleep(forTimeInterval: 5)
}
RunLoop.main.run()

 

코드로 설명하겠습니다.

high qos Queue 내에 있는 Task는 접근할 수 있는 Thread가 1인 Semaphore로 공유자원을 관리합니다. 이 Semaphore는 low qos Queue 내에 있는 Task와 의존성이 있고, low qos Queue 내에 있는 Task가 끝날 때까지 high qos Queue 내에 있는 Task는 완료되지 못합니다.

위 코드의 출력값

이 현상이 Priority inversion입니다. 

 

 

iOS 플랫폼은 다른 플랫폼에 비해 Deadlock이나 Priority inversion에 대해 문제가 생길 일은 별로 없을 것입니다. 

하지만 Race conditions에 대해선 항상 생각하고 있는 것이 좋습니다.

고찰

이로써 GCD 글을 마치게 되었습니다. 글을 쓰면서 GCD에 대해 더 깊게 이해하게 되었고 충분히 GCD에서도 DispatchWorkItem, DispatchSemaphore를 이용하여 Task를 취소하거나, Task를 동시에 몇 개 실행하게 할지 정할 수 있다는 점을 배웠습니다. 앞으로 GCD를 이용할 때 조금이나마 더 나은 코드를 작성할 수 있을 것 같습니다. :)

 

References

Concurrency by Tutorials By Scott Grosch (www.raywenderlich.com/books/concurrency-by-tutorials/v2.0)

developer.apple.com/documentation/dispatch/dispatchworkitem

developer.apple.com/documentation/dispatch/dispatchsemaphore

developer.apple.com/documentation/dispatch/dispatchgroup

stackoverflow.com/questions/46579268/dispatchqueue-barrier-issue