동시성 다이브 - 2. GCD 도입
🧬 동시성 다이브 시리즈
GCD
GCD는 iOS 4(2010)에 도입된 Task 기반 동시성 API로, Grand Central Dispatch의 약자입니다.
의미만 보자면,
크게, 중앙에서, 배치하여 관리한다
즉 “시스템 전체에서 작업을 중앙에서 관리하고, 스레드에 적절하게 배치하여 실행시킨다” 라고 해석할 수 있다.
개발자가 스레드를 직접 생성하거나 관리하지 않고 작업을 DispatchQueue에 할당하면, 스케줄러가 적절하게 스레드에 분배시켜 줍니다.
DispatchQueue
GCD에서 작업을 순서대로 처리하기 위해 제공하는 Queue 객체로, 작업을 할당하면 시스템이 알아서 스레드에 분배하도록 설계되어 있습니다.
- Main Queue
- Serial Queue (= 직렬 큐)
- 단 하나만 존재하며, 메인 스레드에 바인딩되어 있어 할당된 작업들은 모두 메인 스레드에서 실행됩니다.
- UI 업데이트 동작은 메인 스레드에서 실행되어야 하기 때문에 메인 큐에 할당합니다.
- Global Queue
- Concurrent Queue (= 동시 큐)
- 할당된 작업들을 여러 스레드에 분산시켜 병렬로 처리합니다.
- 동시에 실행되기 때문에 작업 순서가 보장되지 않습니다.
- QoS 속성을 통해 실행 방식을 설정할 수 있으며, 속성값 별 독립된 큐로 존재합니다.
- Custom Queue(Private Queue)
- 기본값은 Serial Queue이지만, Concurrent Queue로도 설정할 수 있습니다.
- 직접 고유의 라벨을 작성해 생성합니다.
- 순서가 중요한 작업이나 race condition을 방지해야 할 때 Custom Serial Queue를 활용합니다.
Serial vs Concurrent
- Serial Queue
- 작업을 하나씩 순서대로 실행하기 때문에 앞선 작업이 끝나야 다음 작업이 시작되며, 실행 순서가 확실하게 보장됩니다.
- 공유 상태를 보호해야 할 때나 작업 순서 보장이 필요할 때 사용합니다.
- Concurrent Queue
- 여러 작업을 동시에 실행할 수 있기 때문에 시작 순서만 보장되고 종료 순서는 보장되지 않습니다.
- 네트워크나 계산 작업처럼, 병렬 처리로 성능 이득이 있는 경우 사용합니다.
QoS(Quality of Service)
- 스케줄러가 스레드에 작업을 할당할 때 요청할 실행 방식을 결정하는 속성입니다.
- 작업에 부여한 QoS에 따라 아래 항목의 기준이 설정됩니다.
- CPU 점유율 : 스레드가 CPU를 점유할 수 있는 시간과 낮은 QoS 스레드를 멈추고 먼저 실행될 수 있는 권한 여부
- I/O 우선처리 : 디스크 읽기/쓰기 작업이 필요할 때, 즉시 처리할지 지연(Throttle)시킬지
- 에너지 효율 정책 : 어떤 코어로 작업을 실행할지 (P-core vs E-core)
- 우선순위 (높은순)
- userInteractive : 메인 스레드에서 실행됨, 애니메이션, UI 이벤트(유저와의 상호작용 이벤트)
- userInitiated : 저장된 문서 열기, 클릭 후 데이터 로드
- default : 일반 작업
- utility : 네트워크, 긴 작업
- background : 백업, 인덱싱, 동기화 (백그라운드 실행과는 다릅니다)
Async vs Sync
- Async : 작업을 큐에 할당하면 즉시 반환되어 다음 코드라인을 실행하기 때문에, 호출부가 실행되고 있는 현재 스레드가 멈추지 않습니다.
- Sync : 큐에 보낸 작업이 완전히 끝날 때까지 다음 코드라인으로 넘어가지 않아, 현재 스레드를 block 시킵니다.
주의사항
DispatchQueue.main.sync { ... } 와 같이,Main Queue에서 Sync를 사용하게 되면 어떤 일이 발생할까요?
Dead Lock이 발생할 수 있습니다.
메인 스레드에서 이미 작업이 실행되고 있는데 Main Queue + Sync 작업을 마주하게 됐을 때 발생합니다.
메인 큐는 Serial Queue이기 때문에 메인 스레드에서 할당된 작업을 완료해야 다음 작업을 할당하고 실행할 수 있는데, 실행 중이었던 작업이 완료되지 않은 상태에서 다음에 실행되어야 할 작업을 마주했기 때문에 자기 자신의 작업 종료를 기다림으로써 무한 대기 현상이 발생합니다.
샘플 코드
이해를 돕기 위한 샘플 코드입니다.
override func viewDidLoad() {
super.viewDidLoad()
print("📍 [Step 1] viewDidLoad 실행 중 (메인 스레드 점유 중)")
// ☠️ 문제의 코드: Main Queue에 작업을 Sync로 보냄
DispatchQueue.main.sync {
print("📍 [Step 2] 이 코드는 실행될까?")
}
print("📍 [Step 3] viewDidLoad 끝")
}비동기 작업을 제어하고 관리하기 위한 도구
DispatchWorkItem
- DipatchQueue를 통해 클로저로 작업을 할당하지 않고,
DispatchWorkItem객체 인스턴스로 생성하여 할당합니다. - 인스턴스화를 통해 얻을 수 있는 이점이 있습니다.
- 취소 가능 : 큐에 할당했는데 아직 실행 전이라면 취소가 가능합니다.
- 순서 제어 : 큐에 할당한 작업 다음 다른 작업을 실행하도록 연결할 수 있습니다.
DispatchGroup
- 여러 비동기 작업들이 전부 끝날 때까지 기다리다가 “마지막에 딱 한 번” 알림으로써, 모든 비동기 작업이 완료될 때까지 기다리다가 다음 작업을 처리하고 싶을 때 사용할 수 있는 그룹 객체입니다.
- 사용 방법
enter(): 작업을 시작해야 할 때 직전에 호출leave(): 작업이 끝났을 때 호출notify(): 모든 비동기 작업이 끝났을 때 호출되는 콜백 메서드
- 주의할 점
- enter를 했는데 leave를 하지 않으면 notify가 영원히 호출되지 않을 것이고, enter 보다 leave를 많이 하면 크래시가 발생합니다.
동시성 문제 발생 및 해결
여러 스레드에서 동일한 공유 자원에 동시에 접근하게 되면 흔하게 발생할 수 있는 대표적인 동시성 문제가 있습니다.
“Race Condition(경쟁 상태)”
이 문제를 해결하기 위해 제공되는 도구들이 있습니다.
DispatchSemaphore
- 특정 코드 실행 구간에 대해 동시에 실행될 수 있는 작업의 수를 제한하는 동기화 도구입니다.
DispatchSemaphore(value:)를 통해 semaphore 인스턴스를 생성할 수 있으며,value파라미터를 통해 동시에 실행될 수 있는 작업의 수를 설정합니다.value == 1: 동시 실행 개수가 제한되는 작업을 “임계 구역”이라고 하며, Mutex 역할을 할 수 있습니다.value == N (N > 1): Throttling 역할을 할 수 있습니다.
wait()와signal()메서드를 통해 제한할 코드 실행 구간을 명시적으로 설정할 수 있습니다.wait(): 구간 시작점을 의미합니다.- semaphore 내부 카운트를 통해 동시 작업을 실행해도 되는지 확인하고, 이미 제한된 수만큼 작업이 실행되고 있다면 해당 스레드를 blocking 합니다.
그렇기 때문에 Main Thread에서 하게 되면 위험합니다. - 만약 제한된 수만큼 작업이 실행되고 있지 않다면 semaphore 내부 카운트를 -1 하고, 바로 작업이 실행됩니다.
- semaphore 내부 카운트를 통해 동시 작업을 실행해도 되는지 확인하고, 이미 제한된 수만큼 작업이 실행되고 있다면 해당 스레드를 blocking 합니다.
signal(): 구간의 끝을 의미합니다.- semaphore 내부 카운트를 원래대로 +1 합니다.
- “semaphore를 참조하는 특정 작업”이 동시에 실행되는 것을 안정적으로 제어하고자 사용하는 것이기 때문에, 작업 수 제한은 앱 전역에 영향을 주지 않습니다.
- 실무에서는 주로 네트워크 요청, 파일 I/O, CPU 부하가 큰 작업 등에서 재사용 메서드 내부의 실제 실행 구간에 동시성 제한을 두는 용도로 활용됩니다.
Dispatch Barrier
- Concurrent Queue를 사용하면서 race condition을 방지하기 위해, 직렬 큐처럼 작업을 직렬화하는 속성입니다.
- 주로 Concurrent Queue를 사용하면서 “읽기(Read)는 동시에, 쓰기(Write)는 한 번에 하나씩” 처리하고 싶을 때 사용합니다. (Read-Write Lock 패턴)
.async(flags: .barrier)와 같이 속성을 설정합니다.
- barrier 작업은 동일한 큐에 이미 할당된 작업들이 모두 끝난 뒤 실행되며, barrier 작업이 끝날 때까지 이후 작업들은 대기합니다.
- 같은 큐에서 할당된 작업만을 취급하기 때문에, 다른 큐에서 동일한 자원에 접근함으로써 발생할 수 있는 race condition은 barrier flag로 방지할 수 없다는 한계점이 있습니다.
- 동일한 자원에 접근하는 작업을 “자원 접근 전용 큐”에 할당하여 접근을 직렬화하면, 위와 같은 한계점을 구조적으로 해결할 수 있습니다.