감자주먹밥

[Swift] Swift Concurrency - Task 본문

IOS/Swift

[Swift] Swift Concurrency - Task

JustHm 2023. 3. 6. 22:06
728x90

Task

Asynchronous Function을 생성하고 실행하려 할 때 viewDidLoad나 일반적인 함수에서는 실행이 되지 않는 것을 알 수 있다.

그 이유는 Asynchronous Function은 항상 concurrent context에서 실행되어야 하기 때문이다. 

이를 실행하려면 Task를 사용하면 된다. Task는 프로그램 일부를 비동기적으로 실행할 수 있는 작업 단위다.

Task를 사용하면 임의의 스레드에서 다른 실행 맥락과 함께 동시에 실행된다. 그리고 각 Task는 다른 Task와 동시에 실행할 수 있다. task가 여러개 만들어 지면 task들은 각각 독립적으로 작업을 수행한다.

  • Task Block은 비동기로 실행된다.
  • Task block의 작업은 await를 만나 중단될 수는 있지만, 순차적으로 처리된다.
  • Task는 독립적으로 작업을 처리한다.

Structured Concurrency & Unstructured Concurrency

Structured Concurrency

구조화 된 방식으로 동시성 처리하기

  • async let
  • TaskGroup

Async let

async/await 포스트에서도 알아봤지만 async let은 concurrency binding을 하여 child task를 생성해준다. 이렇게 만들어진 task는 변수에 바인딩 되어 있다가 await를 만나고 나서 동작하게 된다. 

func fetchOneThumbnail(withId id: String) async throws -> UIImage {
    let imageReq = imageRequest(for: id), metadataReq = metadataRequest(for: id)
    async let (data, _) = URLSession.shared.data(for: imageReq)
    async let (metadata, _) = URLSession.shared.data(for: metadataReq)

    guard let size = parseSize(from: try await metadata),
          let image = try await UIImage(data: data)?.byPreparingThumbnail(ofSize: size) else {
            throw ThumbnailFailedError()
          }
    return image
}

하나씩 request를 기다리는게 아닌 child task를 생성하여 병렬적으로 처리를 할 수 있다.

async let으로 child task를 생성하게 되면 그것은 Task Tree라는 계층구조의 일부다.

위 예제 코드에서 Task Tree가 생성되는 구조는 다음과 같다.

  1. fetchOneThumbnail을 실행하는 Task 생성.
  2. async let 으로 data, metadata의 정보를 가져올 URLSession 요청을 child Task로 생성

이렇게 생성된 Task Tree의 child task들의 동작이 모두 종료되어야 Parent task를 종료할 수 있다.

그런데 만약 child task중 하나가 비정상적인 종료를 하게 된다면 어떻게 진행될까?

생각해보면 child task가 바로 모두 종료되고, ParentTask까지 최종 종료 될 거 같지만 몇 가지 단계를 거치고 종료된다.

  1. data(child task)의 에러 발생
  2. data(child task) cancel 마킹. 그리고 함수를 탈출하기 전, cancel된 task를 기다린다.
  3. cancel 된 task의 subtask 모두 cancel 처리
  4. 가장 하위task가 cancel되고 finish 되었다면 위로 상위로 올라가며 완료처리가 된다.
  5. 최종적으로 최상위Task의 하위 task가 finished 라면 최상위 Task가 종료된다.

ARC가 메모리 수명을 자동으로 관리하는 것처럼 Task의 LifeCycle 관리를 도와 실수로 leaking이 생기는 걸 방지한다.

Task를 Cancel 했는데 finish를 기다린다?
그 이유는 cancel은 stop이 아니기 때문이다. 여기서 cancel은 "task가 작업을 완료해 결과를 받아와도 사용하지 않는다." 
코드는 명시적으로 취소 여부를 확인하고 적절한 방법으로 실행을 종료해야 한다. 
그래서 cancel이 되어도 finish 될 때 까지 기다린다는 것.

Task Cancel에 대해 항상 숙지해야한다. 코드에서 명시적으로 cancellation 체크를 하고, 적절한 방법으로 실행 중지를 해야한다.

Task가 중요한 트랜잭션 중이거나, 네트워크 연결이 열려있는 경우 작업을 중지하는 것은 올바르지 않다. 코드에서 명시적으로 취소여부를 확인하고, 적잘한 방법으로 실행종료를 해야한다. 

func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
    var thumbnails: [String: UIImage] = [:]
    for id in ids {
        //Task가 cancel 되어 있는지 확인하고 cancel되어 있다면 throw
        try Task.checkCancellation() 
        thumbnails[id] = try await fetchOneThumbnail(withID: id)
    }
    return thumbnails
}

// Task.isCancelled 도 있다.

위 예제 코드는 중간에 cancel 되면 에러를 던지는데, Task.isCancelled로 취소상태를 확인하고 break를 할 수도 있다.하지만, 이 함수를 사용해 데이터를 받는 곳에서 데이터가 일부만 들어올 수 있다는 것을 인지하고 코드를 작성해놔야한다.

Group Tasks

async let이 정적으로 task를 바인딩해 관리했다면, 동적으로 관리하는 방법도 있다.

func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
    var thumbnails: [String: UIImage] = [:]
    try await withThrowingTaskGroup(of: Void.self) { group in
        for id in ids {
            group.addTask {
                thumbnails[id] = try await fetchOneThumbnail(withID: id) //❌
            }
        }
    }
    return thumbnails
}

TaskGroup을 이용해 child Task들의 Group을 만들어서 관리하는 방법이다.

withTaskGroup, withThrowingTaskGroup 함수를 사용해서 TaskGroup을 생성할 수 있다. 파라미터는 3가지가 있다.

of: child task의 반환타입
returning: group task의 반환타입
body: group에 child task의 result가 collectionType으로 저장

예제 코드에서 ❌표시 라인은 사실 컴파일 에러가 나는 라인이다. 

Mutation of captured var 'thumbnails' in concurrently-executing code

이유는.. Task가 실행되는 동안 capturing된 변수들이 변할 수 있기 때문이다.

group.addTask 동시 실행 코드에 thumbnails라는 공유자원에 group내 각 task가 접근하기 때문에 data race 이슈가 발생 할 수 있다.

해결 방법은 Sendable Closure를 사용하면 되는데 그게 어딨을까

withTaskGroup의 body가 Sendable Closure다. Sendable Closure는 Mutable 변수가 캡처되지 않도록 방지하는 역할을 한다.

func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
    var thumbnails: [String: UIImage] = [:]
    try await withThrowingTaskGroup(of: (String, UIImage).self) { group in
        for id in ids {
            group.addTask {
                return (id, try await fetchOneThumbnail(withID: id))
            }
        }
        for try await (id, thumbnail) in group {
            thumbnails[id] = thumbnail
        }
    }
    return thumbnails
}

task에서는 값을 반환하는 역할만 하고, 만들어진 tasks에서 값을 빼와 변수에 할당해주기만 하면 된다. 이것도 병렬 처리가 된다.

Unstructured Tasks

비동기 코드를 동기 코드블럭 내에서 한 번 실행할 땐 구조화 된 Task가 필요 없기 때문에 구조화 되지 않은 Task를 사용한다.

Task 생성 시 origin context의 속성을 상속 받으면서도 독립된 영역에서 실행되는 Task를 말한다..

  • Parent task가 필요없거나 non-async에서 async작업이 필요한 경우.
  • Task의 라이프타임을 단일 영역(single scope)이나 함수 내로 한정 할 수 없을 때
  • cancellation, 에러 핸드링을 직접 관리해야한다.
  • 예를 들어 객체가 호출되어 active 상태가 될때 시작되고, 다른 메소드가 호출되며 객체가 deactive 상태가 되면 실행을 취고 하고 싶은 경우

Unstructured Concurrency 는 parent task가 없고 유연하게 관리할 수 있다는게 특징이지만, 정확성에 대한 관리와 책임은 개발자에게 있다.

 

 

@MainActor
class MyDelegate: UICollectionViewDelegate {
    func collectionView(_ view: UICollectionView,
                        willDisplay cell: UICollectionViewCell,
                        forItemAt item: IndexPath) {
        let ids = getThumbnailIDs(for: item)
        Task {
            let thumbnails = await fetchThumbnails(for: ids)
            display(thumbnails, in: cell)    
        }
    }
}

동기 블럭에서는 await를 쓸 수 없기 때문에 await를 사용할 수 있는 concurrent context가 필요하다. Task를 직접 생성하여 concurrent context를 제공받고 비동기 함수를 실행 할 수 있다.

위 예제 코드는 Main Actor 에서 생성되었기 때문에 Task는 MainActor에 스케쥴된다.

Cancel the Unstructured Task

@MainActor
class MyDelegate: UICollectionViewDelegate {
    var thumbnailTasks: [IndexPath: Task<Void, Never>] = [:]

    func collectionView(_ view: UICollectionView,
                        willDisplay cell: UICollectionViewCell,
                        forItemAt item: IndexPath) {
        let ids = getThumbnailIDs(for: item)
        thumbnailTasks[item] = Task {
            defer { thumbnailTasks[item] = nil } // 일단 화면에 보여줬으면 Task는 필요없음. cancel하기 위해 필요한 것
            let thumbnails = await fetchThumbnails(for: ids)
            display(thumbnails, in: cell)
        }
    }

    func collectionView(_ view: UICollectionView,
                        didEndDisplay cell: UICollectionViewCell,
                        forItemAt item: IndexPath) {
        thumbnailTasks[item]?.cancel()
    }
}

Unstructured Task는 LifeCycle Scope가 없어 자동으로 취소되지 않는다. 위 예제 코드는 스크롤 시 사라지면 Task를 취소해준다.

Dictionary 변수에 id로 Task를 관리해야 취소될 task를 찾아 취소할 수 있다. Task가 삭제되고 나면 Dictionary에서 제거된다.

예제에서 생성된 Task는 main actor의 속성을 받아 만들어진 task로 병렬도 실행되지 않는다. 따라서 dataRace 걱정없이 MainActor Class에 저장된 변수를 Task에서도 사용할 수 있다.

didEndDisplay에서 이미 사라진 cell에 작업을 cancel시켜 해당 Task를 관리할 수 있다.

Detached Task

Detached Task는 Unstructured Task 면서 특별한 특징을 가지고 있다. 생성되는 곳의 context에게서 아무것도 상속받지 않고 완전히 독립적으로 생성되는 Task다.

Child Task는 Parent Task의 priority와 Task-local storage를 상속하며, Parent Task를 취소하면 모든 child task가 자동으로 취소되는데, detached task는 이런 고려 사항을 수동으로 처리해야한다.

공식문서에는 detached task를 child task처럼 Structured Concurrency 기능을 사용해 작업을 모델링 해야한다면 사용하지 말라고 하고 있다. 

Detached Task는 완전히 독립적으로 실행되는 Task로 MainActor에서 실행될 필요가 없는 캐싱을하는 것을 Detached Task로 구현할 수 잇따.

@MainActor
class MyDelegate: UICollectionViewDelegate {
    var thumbnailTasks: [IndexPath: Task<Void, Never>] = [:]

    func collectionView(_ view: UICollectionView,
                        willDisplay cell: UICollectionViewCell,
                        forItemAt item: IndexPath) {
        let ids = getThumbnailIDs(for: item)
        thumbnailTasks[item] = Task {
            defer { thumbnailTasks[item] = nil } 
            let thumbnails = await fetchThumbnails(for: ids)

            Task.detached(priority: .background) {
                withTaskGroup(of: Void.self) { group in
                    group.async { writeToLocalCache(thumbnails) }
                    group.async { log(thumbnails) }
                    group.async { ... }
                }
            }

            display(thumbnails, in: cell)
        }
    }
}

만약 backgroundTask가 여러개 생긴다면 위 예제처럼 withTaskGroup을 사용해 처리할 수 있다.

detached task 에서는 명시적으로 self를 캡처해야한다.

추가로 읽어보면 좋은 것. 

http://minsone.github.io/swift-concurrency-AnyCancelTaskBag

Rx의 DisposeBag처럼 TaskCancelBag을 만들어 관리하는 법

 

[Swift 5.7+][Concurrency] Task의 CancelTaskBag 구현하기

Swift의 Concurrency에서 Task를 이용해서 비동기 작업을 처리합니다. Task { try await Task.sleep(nanoseconds: 10 * 1_000_000_000) print("Hello") } 하지만, Task로 비동기 작업 도중에 Task를 실행한 객체가 사라지거나 할

minsone.github.io

http://minsone.github.io/swift-concurrency-weak-self

Task에서의 [weak self] - 1

 

[Swift 5.7+][Concurrency] Class에서 Task 사용시 weak self를 사용하자

Swift의 Task 사용시 Closure를 이용하여 비동기 작업을 구현합니다. class Alpha { init() { print("\(Date()) init") Task { print("\(Date()) Before Hello Alpha") try await Task.sleep(nanoseconds: 10 * 1_000_000_000) print("\(Date()) After He

minsone.github.io

https://jeong9216.tistory.com/513

Task에서의 [weak self] - 2

 

[Swift] async/await와 [weak self]

서론 최근 completion handler를 async/await로 리팩토링하면서 [weak self]에 대해 궁금한 점이 생겼습니다. 이번 포스팅은 자세한 내용이 아니라 간단 궁금증 해결(?)인 점 이해해주시고 틀린 점이 있다면

jeong9216.tistory.com

 

728x90

'IOS > Swift' 카테고리의 다른 글

[Swift] XML Parser 사용하기  (0) 2023.04.05
[Swift] Swift Concurrency - Actor  (0) 2023.03.14
[Swift] Swift Concurrency - async/await  (0) 2023.03.02
[Swift] 동시성 프로그래밍 GCD, Operation  (0) 2023.02.25
Xcode 단축키 모음  (2) 2022.01.13
Comments