본문 바로가기

iOS/Concurrency

Concurrency (5) Intermediate async/await & Checked Continuation

이전글 - Concurrency(4) Custom Asynchronous Sequences With AsyncStream
 
Modern Concurrency in Swift 를 읽고 간단 정리


 

이전 글에서 NotificationCenter 처럼 기존 API에 async/await을 적용하는 것을 살펴보았다.

이번 글에서도 이어서 기존 코드들에 Swift Concurrency를 활용하는 방안에 대해 소개 !

 

Introducing Continuations

이전 애플의 주요한 비동기 방식

  • completion callbacks
  • delegate

https://www.kodeco.com/books/modern-concurrency-in-swift/v1.0/chapters/5-intermediate-async-await-checkedcontinuation

 

--> continuation을 활용하여 Swift 동시성 모델로 전환할 수 있다 !

 

 

Continuation

  • 특정 시점에  프로그램의 상태를 추적하는 객체
  • Swift 동시성 모델은 비동기 작업의 각 단위에 대해 전체 쓰레드를 생성하는 대신 Continuation을 할당
  • 동시성 모델은 쓰레드 생성을 CPU 코어 개수만큼으로 제한
  • 쓰레드 간의 전환 대신 Continuation 간의 전환이 이뤄져 더 효율적

https://www.kodeco.com/books/modern-concurrency-in-swift/v1.0/chapters/5-intermediate-async-await-checkedcontinuation

 

 

await 호출과 실행 중단

  • await 키워드를 호출하면 현재 코드의 실행을 일시 중단 (suspend)
  • 이때 함수의 상태들 (변수, 스택, 실행 흐름 등)은 heap 영역에 저장

Continuation의 생성과 사용

  • 코드가 await 호출로 중단될 때, Continuation이 생성
  • 비동기 작업이 완료되면, 이 Continuation을 이용해 중단된 상태를 복원하고 작업을 재개 (resume)

--> Continuation을 직접 만들어 callback, delegate 기반의 기존 코드를 비동기 모델에 맞게 확장할 수 있다 !

 

Creating Continuations Manually

CheckedContinuation

  • 중단될 실행을 재개하거나 오류를 던질 수 있는 매커니즘
  • 런타임 체크를 제공

UnsafeContinuation

  • 성능이 중요하고 안전성 검사가 필요 없을 때 CheckedContinuation의 대안

생성

resume

  • Continuation은 반드시 한 번 resume을 호출해야 함
    • 재개하지 않으면 해당 작업이 무기한으로 중단된 상태로 남음
    • ChekcedContinuation은 이러한 조건이 위반되는지 런타임 검사를 수행
    • UnsafeContinuation은 조건 검사를 런타임에 강제하지 않음
    • 이는 Swift 작업을 event loop, delegate method, callback 등 비동기 스케줄링 메커니즘과 연결하는데 있어 낮은 오버헤드를 목표로 하기 때문

 

[ resume을 0번 호출하는 경우 ]

  • Checked는 런타임에서 아래와 같은 콘솔 메세지 노출

  • Unsafe는 묵묵부답

 

[resume을 2회 호출하는 경우]

  • Checked는  런타임 에러 + 친절한 이유 설명

  • Unsafe는 불친절한 런타임 에러

 

Wrapping the Deleagte Pattern

이 책에서는 CoreLocation에서 위치를 찾는 동작을 async/await 코드로 wrapping 하는 방법을 소개한다.

 

delegate 패턴에서 현재 위치 값을 받아오기 위해 다음의 동작들이 필요하다.

  1. CLLocationManager를 이용하여 위치 권한 허용 요청
  2. 위치 권한이 허용되어 있다면 위치를 manager에서 확인하도록 요청
  3. CLLocationManagerDelegate에서 성공, 실패 여부를 받아서 처리

CheckedContinuation을 이용하여 변경할 수 있는데 주의할 점은 resume이 한 번 호출되지 않는 상황들(0회 또는 2회 이상)을 방어하는 것이다.

@MainActor
class BlabberModel: ObservableObject {
  private let manager = CLLocationManager()
  private var delegate: ChatLocationDelegate?
  
  ...
  
  /// Shares the current user's address in chat.
  func shareLocation() async throws {
    let location: CLLocation = try await withCheckedThrowingContinuation { [weak self] continuation in
      guard let self else {
        continuation.resume(throwing: "self is not known referenced")
        return
      }
      
      self.delegate = ChatLocationDelegate(manager: self.manager, continuation: continuation)
      
      if self.manager.authorizationStatus == .authorizedWhenInUse {
        self.manager.startUpdatingLocation()
      }
    }
    
    print(location.description)
    self.manager.stopUpdatingLocation()
    self.delegate = nil
  }
}

 

import Foundation
import CoreLocation

class ChatLocationDelegate: NSObject, CLLocationManagerDelegate {
  typealias LocationContinuation = CheckedContinuation<CLLocation, Error>
  
  private var continuation: LocationContinuation?
  
  init(manager: CLLocationManager, continuation: LocationContinuation) {
    self.continuation = continuation
    super.init()
    manager.delegate = self
    manager.requestWhenInUseAuthorization()
  }
  
  deinit {
    self.continuation?.resume(throwing: CancellationError())
  }
  
  func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
    switch manager.authorizationStatus {
    case .notDetermined:
      break
    case .authorizedAlways, .authorizedWhenInUse:
      manager.startUpdatingLocation()
    default:
      self.continuation?.resume(throwing: "The app isn't authorized to use location Data")
      self.continuation = nil
    }
  }
  
  func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
    guard let location = locations.first else { return }
    self.continuation?.resume(returning: location)
    self.continuation = nil
  }
  
  func locationManager(_ manager: CLLocationManager, didFailWithError error: any Error) {
    self.continuation?.resume(throwing: error)
    self.continuation = nil
  }
}

 

  • 위치 권한을 허용하지 않았을 경우 LocationManager는 초기 1회에 에러를 던지고 그 이후에는 던지지 않는다.
  • 이 때문에 deinit에서 continuation resume 처리를 추가로 해주고 있다.
  • CancellationError는 작업이 취소되었을 때 알려주는 스탠다드한 방법이라고 책에서 소개

Wrapping Callback APIs With Continuation

이번에는 completion callback을 async/await 코드로 wrapping하는 방법을 소개한다.

 

CLLoaction을 String으로 변환해주는 Encoder가 아래와 같이 있다.

enum AddressEncoder {
  /// Converts the given location into the nearest address, calls `completion` when finished.
  ///
  /// - Note: This method is "simulating" an old-style callback API that the reader can wrap
  ///   as an async code while working through the book.
  static func addressFor(location: CLLocation, completion: @escaping (String?, Error?) -> Void) {
    let geocoder = CLGeocoder()

    Task {
      do {
        guard
          let placemark = try await geocoder.reverseGeocodeLocation(location).first,
          let address = placemark.postalAddress else {
            completion(nil, "No addresses found")
            return
          }
        completion(CNPostalAddressFormatter.string(from: address, style: .mailingAddress), nil)
      } catch {
        completion(nil, error)
      }
    }
  }
}

 

사용하는 쪽에서 Continuation으로 묶어주면 끝 !

    let address: String = try await withCheckedThrowingContinuation { continuation in
      AddressEncoder.addressFor(location: location) { address, error in
        switch (address, error) {
        case (nil, let error?):
          continuation.resume(throwing: error)
        case (let address?, nil):
          continuation.resume(returning: address)
        case (nil, nil):
          continuation.resume(throwing: "Address encoding Failed")
        case let (address?, _):
          continuation.resume(returning: address)
        }
      }