본문 바로가기

Swift 공부

WWDC 2021 Explore structured concurrency in Swift 정리

WWDC 정리글 2차 어제 async await 영상을 정리했는데 관련 영상인 Swift의 구조화된 동시성 영상도 여유가 있을때 미리 정리해두려고한다.

 

프로그래밍은 시간이 지나면서 위에서 아래로 코드를 읽는 형태로 발전해나갔고 구조화잘되있고 정리가 잘 된 위 -> 아래 코드블럭은 개발자의 가독성이 높아진다.

 

func fetchThumbnails(
    for ids: [String],
    completion handler: @escaping ([String: UIImage]?, Error?) -> Void
) {
    guard let id = ids.first else { return handler([:], nil) }
    let request = thumbnailURLRequest(for: id)
    let dataTask = URLSession.shared.dataTask(with: request) { data, response, error in
    	//기존의 코드는 이곳에서 guard let / if let 을 통해 데이터를 분류하게되고
        //에러가 발생했을경우 handler를 통해 에러를 방출시켜주는 작업을 컴플리션 블럭안에서 한번
    	guard let response = response,
              let data = data
        else {
            return handler(nil, error)
        }
        // ... check response ...
        UIImage(data: data)?.prepareThumbnail(of: thumbSize) { image in
            guard let image = image else {
                return handler(nil, ThumbnailFailedError())
            }
            //그리고 이미지에 데이터를 넣는 과정이 for문이기때문에 이안에서 또다시 핸들러를 한번 처리를 진행해줘야한다.
            fetchThumbnails(for: Array(ids.dropFirst())) { thumbnails, error in
                // ... add image to thumbnails ...
            }
        }
    }
    dataTask.resume()
}

 

 

위와 같은 코드는 가독성이 떨어질뿐더러 지난번 영상에서 이야기했던거의 연장으로 핸들러 처리를 까먹으면 실행이 취소가 되지않는 이슈가있다.

 

하지만 아래의 async / await 로 정리된 코드를 보면 위의 코드보다 가독성도 좋아질뿐더러 핸들러처리를 컴파일단계에서 잡아주기때문에 개발자의 실수를 잡아줄수있다

func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
    var thumbnails: [String: UIImage] = [:]
    for id in ids {
        let request = thumbnailURLRequest(for: id)
        let (data, response) = try await URLSession.shared.data(for: request)
        try validateResponse(response)
        guard let image = await UIImage(data: data)?.byPreparingThumbnail(ofSize: thumbSize) else {
            throw ThumbnailFailedError()
        }
        thumbnails[id] = image
    }
    return thumbnails
}

 

하지만 위의 코드에서도 문제가있다 만약에 다운받아야하는 이미지가 수천개라면? 해당작업을 한번에 하나씩 처리하는건 비효율적이다.

 

이런 비효율성을 해결하기위해 swift에서는 task라는 새로운 기능을 추가해주었다.

 

task는 비동기 코드를 실행을 도와주고 다른 컨텍스트와 관련하여 동시에 실행을 도와준다 (동시성)

task는 안전하고 효울적으로 작업이 동시에 실행되도록 자동으로 예약을해준다.

비동기 함수를 호출해도 해당 호출에 대한 새 작업이 생성되지않는다 

자세한 설명

  1. 작업의 연속성: 비동기 함수를 호출할 때, 이는 현재 실행 중인 Task의 일부로 간주됩니다. 새로운 독립적인 Task가 생성되지 않습니다.
  2. 컨텍스트 공유: 호출된 비동기 함수는 호출자의 Task 컨텍스트를 공유합니다. 이는 우선순위, 취소 상태 등을 포함합니다.
  3. 리소스 효율성: 각 비동기 호출마다 새 Task를 만들지 않음으로써 시스템 리소스를 효율적으로 사용합니다.
Task {
    let result1 = await asyncFunction1()
    let result2 = await asyncFunction2()
    print(result1, result2)
}

아래의 코드에서 하나의 Task만 생성이되고 asyncFunction1과 asyncFunction2는 새로운 Task를 생성하지않고 동일한 Task내에서 순차적으로 실행된다

 

 

Task {
    let result1 = await asyncFunction1()
    let result2 = await asyncFunction2()
    print(result1, result2)
}

vs 

Task {
    async let result1 = asyncFunction1()
    async let result2 = asyncFunction2()
    await print(result1, result2)
}

 

 

위와 아래의코드의 차이를 보자 하나는 함수앞에 await키워드를 사용하였고

아래의 코드는 let 앞에 async키워드를 사용하였다 이게 무엇을 의미하냐?

 

위의 코드의 순서는 result1이 들어오고 result2를 불러오는 결과를 일으키지만

아래의 코드는 result1, result2 동시에 요청하여 둘중에 누가먼저들어올지 알수가없게된다

 

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
}

 

위의 코드에서 만약 data를 가져오는 과정에서 문제가생겼다면? swift에서는 해당 함수가 실행될수없음을 인지하고 실행을 취소시켜준다.

 

중요한점? data / metadata를 가져오는 요청은 모두 실행은되었지만 둘중 하나의 값이 오류가되어 처리가 될수가없다면 다른 하나의값을 사용하지않거나 요청을 취소시켜 리소스를 효율적으로 관리할수있게해준다.

 

 

 

동적으로 갯수가 변하는 썸네일 이미지데이터를 여러 이미지를 동시요청 처리는 어떻게 처리할수있을까?

내부적으로 async let을 통해서 가져온다면 좋겠지만 몇개의 이미지가 있을지 예상이 되지않는 상황에서

이런 작업은적절하지 않다라고 느껴진다.

 

그래서 swift에서는 taskgroup이라는 기능을 제공해주는데 이 함수는 withThrowingTaskGroup이라는 키워드로 관리가 되는데 간단하게 이야기하자면 

 

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.async {
                // Error: Mutation of captured var 'thumbnails' in concurrently executing code
                thumbnails[id] = try await fetchOneThumbnail(withID: id)
            }
        }
    }
    return thumbnails
}

 

해당코드에서 동시에 요청하는 갯수가 동적으로 변하는 id에 대해 fetchOneThumbnail의 요청이 모두 처리되었을때의 타이밍을잡아준다.

 

하지만 위의 코드는 문제가 발생할수있다. 딕셔너리 구조를 동시성을 통해 딕셔너리에 파일을 등록한다면 이는 문제를 일으킬수있다는 점이다.(Data Race)

Swift에서는 이런 문제를 해결하기위해 @Sendable 새롭게 제공해줍니다. (추후 Actor에서 한번더 공부)

 

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.async {
                return (id, try await fetchOneThumbnail(withID: id))
            }
        }
        // Obtain results from the child tasks, sequentially, in order of completion.
        for try await (id, thumbnail) in group {
            thumbnails[id] = thumbnail
        }
    }
    return thumbnails
}

 

withThrwoingTaskGroup키워드에 어떤걸 방출하는지 키워드를 입력해주고 

 

추가된 아래의코드를통해

 

// Obtain results from the child tasks, sequentially, in order of completion.
        for try await (id, thumbnail) in group {
            thumbnails[id] = thumbnail
        }

 

코드를 통해 데이터레이스 문제가 발생하지않도록  썸네일s 에 순차적으로 키벨류가 들어갈수있도록 처리해준다 

 

여기에 관련된 추가적인 지식은 AsyncSequence 에서 추가적으로 확인해보도록 해야겠다.

 

만약에 위의 과정에서 뭔가 문제가생겨 모든 작업들을 취소해야한다면 group의 cancelall을 이용해 모든 위에서 아래로 취소를 전파할수있습니다.

 

 

그리고 위의과정에서 하위작업중에 오류가 발생한다면 어떻게 처리될까를 소개하는 과정에서 포크조인 패턴이라는게 나오는데

처음들어보는 패턴이기에 정의해본다. 

포크-조인 패턴 (Fork-Join Pattern)

포크-조인 패턴은 병렬 프로그래밍에서 사용되는 알고리즘 디자인 패턴으로, 큰 작업을 작은 부분으로 재귀적으로 분할하여 병렬로 처리한 후 결과를 합치는 방식입니다.

주요 개념

  1. 포크 (Fork):
    • 큰 작업을 더 작은 하위 작업들로 분할합니다.
    • 각 하위 작업은 독립적으로 병렬 실행될 수 있습니다.
  2. 실행 (Execution):
    • 분할된 작업들이 병렬로 실행됩니다.
    • 각 작업은 재귀적으로 더 작은 작업으로 분할될 수 있습니다.
  3. 조인 (Join):
    • 모든 하위 작업이 완료되면 결과를 결합합니다.
    • 최종 결과를 생성하기 위해 부분 결과들을 합칩니다.

작동 방식

  1. 문제를 작은 부분으로 나눕니다 (포크).
  2. 각 부분을 병렬로 해결합니다 (실행).
  3. 부분 해결책들을 하나의 결과로 결합합니다 (조인).

장점

  • 효율적인 병렬 처리로 성능 향상
  • 복잡한 문제를 더 작고 관리하기 쉬운 부분으로 분해
  • 재귀적 알고리즘에 적합

단점

  • 오버헤드가 발생할 수 있음 (작업 분할 및 결과 병합 과정)
  • 너무 작은 작업으로 분할 시 성능 저하 가능
  • 병렬화에 적합하지 않은 문제에는 비효율적일 수 있음

 

 

Task { } 를 통해 구성된 작업은 시작된 컨텍스트의 액터를 계속 상속하고 그룹 작업이나 async let과 마찬가지로 원본 작업의 우선순위및 기타 특성들도 모두 상속한다. 하지만 Task.detached를 통해 꼭 상속을 받지 않는 방식도 사용할수있다.

하지만 이렇게 직접 Task { } 를통해 관리된값은 자동으로 취소가되는 과정이 없기때문에 꼭 취소해주는 과정을 거쳐야한다.