본문 바로가기

학습 노트/iOS (2021)

173. GCD #1 (Grand Central Dispatch)

Grand Central Dispatch

GCD는 Thread를 자동으로 생성하고, 효율적으로 관리하는 역할을 한다.
Thread Pool을 사용하기 때문에 자원을 최대한 적게 사용하면서 빠른 성능을 제공한다.
또한, 직관적이고 단순한 API를 제공한다.

GCD는 모든 애플 기기들에서 동일하게 사용할 수 있다.

GCD의 핵심 객체는 Dispatch Queue이다.
Task를 추가할 때는 Block의 형태로 추가하거나, Dispatch Work Item으로 Capsule화 해서 추가한다.
FIFO 방식으로 Task를 관리하고, 환경과 방식에 따라 실행 순서를 제어한다.
이러한 모델을 Work-Queu Programming Model이라고 부르기도 한다.

Dispatch Queue는 Task를 실행하는 방식에 따라 Concurrent Queue와 Serial Queue로 구분된다.

Concurrent Queue
Queue에 추가된 작업을 동시에 실행한다.
동시에 실행되는 작업의 수는 상태에 따라 자동으로 결정된다.
Concurrent Option을 통해 직접 생성하거나 Global Dispatch Queue를 사용할 수 있다.

Serial Queue
Queue에 추가된 순서대로 하나씩 실행한다.
하나 이상의 작업이 동시에 실행되지 않기 때문에, Queue기반 동기화에 사용된다.
새로운 Dispatch Queue를 생성하면 이러한 Serial Queue로 생성된다.
Main Queue는 Main Thread에서 동작하는 특별한 Serial Queue로 앱이 시작될 때 자동으로 생성되고,
속성을 통해 언제든 사용할 수 있다.
UI 업데이트 코드는 반드시 이 Main Queue에서 실행되어야한다.

Dispatch Queue의 우선순위
Dispatch Queue의 우선순위는 QOS를 따른다.

  • userInteractive
  • userInitiated
  • utility
  • background

위의 4 단계로 구분하고,
우선순위가 높을수록 자원을 더 오래 점유할 수 있고, 더 빠르게 실행된다.

//
//  DispatchQueueViewController.swift
//  Concurrency Practice
//
//  Created by Martin.Q on 2021/12/23.
//

import UIKit

class DispatchQueueViewController: UIViewController {
	
	@IBOutlet weak var valueLabel: UILabel!
	
	@IBAction func basicPattern(_ sender: Any) {
		var total = 0
		for num in 1...100{
			total += num
			Thread.sleep(forTimeInterval: 0.1)
		}
		valueLabel.text = "\(total)"
	}
	
	@IBAction func sync(_ sender: Any) {
	}
	
	@IBAction func async(_ sender: Any) {
	}
	
	@IBAction func delay(_ sender: Any) {
	}
	
	@IBAction func concurrentIteration(_ sender: Any) {
	}
	
	override func viewDidLoad() {
        super.viewDidLoad()
    }

}

버튼을 누르면 1에서 100까지 순회하며 값을 더하고, 이를 Label에 출력한다.

하지만 이 경우 코드는 실행되지만 Label도 업데이트 되지 않고 반응을 멈춘다.

이유는 Action 메소드가 Main Queue에서 코드를 실행하고, 0.1초 * 100 = 10초 로 총 10초의 시간동안,
버튼의 코드가 Main Queue를 점거해 다른 이벤트를 처리할 수 없게 만든다.
따라서 매우 빠르게 실행되거나 UI를 업데이트 하는 코드 외에는 Background Queue에서 동작하도록 구현해야한다.

@IBAction func basicPattern(_ sender: Any) {
	DispatchQueue.global().async {
		var total = 0
		for num in 1...100{
			total += num
			Thread.sleep(forTimeInterval: 0.1)
		}
		DispatchQueue.main.async {
			self.valueLabel.text = "\(total)"
		}
	}
}

수정한 코드는 위와 같다.
Background Queue에서 동작할 코드를 global.async 내에 작성하고,
UI를 업데이트 하거나 Frame을 재설정 하는 등의 코드는 해당 블록 내에 main.async 블록을 만들어 작성한다.
이 방식이 가장 기본적인 방식이다.

이제 Background Queue에서 작업이 실행되는 동안에도 UI들이 정상적으로 반응한다.

DispatchQueue.global()은 시스템 소유의 Background Queue이고, DispatchQueue.main은 Main Queue이다.

DispatchQueue 직접 생성하기.

let serialQ = DispatchQueue(label: "SerialQ")

Serial Queue는 위와 같은 방식으로 생성한다.
전달한 label 파라미터는 해당 Queue를 구분하기 위한 식별자의 역할을 한다.
기본값이 SerialQueue이기 때문에 별도의 작업을 하지 않아도 간단하게 생성할 수 있다.

let concurrentQ = DispatchQueue(label: "concurrentQ", qos: , attributes: .concurrent, autoreleaseFrequency: DispatchQueue.AutoreleaseFrequency, target: DispatchQueue?)

Concurrent Queue를 생성하는 메서드는 다양한 파라미터를 사용한다.
label을 제외한 모든 파라미터는 기본 값을 가지고 있고, label만 전달한다면 SerialQueue를 생성하는 메서드와 동일해진다.
이 중 attribute 파라미터만 concurrent로 변경하면 Concurrent Queue를 생성할 수 있다.

let concurrentQ = DispatchQueue(label: "concurrentQ", attributes: .concurrent)

따라서 위와 같은 형태가 된다.

생성한 Queue에 작업을 추가하기

생성한 Queue에 작업을 추가하는 방법은 두가지가 존재한다.

@IBAction func sync(_ sender: Any) {
	concurrentQ.sync {
		for _ in 1...3 {
			print("hello")
		}
		print("check 1")
	}
	
	print("check 2")
}
@IBAction func async(_ sender: Any) {
	concurrentQ.async {
		for _ in 1...3 {
			print("hello")
		}
		print("check 1")
	}
	
	print("check 2")
}

위의 sync와 async 메소드는 작업을 Queue에 추가하는 메서드이지 실행하는 메서드가 아니다.

둘은 서로 다른 방식으로 Queue에 추가했기 때문에 결과도 다르다.

왼쪽의 Sync 방식은 hello가 출력된 뒤 check 1, check 2 순서로 출력이 된다.
Sync 방식은 동기 방식으로 작업을 추가한 뒤 즉시 반환하지 않고, 완료될 때 까지 대기한 후 반환한다.
해당 방식은 Lock과 유사한 기능을 구현할 때 사용한다.
해당 메소드를 Main Queue에서 호출하면 충돌이 일어나므로 주의해야한다.

오른쪽의 Asysnc 방식은 check 2가 가장 먼저 출력된다.
Async 방식은 비동기 방식으로 작업을 추가한 다음 즉시 반환한다.
어떤 Queue에서 호출하더라도 Thread를 Blocking 하지 않기 때문에 가장 많이 쓰이는 방식이다.

단, 이 둘이 Dispatch Queue의 동작 방식에는 영향을 끼치지 않는다.
즉, 동기방식으로 메서드를 구현했다고 Concurrent Queue가 Serial Queue와 같은 방식으로 동작하지는 않는다는 의미이다.
여전히 여러 작업을 동시에 실행할 수 있으며, 다른 작업이 추가되면 현재 실행중인 작업과 동시에 실행되게 된다.

작업 지연시키기

@IBAction func delay(_ sender: Any) {
	let delay = DispatchTime.now() + 3
	
	concurrentQ.asyncAfter(deadline: delay) {
		print("check 1")
	}
	print("check 2")
}

Dispatch Queue에서 사용하는 시간의 형식은 DispatchTime이다.
Queue의 ayncAfter 메서드를 사용해 다양한 방식으로 지연시키는 것이 가능하다.
지금은 지정한 시간 만큼 지연시킨 뒤 로그를 출력한다.

결과는 위와 같다.
Async 방식이기 때문에 Queue에 추가됨과 동시에 반환이 이뤄지고,
이에 따라 check 2가 먼저 출력된다.
이후 3초 지연된 check 1이 이어서 출력된다.

반복 처리하기

@IBAction func concurrentIteration(_ sender: Any) {
	var start = DispatchTime.now()
	for index in 0..<20 {
		print(index, separator: " ", terminator: " ")
		Thread.sleep(forTimeInterval: 0.2)
	}
	var end = DispatchTime.now()
	print(Double(end.uptimeNanoseconds - start.uptimeNanoseconds) / 1000000000)
}

일반적인 for 반복문을 통해 0부터 19까지 0.2초의 지연으로 값을 출력한다.
또한 시작하기 전의 현재시간과 끝난 후의 현재 시간을 사용해 작업에 걸린 시간을 계산해 출력하도록 돼있다.

작업에 소요된 시간은 4초 정도이다.

만약 작업이 순서대로 종료되지 않아도 되고, 그저 빨리만 끝나면 된다면 병렬처리를 통해 속도를 높힐 수 있다.

@IBAction func concurrentIteration(_ sender: Any) {
	var start = DispatchTime.now()
	for index in 0..<20 {
		print(index, separator: " ", terminator: " ")
		Thread.sleep(forTimeInterval: 0.2)
	}
	var end = DispatchTime.now()
	print(Double(end.uptimeNanoseconds - start.uptimeNanoseconds) / 1000000000)
	
	start = DispatchTime.now()
	DispatchQueue.concurrentPerform(iterations: 20) { (index) in
		print(index, separator: " ", terminator: " ")
		Thread.sleep(forTimeInterval: 0.2)
	}
	end = DispatchTime.now()
	print(Double(end.uptimeNanoseconds - start.uptimeNanoseconds) / 1000000000)
}

concurrentPerform 메소드는 첫번째 파라미터로 전달된 범위 에서 자원이 허락하는 만큼  병렬로 전달되는 코드를 처리한다.
똑같이 0부터 19까지의 값을 출력하고, 완료되면 작업에 소요된 시간을 출력하도록 구현했다.

순서는 뒤죽박죽이지만 같은 작업에 소요되는 시간이 획기적으로 줄어들었다.

단, 이전에 언급한 대로, 작업의 순서에 상관 없이 속도를 우선으로 하기 때문에,
순서에 민감한 경우 사용하면 안된다는 점을 명심해야한다.