machio Development Diary

思いつきで技術的なことをつらつらと

【2023年版】数あるSwiftの非同期処理の実現方法をきちんと理解した上で使い分けよう

これは Qiita iOS Advent Calendar 2023 の13日目の記事です。

概観

背景

この記事の目的一言で表すと、まさにタイトルの通り、この記事だけで『数あるSwiftの非同期処理の実現方法を、きちんと理解した上で使い分けられるようになる』ということになります。

モバイルアプリに限らず、開発者であれば誰でも触れ続けることが約束されている非同期処理。Swiftに関して言うと GCDOperationQueueThread など、昔から様々な関連クラスがあった上に、iOS13から追加された Combine によってリアクティブな記述が可能になったり、Swift5.5から登場した Swift Concurrency によってさらに複雑さを増しました。

困った時のためのHow to記事は世の中に溢れていますが、「なぜそう書く?」というところまで突き詰められているものが少なかったり、昔からあるAPIとSwift Concurrencyを比較してくれているものが無かったりということに気が付きました。

ちょうど社内で勉強会をしたという背景もあり、今一度あらためて全てを洗ってみよう、そして同じようなことで困っているiOSエンジニアの皆さんの力になりたいというのがこの記事になっています。
例によってマサカリやご意見は大歓迎です。皆さんのお力も借りて、よりよい記事にしていければと思っております。

こんな人へ向けた記事

  • Xcodeに怒られたからDispatchQueue.main.async {} を脳死で書き足した経験がある
  • SwiftのコアライブラリにThreadクラスがあることをそもそも知らない
  • Swift Concurrency にあまりついていけていない
  • iOS開発で 非同期処理全般を理解せずに感覚で書いている

この記事で扱う非同期処理の実現方法

以下の5つについて扱います。

  • GCDを用いた非同期処理
  • Operation、OperationQueueクラスを用いた非同期処理
  • Threadクラスを用いた非同期処理
  • Combineを用いた非同期処理
  • Swift Concurrency を用いた非同期処理

PromiseKitやRxSwiftなどの高名なサードパーティライブラリを用いた方法もありますが、2023年末現在ではトレンドとしてはすでにかなり下火のようなので、本記事では扱いません。

早速見ていきましょう。

GCDを用いた非同期処理

GCDとは

GCDとは、Grand Central Dispatch の略で、非同期処理を行うための低レベルAPI群です。iOS4.0、Mac OS X 10.6から導入された、C言語ベースのシステムレベルの技術であり、コアライブラリの libdispatch に実装されています。

特徴として、スレッド管理を全く意識せずに非同期処理を実現できる ということが挙げられます。タスクをキューに追加しておくと スレッドプール によって処理が随時実行されるという簡単な仕組みになっており、プログラマはスレッド自体は一切操作することなく、タスクをキューに追加するだけで非同期処理を実現できます。スレッドの管理はシステムが行い、CPUのコア数や負荷の状況などを考慮して自動で最適化を行ってくれます。

スレッドプールとは、非同期処理を実現するためデザインパターンの1つです。あらかじめ用意しておいたスレッドを使い回すことで、スレッドを頻繁に生成・破棄することによるオーバーヘッドを回避できます。

Thread Poolingの仕組み

DispatchQueue

DispatchQueueとは、GCDにおけるキューの呼称です。よく見るあいつはGCDにおける機能群の一つだったんですね。

DispatchQueueへのタスク引き渡し

DispatchQueueには2種類のタスクの引き渡し方式があります。

  • 並列(Concurrent)方式

    • (CPUに余力があれば) 現在実行中の処理の終了を待たずに、次の処理を並列して実行してくれます
    • 処理の順番は保証されませんが、全体的に早く処理されます
  • 直列(Serial)方式

    • 現在実行中の処理の終了を待ってから、次の処理を実行します
    • 常に1つずつ順番通り処理されるので、順番が重要なタスクはこちらを用いるべきです

DispatchQueueの優先度

DispatchQueueには、キュー自体に実行優先度を指定できる仕組みがあります。
この用途に応じたタスクの実行優先度を分類して表したものを QoS (Quality as Service) と呼びます。

分類 概要
userInteractive ユーザからの入力に対して即座に反映させるべき処理に用いられる
例) アニメーションの実行など
userInitiate ユーザからの入力を受けて実行される一般的な処理に用いられる
例) ボタンがタップされた時の内部処理など
default userInitiate と utility の間の優先度で、QoSを指定しない場合に利用される
ユーザによって明示的に指定されるべきではない
utility 視覚的な情報の更新を伴いながらも、即時の結果を要求しない処理に用いられる
例) プログレスバー付きのダウンロードなど
background バックグラウンドで行われて、数分から数時間かかっても問題ない処理に用いられる
例) データのバックアップなど

DispatchQueueの分類

DispatchQueueには以下の3種類の分類があります。

分類 個数 生成方法 タスク引渡し 概要
Main 1つ デフォでOSが用意 直列方式 ・DispatchQueue.main で呼び出せる
・メインスレッドでタスクを実行する
・UIの更新はこのキューで行われなければならない
Global 5つ デフォでOSが用意 並列方式 ・DispatchQueue.global(qos: ) で呼び出せる
・QoSの数だけ存在し、QoSで優先順位を定義する
Private 自由 ユーザが生成 ユーザーが指定 ・MainとGlobalだけだと不都合がある場合に利用する
・QoSやタスク引渡し方式を指定することが出来る

脳死で書いていた DispatchQueue.main.async {} は 『OSが用意してくれている Main Dispatch Queue というキューに、直列方式でタスクを追加する』という実装だったということがここまででわかると思います。

これら3つの使い分けですが、特別な必要性がない場合には Main と Global のみを利用、本当にOSが用意しているものでは不十分だった際のみ Private Dispatch Queue を作成するという方針でよいと思います。 (ただ、10年弱iOSを書き続けていて、Private Dispatch Queueが必要だったケースはほぼほぼありませんでした。)

(Private Dispatch Queue)

上記の通り、使うことはほぼないと思いますが、簡単な使い方を記載しておきます。 DispatchQueue型のイニシャライザから生成するのですが、その際以下の引数を受けます。

  • label: キューの名前を指定します
    • 一般的に逆順DNS(Domain Name System)形式で命名します
  • os: キューの優先度をQoSで指定します
  • attributes: キューの様々なオプションを指定します
    • .concurrent: キューが並列になります(デフォルトは直列)
    • .initiallyInactive: タスクをキューの中で待機させ、手動で .activate() するまで実行しないようにします
let privateQueue = DispatchQueue(
    label: "com.my_company.my_app.upload_queue",
    qos: .default,
    attributes: [.concurrent])

GCDの使い方

DispatchQueueの代表的な操作のサンプルコードを記載します。
例として Global Dispatch Queue (QoSは userInteractive) を操作しています。

// Queueに同期的にタスクを追加する(sync)
DispatchQueue.global(qos: .userInteractive).sync {
    // Task codes
    // 他の場所で同じQueueが使われていた場合、デッドロックが発生する可能性も
}

// Queueに非同期的にタスクを追加する(async)
DispatchQueue.global(qos: .userInteractive).async {
    // Task codes
}

// 直列処理タスクを追加する(.barrier option)
DispatchQueue.global(qos: .userInteractive).async(flags: .barrier) {
    // Task codes
    // .barrierオプションとともに追加されたタスクについては、1つずつ順番に処理を行う
    // 並列処理ディスパッチキューの中で例外的に直列処理したいタスク群が存在する場合に使用する
}

// タスクを遅延追加する(asyncAfter)
DispatchQueue.global(qos: .userInteractive).asyncAfter(deadline: .now() + 10) {
    // Task codes
    // 設定時間経過後にタスクが追加される設定時間経過後にタスクが追加される。
}

// タスクの一時停止と再開(suspend, resume)
DispatchQueue.global(qos: .userInteractive).suspend()
DispatchQueue.global(qos: .userInteractive).resume()

Operation、OperationQueueクラスを用いた非同期処理

Operation、OperationQueueクラスとは

Operation、OperationQueueは、コアライブラリである Foundation に実装されている、非同期処理を実現するためのクラスです。

Operation は、実行されるタスクとその関連情報をオブジェクト指向的手法でカプセル化したもの、OperationQueueは、Operationを優先度と準備状態に基づき実行するキューとなっております。

内部ではGCDを用いるため、GCDと同様にプログラマはスレッド自体は一切操作することなく、タスクをキューに追加するだけで非同期処理を実現できます。しかし、GCDでは Main Dispatch Queue と Global Dispatch Queue をOSが用意してくれていたのに対し、こちらは OperationQueue というキューを自前で生成しなければならないという違いがあります。

一方で、出来ることベースだけで考えるとGCDの上位互換になっているとも言えます。

以下で具体的な利用方法を解説し、最後にGCDと比較します。

Operation、OperationQueueクラスの使い方

タスクの定義とキューへの追加

先述の通り、Operation は、実行されるタスクとその関連情報をオブジェクト指向的手法でカプセル化したものです。
Operation のサブクラスとしてタスクを定義する形式にすることで、扱いやすいインタフェースを提供しています。

タスクの処理自体は、overrideした main() メソッドの中に記述します。
変数をプロパティとして管理することで、タスクの関連情報も簡潔に利用することができます。

以下に実装例を挙げます。

class MyOperation: Operation {
    // タスクの関連情報をプロパティとして保持する
    let myProperty: Int

    init(myProperty: Int) {
        self.myProperty = myProperty
    }

    // main()関数をoverrideすることでタスクの処理を記述する
    override func main() {
        print(myProperty)
    }
}

そして、OperationQueueは、Operationを優先度と準備状態に基づいて実行するキューです。
GCDと違って、最初から用意されているキューは存在しないため、コンストラクタによって自前で生成して利用します。
同時に並列処理できるタスクの数 と QoS をプロパティとして指定することが出来ます。

インスタンスメソッドである addOperation(_:)、もしくは addOperations(_, waitUntilFinished:) によってタスクを追加することで、即座に処理が実行されます。
waitUntilFinished はBool型で、trueを与えることで、第一引数で与えた全てのタスクの実行が終わるまで、呼び出したスレッドをブロックするように出来ます。

let queue = OperationQueue()
queue.maxConcurrentOperationCount = 2 // 同時に並列処理できるタスクの数を設定
queue.qualityOfService = .userInitiated // QoSを設定

queue.addOperation(MyOperation(myProperty: 0))
queue.addOperations([MyOperation(myProperty: 1), MyOperation(myProperty: 2)], waitUntilFinished: false)

タスク間の依存関係の設定

Operation、OperationQueueクラスのGCDに対する優位性の1つに、タスク間の依存関係を簡単に記述できる というものがあります。
ここでの依存関係とは「タスクAはタスクBが完了した後でないと実行しない」という関係を意味しています。

この記述には、Operationクラスの addDependency(: ) メソッドを用います。あるタスクに対して、それよりも先に実行されるべきタスクを引数に渡すことで、簡潔に依存関係を作成することが出来ます。

2つのタスクが、それぞれ別のスレッドで実行予定のタスクだったとしても依存関係はきちんと保たれます。

class OperationA: Operation {
    override func main() {
        print("OperationA is executed.")
    }
}

class OperationB: Operation {
    override func main() {
        print("OperationB is executed.")
    }
}

let queue = OperationQueue()
queue.maxConcurrentOperationCount = 1
queue.qualityOfService = .userInitiated

// [OperationA, OperationB] の順番で入ったOperationの配列を用意する
var operations = [Operation]()
operations.append(OperationA)
operations.append(OperationB)

// OperationB が完了するまで OperationA を実行しないように関連付ける
operations[0].addDependency(operations[1])

// OperationQueueに [OperationA, OperationB] を追加
queue.addOperations(operations, waitUntilFinished: false)

// 実行結果
// --> OperationB is executed.
// --> OperationA is executed.

上記の例では、OperationQueueのmaxConcurrentOperationCount(同時に並列処理できるタスクの数)が 1 に設定されているので、依存関係を記述しなければ、本来はキューに追加された順に OperationA → OperationB の順番で実行されるはずです。ですが、「OperationAは必ずOperationBの後に実装する」という依存関係を定義したことで、OperationB → OperationA の順に実行されることが確約されるようになりました。

このように、Operation、OperationQueueクラスを用いることで、タスク間の依存関係を簡単に記述できます。

タスクのキャンセル

Operation、OperationQueueクラスのGCDに対するもう1つの優位性が、キューに追加したタスクを簡単にキャンセルできる というものがあります。

GCDは、タスクをクロージャで渡すという記述方法であるため、1度キューに積んだタスクを参照することがそもそも難しく、キューに追加後のタスクをキャンセルすることは非常に困難です。

一方で、Operationクラスには cancel() というメソッドが用意されており、これによって簡単にタスクをキャンセルすることができます。

class MyOperation: Operation {
    let myProperty: Int

    init(myProperty: Int) {
        self.myProperty = myProperty
    }

    override func main() {
        print("Operaion  \(myProperty) is executed.")
    }
}

let queue = OperationQueue()
queue.maxConcurrentOperationCount = 1
queue.qualityOfService = .userInitiated

// プロパティが 0 ~ 5 のMyOperationの配列を用意して、キューにまとめて積む
var operations = [Operation]()
for i in 0..<6 {
    operations.append(MyOperation(myProperty: i))
}

queue.addOperations(operations, waitUntilFinished: false)

// プロパティが3のタスクをキャンセルする
operations[3].cancel()

// 実行結果
// --> Operation 0 is executed.
// --> Operation 1 is executed.
// --> Operation 2 is executed.
// --> Operation 4 is executed.
// --> Operation 5 is executed.

上記の例では、プロパティが3のタスクがキューに積まれた後にキャンセルされていることがわかると思います。

また、OperationQueue クラスの cancelAllOperations() メソッドを用いることで、キューに積まれた未実行のOperationを全てまとめてキャンセルすることも出来ます。

// キューに積まれた未実行のOperationをまとめてキャンセルする
queue.cancelAllOperations()

このように、Operation、OperationQueueクラスを用いることで、タスクのキャンセルも簡単に記述できます。

ただし、1点注意点があります。
Operationの処理の実行途中で cancel() が呼ばれた場合、そのOperationの処理は中断されず、最後まで実行されてしまいます

もし、main() の中に書かれた処理を実行中にタスクがキャンセルされた場合のキャンセルケースを実装したい場合、 isCancelled というフラグを各時点で確認することで実装します。

class MyOperation: Operation {
    let myProperty: Int

    init(myProperty: Int) {
        self.myProperty = myProperty
    }

    override func main() {
        print("Operaion  \(myProperty) is started.")

        // この時点でタスクがキャンセルされていないかを確認する
        if (isCanceled) {
            print("Operaion  \(myProperty) is canceled.")
            return
        }

        print("Operaion  \(myProperty) is executed.")
    }
}

上記のように記述することで、もし main() に記述された処理が実行されている最中にタスクがキャンセルされた場合の処理を定義することが出来ます。

GCDとの比較

項目 GCD Operation、OperationQueueクラス
タスクの記述 比較的簡単 (クロージャを用いて簡潔に記述できる) 比較的煩雑 (Operationクラスを用いた定義が必須)
キューの生成 不要 (グローバルキューを利用できる) 必要 (OperationQueueの生成が必須)
タスクのキャンセル 実装が困難 cancel() で容易に実装可
タスク間の依存関係 実装が困難 addDependency() で容易に実装可
まとめ 簡単に記述できるが
複雑なタスクの操作や複数タスクの関連付けが出来ない
記述に手間がかかるが
複雑なタスクや関連のある複数のタスクの操作に向いている

余談:Operationクラスは単体でも実行できる

実は Operation は、start() メソッドを明示的に呼ぶことで、OperationQueueを併用しなくても単体で実行することが可能です。

ですが、使いにくい上に、OperationQueueと併用する方法に対する優位性もあまり感じされないので、使わない方がいいですという話を以下でします。なので読み飛ばしていただいて大丈夫です。

Operationクラスの公式リファレンスに以下のような記載があります。

If you do not want to use an operation queue, you can execute an operation yourself by calling its start() method directly from your code. Executing operations manually does put more of a burden on your code, because starting an operation that is not in the ready state triggers an exception. The isReady property reports on the operation’s readiness.

Operation | Apple Developer Documentation

要約すると、Operation が実行可能な状態ではないときに実行すると例外が発生してしまうため、isReady というフラグを使ってOperationの準備状況を自前で確認する必要があり、コードの負担が増えてしまうと言われています。

また、Operationには以下の2種類の実装方法があります。

タイプ 用途 タスクの記述
non-concurrent 同期的なタスクの記述に用いる main() をoverrideして書く
concurrent 非同期的なタスクの記述に用いる start() をoverrideして書く

Operaitonクラスのインスタンスは、main() の処理が完了すると、キューから削除されてしまいます。
そのため、concurrent(非同期的)なタスクを main() に記述すると、その完了を待たずにキューから削除されてしまうことになります。

そこでどうするかというと、Operation の処理が開始されるときに呼ばれる start() というメソッドをoverrideして非同期処理を記述し、Operationの実行状態を表す isExecuting と Operationの終了状態を表す isFinished という2つのフラグを手動で更新することで、Operationの状態を自前でハンドルするという実装方法になります。
詳しく知りたいという方は、Operation Queues - Concurrency Programming Guide をご覧ください

以上のように、OperationQueueを使っていたときはよしなにやってくれていたOperationの状態管理を、Operationを単体で利用しようとすると、ゴリゴリ自分で書かなければいけないことになります。
Operation クラスは、基本的には OperationQueue クラスとセットで利用することをおすすめします。

Threadクラスを用いた非同期処理

Threadクラスとは

Threadクラスは、コアライブラリである Foundation がスレッドそのものを実装したものです。このクラスを用いることで、プログラマが生のスレッドにアクセスすることが出来て、生成と制御を自身で行うことができます。

どうしても特定のタスク専用の新しいスレッドを作成したい場合や、GCDのスレッドプーリングの仕組みに依存しない独自の並列処理システムを組み上げたい時などに利用しましょう。そんな時が人生に何度あるのだろうかとは思ってしまいますが…

Threadクラスの使い方

Threadクラスの使い方は、Operationクラスとほぼ同じと言っても過言ではないです。Threadクラスの公式リファレンスでも以下のように言及されています。

The Thread class supports semantics similar to those of Operation for monitoring the runtime condition of a thread. You can use these semantics to cancel the execution of a thread or determine if the thread is still executing or has finished its task. Canceling a thread requires support from your thread code; see the description for cancel() for more information.

Thread | Apple Developer Documentation https://developer.apple.com/documentation/foundation/thread

Thread の作成

Thread を新しい生成する際は、Threadクラス のサブクラスとしてタスクを定義します。
行いたい処理自体は、overrideした main() メソッドの中に記述します。

// Threadクラスのサブクラスとして定義する
class MyThread: Thread {
    override func main() {
        print("executed.")
    }
}

// 初期化した後に start() メソッドを呼ぶことで処理を開始する
let thread = MyThread()
thread.start()

Thread のキャンセル

タスクのキャンセルについても、Operationと同様に書くことが出来ます。

ただ、Operationクラスと同様に1点注意点があります。
処理の実行途中で cancel() が呼ばれた場合、その Thread の処理は中断されず、最後まで実行されてしまいます

もし、main() の中に書かれた処理を実行中にタスクがキャンセルされた場合のキャンセルケースを実装したい場合、 isCancelled というフラグを各時点で確認することで実装します。

let thread = MyThread()
thread.start()

// タスクの処理をキャンセルする
thread.cancel()

その他のThread操作

一部のThread 操作に関するメソッドはクラスメソッドとして定義されていて、実行することでカレントスレッドの操作ができます。自前のThreadクラスについてこれらの操作を行いたい時には、そのThreadの処理の中で呼ぶことで操作が可能になります。

class MyThread: Thread {
    override func main() {

        // forTimeIntervalで指定した秒数だけスレッドを停止する
        Thread.sleep(forTimeInterval: 10) 

        // Date型の値で指定した日時までスレッドを停止する
        Thread.sleep(until: Calendar.current.date(byAdding: .minute, value: 1, to: Date())!)

        // スレッドの実行を途中で終了する
        Thread.exit()

        // 上記の exit() により、この処理が実行されることはない
        print("executed.")
    }
}

ぶっちゃけいつ使うのか?

Thread クラスを利用してスレッドの管理を手動で行う必要があるケースは極めて稀、もっと言えばほぼ無いと言っていいと思います。

煩わしい上にバグの温床であるスレッド管理を全て肩代わりしてくれる GCDやOperation、OperationQueueがせっかく用意されているので、まずはそれらで代替することで何とか Thread管理を避けられないかを考えるべきだと思います。

乱暴にまとめてしまえば、なるべく使わなくていい方法を考えよう という結論になります。

[TBD] Combineを用いた非同期処理

時間が無くて書ききれておりません… また時間を見つけて埋めようと思います。

Swift Concurrencyを用いた非同期処理

Swift Concurrency とは

Swift 5.5からSwiftは並列処理を言語機能としてサポートするようになったのですが、そこで登場した非同期処理・並列処理をサポートする機能群を総称して Swift Concurrency と呼びます。

Swift Concurrency の概念は大きく以下の3つに大別できます。

  • async / await
    • 非同期処理を簡潔に記述するための構文
  • Structured Concurrency
    • 各並列処理間の関係性を整理するための概念
  • Actor
    • 安全な並列処理を記述するための新たな型の定義方法

Swift Concurrency にまつわる様々なプロポーザルの関係性を表した図

今回はこのうち、非同期処理の実現に関係が深い、async/await と Structured Concurrency について、解説していきます。

async/await

async/await とは

async/await とは、非同期処理を簡潔に記述するための構文です。JavaScript を書く方には馴染みのある構文だと思いますが、Swift Concurrencyの流れでSwiftでも利用可能になりました。

これまでの非同期処理は、完了後の処理をクロージャで渡して、その引数で結果を受け取る書き方が一般的でした。非同期関数内でクロージャを手動で呼ぶためクロージャを呼び出し忘れたり、複数回呼んでしまうリスクがあります。

以下に、async/await 登場以前の実装方法で、「外部APIを叩いて画像を取得した上でサムネイルとして整形して、その結果を引数に completionHandler を呼ぶ」というよくある関数を実装したものを提示します。

非同期処理を連続で呼ぶときにクロージャを多用することとなりネストも深くなるため、コードが冗長かつ可読性が低くなってしまっていることがわかると思います。

// async/await 登場以前の書き方
func fetchThumbnail(for id: String, completion: @escaping (UIImage?, Error?) -> Void) {
    let request = thumbnailURLRequest(for: id)
    let task = URLSession.shared.dataTask(with: request) { data, response, error in
        if let error = error {
            completion(nil, error)
        } else if (response as? HTTPURLResponse)?.statusCode != 200 {
            completion(nil, FetchError.badID)
        } else {
            guard let image = UIImage(data: data!) else {
                completion(nil, FetchError.badImage)
                return
            }
            image.prepareThumbnail(of: CGSize(width: 40, height: 40)) { thumbnail in
                guard let thumbnail = thumbnail else {
                    completion(nil, FetchError.badImage)
                    return
                }
                completion(thumbnail, nil)
            }
        }
    }

    task.resume()
}

一方、async/await は以下のような仕組みで機能します。

  • async を関数につけると、その関数の中は“非同期なコンテキスト” と見なされるようになり他の async が付いた関数を呼べるようになる
  • async が付いた関数を呼び出す際はawait というキーワードを付けて呼び出すことで結果を “待つ” ことが出来る

先ほどの関数を async/await で書き換えると以下のようになります。

// async/await を用いた書き方
func fetchThumbnail(for id: String) async throws -> UIImage {
    let request = thumbnailURLRequest(for: id)
    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 を利用することで、非同期処理も同期処理と同じように記述することができるため、コードの行数やネストの深さが減り、可読性も改善されます。

Suspension Point

先述の通り、async関数をawaitを付けて呼び出すと処理の実行が一時的に中断(Suspend)され、非同期的に結果が得られてから続きが再開(Resume)されます。
そのため awaitキーワードのある部分を Suspension Point と呼びます。

スレッドで処理の実行が Suspendされている間、実はそのスレッドは解放されて他の処理の実行に回ることも出来るようになっています。
そして、処理がResumeされた時にそのスレッドが埋まっていた場合、処理の続きは他のスレッドで実行されることになります。

そのため、Suspension Point の前後の処理は別スレッドで行われることもあり得るということになるので注意しましょう。

async/await におけるスレッド管理の例

async/await を使う際の注意

async を関数につけると、その関数の中は“非同期なコンテキスト” と見なされるようになり他の async が付いた関数を呼べるようになる」と先述しましたが、裏を返せば「async関数は async関数からしか呼び出せない」ということになります。

つまり、コールスタックを遡っていくと必ずasync関数を呼び出す入口が必要になるということになるのです。

同期的な処理の中に、突然 async を登場させるために、Task という概念が必要になります。逆に全ての async 関数は Task に紐付けられ、 Task上で実行されます。async/await と Task はかなり密接に関わる概念なのですが、詳しくは次の Strucutured Concurrency の章で解説します。

Structured Concurrency

Structured Concurrency とは

Swift は “構造化プログラミング(Structured Programming)” という概念を採用しています。
具体的には、「原則として処理は上から下に流れ、他のプロセスにジャンプすることは出来ない」「変数は定義されたブロック内でのみ利用可能」という原則によって構成されています。

Structured Concurrencyとは、並列に実行されるそれぞれの処理の関係性についてもスコープで構造化することで並列処理を管理しやすいようにしようという考え方です。

Structured Concurrency の概念図

Structured Concurrencyは、各並列処理を表す Task という概念によって実現されます。複数のタスクを “安全で効率的なときだけ” 並列で実行することで、Taskは非同期的な処理を並列に実行するためのコンテキストを提供します。

async let Task

構造化された Task を生成する最も簡単かつ一般的な方法が async let Task です。

asyncメソッドの返り値としてasync letで変数を定義すると、そのメソッドの完了を待たずに処理が進みます。そして、その変数を利用するところで変数にawaitをつけることで、初めてその処理が終わるまでプログラムが中断されます。

func fetchThumbnail(URL url) throws -> Data {
    // async let で thumbnail という変数を定義する
    // (downloadDataはasync関数)
    async let thumbnail = downloadData(from: url)

    // downloadDataの完了を待たずに進む

   // このawaitがSuspension Point になり、 downloadData()の完了を待つ
    return await thumbnail
}

async let による変数定義では、変数の初期化処理を実行するための新しいTaskが、現在処理を実行中のTaskの子Task として生成され、実際の処理は 子Task の方で実行されます。

fetchThumbnail と async let thumbnail のTaskの関係

async let による変数定義の時点では Suspension Point とならず、変数に実際の値が代入されないままプレースホルダーが設定されただけの状態になります。その変数を参照する際に処理が完了している必要があるため、呼び元では await を指定し、その部分が Suspension Point となります。

注意すべきなのが、async let で宣言された定数はそのスコープを抜ける前に必ず await されなければならないという点です。async let のまま return するなど、スコープの外に持ち出すことはできません。仮にスコープ内で await しなかった場合、スコープから抜ける前に自動的に await されます。

また、async let を並べることで、並列的に処理を行うことも可能です。

func fetchThumbnails(URL largeUrl, URL smallUrl) throws -> (large: Data, small: Data) {
    async let largeThumbnail = downloadData(from: largeUrl)
    async let smallThumbnail = downloadData(from: smallUrl)
    
   // ここで2つのdownloadData()両方が完了するのを待つ
    let thumbnails = await (large: largeThumbnail, small: smallThumbnail)
    return thumbnails
}

この場合、2つのasync let による変数定義によって、2つの子Taskが生成され、それらが並列に走っています。

fetchThumbnail と 2つの async let のTaskの関係

Structured Concurrency では、Task間にこういった “構造” が保たれることで、タスクが管理しやすく、並列処理のライフサイクルが理解しやすい状態を保つことができるようになっています。

TaskGroup

async let Task でも複数のTaskの並列実行が可能でしたが、より柔軟に任意の数の処理を並列に実行するためには TaskGroup を用います。

TaskGroupを利用する際には、グローバル関数である withTaskGroup()、もしくはそのエラー対応版の withThrowingTaskGroup() を用います。結果の型を引数で指定することで、クロージャの引数としてタスクグループが受け取れるのでそこに対して addTask() で処理を追加していきます。

func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
    var thumbnails: [String: UIImage] = [:]

    // withThrowingTaskGroup() によってTaskGroupを生成
    // (String, UIImage)型を結果の型として指定している
    try await withThrowingTaskGroup(of: (String, UIImage).self) { group in
        for id in ids {

            // 各idに紐づいたTaskを追加している
            group.addTask {
                return (id, try await fetchOneThumbnail(withID: id))
            }
        }
        // 子タスクから結果を完了した順に逐一取得する
        for try await (id, thumbnail) in group {
            thumbnails[id] = thumbnail
        }
    }
    return thumbnails
}

addTask()で追加された処理は、現在のTaskを親とする子Taskで実行されます。上の例の場合、addTask() によって追加された各処理は、fetchThumbnails() に紐づくTaskの子タスクとして実行されます。

もちろん親タスクがキャンセルされれば各子タスクもキャンセルされ、全ての子タスクが完了しなければ親タスクは完了しません。

また、TaskGroup に対して cancelAll() を呼び出すことで、 TaskGroup に含まれるTaskを全て明示的にキャンセルすることもできたり、タスクの優先度を TaskPriority 型で指定することができたりします。この辺りはかなりOperation、OperationQueueクラスの操作と類似していますね。

Unstructured Task、Detached Task

ここまで、構造化されたTaskによる並列処理の実現方法を見てきましたが、逆に構造化 “されていない” 並行性が必要なケースも存在します。例としては以下のような場合があります。

  • 非同期的でないコンテキストから非同期的な処理を開始したい場合(Viewのライフサイクルなど)
  • 子Task の処理が 親Task のライフサイクルを越えて実行され続けるような処理を行いたい場合

このような時には、Task.init を用いることで、構造化されていないTaskを初期化することが出来ます。このように生成されたTaskを Unstructured Task と呼びます。

func fetchThumbnail(URL url) async throws -> Data {
    let thumbnail = await downloadData(from: url)

    // サムネイルを保存するUnstructured Taskを生成
    // (init() は省略できる )
    Task {
        await saveThumbnail(thumbnail: Thumbnail)
    }
   
    return thumbnail
}

上記の例では、構造化されていない、つまりfetchThumbnailのライフサイクルに縛られないUnstructured Taskを生成しており、このように書くことで、fetchThumbnail() は saveThumbnail() が終了するのを待たずに完了し、逆に saveThumbnail() は fetchThumbnail() が完了した後でも処理を続けることが出来ます。

このように Unstructured Taskは、親のライフサイクルに縛られないという性質を持っている一方で、実行の優先度などのもともとのコンテキストが持っていた情報をほとんど引き継ぎます。

そこで、Task.detached を用いることで、もともとのコンテキストが持っていた情報を全く引き継がないUnstructured Task を初期化することも出来ます。このようなTaskを Detached Task と呼びます。

func fetchThumbnail(URL url) async throws -> Data {
    let thumbnail = await downloadData(from: url)

    // サムネイルを保存するDetached Taskを生成
    // fetchThumbnail()に紐づくTaskのコンテキストを全く引き継がない
    Task.detached(priority: .background) {
        await saveThumbnail(thumbnail: Thumbnail)
    }
   
    return thumbnail
}

Detached Taskを利用する際には、「構造化されていない = そのライフサイクルをプログラマ自身が管理しなければいけない」ということに注意が必要です。

Taskの分類

ここまでで紹介した4種類のTaskについて、それぞれの特徴を比較する表を掲載します。どのTaskが使うのを適切なのかを調べる際の参考にしてみてください。

各Taskの分類の比較

まとめ

結局どれを使うべきなのか?

ここまで以下の5つの非同期処理の実現方法について解説してきました。

  • GCDを用いた非同期処理
  • Operation、OperationQueueクラスを用いた非同期処理
  • Threadクラスを用いた非同期処理
  • Combineを用いた非同期処理
  • Swift Concurrency を用いた非同期処理

最終的に気になるのは、「結局どれをいつ使うべきなの?」ということだと思います。

まずは特殊な Combine から見ていきたいと思います。
こちらはRxなどを書いてきた経験や知見があって、Reactive Programmingが手に馴染む方は候補に入れていただいてもいいと思います。ただし、文法が特殊で学習コストがかかる上に知識の横展開がしにくいこと、主にActorの登場によってSwift Concurrencyを無視してiOS開発を行うのが困難になってきていることを考えると、今から着手するというのはあまりおすすせしません。

次にThreadクラスについてですが、前述の通り、可能な限り使わなくていい方法を模索すべきだと思います。
煩わしい上にバグの温床であるスレッド管理を全て肩代わりしてくれる GCDやOperation、OperationQueueがせっかく用意されているので、まずはそれらで代替することで何とか Thread管理を避けられないかを考えるべきでしょう。

単純かつ独立した非同期処理を記述する場合は、GCDの利用で十分です。
非同期な処理の後でMainスレッドに制御を戻してViewを操作する場合など、既存のDispatchQueueのみの利用で数行で実現できます。そのためだけにOperaion、OperationQueueなどを使うのはtoo muchだと思います。

最後にOperaion、OperationQueueクラスとSwift Concurrencyの比較です。正直なことを言ってしまえば、出来ることはあまり変わらないので好みの問題だと思います。

「タスクの再利用性を重視して、タスクの処理のその関連情報をオブジェクト指向的にまとめて管理したい」のであれば Operaion、OperationQueueクラス。「タスクを構造的に管理することを重視して、複数のタスクが絡み合った処理も完結に記述したい」のであれば Swift Concurrency がおすすめです。

あくまで個人的な意見なので参考程度にしつつ、今後の実装に活かしていただければと思います。

おわりに

社内勉強会での内容をまとめ直しつつ、話したいことを肉付けしていくようにしていたら、とんでもないボリュームの記事になってしまいました。

冒頭でも申し上げましたが、マサカリやご意見は大歓迎です。この記事が少しでも皆さんの学びの助けになり、逆に僕にとっても更なる学びになることを願っています。