iOS GCD Concurrency

Concurrency

필요성

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

사용법

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

Sync vs Async

  • Sync: Queue로 보낸 작업이 완료 될 때까지 기다림
swift-concurrency
let queue = DispatchQueue.global()

queue.sync { // 1초 task }
queue.sync { // 2초 task }
queue.sync { // 3초 task }

// 총 6초 더 걸림 (약 6.001초 걸림)
  • Async: Queue로 보낸 작업을 기다리지 않고 다음 코드 실행
swift-concurrency
let queue = DispatchQueue.global()

queue.async { // 1초 task }
queue.async { // 2초 task }
queue.async { // 3초 task }

// 총 6초 안 걸림 (약 0.001초 걸림)

Serial vs Concurrent

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

GCD

swift-concurrency

GCD란

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

기본 요소들

  • Queue: FIFO 대기 행렬
  • GCD: 관리자
  • Thread: 노동자
  • Task: 작동할 코드 블럭

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)

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

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)

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

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

Operation

Operation

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

GCD vs Operation

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

작업 상태 관리

  • isReady: 작업이 실행 준비가 되었는지
  • isExecuting: 현재 실행 중인지
  • isFinished: 작업이 완료되었는지
  • isCancelled: 작업이 취소되었는지
let operation = BlockOperation {
    print("작업 실행 중...")
}

print(operation.isReady)     // true
print(operation.isExecuting) // false
print(operation.isFinished)  // false

의존성 관리

let downloadOperation = BlockOperation {
    print("이미지 다운로드")
    Thread.sleep(forTimeInterval: 2)
}

let filterOperation = BlockOperation {
    print("이미지 필터 적용")
    Thread.sleep(forTimeInterval: 1)
}

let uploadOperation = BlockOperation {
    print("이미지 업로드")
    Thread.sleep(forTimeInterval: 1)
}

// 의존성 설정: 다운로드 → 필터 → 업로드 순서로 실행
filterOperation.addDependency(downloadOperation)
uploadOperation.addDependency(filterOperation)

let queue = OperationQueue()
queue.addOperations([uploadOperation, filterOperation, downloadOperation], waitUntilFinished: true)

동시 실행 수 제한

let queue = OperationQueue()
queue.maxConcurrentOperationCount = 3 // 최대 3개 작업만 동시 실행

큐 일시 중지 및 재개

queue.isSuspended = true  // 큐 일시 중지
queue.isSuspended = false // 큐 재개

작업 취소

// 모든 작업 취소
queue.cancelAllOperations()

// 특정 작업 취소
operation.cancel()

Custom Operation

  • 복잡한 작업을 위해 Operation을 상속받은 커스텀 클래스를 만들 수 있음
class ImageProcessingOperation: Operation {
    private let imageURL: URL
    private var task: URLSessionDataTask?
    
    init(imageURL: URL) {
        self.imageURL = imageURL
        super.init()
    }
    
    override func main() {
        // 취소 상태 확인
        guard !isCancelled else { return }
        
        // 실제 작업 수행
        let semaphore = DispatchSemaphore(value: 0)
        
        task = URLSession.shared.dataTask(with: imageURL) { data, response, error in
            defer { semaphore.signal() }
            
            guard !self.isCancelled else { return }
            
            if let data = data {
                print("이미지 다운로드 완료: \(data.count) bytes")
            }
        }
        
        task?.resume()
        semaphore.wait()
    }
    
    override func cancel() {
        task?.cancel()
        super.cancel()
    }
}

주의사항

Main Thread

  • UI 업데이트는 반드시 Main Thread에서 이루어져야 함
DispatchQueue.main.async {
    // UI 업데이트
}
  • Main Queue에서 다른 Queue로 작업을 보낼 때 sync 사용 금지
  • 버벅이는 현상 발생 가능
// 메인 쓰레드

DispatchQueue.global().sync {
    // 금지
}

Retain cycle

  • 객체에 대한 캡처 현상 주의, retain cycle 발생 가능
DispatchQueue.global().async { [weak self] in
    // 캡처 방지
}

Deadlock

  • 데드락(= 교착 상태)이란 서로의 리소스를 기다리느라 작업이 영원히 대기 상태로 빠지는 현상
  • Main Thread에서 DispatchQueue.main.sync 사용하면 데드락 발생
// 메인 쓰레드

DispatchQueue.main.sync {
    // 금지
}
  • 현재와 같은 Queue에 sync로 작업을 보내면 안됨
  • Serial Queue에서 자기 자신에게 sync로 작업을 보내면 데드락 발생 가능
let serialQueue = DispatchQueue(label: "com.example.serial")

serialQueue.async {
    serialQueue.sync {
        // 데드락 발생: Serial Queue에서 자기 자신에게 sync 작업 전송
    }
}

Race Condition

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

Priority Inversion

  • 우선순위가 높은 큐의 작업이, 우선순위가 낮은 큐의 작업 때문에 뒤로 밀리는 상황
  • .userInteractive(높음) 큐와 .utility(낮음) 큐가 같은 리소스를 잠근 경우
  • 먼저 잠근 쪽이 낮은 우선순위 큐라면, 높은 우선순위 작업도 잠금이 풀릴 때까지 대기해야 함
  • 우선순위 역전을 방지하기 위해, 정말 필요한 경우가 아니면 서로 다른 QoS끼리 자원을 공유하는 상황을 피해야함
  • 높은 우선순위가 필요한 작업은 따로 전용 큐로 분리
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)
}

참고