iOS GCD Concurrency

동시성 프로그래밍

필요성

  • 가장 강력한 쓰레드인 Main Thread라고 해도 과도한 작업을 할당하면 성능 저하 발생
  • 앱의 빠른 속도와 성능을 원한다면, 작업을 적절히 여러 쓰레드로 분산시켜야 함

사용법

  • 기본적으로 GCD 혹은 Operation을 통해 동시성 프로그래밍 사용할수 있음
  • Swift 5.5부터 언어단에서 동시성 프로그래밍을 제공하는 Swift Concurrency도 있음
  • RxSwift 혹은 Combine 같은 반응형 프레임워크를 통해서도 동시성 프로그래밍을 할 수 있음

Sync vs Async

  • Sync: Queue로 보낸 작업이 완료 될 때까지 기다림
swift-concurrency
  • Async: Queue로 보낸 작업을 안 기다리고 다음 코드 실행, 끝나기를 기다리지 않음
swift-concurrency

Serial vs Concurrent

  • Serial queue: 한개의 쓰레드에서 전부 진행, 순서 보장됨. 순차적 실행이 필요한 작업에 적합.
swift-concurrency
  • Concurrent queue: 여러개의 쓰레드에서 진행, 종료 순서 보장되지 않음. 빠른 처리가 필요한 독립적인 작업에 적합.
swift-concurrency

GCD vs Operation

GCD: Grand Central Dispatch

  • 앞서 말했듯이, iOS GCD는 동시성 프로그래밍을 할 수 있는 한 가지 방법
  • 작업을 Dispatch Queue에 추가하면 GCD가 자동으로 쓰레드를 생성하고 실행
  • GCD는 작업이 완료되면 쓰레드를 자동으로 제거까지 해줌

Operation

  • GCD 기반으로 동작하는 상위 레벨 프레임워크
  • 동시에 실행할 수 있는 동작의 최대 수 지정 가능
  • 동작 일시 중지 및 취소 가능
  • 작업 간 의존성 설정 가능

GCD vs Operation

  • GCD: 가볍고 효율적인 구현, 간단한 작업에 적합, 빠른 성능
  • Operation: 복잡한 작업 관리, 작업 상태 모니터링, 작업 간 의존성 관리, 작업 취소 및 재시도 기능

GCD

swift-concurrency

기본 개념

  • Task: 작업 (코드 블럭)
  • Thread: 노동자
  • Queue: 대기 행렬 (FIFO 구조)

Dispatch Queue 종류

  • Main Queue: 1개, Serial, Main Thread
  • Global Queue: Concurrent, QoS 종류 6개
  • Custom Queue: Serial/Concurrent 선택 가능, QoS 설정 가능
// Serial 커스텀 큐 생성
let serialQueue = DispatchQueue(label: "com.hohyeonmoon.serial")

// Concurrent 커스텀 큐 생성
let concurrentQueue = DispatchQueue(label: "com.hohyeonmoon.concurrent", attributes: .concurrent)

Qos 종류

  • userInteractive: UI 업데이트, 애니메이션 등 즉각적인 반응이 필요한 작업
  • userInitiated: 문서 열기 등 즉시 필요하지만 약간의 대기 가능한 작업
  • default: 일반적인 작업
  • utility: 데이터 다운로드 등 시간이 걸리는 작업
  • background: 백업, 동기화 등 당장 필요 없는 작업
  • unspecified: QoS 정보가 없는 작업
let queue = DispatchQueue(label: "com.hohyeonmoon.concurrent", qos: .userInitiated, attributes: .concurrent)

Task를 Queue에 추가

  • 네트워크 작업 등을 백그라운드에서 진행하고
  • 결과를 통해 메인 스레드에서 UI를 업데이트 하는 예시이다
DispatchQueue.global(qos: .utility).async {
  guard let self else { return }

  // 작업 진행

  DispatchQueue.main.async {
    // UI 업데이트
  }
}

DispatchWorkItem

  • 클로저로 전달하는 방법 외에도 DispatchQueue에 작업을 제출하는 방법이 있다
  • DispatchWorkItem은 큐에 제출하려는 코드를 보관할 실제 객체를 제공하는 클래스
let queue = DispatchQueue(label: "com.hohyeonmoon.concurrent")
let workItem = DispatchWorkItem { print("실행됨") }

queue.async(execute: workItem)
  • cancel()을 통해 작업을 취소할 수도 있다
  • notify(queue:execute:)을 통해 작업이 완료되면 콜백을 통해 알림을 받을 수도 있다
let queue = DispatchQueue(label: "com.hohyeonmoon.concurrent")
let backgroundWorkItem = DispatchWorkItem { }
let updateUIWorkItem = DispatchWorkItem { }

backgroundWorkItem.notify(queue: DispatchQueue.main, execute: updateUIWorkItem)
queue.async(execute: backgroundWorkItem)

DispatchGroup

  • 여러 작업을 그룹으로 묶어서 관리할 수 있다
  • 작업이 모두 완료되었을 때 콜백을 통해 알림을 받을 수 있다
let group = DispatchGroup()

someQueue.async(group: group) { ... }
someQueue.async(group: group) { ... }
someOtherQueue.async(group: group) { ... }

group.notify(queue: DispatchQueue.main) {
  // 모든 작업이 완료되었을 때 실행될 코드
}
  • wait(timeout:)을 통해 synchronous 하게 작업이 완료될 때까지 기다릴 수 있다
let group = DispatchGroup()

someQueue.async(group: group) { ... }
someQueue.async(group: group) { ... }
someOtherQueue.async(group: group) { ... }

if group.wait(timeout: .now() + 60) == .timedOut {
  // 작업이 60초 이상 걸리면 실행될 코드
}
  • enter()leave()를 통해 작업을 시작하고 완료할 수 있다
let group = DispatchGroup()

group.enter()
someQueue.async {
  // 작업 진행
  group.leave()
}

DispatchSemaphore

  • 동시에 실행할 수 있는 작업의 최대 수를 제한하는 객체
  • wait()을 통해 신호를 기다리고, signal()을 통해 신호를 보낸다
  • 최대 동시 실행 수를 제한하여 작업 수를 제어할 수 있다
let semaphore = DispatchSemaphore(value: 2)

for i in 1...10 {
  queue.async {
    semaphore.wait()
    defer { semaphore.signal() }

    print("Downloading image \(i)")

    // Simulate a network wait
    Thread.sleep(forTimeInterval: 3)

    print("Downloaded image \(i)")
  }
}

주의사항

Race Condition

  • Shared property에 여러 스레드가 동시에 접근하고 수정하려 할 때 발생
  • 방지하기 위해, 해당 변수를 하나의 Serial 큐를 통해서만 접근하도록 보장
  • 읽기·쓰기를 모두 안전하게 감싸는 클래스 구현

Dispatch Barrier

  • 여러 읽기 연산은 동시에 수행하면서, 쓰기 연산 시에는 큐를 독점해야 할 때 사용
  • 이미지 배열에 다수 스레드가 동시 읽기를 수행하다가 새로운 이미지를 추가할 때
  • barrier 플래그를 사용해 기존 읽기 연산이 모두 끝날 때까지 대기 후 독점적으로 쓰기 처리
class BarrierImages {
  private let queue = DispatchQueue(label: "...", attributes: .concurrent)

  private var _images: [Image] = []

  var images: [Image] {
    var copied: [Image] = []

    queue.sync {
      copied = _images
    }

    return copied
  }

  func add(image: Image) {
    queue.sync(flags: .barrier) { [weak self] in
      self?._images.append(image)
    }
  }

  func remove(at index: Int) -> Image? {
    var removed: Image? = nil

    queue.sync(flags: .barrier) { [weak self] in
      removed = self?._images.remove(at: index)
    }

    return removed
  }
}

Deadlock

  • 서로의 리소스를 기다리느라 작업이 영원히 대기 상태로 빠지는 현상
DispatchQueue.global().async {
    DispatchQueue.global().sync {
        // 금지
    }
}
  • 위와 같이, 현재 Queue와 동일한 Queue에 sync로 작업을 보내면 데드락 발생 가능
// 메인 쓰레드
DispatchQueue.main.sync {
    // 금지
}
  • Main Thread에서 DispatchQueue.main.sync 사용하면 데드락 발생 가능

Priority Inversion

  • 우선순위가 높은 큐의 작업이, 우선순위가 낮은 큐의 작업 때문에 뒤로 밀리는 상황
  • .userInteractive(높음) 큐와 .utility(낮음) 큐가 같은 리소스를 잠근 경우
  • 먼저 잠근 쪽이 낮은 우선순위 큐라면, 높은 우선순위 작업도 잠금이 풀릴 때까지 대기해야 함
let high = DispatchQueue.global(qos: .userInteractive)
let low = DispatchQueue.global(qos: .utility)

let semaphore = DispatchSemaphore(value: 1)

high.async {
    Thread.sleep(forTimeInterval: 2)
    semaphore.wait()
    defer { semaphore.signal() }

    print("High priority task is now running")
}

low.async {
    semaphore.wait()
    defer { semaphore.signal() }

    print("Running long, lowest priority task")
    Thread.sleep(forTimeInterval: 5)
}
  • 우선순위 역전을 방지하기 위해, 정말 필요한 경우가 아니면 서로 다른 QoS끼리 자원을 공유하는 상황을 피해야함
  • 높은 우선순위가 필요한 작업은 따로 전용 큐로 분리

기타 주의사항

  • UI 업데이트는 반드시 Main Thread에서 이루어져야 함
DispatchQueue.main.async {
    // UI 업데이트
}
  • Main Queue에서 다른 Queue로 작업을 보낼 때 sync 사용 금지
  • 버벅이는 현상 발생 가능
// 메인 큐
DispatchQueue.global().sync {
    // 금지
}
  • 객체에 대한 캡처 현상 주의, retain cycle 발생 가능
DispatchQueue.global().async { [weak self] in
    // 캡처 방지
}

참고