[회고] 신입 iOS 개발자가 되기까지 feat. 카카오 자세히보기

🍎 Apple/Concurrency & GCD

[Concurrency] Explore structured concurrency in Swift

inu 2023. 10. 29. 00:39
반응형

본 게시글은 WWDC21의 Explore structured concurrency in Swift 세션의 내용을 담고 있습니다.

 

https://developer.apple.com/videos/play/wwdc2021/10134/

 

Explore structured concurrency in Swift - WWDC21 - Videos - Apple Developer

When you have code that needs to run at the same time as other code, it's important to choose the right tool for the job. We'll take you...

developer.apple.com


Structured Programming

컴퓨팅 초기에는 제어흐름이 이곳 저곳 흩어져 있어 프로그램을 읽기가 어려웠다. 본 게시글은 WWDC21의 Explore structured concurrency in Swift 세션의 내용을 담고 있습니다.

 

하지만 현대의 언어들은 위와 같이 제어흐름을 보다 균일하게 만들도록 Structured Programming을 사용하기 때문에 기존의 문제가 많이 개선되었다.

  • if-then block은 Structured Programming을 사용하는 예시이다. 중첩된 code block이 위에서 아래로 이동하는 동안에만 조건에 따라 실행되도록 지정한다.
  • static scope를 기반으로 한 swift는 제어흐름을 균일하도록 만들고, 변수의 수명도 block이 떠날 때 종료되도록 해 이해하기 쉽다.

구조화된 제어흐름은 자연스럽게 순서를 지정하고 함께 중첩될수도 있다. 이것이 Structured Programming의 기초이다. 우리는 정적인 scope를 열고 위에서 아래로 코드를 읽으면서 여러 scope를 오가는 것을 당연하게 느낀다.

 

하지만 최근에는 asynchronous(비동기)와 concurrent(동시성)적인 개념이 많이 도입되면서 Structured Programming에 입각한 코드를 작성하기가 어려워졌다. 비동기와 동시성이라는 개념 자체가 제어 흐름에서 벗어나 순서를 예측할 수 없도록 만들기 때문이다.

Structued Prgramming with asynchronous code

func fetchThumbnail(for ids: [String], completion handler: @escaping ([String: UIImage]?, Error?) -> Void) {

    guard let id = ids.first else {
        handler([:], nil)
        return
    }

    let request = URLRequest(url: URL(string: id)!)

    let task = URLSession.shared.dataTask(with: request, completionHandler: { data, response, error in
        if let error {
            handler(nil, error)
        } else if (response as? HTTPURLResponse)?.statusCode != 200 {
            handler(nil, HyunndyError.noImage)
        } else {
            guard let image = UIImage(data: data!) else {
                handler(nil, HyunndyError.noImage)
                return
            }

            image.prepareThumbnail(of: CGSize(width: 40.0, height: 40.0), completionHandler: { thumbnail in
                guard let thumbnail else {
                    handler(nil, HyunndyError.noImage)
                    return
                }

                fetchThumbnail(for: Array(ids.dropFirst()), completion: { thumbnails, error in
                    // .... ad image to thumbnails .....
                })
            })
        }
    })

    task.resume()
}

이 코드는 구조화(Structed)되지 않았다. 왜냐면 completion handler를 사용했기 때문이다. 또한 이 함수는 ids로 전달된 url들을 하나씩 처리하면서 재귀적으로 값을 모아 반환하고 있다. 이는 제어흐름 파악에 큰 어려움을 준다.

 

이제 이를 async/await를 통해 개선해보자.

func fetchThumbnail2(for ids: [String]) async throws -> [String: UIImage] {
    var thumbnails: [String: UIImage] = [:]

    for id in ids {
        let request = URLRequest(url: URL(string: id)!)
        let (data, response) = try await URLSession.shared.data(for: request)

        try validateResponse(response)
        guard let image = await UIImage(data: data)?.byPreparingThumbnail(ofSize: CGSize(width: 40.0, height: 40.0)) else {
            throw HyunndyError.noImage
        }

        thumbnails[id] = image
    }

    return thumbnails
}

이렇게하면 훨씬 더 구조적인 이해가 편해진다. (structured programming)

다만 이 케이스의 경우 이미지를 하나씩 받아오고 있기 때문에 다소 비효율적이다. 이를 동시에 병렬적으로 받아오면 더 효율적일 것이다.

Task

Task는 async한 코드를 실행하기 위한 실행 컨텍스트(execution context)를 제공한다. 각각의 Task는 다른 Task와 동시에 실행된다.

안전하고 효율적인 타이밍에 알아서 병렬로 실행되도록 내부적으로 스케줄링된다. Task는 명시적으로 호출해주지 않으면 새롭게 만들어지지 않는다. async 메서드를 호출하더라도 새로운 Task가 만들어지는 것은 아니다.

Async-let task

기존의 let에 특정한 data를 binding하는 과정은 이와같이 진행된다. URLSession에서 데이터를 받아오는 행위는 오랜 시간이 걸리므로 이를 비동기적으로 처리하고 추후 작업이 끝났을 때 남은 구문을 처리한다.

이렇게 처리하면 async let concurrent binding이 발생한다. 기존의 binding은 sequential binding이라고 하는데, concurrent binding과 sequential binding은 매우 다르다.

concurrent binding 과정에서는 child Task가 생성된다. 이는 concurrent binding을 처리중인 Task의 Child Task가 된다.

  • 여기서 하얀색 화살표는 Parent Task의 실행과정이고, 초록색 화살표는 Child Task의 실행과정이다.

Child Task가 작업을 처리하는 동안 Parent Task는 이어지는 동작들을 처리한다. (following statements) 그리고 결과값이 실체로 필요할때 await하면서 Child Task가 마무리될때까지 기다린다.

/// Structured Concurrency with seuquential binding
    func fetchOneThumbnail(withId id: String) async throws -> UIImage {
        let imageReq = URLRequest(url: URL(string: id)!), metaReq = URLRequest(url: URL(string: id)!)

        let (data, _) = try await URLSession.shared.data(for: imageReq)
        let (metadata, _) = try await URLSession.shared.data(for: metaReq)

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

        return image
    }

sequential binding의 예시 먼저보자. 하지만 이는 await가 하나씩 수행되면서 순차적으로 작업이 처리된다.

/// Structured Concurrency with async-let concurrent binding
    func fetchOneThumbnail2(withId id: String) async throws -> UIImage {
        let imageReq = URLRequest(url: URL(string: id)!), metaReq = URLRequest(url: URL(string: id)!)

        async let (data, _) = URLSession.shared.data(for: imageReq)
        async let (metadata, _) = URLSession.shared.data(for: metaReq)

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

        return image
    }

다음으로 concurrent binding의 예시이다. 이는 각각의 child task가 병렬적으로 동시에 작업을 처리한다.

Structured task guarantees

Child Task 개념에서 대충 눈치를 챘겠지만, Task도 Tree 형태의 계층구조를 만들 수 있다. 이는 단순한 구현적인 의미를 넘어선 의미가 있다. 이는 cancellation, priority, 그리고 task-local variables같은 Task의 속성에 영향을 준다. 특정한 async function에서 다른 async function을 호출하면, 하나의 실행 단위에서 같은 Task가 활용된다.

자식 Task가 모두 완료되어야 부모 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
}

위 코드를 예시로 들면 imagedata에 대한 Task를 받아오기 전에 먼저 metadata에 대한 Task가 마무리되어야 한다. 만약 처음으로 await 중인 Task에서 에러가 발생한다면 전체 함수는 즉시 에러를 던지며 종료되어야 한다. 하지만 다음으로 await 중이던 Task는 어떻게될까?

 

Swift는 자동으로 취소되었음을 표기한다음(cancelled) 작업은 그대로 이어서하도록 두고 완료되면 해당 Task를 종료한다. 단순히 cancelled를 표기하는 것은 Task를 멈추게 만들지 않는다. 이는 단순히 결과가 필요없어졌음을 알릴 뿐이다.

  • cf. 깃발마크는 종료되었음을 의미. metadata를 받아오는 task는 에러로 인해 종료된 것.

 

부모 Task가 cancel되면 하위 Task도 자동으로 cancel된다. URLSession의 구현체가 이미지 다운로드를 위한 자신만의 Task를 만들었다면, 그들 역시도 cancellation 상태로 표기될 것이다.

 

fetchOneThumnail 함수는 이렇게 하위에서부터 하나씩 Task가 종료되면 최종적으로 오류를 발생시키면서 종료된다.

 

이런 ‘guarantee’가 structured concurrency의 근간이다. 이는 ARC가 메모리 수명을 자동으로 관리하는 것과 마찬가지로 Task의 lifetime을 관리할 수 있도록 도와 Task 작업이 사고로 leaking되는 것을 방지한다.

 

지금까지는 cancellation이 어떻게 전파되는지 알아보았다. 그럼 언제 Task가 최종적으로 ‘Stop’되는가? 중요한 트랜잭션(하나의 단위로 이어지는 작업)이 있거나 네트워크가 연결되어있는 경우, 무조건적으로 작업을 중단하는 것은 올바른 처리가 아니다.

 

이것이 swift에서 task cancellation이 협업적(cooperative)으로 처리되는 이유이다. 코드에서 cancellation 여부를 명시적으로 확인하고 적절한 방법으로 실행을 종료해라. 비동기 여부와 관계없이 현 Task에서의 cancellation 여부를 확인할 수 있다.

 

만약 단위가 큰 작업을 수행중이라면 반드시 cancellation을 고려한 코드를 작성해야한다. 만약 더이상 결과값이 필요하지않다면, 적절한 위치에서 작업을 중단하는 것이 좋다.

func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
    var thumbnails: [String: UIImage] = [:]
    for id in ids {
        try Task.checkCancellation()
        thumbnails[id] = try await fetchOneThumbnail(withID: id)
    }
    return thumbnails
}

Task.checkCancellation() 메서드를 통해 현재 Task의 cancellation 여부를 확인하고 바로 Task를 종료하면서 에러를 전달할 수 있다.

func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
    var thumbnails: [String: UIImage] = [:]
    for id in ids {
        if Task.isCancelled { break }
        thumbnails[id] = try await fetchOneThumbnail(withID: id)
    }
    return thumbnails
}

혹은 Task.isCanelled를 통해 cancellation 여부를 기반으로 특정한 핸들링을 수행할수도 있다.

Group Task

Group Task는 async-let 보다 더 유연한 기능을 제공한다.

fetchOneThumbnail 함수는 내부정도 2개의 child task를 보유하게 된다. 반복문에서 하나의 썸네일을 가져오는 작업을 수행할때마다 두개의 task가 모두 완료되어야만 다 음으업을 처리할 수 있다.

 

만약 모든 작업을 최대한 동시에 수행하고 싶다면 어떻게 해야할까? id 개수에 따라 작업이 달라질 수 있어 정적인 동시성의 양(static amount of concurrency)을 측정할 수 없는데, task group을 사용하면 가능하다.

 

dynamic amount of concurreny를 제공하는 structured concurrency의 형태이다.

 

withThrowingTaskGroup 메서드를 사용해 task group을 사용한다. 이 메서드는 child task를 생성할 수 있는 그룹 오브젝트를 제공한다. 그룹에 추가된 작업은 그룹이 정의된 block의 범위보다 오래 지속될 수 없다.

 

모든 반복문 루프를 scope 안으로 넣었기 때문에 dynamic한 개수의 task를 group을 기반으로 생성할 수 있다.

 

cf. 영상에서는 async라는 함수를 사용하라고 설명하는데, deprecated 되었으니 대신

이 함수들을 사용하면 된다.

 

아무튼 group을 기반으로 child task를 생성하면, 이들을 즉시 실행된다. group 개체가 scope를 벗어나면 내부에 존재하는 모든 task의 완료가 암시적으로 대기 상태가 된다. 이는 앞서 이야기한 Task tree 규칙의 결과이다. 우선 우리가 원하는대로 동시성은 완성시켰다.

 

다만 이는 아직 완성된 코드가 아니다. 이대로 컴파일을 실행하면 data race issue에 대한 경고가 뜰것이다.

문제는 child task에서 외부 변수인 thumbnails에 접근하면서 발생한다. thumnails는 dictionary로, thread safe하지 않다. (not sendable)

Task를 만들면 이는 새로운 closure type인 @Sendable closure가 된다. @Sendable closure는 외부에 영향을 주어 변경을 가할 수 있는 변수를 캡처하는 것이 제한된다. (not sendable)

그래서 이를 개선해 각 child task에서 id와 image의 tuple을 반환하도록 한다. Task Group 또한 AsyncSequence의 일종이기 때문에 for-await-in 루프 사용이 가능하다. group은 순서에 상관없이 끝나는대로 값을 반환할 것이다.

 

async-let task와 약간 다른 점이 있다. group의 결과를 받아오던 도중 특정 child task에 error를 던지며 마무리되었다고 해보자. 이 error는 group의 block까지 전달되며 throw되므로 group의 모든 작업이 암시적으로 cancel상태가 된다.

 

차이는 group이 scope를 벗어났을 때 발생한다. 이 경우 cancel이 암시적으로 일어나지 않는다. 이는 task group을 통해 fork-join pattern을 구현하기 쉽게 만들어준다.

  • fork-join pattern? 여러 개의 작업을 기반으로 하나의 결과를 생성하는 패턴.

필요할 경우 group의 cancelAll같은 메서드를 사용하는 것도 방법이다.

Unstructured Tasks

Unstructed Task도 존재한다. 이는 Structed Task와 다르게 수동 관리가 필요하지만 훨씬 유연하다. Task가 명확한 계층 구조에 속하지 않는 상황은 많다.

  • 동기 코드 도중 Task를 생성했을 때
  • Task의 cancel 여부가 특정한 조작에 따라 수행될 경우 (하나의 범위 내에서 lifetime 관리가 되지 않는 경우)

여기 MainActor를 준수하는 Delegate 객체가 있다. 하지만 이는 불가능하다. 해당 메서드가 async하지 않기 때문에 async 메서드를 호출할 수 없다.

이럴 때 Unstructed Task를 사용해서 처리해줘라.

 

일반 메서드 실행 도중 Task를 만나면, Swift는 원래 속하던 scope와 동일한 actor에서 실행되도록 예약한다. 위 코드의 경우 MainActor일 것이다. 하지만 제어권은 즉시 호출자에게 반환된다. 따라서 fetchThumnails 작업은Main Thread를 즉시 차단하지 않고 Main Thread에 여유가 있을 때 Main Thread에서 처리된다.

 

이렇게 형성된 Task는 실행된 context의 actor, priority 등을 상속받는다. 하지만 이러한 Task는 Scope가 지정되지 않는다. 따라서 실행된 위치의 scope에 구속되지 않는다. 이는 편하지만, Structed Concurrency에서 제공하던 편리함을 수동으로 처리해줘야 한다. cancel 및 error가 자동으로 전파되지 않으며, 명시적으로 조치하지 않으면 task의 결과가 암시적으로 await되지도 않는다.

 

이제 코드를 개선해보자.

 

만약 thumbnail을 가져오던 도중 스크롤이 발생해 해당 cell을 더이상 볼 필요가 없는 상태가 된다면, 이를 취소해줄 것이다.

@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)
            display(thumbnails, in: cell)
        }
    }

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

Task를 따로 dictionary로 관리하고, 이를 취소해주는 코드를 추가했다. 작업이 안정적으로 끝나면 dictionary에서 해당하는 Task를 삭제한다. 그리고 cell이 안보이게된 경우에도 진행중인 작업이 있다면 명시적으로 cancel 처리한다.

위 코드를 보면 외부 변수인 thumbnailTasks에 Task에서 접근하고 있다. 이는 내부적으로 Task가 MainActor에서 돌아가기 때문이다. 하지만 때때로 이를 원하지 않을 수 있다.

Detached tasks

이럴 때 사용하는 것이 Detached tasks이다. 이름에서 알수있듯이 이는 context로부터 독립적이다. 그들은 여전히 Unstructed task이다. Lifetiem이 다른 요소에 의해 관리되지 않는다.

  • 하지만 Detached Task는 상위 Context로부터 어떤 것도 물려받지 않는다.
  • 즉, 같은 actor에서 실행될 필요도 없으며 같은 priority를 가지지도 않는다.
  • 기본값을 기반으로만 형성되지만, optional한 파라미터를 통해 이를 조절할수도 있다.
@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) { g in
                    g.async { writeToLocalCache(thumbnails) }
                    g.async { log(thumbnails) }
                    g.async { ... }
                }
            }
            display(thumbnails, in: cell)
        }
    }
}

detached를 통해 독립적인 Task를 만들고 여기서 추가적으로 TaskGroup을 형성하여 처리한 코드이다. (내부에 Structed Concurrency를 형성) 이렇게하면 task cancel이 필요한 경우에도 최상위 task인 detached task만 cancel하면 내부는 알아서 처리된다.

  • local cache를 형성하거나
  • log를 뿌리거나
  • 그 외 background에서 수행해도 괜찮은 작업을 처리한다.

Summary

반응형

'🍎 Apple > Concurrency & GCD' 카테고리의 다른 글

[Concurrency] Task Group의 동시성을 제한하는 방법  (0) 2023.11.13
[Concurrency] Actor  (0) 2023.03.31
[Concurrency] Continuation  (2) 2023.03.31
[Concurrency] Task  (0) 2023.03.31
[Concurrency] async / await  (2) 2023.03.31