GCD는 애플에서 제공하는 유용한 비동기 프로그래밍 라이브러리 이다. iOS, macOS, WatchOS 등 많은 애플 제품에서 사용된다. GCD는 멀티코어, 멀티 스레딩 환경에서 코드를 더 효과적으로 실행하기 위해 설계되었다. GCD는 작업(Task)을 적절한 스레드나 프로세서 코어에 분배하여 동시성 및 병렬 처리를 단순화하고 최적화한다.
성능 향상: 앱은 빠른 반응성을 요구한다. 네트워크 요청, 데이터 처리 등 시간이 많이 소요된는 작업을 비동기적으로 처리하여 사용자 인터페이스가 블록 되지 않게 하여 반응성을 향상 시킨다.
자원 활용: 멀티 코어 프로세서를 효율적으로 활용하기 위해 여러 스레드에서 동시작업을 수행해야 한다. GCD를 통해 간편하게 자동으로 적절한 수의 스레드 관리가 가능하다.
코드 간소화: 스레드를 직접 관리하는 것은 복잡하고 많은 에러가 발생했다. GCD는 이러한 복잡성을 추상화 하여 개발자가 직접 스레드를 관리하지 않고 비동기 코드를 더 쉽게 작성하도록 한다.
요즘 대부분의 디바이스는 멀티코어 프로세서를 가지고 있다. 각 코어는 독립적으로 작동하기 때문에 다양한 작업을 동시에 처리 가능하다. 하지만 이러한 자원을 제대로 활용하기 위해서는 효과적인 병렬 프로그래밍 방식이 필요했다. (아무리 코어가 많아도 하나의 코어만 사용하면 다른 코어들은 낭비)
GCD는 이러한 멀티코어 환경에서 여러 코어에 자동으로 작업을 분배하여 애플리케이션의 성능이 최적화 되고, 하드웨어 리소스를 효율적으로 활용할 수 있다.
결국 GCD는 다루기 어렵고 복잡한 동시, 병렬, 비동기 등의 처리를 자동으로 조절해주며 현대의 진보된 기술력을 최대한 활용 가능하게 해준다.
디스패치큐는 GCD의 핵심 구성요소로 작업을 수행할 대기열(Queue)이다.
위의 GCD에 대한 설명을 작업(Task)을 적절한 스레드로 분배한다고 하였는데 어떻게 하면 될까?
그냥 Queue에 보내기만 하면 된다. iOS는 Queue에 있는 작업들을 알아서 적절한 스레드로 분산처리 한다. (DispatchQueue의 메서드를 통해 내부블럭에 수행할 작업(Task) 작성해주면 됨)
개발자가 동기/비동기만 정해주면 내부적으로 어떤 스레드가 생성되고 어떤 스레드에 분배되는지 신경쓸 필요가 없다.
큐에 추가된 작업은 FIFO (First-In, First-Out) -> 먼저 추가된 작업이 먼저 실행된다. 하지만 먼저 시작했다고 해서 먼저 완료되는 것은 아니다.
Main Queue: 주로 UI관련 작업, 말 그대로 애플리케이션의 메인 스레드에서 실행되는 큐
Global Queue: 백그라운드 작업, 여러 우순선위를 갖는 병렬 큐
Custom Queues: 사용자가 직접 생성, 직렬 또는 병렬로 설정 가능
Main Queue와 Global Queue와 다르게 Custom Queues는 직렬 or 병렬 선택?
Main Queue는 UI 업데이트와 관련된 작업이 순차적으로 실행되어야 함을 보장하기 위해 iOS 시스템 자체적으로 직렬큐로 정의되어 있다, Global Queue는 다양한 QoS(Quality of Service) 수준을 갖는 병렬큐로 정의되어 있다.
반면 커스텀 큐는 사용자가 정의해야 한다.
Main Queue와 Global Queue 예시 코드
let mainQueue = DispatchQueue.main
let backgroundQueue = DispatchQueue.global(qos: .background)
Custom Queues 예시 코드
// 직렬큐
let serialQueue = DispatchQueue(label: "com.example.MySerialQueue")
serialQueue.async {
sleep(2) // 2초 동안 대기
print("Task 1")
}
serialQueue.async {
print("Task 2")
}
// 출력:
// (2초 대기 후) Task 1
// Task 2
// 병렬큐
let concurrentQueue = DispatchQueue(label: "com.example.MyConcurrentQueue", attributes: .concurrent)
concurrentQueue.async {
sleep(2) // 2초 동안 대기
print("Task 1")
}
concurrentQueue.async {
print("Task 2")
}
// 가능한 출력:
// Task 2
// (2초 대기 후) Task 1
위 코드에서 직렬큐를 보면 비동기적으로 작업을 큐에 보냈지만 직렬방식으로 작업을 처리하기 때문에 먼저 실행되는 작업이 완료될 때까지 다음 작업이 실행되지 않고 있다.
Dispatch Queue를 사용여 작업을 큐에 보낼 때 동기적으로 작동할지 비동기적으로 작동할지 선택해야 한다.
Dispatch Queue를 사용할때의 관점으로 알아보자
DispatchQueue.main.async {
// 메인 큐에서 비동기로 실행될 코드
}
DispatchQueue.main.sync {
// 메인 큐에서 동기로 실행될 코드
}
DispatchQueue.globar(qos: .backgound).async {
// 백그라운드에서 비동기로 실행될 코드
}
데드락(DeadLock)
교착상태라고도 부름, Main Queue에서 동기(sync)적으로 호출된 다른 작업을 실행 할 때 해당 작업 내에서 다시 Main Queue에 접근하려고 할 때 발생한다. 해당 작업이 완료되길 기다리고 있는 상태에서 또 다른 작업을 비동기적으로 실행시키면 두 작업 모두 완료되지 않아 대기 상태에 빠지게 되고 앱이 멈춰버린다.
UI블로킹(Blocking)
메인 큐에서 시간이 오래 걸리는 동기 작업을 수행하면, 해당 작업이 완료될 때까지 메인에서 진행되는 UI를 비롯한 작업들이 처리되지 않고 대기하게 되고 사용자는 앱이 뭠췄다고 느끼게 됨
비효율적인 리소스 사용
너무 많은 비동기 작업은 오히려 앱의 성능 저하나 여러 문제점을 초래함, 시스템 리소스(메모리, CPU)등 과도하게 사용될 수 있음
비동기 작업은 작업실행 순서를 보장하지 않는다, 하지만 때로는 작업실행 순서가 보장되지 않는 비동기 작업들이 모두 완료된 후 특정 이벤트를 처리하고 다음 단계로 넘어가야 하는 경우가 있다. 이런 경우 DispatchGroup을 사용하여 비동기 작업들 간 동기화가 가능하다.
let group = DispatchGroup()
let queue = DispatchQueue.global(qos: .background)
queue.async(group: group) {
// 작업 1
}
queue.async(group: group) {
// 작업 2
}
group.notify(queue: .main) {
// 모든 작업이 완료된 후 실행될 코드
}
DispatchGroup을 사용하면 여러 비동기 작업들 사이의 동기화와 작업 완료를 효과적으로 관리할 수 있습니다.
좀더 복잡한 경우 enter()와 leave()메서드를 사용할 수 있다.
let group = DispatchGroup()
let queue = DispatchQueue.global(qos: .background)
group.enter()
queue.async {
// 작업 1
group.leave()
}
group.enter()
queue.async {
// 작업 2
group.leave()
}
group.enter()
queue.async {
// 작업 3
group.leave()
}
group.notify(queue: .main) {
// 모든 작업이 완료된 후 실행될 코드
}
enter()가 호출되면 내부적으로 카운트카 1이 증가하고 leave()메서드가 호출되면 카운트가 1 감소한다. 카운트가 0이되면 notify메서드가 호출된다.
이전에 과도하게 비동기처리를 많이하면 오히려 성능 저하가 올 수 있다고 했다.
DispatchSemaphore는 기본적으로 세마포어(semaphore)를 GCD에서 사용할 수 있도록 한것이라고 한다. 값을 설정하면 값보다 더 많은 스레드가 자원에 접근하려고 하면, 초과된 스레드들은 대기 상태가 된다. 즉 동시에 사용 가능한 스레드 수를 제어하는 것이다.
wait()와 signal() 메서드의를 통해 제어가능하다. 원리를 보자
semaphore.wait(): 작업 전에 작성한다.. 세마포어 값이 0이하라면 대기 한다. 만약 1 이상이면 1을 감소시킨다.
semahpore.sinnal(): 작업 완료 후에 작성한다.. 세마포어 값을 1 증가시킨다.
즉 지정한 값만큼만 동시작업이 가능해진다.
예시 코드
let semaphore = DispatchSemaphore(value: 2)
DispatchQueue.global().async {
semaphore.wait() // 세마포어 대기
// 작업 1 실행
semaphore.signal() // 세마포어 신호 전송
}
DispatchQueue.global().async {
semaphore.wait() // 세마포어 대기
// 작업 2 실행
semaphore.signal() // 세마포어 신호 전송
}
DispatchQueue.global().async {
semaphore.wait() // 세마포어 대기
// 작업 3 실행
semaphore.signal() // 세마포어 신호 전송
}
해당 코드는 3가지의 비동기 작업이 있지만 DispatchSemaphore의 값이 2 이기 때문에 작업 1과 작업 2가 비동기적으로 먼저 실행되고 작업 3은 대기 상태를 유지하다가 작업1 또는 작업2 중 완료된 작업이 생기면 작업 3은 작업을 시작한다.
작업을 캡슐화하여 다양한 큐에 재사용할 수 있다.
let workItem = DispatchWorkItem {
// 작업 코드
}
DispatchQueue.global().async(execute: workItem)
workItem.cancel()
workItem.notify(queue: .main) {
// 작업이 완료된 후 실행될 코드
}
작업을 지연하거나 특정 시간에 실행하도록 예약할 때 사용, 높은 절밀도로 시간을 나타냄, 특정 지연 후에 작업 큐 예약 가능
let futureTime = DispatchTime.now() + .seconds(3)
DispatchQueue.global().asyncAfter(deadline: futureTime) {
// 3초 후에 실행될 작업
}
GCD는 복잡한 동시성 문제를 간소화하여 개발자에게 사용하기 간편하며 강력한 기능들을 제공하고 있다. 편리한 기능이지만 주의할 점도 있다. 아직 많이 사용해보지 않아 주의점에 대해서는 간단하게만 다루었다. 앞으로 직접 사용해보면서 익숙해지면서 주의점에 대해서도 알아가야 겠다.