iOS GCD Concurrency
동시성 프로그래밍
필요성
- 가장 강력한 쓰레드인 Main Thread라고 해도 과도한 작업을 할당하면 성능 저하 발생
- 앱의 빠른 속도와 성능을 원한다면, 작업을 적절히 여러 쓰레드로 분산시켜야 함
사용법
- 기본적으로 GCD 혹은 Operation을 통해 동시성 프로그래밍 사용할수 있음
- Swift 5.5부터 언어단에서 동시성 프로그래밍을 제공하는 Swift Concurrency도 있음
- RxSwift 혹은 Combine 같은 반응형 프레임워크를 통해서도 동시성 프로그래밍을 할 수 있음
Sync vs Async
- Sync: Queue로 보낸 작업이 완료 될 때까지 기다림
- Async: Queue로 보낸 작업을 안 기다리고 다음 코드 실행, 끝나기를 기다리지 않음
Serial vs Concurrent
- Serial queue: 한개의 쓰레드에서 전부 진행, 순서 보장됨. 순차적 실행이 필요한 작업에 적합.
- Concurrent queue: 여러개의 쓰레드에서 진행, 종료 순서 보장되지 않음. 빠른 처리가 필요한 독립적인 작업에 적합.
GCD vs Operation
GCD: Grand Central Dispatch
- 앞서 말했듯이, iOS GCD는 동시성 프로그래밍을 할 수 있는 한 가지 방법
- 작업을 Dispatch Queue에 추가하면 GCD가 자동으로 쓰레드를 생성하고 실행
- GCD는 작업이 완료되면 쓰레드를 자동으로 제거까지 해줌
Operation
- GCD 기반으로 동작하는 상위 레벨 프레임워크
- 동시에 실행할 수 있는 동작의 최대 수 지정 가능
- 동작 일시 중지 및 취소 가능
- 작업 간 의존성 설정 가능
GCD vs Operation
- GCD: 가볍고 효율적인 구현, 간단한 작업에 적합, 빠른 성능
- Operation: 복잡한 작업 관리, 작업 상태 모니터링, 작업 간 의존성 관리, 작업 취소 및 재시도 기능
GCD
기본 개념
- 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
// 캡처 방지
}