Swiftの非同期処理を比べてみる

はじめに

R&DチームでMOTやiOSアプリ開発を行なっている葉山です(前回のMOT記事はこちら)。

Swiftで非同期処理を行う場合、以前であればコールバックにより処理するか、PromiseKit・HydraといったPromise(Future)パターンを記述できるライブラリによって対応していました。また、iOS13以降であればCombineも選択肢の一つとなります。

しかし、Swift 5.5で Swift Concurrencyが追加され、async/awaitをはじめとした非同期・並行処理関連の機能が拡充されました。自分が現在開発を行なっているプロダクトでも、最近本格的にSwift Concurrencyの機能を利用し始めました。

Swift Concurrency は主に3つの要素(async/await・Task・アクターモデル)によって構成され、これらによりモダンで簡潔な非同期・並行処理を実現できるとされています。

それではSwift Concurrencyの登場によって、以前までの非同期処理ライブラリは必要とされなくなるのでしょうか?本記事ではSwift Concurrencyを最近導入し始めた自分が、今まで使用してきたPromiseKitやCombineとの比較を行なってみたいと思います。

本稿の情報は執筆時点(2022/3/22)のものです。特にSwift Concurrencyに関してはSwift 6等で今後更なるアップデートが入る予定であり、仕様が変更される可能性があります。

それぞれの概説

まずは各非同期処理パラダイムの概要を紹介します。

コールバックによる非同期処理

Swiftにおいて非同期処理を実現する一つの方法が、クロージャを用いたコールバックです。以下に実際の例を紹介します。

func downloadData(from url: URL,
    completion: @escaping (Data?, Error?) -> Void) {
    // エラーを返す場合は以下のようにErrorをcompletionに渡す
    completion(nil, error)
    // 正常に結果を返す場合は以下のようにDataをcompletionに渡す
    completion(data, nil)
}

// 呼び出し例
downloadData(from: url) { data, error in
    // このクロージャでdownloadData完了後の処理を記述
    // dataを処理し、errorをハンドリング
    ...
}

@escaping属性をつけたエスケーピングクロージャは、その関数のスコープから抜けた後も存在し実行することができます。上記の例のcompletionクロージャは、データ取得完了後の処理を定義するために使用されます。

このコールバックによる非同期処理の一番の問題は、可読性の低下でしょう。コールバックによる非同期処理を逐次的に行うことを考えると、ネストがどんどんと深くなってしまうことは容易に想像ができるかと思います。

// 全てコールバックによる非同期関数で、逐次的に処理を行う
doHoge() { foo in
    doFuga(foo) { bar in
        doPiyo(bar) { baz in
            ...
        }
    }
}

従って、上記のようなネスト地獄を避けるために、以降で紹介するような非同期処理ライブラリが使用されます。

PromiseKit

github.com

いわゆるPromise(Future)パターンをSwiftで利用可能にするOSSライブラリです。PromiseKitの典型的な実装パターンとしては、以下のようにthendoneなどで処理をチェインさせる構造になります。

// Promiseを返す非同期な関数
func hoge() -> Promise<Foo, Error>

firstly {
    hoge()
}.then {
    ...
}.done {
    ...
}.catch {
    // エラーハンドリング
    ...
}

firstlyは単なるシンタックスシュガーですので必須ではありませんが、可読性が上がるという理由で公式サンプルではよく使用されています。hoge().then { ... }から始めることも可能)

Promiseは処理完了を待ってから、その結果を取得し利用することができます。従って、上記の例ではhoge()は何か時間のかかる処理を実行し、その結果をPromiseとして返すため、完了後にthenで受け取って処理しています。このようにPromiseをチェイン構造で逐次的に処理していくのがPromiseKitの典型的な利用パターンです。

ちなみにPromiseKitでは、エラーを返さないPromiseとしてGuaranteeも用意されています(使い方としては同様)。

Combine

developer.apple.com

iOS13以降で使用可能となった、Reactive Programmingな実装を実現するためのフレームワークです。こちらもCombineの典型的な利用パターンを紹介します。

// AnyCancellable オブジェクトを保持
// AnyCancellable.cancel() によってキャンセルが可能
var cancellables = Set<AnyCancellable>()

// Publisher は値の変更を通知(発行)する
somePublisher
    // 多様なOperators によって値を処理
    .map {
        ...
    }
    .compactMap { $0 }
    .first()
    // Publisherの通知を購読(subscribe)する
    .sink {
        ...
    }
    .store(in: &self.cancellables)

Publisherによって変更を通知し、必要に応じてOperatorsで値を処理します。最後にPublisherを購読することで、値の変更イベントに応じて処理を行うような実装を実現することができます。

Swift Concurrency

最初に紹介した通り、Swift ConcurrencyはSwift 5.5で追加された非同期・並行処理関連の機能群です。本稿でその機能を全て紹介するには厳しい分量ですので、概説として以下の記事をオススメさせていただきます。

zenn.dev

Swift 5.5時点での Swift Concurrencyについて、非常にわかりやすく概説されている記事です。全容は上記記事をご参照いただくとして、ここでは概略だけ紹介させていただきます。

Swift Concurrencyは主に以下の3つの機能で構成されていると言えます。

  • async/await
  • Task関連の概念("構造化された並行性"と呼ばれている部分)
  • アクターモデル

本稿に最も関連のあるasync/awaitに関して簡単に紹介します。典型的な利用例として、WWDC21の Meet async/await in Swift より以下のサンプルを見てみます。

// 関数にasync がつけられていることで、これが非同期な関数であることがわかる
func fetchThumbnail(for id: String) async throws -> UIImage {
    let request = thumbnailURLRequest(for: id)
    // async で定義された関数を呼び出す際、await によって結果を待つことができる
    let (data, response) = try await URLSession.shared.data(for: request)
    guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw FetchError.badID }
    let maybeImage = UIImage(data: data)
    guard let thumbnail = await maybeImage?.thumbnail else { throw FetchError.badImage }
    return thumbnail
}

asyncキーワードは、それが非同期のコンテキストであることを意味します。awaitが付けられた箇所はサスペンションポイントと呼ばれ、処理が一時停止(サスペンド)される可能性があることを表します。現状(Swift 5.5時点)では、サスペンションポイントとなるのはawaitだけですので、コードを見ればどこで処理が一時停止するのかが一目でわかります。

ちなみに、既存のコールバックによる非同期関数は、withCheckedThrowingContinuationErrorを返さないならwithCheckedContinuation)でラップすることでasync/awaitに対応させることができます。

// 既存のコールバック関数(先ほどの例)
func downloadData(from url: URL,
    completion: @escaping (Data?, Error?) -> Void)
    
// withCheckedThrowingContinuation でラップ
func downloadData(from url: URL) async throws -> Data {
    try await withCheckedThrowingContinuation { continuation in
        downloadData(from: url) { data, error in
        if let error = error {
            continuation.resume(returning: data)
        } else {
            continuation.resume(throwing: error)
            }
        }
    }
}

比較

PromiseKitとCombineの関係

最も重要なポイントとしては、Promise(Future)パターンとReactive Programmingフレームワークでは対象とする用途が異なるという点です。イメージとしては、以下のような違いだと解釈しています。

  • PromiseKitが提供するPromise(Future)パターン:あるデータフローの一度きりの処理を表現するような用途に適している
  • Combineが提供するReactive Programmingフレームワーク:継続的に変化を検知して処理を行うような用途に適している

具体的な例で説明します。例えば、APIを叩いてその結果に対して処理を行うような非同期処理を一度呼び出す場合、それを記述するにはPromise(Future)パターンで問題なく対応できます。他方、例えば位置情報のような継続して変化が通知されるものに対して、その変化イベントごとに都度処理を実行する場合にはCombine(Reactive Programmingフレームワーク)が適しているでしょう。

ただし、Combineにおいても Future によってPromise(Future)パターン的に記述することが可能です。FuturePublisherの一種で、Operatorsで値を変換したりSubscriberで値を購読することが可能です。

(ざっくりと以下のようなイメージ)

// Futureを返す非同期関数
func hoge() -> Future<DataType, Never> {
    Future() { promise in
        // 何か時間のかかる処理
        ...
        // 完了時、promiseの実行によりFutureが値を発行
        promise(Result.success((value)))
    }
}

// 呼び出し元
hoge()
    // FutureはPublisherなので、Operatorsで処理したりSubscriberで購読したり
    .map { ... }
    .sink {
        ...
    }

また、当然ながらCombineはiOS13以降でのみ使用可能なので、それ以前のOSをサポートする必要のあるプロダクトでは代替手段を利用する必要があります。その場合には、Promise(Future)パターンを記述するためにPromiseKitなどを利用することも検討できるかと思います。

PromiseKitとSwift Concurrencyの関係

非同期の並行処理をPromiseKitとSwift Concurrencyで行うシンプルな例をそれぞれ比べてみましょう(いずれも公式サンプルから抜粋)。

まずはPromiseKitにおける例です。画像の取得と位置情報の取得という2つの非同期処理を並列に実行します。

let fetchImage = URLSession.shared.dataTask(.promise, with: url).compactMap{ UIImage(data: $0.data) }
let fetchLocation = CLLocationManager.requestLocation().lastValue

firstly {
    when(fulfilled: fetchImage, fetchLocation)
}.done { image, location in
    // imageとlocationを使って処理
    ...
}.catch { error in
    // エラーハンドリング
}

whenで全てのPromiseの完了を待ち、doneによって完了後の挙動を記述します。必要であればcatchによってエラーハンドリングを行います。

次にSwift Concurrencyによる例です。以下はWWDC21の Explore structured concurrency in Swift で紹介されていたサンプルの抜粋です。

// idに対応する画像とメタデータを取得し、画像をサムネイルに変換する関数
func fetchOneThumbnail(withID id: String) async throws -> UIImage {
    let imageReq = imageRequest(for: id), metadataReq = metadataRequest(for: id)

    // async let で定義することで子タスクに処理を移譲し並行に処理
    async let (data, _) = URLSession.shared.data(for: imageReq)
    async let (metadata, _) = URLSession.shared.data(for: metadataReq)

    // async let で定義した変数の参照地点でawait し、両方のデータの取得が完了するまでサスペンド
    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で定義することでその処理を子タスクに移譲し、await の地点で両方のデータ取得が完了するまで待ちます。これにより、画像の取得とメタデータの取得を並行に処理しています。

上述の比較から、async letはPromise(Future)と一対一ではないにしろ、近い機能要素であることがわかります(実際には、Swift ConcurrencyのPromise(Future)に対応する機能は、その他の構成要素も含めて成り立っているものと思われます)。

ちなみに、Swift Concurrencyのaswnc/awaitを、従来のPromiseKitのようなパターンで記述できるようにするライブラリも最近開発されているようです。

CombineとSwift Concurrencyの関係

多くの場合で、Swift Concurrencyを使って従来のCombineによる実装を置き換えることが可能だと思われます。例えば、以下にあげた例のようにCombineでの処理をSwift Concurrencyのasync/awaitを使って書き直してみます。

  • Combine
// API通信しJSONデコードする関数
func load(from url: URL) -> AnyPublisher<DataType, Error> {
    URLSession.shared
        .dataTaskPublisher(for: url)
        .map(\.data)
        .decode(type: DataType.self, decoder: JSONDecoder())
        .eraseToAnyPublisher()
}
  • Swift Concurrency (async/await)
// Swift Concurrencyで同様の処理を行う場合
func load(from url: URL) async throws -> DataType {
    let (data, _) = try await URLSession.shared.data(from: url)
    let decoder = JSONDecoder()
    return try decoder.decode(DataType.self, from: data)
}

Swift Concurrencyで書くことにより、PublisherやAnyCancellableのオブジェクトを自前で管理する必要がなくなり、よりシンプルに書ける場合も多いかもしれません。

しかし、そもそもCombineのようなReactive Programmingフレームワークは、あらゆる非同期処理に対応するための道具ではなく、限られた用途に向けたスタイルのフレームワークです。従って、Combineの演算子群を使った方がよりシンプルに書ける場合もあるかと思われます。例えば、throttledebounceで処理の実行間隔を制限したりremoveDuplicatesで重複を削除したり、要素ごとに変換したりといった場面では、変わらずCombineのOperatorsは便利です。

// CombineのOperatorsの例
somePublisher
    // 実行間隔を5秒に制限
    .throttle(for: .seconds(5), scheduler: DispatchQueue.main, latest: true)
    // それ以前の要素と重複するものは削除
    .removeDuplicates()
    // 条件に応じて要素を抽出
    .filter { ... }
    // nilでない戻り値だけにfilterする
    .compactMap { ... }

まとめ

本記事では各非同期処理パラダイムの比較を行い、それぞれの特色と構文の関係性などをみてきました。

PromiseKit・Combineの機能要素は簡潔であり、学習コストは比較的低いかと思います。また、Swift Concurrencyは単なるPromise(Future)だけでなく、Taskやアクターモデルなど主に3つの非同期・並行処理機能の要素をまとめたものとなります。そのため学習コストはPromiseKitやCombineと比べると少し高くなりますが、安全性などで有利なコードが書けるといった利点もあります。今後はSwift Concurrencyが主流にはなるかと思いますが、Promise(Future)パターンやCombineの方がシンプルに書ける場合もあるはずです。また旧OSのサポートにおいては各種非同期処理OSSライブラリが活用されるでしょう。開発するプロダクトに適したライブラリの選択が引き続き求められるかと思います。

オプティムではiOSに限らず、多様な領域にチャレンジしたい挑戦心を持った方を募集しています。興味があればぜひ採用情報もご覧ください。

www.optim.co.jp

参考にさせていただいた記事など

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

https://zenn.dev/akkyie/articles/swift-concurrency

https://zenn.dev/youandtaichi/articles/ae9450c9de0030

https://wwdcbysundell.com/2021/the-future-of-combine/