iOS Concurrency: GCD - 2. 본문

J.Noma·2021년 12월 19일
0

iOS : 동시성

목록 보기
2/3

내용전반: iOS개발자 앨런
내용전반: 동시성 프로그래밍
내용전반: naljin님 블로그
내용전반: LINE Engineering - Swift Concurrency에 대해서
메인스레드와 UI작업: iOS: Why the UI need to be updated on Main Thread


학습목표

  • Dispatch Queue의 3가지 종류와 각각의 특성에 대해 이해합니다
  • QoS 등 Dispatch Queue에 어떤 설정들을 할 수 있는지 공부합니다
  • Dispatch Queue를 사용할 때 유의사항을 이해합니다
  • Dispatch Group의 개념과 사용법을 이해합니다
  • Dispatch WorkItem의 용도를 이해합니다
  • Dispatch Semaphore의 메커니즘과 용도를 이해합니다

Remind List

  • UI 작업은 반드시 메인 큐에서 처리해야 한다
  • DispatchQueue의 생성자 파라미터로 .initiallyInactive라는 것도 있다
  • 메인 스레드에서 다른 큐로 작업을 보낼 때는 반드시 비동기로 보내야 한다
  • Dispatch Group 사용 시, 하위 비동기 작업이 있다면 전부 grouping 해야 한다
  • Dispatch WorkItem로 묶으면 간접적으로 취소/순서기능을 적용할 수 있다

🐶 DispatchQueue

✔️ DispatchQueue의 3가지 종류

우리는 동시 작업을 구현하기 위해 GCD의 큐에 작업을 넣어야 한다고 했습니다. 이 큐의 이름이 바로 DispatchQueue입니다. 그리고 GCD의 큐는 특성에 따라 크게 Serial/Concurrent 큐로 나뉠 수 있다고 했습니다. 이 분류 하에서 우리는 3가지 종류의 DispatchQueue를 사용할 수 있습니다. (이미 만들어진 2가지 + 우리가 직접 만드는 1가지)

▪️ 1. main Queue

이미 만들어진 큐 중 하나로 Serial한 특성을 갖습니다. 따라서 하나의 스레드를 대상으로 하는데 이 스레드가 바로 메인 스레드이기에 자신은 메인 큐가 됩니다. 메인 스레드는 별도의 처리를 하지 않은 모든 작업을 수행하는 스레드로 App구동에 반드시 필요합니다. 따라서, 메인 큐는 App과 함께 생성되며 App 내에 단 하나만 존재합니다

▪️ 2. global Queue

이미 만들어진 큐의 나머지 하나로 Concurrent한 특성을 갖습니다. 메인 큐처럼 막중한 임무를 갖고 태어났다기보다는 비동기처리를 위해 편하게 막 가져다 쓰는(?) 목적으로 만들어졌다고 합니다. 다만 주의할 점은, 그런 목적으로 인해 큐에 대해 우리가 정의하지 않는, 정의하지 못하는 설정들이 있기에 Dispatch Barrier 등과 같은 일부 기능을 사용할 수 없습니다

Concurrent 큐에는 6가지 종류의 QoS라는 개념이 있는데, global은 이 6가지 종류 각각에 대해 다른 큐 객체를 생성한다는 특징이 있습니다.

▪️ 3. Custom(private) Queue

커스텀 큐는 말 그대로 우리가 원하는대로 설정/생성할 수 있는 큐를 말합니다. Serial/Concurrent 등의 특성을 생성자 파라미터로 설정을 해줄 수 있습니다 (기본 설정은 Serial입니다). 커스텀 큐 혹은 프라이빗 큐라고 불립니다.

convenience init(
    label: String, 
    qos: DispatchQoS = .unspecified, 
    attributes: DispatchQueue.Attributes = [], 
    autoreleaseFrequency: DispatchQueue.AutoreleaseFrequency = .inherit, 
    target: DispatchQueue? = nil
)
  • label
    디버깅 환경에서 추적하기 위한 식별자같은 존재. 유일하게 default를 가지지 않아 반드시 설정해야 하는 파라미터입니다

  • qos
    큐의 QoS를 설정합니다. 별도로 설정하지 않으면 OS가 알아서 추론합니다

  • attributes
    .concurrent 혹은 .initiallyInactive를 줄 수 있습니다. 값을 주지 않으면 Serial입니다. (initiallyInactive는 activate() 메서드를 호출하기 전까지 작업을 처리하지 않도록 하는 설정입니다)

  • autoreleaseFrequency
    (작성중) 큐가 객체를 자동으로 해제(auto-release)하는 빈도?
    = .inherit: target과 동일
    = .workItem: workItem이 실행될 때마다 해제
    = .never: auto-release하지 않음

  • target
    (작성중) The target queue on which to execute blocks

Custom Queue 생성 예시


✔️ DispatchQueue 구현 예제

▪️ 1. 일반 사용 예제

//main Queue
DispatchQueue.main.async {
    ...
    ...
}

//global Queue
DispatchQueue.global().async {
    ...
    ...
}

//global Queue
let queue = DispatchQueue(label: "커스텀큐")

queue.async {
    ...
    ...
}

▪️ 2. UI 업데이트 예제

let queue = DispatchQueue(label: "imagetransfrom")

queue.async {
    let smallImage = image.resize(to: rect)
    
    DispatchQueue.main.async { 
        imageView.image = smallImage
    }
}

▪️ 3. 자체 비동기 함수 예제

// - runQueue : 현재 큐
// - completionQueue : 컴플리션핸들러를 수행할 큐
// - completion : 컴플리션핸들러 내용

func asyncTiltShift(
    _ inputImage: UIImage?, 
    runQueue: DispatchQueue, 
    completionQueue: DispatchQueue, 
    completion: @escaping (UIImage?, Error?) -> ()
) {
    runQueue.async {
        let outputImage = tiltShift(image: inputImage)
        
        completionQueue.async {
            completion(outputImage, .none)
        }
    }
}

✔️ QoS (Quality of Service)

WWDC: Building Responsive and Efficient Apps with GCD

▪️ 정의

작업의 우선순위를 뜻합니다. 높은 우선순위의 작업은 iOS가 중요한 일임을 인지하여 알아서 더 많은 스레드를 할당하고 스레드의 우선순위 또한 높혀 결론적으로 CPU/IO스케줄링 같은 '시스템 자원'에 대한 우선순위를 높혀 줍니다.

QoS는 async메서드/WorkItem을 통해 작업에 부여하거나 DispatchQueue에도 부여할 수 있습니다.

❗️동작예시: 높은 우선순위를 가진 큐

▪️ 큐와 작업의 QoS가 다른 경우

❗️if 작업.qos > 큐.qos { 큐의 우선순위가 '일시적으로'상승합니다 }
작업의 우선순위가 큐의 우선순위보다 높은 경우, 해당 작업을 수행하는 동안 큐의 우선순위가 일시적으로 상승합니다. 이후, 높은 우선순위의 작업들을 스레드로 모두 할당하고 나면 큐의 우선순위는 다시 되돌아옵니다

❗️if 작업.qos < 큐.qos { 작업의 우선순위가 상승합니다 }
반대로, 큐의 우선순위가 높은 경우는 작업의 우선순위가 큐와 동일하게 맞춰집니다

참고로, 세마포어나 그룹에는 이렇게 qos를 부여하고 우선순위를 elevating하는 개념이 없습니다. 큐와 작업 간에만 가능 (오역 여지가 있으므로 원본을 참고바랍니다)


✔️ DispatchQueue 사용 시 유의사항

▪️ 1. UI 관련 작업은 반드시 '메인 큐'에서 처리해야 합니다

iOS뿐만 아니라 모든 Apple 플랫폼에서 UI를 그리는 것은 메인 스레드가 담당합니다. 외부로부터 이미지를 받아오는 일은 다른 스레드에서 하더라도 이를 UI에 업데이트하는 것은 메인 스레드가 해야 합니다

이유1. UIKit은 Thread Unsafe합니다
기본적으로 Thread-safe하게 코드를 설계하는 것은 매우 비용이 많이 드는 일(빡셈)입니다. 더구나 UIKit 같은 거대한 프레임워크를 전부 Thread-safe하게 설계하는 것은 비현실적입니다. 게다가 UI 작업에 있어 Thread-safe하게 설계한다는 것은 단지 non-atomic을 atomic하게 만드는데서 그치는게 아니라 다양한 문제를 해결해야 합니다

그래서 Apple은 UI 작업을 '하나의 Serial Queue'에서 처리하도록 하는 방향으로 이를 해결합니다. 이 '하나의 Serial Queue'는 유저 이벤트를 처리하는 Main Run Loop가 돌아가는 메인 스레드(큐)를 지칭합니다.

이유2. 성능 상 비효율적입니다
iOS에서 UI 업데이트는 Run Loop에서 처리됩니다. 우선 Run Loop는 (1)외부 이벤트 수집, (2)이벤트 처리. 이 두 과정을 주기적으로 반복합니다. 즉, Run Loop에서 어떤 작업을 한다는 것은 특정 시간 간격동안 이벤트들을 모아서 한 번에 처리함을 의미합니다

Run Loop를 통해 UI 업데이트가 처리되는 과정을 View Drawing Cycle라고 부릅니다. 문제는 여러 다른 스레드에서 UI 업데이트를 하게 되면, 각자의 Run Loop를 통해 렌더링 요청을 하게 되므로 '모아서 한 번에 요청'하지 않고 여러번 요청하게 됩니다. 렌더링 작업 자체의 리소스 요구가 크므로 이렇게 나누어 처리하는 것은 비효율적입니다

우리는 UI 업데이트를 메인 스레드에서 동작시키기만 한다면 메인 스레드에서 Main Run Loop를 타고 여타 UI 관련 작업들과 함께 동시에 처리될 수 있습니다

▪️ 2. sync 메서드와 관련해 '절대'하면 안되는 코드 2가지

주의1. 메인 큐에서 다른 큐로 보낼 때는 항상 비동기적으로 보내야 한다
UI가 멈추지 않도록, 다른 큐로 작업을 보낼 때는 항상 비동기적으로 보내야 합니다

주의2. 현재 큐가 Serial이라면, 자기자신 큐에게 sync로 보내면 안된다
결론적으로 Deadlock이 발생합니다. sync로 작업을 보내면 그 작업이 완료될 때까지 멈추게 됩니다. Serial 큐가 자기자신에게 sync로 작업을 보내는 경우, '완료를 기다리며 아무것도 못하는 스레드'와 '요청된 작업을 처리해야 하는 스레드'가 동일해지는 꼴이 되어 Deadlock이 발생합니다.

Concurrent 큐의 경우, 자기자신 큐에게 보내더라도 다른 스레드에게 작업을 요청할 수 있어 일반적으론 발생하지 않습니다 (동일한 스레드로 작업을 요청해버리면 발생 가능합니다)

▪️ 3. weak, strong 캡처에 유의합니다

GCD에서 작업을 보낸다는 것은 '클로저'를 보내는 것입니다. 따라서 코드를 작성할 때 인스턴스 캡처와 관련된 이슈를 염두에 두어야 합니다

클로저 이슈 예시

func doSomething() {
    let vc = ViewController()
    DispatchQueue.global().async { [weak vc] in
        sleep(3)
        print("\(vc?.name)")
    }
}
doSomething()
// 출력 : nil

▪️ 4. 비동기 작업에 CompletionHandler가 필요한 이유

비동기 작업은 (작업을 맡긴 스레드가 기다리지 않기에) 언제 종료되는지를 직접적으로 추적할 수 없었습니다. 비동기 작업이 종료된 시점. 작업이 완료되어 그 결과를 사용할 수 있는 시점을 알려주고 작업의 결과를 사용하기 위해 CompletionHandler가 존재하며 거의 모든 비동기 작업이 가지고 있습니다



🐹 Dispatch Group

✔️ 정의

디스패치 그룹은 '여러 개의 작업이 모두 끝난 하나의 시점'을 추적하기 위해 존재합니다. 아래 그림에서 디스패치 그룹 개념을 통해 Group1(파랑)에 속한 작업들이 모두 끝난 하나의 시점을 추적할 수 있습니다

Dispatch Group은 async작업을 대상으로만 사용됩니다 (sync작업은 완료시점을 이미 정확히 알 수 있으므로)

그룹 개념이 필요한 예시
각 브랜드 이미지를 서버로부터 불러오는데, 일부 이미지만 로드된 경우 완료된 것만이라도 먼저 보여줄 수도 있지만 경우에 따라 한 번에 보여주는게 필요할 수 있다. 이 때 각 브랜드 이미지 로드 작업을 그룹으로 묶어 UI에 보여주는 시점을 하나로 통일할 수 있다


✔️ 동작 메커니즘

WWDC: Concurrent Programming With GCD in Swift 3

  1. queue.async(group: group) {...}과 같은 코드를 실행하면 Dispatch Group 인스턴스의 아이템 카운터의 값이 증가합니다. 아이템 카운터는 완료를 기다려야 할 작업의 개수입니다

  2. group.notify(queue: queue) {...}를 실행하면 작업들의 완료를 기다리게 됩니다

  3. 작업들이 하나씩 실행되기 시작하고 완료되면서 아이템 카운터의 값을 감소시킵니다

  4. 모든 작업이 완료되어 아이템 카운터가 0이 되면 Dispatch Group 인스턴스는 notify()에 정의한대로 특정 큐로 지정한 작업을 제출합니다


✔️ 구현 예제

▪️ [STEP1] group에 등록하기

2가지 방식이 있습니다. (1)aysnc의 argument로 넣어주는 방식과 (2)enter/leave 메서드를 활용하는 방식이 있습니다

방식1. aysnc의 argument로 넣어주는 방식

let group1 = DispatchGroup()

DispatchQueue.global().async(group: group1){ ... }
DispatchQueue.global().async(group: group1){ ... }
DispatchQueue.global().async(group: group1){ ... }

방식2. enter/leave 메서드를 활용하는 방식

let group1 = DispatchGroup()

group1.enter()
DispatchQueue.global().async(){ ... }
DispatchQueue.global().async(){ ... }
DispatchQueue.global().async(){ ... }
group1.leave()

▪️ [STEP2] 완료 처리

완료를 처리하는 방식에도 '비동기방식'과 (완료를 기다리는) '동기방식'의 메서드가 구분되어 있습니다

방식1. notify (비동기방식)

let group1 = DispatchGroup()

DispatchQueue.global().async(group: group1){ ... }
DispatchQueue.global().async(group: group1){ ... }
DispatchQueue.global().async(group: group1){ ... }

group1.notify(queue: DispatchQueue.main) {
    print("모든 작업이 완료되었습니다")
}

방식2. wait (동기방식)

이전 섹션에서 언급했듯, 이런 sync 동작은 메인 스레드에서 수행하면 안됩니다

wait() 메서드는 해당 그룹의 작업이 모두 끝날 때까지 기다립니다. notify와는 다르게 timeout 개념이 존재하며 timeout 발생 여부에 따라 분기문을 설정할 수도 있습니다

- 무한정 기다리는 예제

let group1 = DispatchGroup()

DispatchQueue.global().async(group: group1){ ... }
DispatchQueue.global().async(group: group1){ ... }
DispatchQueue.global().async(group: group1){ ... }

//wait에 파라미터를 주지 않으면 계속 기다립니다
group1.wait()
print("모든 작업이 완료되었습니다")

- 일정 시간 동안만 기다리는 예제

let group1 = DispatchGroup()

DispatchQueue.global().async(group: group1){ ... }
DispatchQueue.global().async(group: group1){ ... }
DispatchQueue.global().async(group: group1){ ... }

//wait()는 DispatchTimeoutResult를 return합니다
if group1.wait(timeout: .now() + 60) == .timedOut {
    print("모든 작업이 완료되었습니다")
}


✔️ Dispatch Group 사용 시 유의사항

▪️ wait() 메서드를 메인 스레드에서 호출하면 안된다

메인 스레드가 멈추지 않도록 합니다

▪️ Group의 작업이 wait() 메서드를 호출하는 스레드로 작업을 보내면 안된다

작업을 수행하는 스레드와 작업완료를 기다리는 스레드가 동일하면 Deadlock이 유발됩니다. 아래 코드에서 메인 스레드는 wait()하느라 멈춰있기 때문에 Task B를 처리할 수가 없습니다

let group1 = DispatchGroup()

DispatchQueue.global().async(group: group1) {
    //Task A
    
    DispatchQueue.main.async(group: group1) {  //Error
    	//Task B
    }
}

group1.wait()

▪️ Group에 넣으려는 클로저에 비동기 작업이 있는 경우

Group에 넣으려는 클로저에 비동기 작업이 있는 경우, 아래 코드와 같은 실수를 범할 수 있습니다. 아래 코드에는 Task B가 끝나지 않았음에도 group이 완료된 것으로 인지되는 문제가 있습니다.

의도대로 동작시키기 위해선 내부에 있는 비동기 작업도 group에 등록하는 것이 필요합니다. 내부의 async 메서드에도 group1을 파라미터로 전달하거나 enter()/leave() 메서드를 활용합니다

let group1 = DispatchGroup()

DispatchQueue.global().async(group: group1) {
    //Task A
    
    DispatchQueue.global().async { 
    	//Task B
    }
    
    //Task C
}


🐸 Dispatch WorkItem

WWDC(14분50초): Concurrent Programming With GCD in Swift 3

작업을 class화한 객체를 말합니다. 이전까지는 Queue에 넣기 직전에 Task를 정의했던 반면, WorkItem에 미리 넣어놓고 필요한 시점에 Queueing 할 수 있습니다. WorkItem에도 QoS를 설정할 수 있습니다. 참고로, QoS를 WorkItem 정의 시점의 context를 기준으로 미리 설정하려면 flags 파라미터에 .assignCurrentContext를 주는 방법이 있습니다

perform() 메서드를 통해 현재 스레드에서 sync하게 동작시킬 수도 있습니다

wait() 메서드를 통해 해당 WorkItem이 완료되길 기다릴 수 있습니다. (Dispatch Group에서 여러 작업의 완료를 기다리던 것과 유사)

✔️ 구현 예제

let item = DispatchWorkItem(qos: .utility) {
    ...
}

DispatchQueue.global().async(execute: item)

✔️ (빈약한) 취소 기능

cancel() 메서드를 통해 작업이 아직 큐에 있는 경우에 한해 작업을 제거할 수 있습니다. 작업이 이미 스레드에 할당되어 실행중인 경우에는 작업을 직접적으로 멈추진 못합니다. (다만 WorkItem의 isCancelled 속성이 true로 설정되는데 이를 활용해 볼 여지는 있습니다)

let item = DispatchWorkItem {
    ...
}

item.cancel()

DispatchQueue.global().async(execute: item) // 이미 취소되어 실행되지 않음

✔️ (빈약한) 순서 기능

이전 섹션에서 다루었던 Dispatch Group처럼 WorkItem도 notify() 메서드를 가지고 있습니다. 이를 사용하여 'Task A가 끝나면 Task B를 실행'한다던지 다소 간접적이지만 순서 기능을 구현할 수 있습니다

let item1 = DispatchWorkItem {
	//Task A
}
let item2 = DispatchWorkItem {
	//Task B
}

item1.notify(queue: .main, execute: item2)

DispatchQueue.main.async(execute: item1)


🐔 Dispatch Semaphore

공유 리소스(여기선 스레드를 의미)에 접근가능한 작업 수를 제한해야 할 경우

✔️ 개념

Semaphore는 모든 스레드가 공유하는 '숫자값'을 가집니다. Semaphore 객체 생성 시점에 이 숫자값을 접근가능한 작업 수만큼으로 초기화합니다. 이후 코드를 진행하며 wait() 메서드는 숫자값이 0이면 기다리고, 0이 아니면 숫자값을 하나 차감하면서 다음 코드를 진행합니다. 반대로 signal() 메서드는 숫자값을 하나 올려주고 바로 다음 코드를 진행합니다

Semaphore의 숫자값을 1로 설정하는 경우, 특정 데이터에 대한 스레드 간 동기화를 위해 Thread-safe를 구현하는데 사용될 수 있습니다. (Thread-safe는 한 번에 하나의 스레드만 접근가능하도록 하는 것을 말하며 다음 포스팅에서 다룰 예정입니다)


✔️ 구현 예제

let semaphore = DispatchSemaphore(value: 3)

for _ in 1...10 {
    semaphore.wait()
    DispatchQueue.global().async {
        print("good")
        semaphore.signal()
    }
}
profile
노션으로 이사갑니다 https://tungsten-run-778.notion.site/Study-Archive-98e51c3793684d428070695d5722d1fe

0개의 댓글