Structured Concurrency(구조화된 동시성)Structured Programming에 기반을 둔 개념이다. 

Structured Programming은 procedual하게 읽을 수 있고, 해당 코드의 output을 예측할 수 있는 프로그래밍을 의미한다. 

 

앞선 포스팅에서 살펴본, async & await는 비동기 작업에 대해 Structured Programming이 가능하도록 지원해 주었다.

하지만 저번 포스팅에서 알아본 async & await로 구현한 코드는 단순 비동기이지, parallel한 실행은 할 수 없었다.

func fetchOneThumbnail(withID id: String) async throws -> UIImage {
    let imageReq = imageRequest (for: id), metadataReq = metadataRequest (for: id)
    
    let (data,_) = try await URLSession.shared.data( for: imageReq)
    let (metadata, _) = try await URLSession.shared. data(for: metadataReq)
    
    guard
        let size = parseSize (from: metadata) ,
        let image = await UIImage (data: data)?.byPreparingThumbnail(ofSize: size)
    else {
        throw ThumbnailFailedError()
    }
    return image
}

다음과 같은 코드에서 try await부분만 살펴보게 되면, 

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

이 두 라인의 코드는 서로 dependency하지 않기에 parallel 하게 돌아갈 수 있지만, 

let (metadata, _ ) ... 코드의 결과는 let (data, _ ) ... 의 코드가 완료된 후 실행되게 된다.

 

Task 

Task는 저번 포스팅에서 말했듯이, 동기 contenxt에서 비동기 작업들을 수행할 수 있게 해 준다.

이는 "concurrently하게 실행하기 위한" asynchronous context를 제공하기 때문이다.

즉, 비동기 작업들을 concurrent하게 수행하기 위해선 Task를 생성할 필요가 있다.

 

하지만, 단순 async function을 await 키워드를 통해 호출하는 것은 Task를 생성하는 것이 아니다.

Task는 explicit하게 생성해주어야 한다. 

 

concurrent를 제공하는 각 Task의 relation에 따라

Structured ConcurrencyUnstructured concurrency를 나눌 수 있다.

 

Structured ConcurrencyTask간의 부모 - 자식 관계가 존재하는 Task Tree라는 개념을 사용한다. 

반면, Unstructured Concurrency는 위와 같은 계층구조가 존재하지 않는다.

 

Task Tree

Structured Concurrency는 Task Tree라는 계층 구조를 통해,

Cancellation, Priority, Local variables에 영향을 미친다.

 

Task Tree에서 Parent Task는 Child Task들의 모든 작업이 끝날 때까지 기다려야 하며

error와 같은 abnormal result에도 끝날 때까지 기다려야 한다.

 

fetchOnedatametadata라는 2개의 Child task가 있다고 가정하자. 

metadata가 error를 throw하면서 종료되었지만, data는 여전히 돌아가는 상태라 가정하자. 

그렇다면, Parent Task는 남은 child Task에게 cancelled를 마킹한다.

이렇게 마킹된 Task는 다시 그들의 Child Task에 마킹을 하여 propagated된다.

 

이는 실제로 Task를 멈추게 하는 것이 아닌, 해당 Task의 결과가 더 이상 필요 없다는 것을 의미하게 된다. 

network 작업 중인 경우 갑자기 끊어버리면 옳지 않기 때문에, 개발자가 이에 대한 적절한 조치를 취해야 한다. 

 

따라서, Swift에서는 cancellation에 대해 2가지 방법을 제공한다.

try Task.checkCancellation()
// cancelled가 마킹될때만 CancellationError를 throw한다. 

Task.isCancelled
// boolean값으로 cancelled가 마킹되면 true가 된다.

Swift에서 비동기 작업들을 concurrent하게 실행하기 위해, Task를 explicit하게 생성해주어야 했다. 

Structured Concurreny을 사용할 수 있도록 Task를 생성해 주는 2가지 방법이 있다.

  • async-let
  • Task Groups

 

 

Async-let Task

async letConcurrency binding으로

child Task를 생성함으로써, Structured Concurrency를 제공한다. 

async let result = URLSession.shared.data(...)   // async 함수

해당 키워드를 사용하게 되면, "="의 오른쪽 코드가 concurrently 하게 수행되며,

await키워드를 만나기 전까지 async let 아래의 코드가 멈추기 않고 수행된다.

func someFunction(for id: URLRequest) async throws -> Data {
    async let (result, _) = URLSession.shared.data(for: id)
    
    print("still run")
    
    // 여기까진 멈추지 않고 실행됨.
    return try await result // await 키워드를 봤을때, suspension
}

 

해당 동작을 자세하게 살펴보자.

 

  • async let 키워드를 만나게 되면, 현재 function(Parent Task)은 Child Task를 생성한다.
  • Child Task에서 작업이 수행되는 부분과 placeholder를 할당한다.
  • placeholder에 작업의 result를 binding한다. 
  • Parent Task는 suspension되지 않고, 작업을 계속 수행한다.
  • Child Task의 result가 필요하면, await키워드를 통해 placeholder에 작업의 결과가 fulfill될 때까지 기다린다.

 

즉, 간단히 얘기하자면 

async let 키워드를 만나면, Child Task를 생성하고 Parent Task는 계속 수행한다. 

await 키워드를 만나면 Child Task의 result가 나올 때까지 기다린다.

 

async let을 통해 이전의 코드를 concurrent하게 바꾸게 되면 아래와 같다.

 

 

 

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
}

이전과 다르게, 두 URLSession은 Concurrent하게 돌아갈 수 있게 된다.

 

 

Task Groups

async let은 concurrency한 작업의 개수가 정해져 있을 때 유용하지만,

아래와 같이 concurrency한 작업의 개수가 동적일 때는 Structured Concurrency 중 하나인 Task Groups를 사용한다.

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

Task Group의 생성할 때는 다음 두 메서드를 사용한다.

  • withTaskGroup(of:returning:body:)
  • withThrowingTaskGroup(of:returning:body:) : error를 throwing할 수 있다.
await withTaskGroup(of: // child Task의 result type
                    returning: // groupd Task의 return type
                    body: { group in 
                    // group에 child task의 result가 collection 타입으로 저장
})

group의 return type은 default하게 Child Task의 collection type으로 저장된다. 

따라서, filter, map, reduce와 같은 함수형 프로그래밍을 적용할 수 있다.

 

Child Task를 group에 추가하는 방법은 2가지가 존재하는데, TaskPriority타입의 우선순위를 줄 수 있다.

mutating func addTask(
    priority: TaskPriority? = nil,
    operation: @escaping () async throws -> ChildTaskResult
)

mutating func addTaskUnlessCancelled(
    priority: TaskPriority? = nil,
    operation: @escaping () async throws -> ChildTaskResult
) -> Bool

 

또한, group의 cancelAll()메서드는 group과 group내의 모든 task에 cancelled를 마킹한다. 

addTaskUnlessCancelled의 경우에는 group에 cancelled가 마킹되면 더 이상 task를 추가하지 않기 때문에,

cancelAll() 메서드의 호출 후로는 더 이상 작업을 추가하지 않게 된다.

 

func fetchThumbnails(for ids: [String]) async throws -> [String: String] {
    var thumbnails: [String: String] = [:]
    try await withThrowingTaskGroup(of: Void.self, body: { group in
        for id in ids {
            group.addTask {
                thumbnails[id] = try await someThrowError (for: id)
                print("ids")
            }
        }
    })
    return thumbnails
}

다음과 같이 group.addTask를 통해 Child Taks를 비동기로 group에 추가하게 되면,

suspension되는 것이 아닌 계속 for문을 진행한다.

 

하지만, 해당 코드는 thumbnails라는 변수에 각 childTask에서 write작업을 시도하기 때문에,

Swift에서 Data Race가 발생할 수 있음을 감지하고 complile Error가 나게 된다.

 

이렇게 감지가 가능한 이유는 @Sendable closure type 때문이다. 

 

 

@Sendable type

Swift에서 Task를 생성하면, 

Task는 @Sendable 타입의 클로져에서 이를 수행하는데,

해당 클로져는 mutable 변수의 capture를 금지한다. (위의 코드에선 thumbnails)

mutable 변수는 task가 진행되는 동안 값이 변경될 수 있기 때문이다.

 

위의 코드를 해당 조건에 맞춰 구현하게 되면 아래와 같다.

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
}

 

group내의 Child Task에서 error를 throw하게 되면,

async-let과 같이 group내의 모든 Child Task는 implicit하게 cancelled되고, awaited한다. 

하지만 정상적인 exit를 통해 group의 scope를 벗어나게 되면, cancellation은 implicit하지 않지 않고 awaited만 하게 된다.

 

 

Reference

WWDC 2021

Apple Documentation

Structured Concurrency in Swift: Using async let - Andy Ibanez

Structured Concurrency With Task Groups in Swift - Andy Ibanez

복사했습니다!