[회고] 신입 iOS 개발자가 되기까지 feat. 카카오 자세히보기

🍎 Apple/Concurrency & GCD

[Swift] GCD 정리하기

inu 2022. 5. 30. 22:48
반응형

GCD

  • GCD는 Grand Central Dispatch의 줄임말로, 멀티 코어 프로세스 시스템의 스레드 관리에 대한 책임을 운영체제 레벨에게 넘겨주는 기술입니다.
  • GCD 내부에는 DispatchQueue를 읽는 멀티코어 실행엔진을 가지고 있어 이것이 등록된 작업을 읽어 스레드에 할당합니다.
  • 개발자는 내부 동작에 대해 이해할 필요없이 Queue에 작업을 할당하기만 하면 되기 때문에 스레드관리가 훨씬 쉬워집니다.
  • OperationQueue 역시 내부적으로는 GCD를 활용하고 있습니다.

Dispatch Queue

  • Dispatch Queue는 FIFO Queue의 형태로 작업을 순서대로 전달받습니다.
  • 작업은 Block( { } ) 혹은 Dispatch Work Item 인스턴스로 캡슐화하여 전달합니다.

serialQueue vs. concurrentQueue

let serialQueue = DispatchQueue(label: "SerialQueue")
let concurrentQueue = DispatchQueue(label: "ConcurrentQueue", attributes: .concurrent)
  • Dispatch Queue는 실행 방법에 따라 Serial Queue와 Concurrent Queue로 구분되어집니다.
    • Serial Queue는 추가된 작업을 하나씩 처리됩니다.
      • 두개 이상의 작업이 동시에 처리되지 않기 때문에 Queue 기반의 동기화작업에서 많이 사용합니다.
      • Dispatch Queue를 생성할 때 아무런 옵션을 부여하지 않으면 Serial Queue로 생성됩니다.
    • Concurrent Queue는 추가된 작업을 동시에 처리합니다.
      • 동시에 실행되는 작업의 수는 시스템의 상태에 따라 자동적으로 처리됩니다.

main vs. global()

DispatchQueue.main.async {
    ...
}
  • mainQueue는 main thread에서 동작하는 특별한 Dispatch Queue 입니다.
    • 따라서 UI update 관련 작업은 반드시 mainQueue에서 실행해야합니다.
    • Serial Queue입니다.
    • 앱 생성시점에서 자동으로 생성되기 때문에 언제든 DispatchQueue.main로 접근할 수 있습니다.
DispatchQueue.global().async {
    ...
}
  • global() 메서드는 background thread에서 동작시킬 작업을 관리하는 Dispatch Queue를 리턴합니다.
  • global(qos:)값을 부여하여 그에 해당하는 Dispatch Queue를 받아올 수도 있습니다.

Quality of Service

  • GCD에도 QoS 개념이 존재합니다.
  • 작업의 중요도를 4가지로 구분하여 표현합니다. 상위에 존재하는 QoS가 더 높은 중요도를 가집니다.
    • userInterface
    • userInitiated
    • utility
    • background

sync vs. async

  • sync 메서드는 파라미터로 전달된 작업을 동기 방식으로 추가합니다.
    • 즉, 해당 작업이 완료될 때까지 대기합니다.
    • main queue에서 사용할 경우 에러가 발생하므로 주의하여 사용해야합니다.
      • why? main queue는 serial queue이기 때문에 하나의 task가 종료되어야 다음 task를 실행할 수 있습니다. main queue sync를 호출하고 작업을 전달하면 현재 수행하던 작업을 block하고 해당하는 작업을 수행하려고 합니다. 그런데 이렇게 전달된 작업은 block된 작업이 끝나야만 수행할 수 있습니다. 결국 앱은 deadlock 상태에 빠지며 죽어버리게 됩니다.
  • async 메서드는 파라미터로 전달된 작업을 비동기 방식으로 추가합니다.
    • 작업이 완료될 때까지 기다리지 않습니다.
    • 현재 진행중인 작업에 영향을 주지 않기 때문에 주로 async 메서드로 작업을 추가합니다.
  • 주의할 점은 두 메서드가 Dispatch Queue 내부의 동작방식에는 영향을 주지 않는다는 것입니다.
    • 동작방식은 Dispatch Queue를 생성할 때 결정한 attributes(serial or concurrent)에 따라 결정됩니다.

asyncAfter

  • async 메서드에는 특정시간에 작업을 수행하도록 예약하는 메서드가 존재합니다.
  • deadline에는 DispatchTime값을 전달합니다.
    • DispatchTime의 static 메서드인 now()를 통해 현재시간을 가져올 수 있습니다.
let delay = DispatchTime.now() + 3
concurrentQueue.asyncAfter(deadline: delay) {
    ...
}
  • 이렇게 구현하면 3초 후에 작업을 수행합니다.

concurrentPerform

  • concurrentPerform 메서드는 iterations에 지정된 수만큼의 작업을 반복처리하는데, 시스템 자원이 허락하는 만큼 코드를 병렬적으로 처리합니다.
  • 클로저에는 반복 index가 전달됩니다.
var start = DispatchTime.now()
for index in 0..<20 {
    print(index, terminator: " ")
    Thread.sleep(forTimeInterval: 0.1)
}
var end = DispatchTime.now()
print(Double(end.uptimeNanoseconds - start.uptimeNanoseconds) / 1000000000)

start = DispatchTime.now()
DispatchQueue.concurrentPerform(iterations: 20) { index in
    print(index, terminator: " ")
    Thread.sleep(forTimeInterval: 0.1)
}
end = DispatchTime.now()
print(Double(end.uptimeNanoseconds - start.uptimeNanoseconds) / 1000000000)

// 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 2.025312042
// 0 1 3 5 6 7 4 2 9 8 10 11 12 13 14 15 16 17 19 18 0.303290042
  • 단순히 반복문으로 구현했을 때와 수행시간을 비교해보았습니다.
  • 병렬로 실행할 경우 실행시간이 매우 빨라짐을 알 수 있습니다.
    • 다만 실행순서는 보장되지 않으므로 주의해야합니다.

DispatchWorkItem

  • DispatchWorkItem은 Dispatch Queue 혹은 DispatchSource에 들어갈 작업을 캡슐화하는 객체입니다.
  • 직접 실행할 수도 있지만 그렇게 사용하는 경우는 많지 않습니다.
let workItem = DispatchWorkItem(block: {
    ...
})
  • block을 기반으로 간단하게 생성할 수 있습니다.
DispatchQueue.main.async(excute: workItem)
  • DispatchQueue에서 제공하는 메서드로 해당 작업을 간단하게 추가할 수 있습니다.
workItem.cancel()
  • Operation과 같이 취소 기능을 제공하지만 그리 효율적이지 않아 많이 사용하지 않습니다.
  • Operation과 같이 isCancel 속성에 접근하여 작업을 중단해야합니다.
    • 보통 클로저 내부에서는 해당 속성에 바로 접근할 수 없기 때문에 따로 참조를 만들어두고 접근해야합니다.
workItem.notify(queue: DispatchQueue.main) {
    ...
}
  • notify 메서드를 사용해 종료시 수행할 작업을 정의할 수 있습니다.
  • queue 파라미터에는 해당 작업을 수행할 queue를 입력합니다.
let result = workItem.wait(timeout: 3)

// .timedOut or .success
  • notify 메서드는 반드시 작업이 정상적으로 종료되어야 작동합니다.
    • deadlock이 발생할 경우 수행되지 않습니다.
  • wait 메서드를 사용하면 특정시간만큼만 대기하고 작업이 종료되도록 만들 수 있습니다.
  • 이를 통해 작업의 deadlock을 방지할 수 있습니다.
  • 해당 함수의 리턴값을 통해 작업이 정상적으로 종료되었는지, timeout이 지나 종료되었는지 알 수 있습니다. (.timedOut or .success)
  • wait 메서드는 동기적으로 작동하기 때문에 main thread에서는 사용을 유의해야 합니다.
  • 네트워크에 요청한 작업을 일정시간동안 대기할 때 활용할 수 있습니다.

DispatchSourceTimer

  • 타이머를 기반으로 이벤트 처리 블록을 넣을 수 있는 Dispatch Source입니다.
    • cf. DispatchSource : 파일시스템, 타이머, UNIX Signal 등의 low-level 이벤트의 처리를 조정하는 객체
  • cf. 생성된 타이머는 따로 제거하는 과정이 없다면 계속해서 존재하며 리소스를 사용합니다. 따라서 현재 기능에서 벗어나 페이지를 벗어나는 등의 작업을 수행한다면 반드시 현재 돌아가고 있는 timer를 제거해야합니다.
let timer = DispatchSource.makeTimerSource(flag: [], queue: DispatchQueue.main)
timer.schedule(deadline: .now(), repeating: 1)
timer.setEventHandler(handler: {
    ...
})
  • DispatchSource의 makeTimerSource 메서드로 DispatchSourceTimer를 생성할 수 있습니다.
  • DispatchSourceTimer의 schedule 메서드로 작동시간(deadline) 및 주기(repeating)를 결정할 수 있습니다.
  • DispatchSourceTimer의 setEventHandler 메서드로 해당 이벤트가 발생하면 동작할 작업을 입력할 수 있습니다.
timer.resume()
  • resume 메서드로 timer를 실행합니다.
timer.suspend()
  • suspend 메서드로 timer를 일시중지합니다.
timer.cancel()
  • cancel 메서드로 timer를 완전중지합니다.
  • 이렇게 완전중지된 timer는 재사용할 수 없습니다.

DispatchGroup

  • DispatchGroup은 Dispatch Queue에 추가된 작업을 가상으로 그룹화하여 관리합니다.
  • 서로다른 Dispatch Queue에 추가된 작업을 같은 그룹으로 묶을 수도 있습니다.
  • 그룹화된 작업들은 하나의 큰 작업으로 인식됩니다.
    • 즉 그룹에 포함된 모든 작업이 종료되어야 해당 그룹이 완료됩니다.
let group = DispatchGroup()
group.enter()

DispatchQueue.main.async {
    ...
    self.group.leave()
}
  • 작업을 시작하기 전에 enter 메서드를 통해 작업이 시작되었음을 알리고, 작업이 종료되면 leave 메서드를 호출해 작업 종료를 알리는 방법이 있습니다.
  • 이렇게 구성할 경우 모든 enter로 count된 작업이 모두 종료되면 그룹 작업이 종료된 것으로 인식합니다.
  • 다만 이 방식은 leave 메서드를 호출하는 것을 잊어버릴 위험이 있기 때문에 많이 사용하지 않습니다.
DispatchQueue.main.async(group: group) {
    ...
}
  • Dispatch Queue의 async 메서드에는 group을 파라미터로 받는 메서드가 존재합니다.
  • 이를 사용하면 굳이 enter 및 leave를 호출해줄 필요가 없기 때문에 매우 유용합니다.
group.notify(queue: DispatchQueue.main) {
    ...
}

let result = group.wait(timeout: 3)

// .timedOut or .success
  • DispatchGroup에서는 DispatchWorkItem에서 확인한 것과 같은 notify, wait 메서드를 제공합니다.
  • notify 메서드는 그룹의 작업이 모두 종료된 후 수행할 작업을 정의할 수 있습니다.
  • wait 메서드는 작업 수행을 기다릴 시간을 정의할 수 있습니다. (리턴값을 통해 정상적으로 종료되었는지 확인할 수 있습니다.)

DispatchSemaphore

  • Counting Semaphore의 일종으로 여러 실행 Context에서 Resource에 대한 접근을 제어하는 객체입니다.
  • signal() 메서드를 통해 세마포어 수를 늘리고 wait() 메서드를 통해 세마포어를 감소시키는 것이 핵심입니다.
  • 본격적인 학습에 앞서 예제를 하나 살펴봅시다.
let group = DispatchGroup()
var value = 0

DispatchQueue.global().async(group: group) {
    for _ in 1...1000 {
        value += 1
    }
}

DispatchQueue.global().async(group: group) {
    for _ in 1...1000 {
        value += 1
    }
}

DispatchQueue.global().async(group: group) {
    for _ in 1...1000 {
        value += 1
    }
}

group.notify(queue: DispatchQueue.main) {
    print(value)
}
  • DispatchGroup을 활용해 value를 1씩 늘리는 작업 3개를 수행한 후에 해당 값을 출력하도록 해봤습니다.
  • 이론대로라면 value는 3000이 되어야합니다.

  • 하지만 그에 조금 못미치는 2972가 출력되었습니다.
  • 몇번을 시도해보아도 숫자가 조금씩 변하며 3000에는 미치지 못하는 숫자가 나옵니다.
  • 이는 각 작업 단위에서 value에 동시에 접근했기 때문에 발생하는 일입니다. 2개 이상의 작업이 같은 시간에 value에 접근하여 값을 올리면서 value가 2 이상이 아닌 1만 상승하게 된 것입니다.
  • 이러한 문제를 해결하기 위해서 사용할 수 있는 것이 DispatchSemaphore입니다. (물론 SerialQueue를 활용해 순서대로 작업을 처리하도록 변경할 수도 있습니다.)
let semaphore = DispatchSemaphore(value: 1)
  • value 값을 주면서 DispatchSemaphore 객체를 생성합니다.
  • 해당 value는 한번에 허용할 작업의 개수입니다.
  • 작업을 실행할때는 wait 메서드를 사용합니다. wait 메서드에서는 value가 0보다 큰지 확인합니다. 0이라면 작업을 수행하지 않고 해당 value가 1 이상이 될때까지 기다립니다. 작업이 시작되면 value 값을 1 줄입니다.
    • cf. wait 메서드에 timeout을 부여해 기다릴 시간을 결정할 수도 있습니다. timeout만큼의 시간만 기다리고 작업을 수행합니다. 이를 활용해 deadlock을 방지할 수 있습니다.
  • 작업이 종료되면 signal 메서드를 사용합니다. signal 메서드는 작업이 끝났다는 의미이므로 value 값을 1 늘려줍니다.
let group = DispatchGroup()
var value = 0
let semaphore = DispatchSemaphore(value: 1)

DispatchQueue.global().async(group: group) {
    for _ in 1...1000 {
        semaphore.wait()
        value += 1
        semaphore.signal()
    }
}

DispatchQueue.global().async(group: group) {
    for _ in 1...1000 {
        semaphore.wait()
        value += 1
        semaphore.signal()
    }
}

DispatchQueue.global().async(group: group) {
    for _ in 1...1000 {
        semaphore.wait()
        value += 1
        semaphore.signal()
    }
}

group.notify(queue: DispatchQueue.main) {
    print(value)
}
  • DispatchSemaphore를 활용해서 앞선 코드를 개선해봤습니다.

  • 이제는 제대로 3000이 출력되네요.
  • semaphore를 통해 작업순서를 제어할수도 있습니다.
var value = "변경전"

DispatchQueue.global().async {
    Thread.sleep(forTimeInterval: 1)
    value = "변경후"
}

DispatchQueue.global().async {
    print(value)
}
  • 하나의 작업은 value를 "변경후"로 바꿔주고, 하나의 작업은 이를 출력합니다.
  • 하지만 변경작업은 sleep 메서드를 사용해 지연해줬기 때문에 바로 처리되지 않습니다.

  • 그 결과 작업이 이와같이 출력됩니다.
  • 하지만 이는 우리가 원하는 결과가 아닙니다. 우리는 "변경후"를 출력해주고 싶습니다.
var value = "변경전"
let semaphore = DispatchSemaphore(value: 0)

DispatchQueue.global().async {
    Thread.sleep(forTimeInterval: 1)
    value = "변경후"
    semaphore.signal()
}

DispatchQueue.global().async {
    semaphore.wait()
    print(value)
}
  • DispatchSemaphore를 활용해 이를 해결할 수 있습니다.
  • 두번째 작업의 wait는 특정 작업에서 signal을 발생시켜야 작업수행이 가능해집니다.
  • 첫번째 작업을 끝낼때 signal을 발생시켜줬습니다.

  • 이제는 작업순서가 정렬되어 "변경후"가 출력되네요!

 

참고 및 출처 : https://developer.apple.com/documentation/dispatch/dispatchqueue

 

반응형

'🍎 Apple > Concurrency & GCD' 카테고리의 다른 글

[Concurrency] Task  (0) 2023.03.31
[Concurrency] async / await  (2) 2023.03.31
[Swift] Operation Queue  (0) 2022.05.27
[Swift] Actor  (4) 2022.03.08
[Swift][문서의역] Task  (0) 2022.03.08