본문 바로가기
iOS/Swift

Explore structured concurrency in Swift

by 바등쪼 2023. 2. 11.

 

  • 프로그래밍 언어의 초기에는 instructions의 순서대로 작성되어 가독성이 좋지 않았다.
  • 하지만 오늘날의 언어는 structured programming을 통해 control-flow가 통일성(uniform)을 가지게 되어 가독성이 좋아졌다. (if - then 구문)
  • Swift는 Static Scope를 사용한다.

 

Concurrency에서의 Structured Programming


  • 썸네일 이미지를 가져오는 전통적인 방식의 비동기 처리 코드 (escaping closure 사용)
    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 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())
                }
                fetchThumbnails(for: Array(ids.dropFirst())) { thumbnails, error in
                    // ... add image to thumbnails ...
                }
            }
        }
        dataTask.resume()
    }
  • async/await 키워드를 이용한 structured code
    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
    }
    • no nested
  • 만약 위의 코드에서 수천개의 썸네일을 받아온다면? 한개의 썸네일씩 처리하는 것은 매우 비효율적이다.⇒ Concurrency 필요
💡
Task - task는 코드를 concurrently하게 처리하는 새로운 aysnc context이다. - Swift는 task의 사용을 체크하여 동시성 버그를 예방하도록 돕는다. - aync function을 호출한다고 해서 task가 생성되는 것은 아니다 ⇒ 명시적으로 task를 생성해야 함

 

Async-let tasks


  • async-let binding
  • Sequential Bindings
    • 기존의 직렬 바인딩은 위의 사진의 화살표 순서대로 진행된다.
      1. 앞의 statement 이후에 Initializer(URLSession.shared.data(…))을 evaluate
      1. 썸네일 다운로드를 끝내고 변수(let result)에 Assign
      1. 다음 statement 진행
    • 코드
      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
      }
    • 하지만, 썸네일을 다운로드하는 작업은 시간이 꽤 걸릴 수 있기 때문에 해당 작업을 진행하는 동안 다른 일을 할 수 있도록 하는 것이 효율적이다.

 

  • Concurrent Bindings (with async-let)
    • 진행 순서
      1. 앞의 statement 이후에 새로운 child task를 생성
      1. Initializer을 evaluate하는 것과 동시에 변수(result)에 placeholder assign
      1. 썸네일 다운로드가 진행되는 것과 동시에 다음 statement 수행
      1. 만약 실제로 result 값이 필요한 상황이 오면 parent task는 child task의 수행 완료를 기다린다. (await)
      1. 위의 예시처럼 error가 발생할 수 있는 코드에는 try를 붙인다.
    • 코드
      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
      }
      • async 키워드를 let 앞에 붙여서 concurrent 하게 작업을 수행할 수 있다.
      • 위와 같은 Task 작업은 Task tree에 속하게 된다. (Structred concurrency의 중요한 부분)
      • fetchOneThumnail 함수 수행에서의 task tree
      • Parent Task는 Child Task가 모든 작업을 끝내야 자기 자신의 일을 끝낼 수 있다.
        • 이러한 순서는 child task에서 error가 발생했을 때도 동일하게 동작한다.
          • metadata를 불러오는 과정에서 error가 발생하더라도 곧바고 Parent task가 error를 발생시키는 것이 아니라 다른 child의 작업의 완료 되고 나서 error를 던진다.
            • 단, 다른 child task에 unawaited를 부여하여 취소로 표기한다. 취소로 표기하여도 작업이 중지되는 것은 아니다.
            • 단순히 그 결과게 더 이상 필요하지 않다는 것을 task에 알리는 것이다.
            • task가 취소되면 해당 task의 하위 task도 자동으로 취소된다.
        • 위와 같은 작업 방식의 보장은 ARC가 메모리 수명을 자동으로 관리하는 것처럼 작업 수명 관리를 도와 실수로 작업이 유출되는 것을 방지한다.
      💡
      Cancellation is Cooperative - Task는 cancel 되어도 즉시 멈추지 않는다. - Cancellation은 어떤 곳에서도 확인될 수 있다. - Cancellation을 염두에 두면서 코드를 작성해야한다.
      • Cancellation checking
        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
        }
        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가 도중에 cancel 되었을 때 오직 일부분의 딕셔너리 결과값만 함수의 반환값으로 return 된다. (이러한 함수를 작성한다면 사용자가 부분적으로만 결과를 얻을 수도 있다는 것을 알려야한다.)

 

Group tasks


  • async-let task는 concurrency의 amount가 정해져 있을 때 사용된다. (static할 때)
    • 위의 fetchThmbnails 예시에서 ids를 반복문의 다음 반복이 시작하기 전에 child task가 완료되어야 한다. 만약 fetchOneThumbnail 작업들이 동시에 진행되도록 하고 싶다면 동적인 concurrency amount를 위한 장치가 필요하다.
  • Group tasks는 concurrency의 dynamic amount를 위해 설계되었다.
  • 코드
    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
    위와 같이 코드를 작성하면 data race가 생기기 때문에 컴파일러는 에러를 발생시킨다. 둘 이상의 child tasks가 썸네일을 동시에 thumnails 딕셔너리에 insert를 시도할 경우 충돌이 발생하기 때문이다.
    • data race 문제를 해결한 코드
      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
      }

 

💡
Data-race safety - Task는 @Sendable closure라는 새로운 closure type안의 작업을 수행한다. - mutable variable은 capture 할 수 없다. - 오직 값 타입, actors, 또는 클래스와 같이 자신만의 synchronization을 수행하는 것들만 capture 할 수 있다.

 

 

Unstructured tasks


  • 모든 task가 structured pattern에 적합한 것은 아니다.
    • Some tasks need to laucn from non-async contexts
    • Some tasks live beyond the confines of a single scope
  • Unstructured tasks
    • origin context의 actor isoloation과 priority를 물려받는다.
    • lifetime은 어떤 범위에 국한되지 않는다.
    • 어디에서나 실행할 수 있다. (non-async function 에서도 실행 가능)
    • 수동으로 cancel 하거나 await 해야한다.
  • 예시 코드
    @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)
            }
        }
    }

 

 

Detached tasks


  • Detached tasks
    • Unscoped lifetime, manually cancelled and awaited
    • origin context로 부터 어떠한 것도 물려받지 않는다.
    • Opitonal parameters controls priority and other traits
  • 코드 예시
    @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) {
                    writeToLocalCache(thumbnails)
                }
                display(thumbnails, in: cell)
            }
        }
    }

 

 

 

요약


 


Uploaded by

N2T

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

ARC in Swift: Basics and beyond  (0) 2023.04.13
Understanding Swift Performance  (2) 2023.03.13
Meet AsyncSequence  (0) 2023.02.11
Use async/await with URLSession  (0) 2023.02.11
Meet async/await in Swift  (0) 2023.02.11

댓글