😎

【翻訳】Introduction to Concurrency Swift With Async and Await

2023/07/17に公開

あなたのアプリは、時々ユーザーインターフェイスがフリーズすることに悩まされていませんか?
アプリが反応しないため、アプリがシステムに強制終了されることはありませんか?

上記のような状態に悩まされるアプリは、
ユーザー・インターフェースのインタラクションへの応答を担当する同じプロセスで、
アプリが重いタスクや長いタスクを実行していることが原因である可能性があります。

例えば、ウェブサーバーから画像を取得する場合、数秒かかることがあります。
アプリのプロセスが画像の受信を待っている間は、ユーザーとのインタラクションに反応しません。

では、重いタスクや長いタスクを実行したときに、
アプリのユーザーインターフェイスがフリーズしたり、
クラッシュしたりするのを防ぐにはどうすればいいのでしょうか?

その答えは、ユーザー・インターフェースへの応答を担当するアプリのプロセス・セグメントから、
タスクを新しいセグメントに移動させることです。
アプリのプロセスのセグメントは、スレッドとして知られています。

スレッドでは、複数のコードを同時に実行することができます。

タスクを委任し、委任されたタスクが結果を返すまで自分のタスクを続ける行為は、
タスクを非同期で実行することとして知られています。

タスクが実行されるまでブロックされたまま待っているわけではありません。
タスクが実行されるのを待ってから自分のタスクを続けることを同期的と言います。

アップルは、iOSプラットフォーム上でタスクを同時に実行する複数の方法を提供しています。

グランド・セントラル・ディスパッチ(GCD)
オペレーションとOperationQueues
より最近のSwiftは、asyncとawaitキーワードの使用を通して、
同時実行する新しい方法を提供し始めました。

この投稿では、サラダを作るアプリの例を通して、
Swiftの並行処理機能を使ってタスクを並行して実行する方法を紹介します。
その前に、プロセスとスレッドという言葉の意味をもう少し詳しく簡単に説明します。

この投稿では、あなたがiOSとSwift開発の少なくとも基本的な知識を持っていると仮定します。

サンプルアプリはSwiftUIを使用していますが、SwiftUIの知識は必要ありません。

プロセスとスレッドとは?

ユーザーがホーム画面にいるとしましょう。ユーザーはあなたのアプリをタップします。
するとiOSはあなたのアプリを起動するよう指示します。
この場合、iOSはあなたのアプリ用に新しいプロセスを作成し、スレッドを作成します。

プロセスとはプログラムのインスタンスのことで、この場合はあなたのアプリのことです。
iOSでは、アプリのインスタンスは1つしか実行できません。プロセスまたはインスタンスごとに、
オペレーティング・システムはあなたのプログラムをディスクからメモリにロードします。

iOSの各アプリ・プロセスは1つのスレッドで起動します。
これはメインスレッドまたはUIスレッドとして知られています。
他のスレッドは、アプリやプログラムからアクセスできます。スレッドは同じメモリ空間を共有します。

スレッドは一連の命令です。プログラムはスレッドに命令を送ります。
スレッドはキューに入れられ、先着順に実行されます。

asyncとawaitでタスクを並行して実行する方法

このセクションでは、Swiftのasyncとawaitを実際に使ってみます。既にあるアプリから始めます。
このアプリは、私たちのためにサラダボウルを作成します。サラダを作る順番は以下の通りです。

レタス
トマト
赤玉ねぎ
スイートコーン
ツナとひまわり油
サラダを作る仕事がメインスレッド(UIスレッドとしても知られている)上で行われるので、
サラダが準備されている間、アプリはフリーズします。Swiftの並行処理機能(asyncとawait)を使って、
アプリがフリーズする問題を解決します。

以下は、このセクションで取る手順です。

スターターパックをダウンロードする
asyncとawaitを使って、サラダの下ごしらえをUIスレッドから遠ざける
注文なしでサラダを準備する

始めましょう!

この記事ではSwift 5.5.2とXcode 13.2.1を使用しました。

1.スターターパックのダウンロード

まずはスターターパックをダウンロードしましょう。ターミナルアプリを開き、以下のコマンドを実行します。

cd $HOME
curl https://github.com/anuragajwani/swift-concurrency-salad-maker/archive/starter.zip -o salad_maker.zip -L -s
unzip -q salad_maker.zip
cd swift-concurrency-salad-maker-starter
open -a Xcode SaladMaker.xcodeproj

シミュレーターでアプリを起動します。「サラダを準備する」ボタンをタップしてください。サラダ作りが始まると、
アプリがフリーズすることに気づくでしょう。次のセクションでその原因を調べ、解決しましょう。

2.asyncとawaitを使ってサラダの下ごしらえをUIスレッドから離す

このセクションでは、サラダを作るプロセスを同期から非同期に変更します。
見るのは1つのファイルだけです。XcodeでSaladMaker.swiftを開きます。

class SaladMaker {
    func make(onIngrdientPrepped: @escaping (Ingredient) -> ()) {
        Ingredient.allCases.forEach { (ingredient) in
            self.prepareIngredient(ingredient)
            onIngrdientPrepped(ingredient)
        }
    }
    private func prepareIngredient(_ ingredient: Ingredient) {
        let randomTime = UInt32.random(in: 1...5)
        sleep(randomTime) // chop, cut, etc...
    }
}

現在のコードでは、食材を順番に取り込んで "準備 "しています。
しかし、それはメイン・スレッド上で同期的に行われます。
そのため、ユーザーインターフェイスがユーザーのインタラクションに反応するのをブロックしています。

SaladMakerクラスをasyncとawaitを使うように変更して、非同期でサラダの準備をしましょう。
ただし、準備の順番は重要なので、各材料は同期的に準備します。SaladMakerを以下のように変更します。

class SaladMaker {
    func make(onIngrdientPrepped: @escaping (Ingredient) -> ()) {
        Task(priority: .userInitiated) {
            for ingredient in Ingredient.allCases {
                await prepareIngredient(ingredient)
                onIngrdientPrepped(ingredient)
            }
        }
    }
    private func prepareIngredient(_ ingredient: Ingredient) async {
        return await withCheckedContinuation({ continuation in
            // using DispatchQueue here instead of Task due to unexpected behaviour
            DispatchQueue.global(qos: .userInitiated).async {
                let randomTime = UInt32.random(in: 1...5)
                sleep(randomTime)
                continuation.resume()
            }
        })
    }
}

上記のコードでは、各材料を同期的に準備することから、非同期的に準備することに変更しました。
シミュレーターでアプリを動かしてみましょう。

アプリがフリーズしなくなったことが分かるでしょう。
解決策を分解して、上記のコードがどのように実現したかを見てみましょう。

上記のように、メイン/UIスレッドがユーザーのインタラクションに反応するようになったことに注目してください。

prepareIngredient関数の変更点から見ていきましょう。

private func prepareIngredient(_ ingredient: Ingredient) async {
    return await withCheckedContinuation({ continuation in
        // using DispatchQueue here instead of Task due to unexpected behaviour
        DispatchQueue.global(qos: .userInitiated).async {
            let randomTime = UInt32.random(in: 1...5)
            sleep(randomTime)
            continuation.resume()
        }
    })
}

関数は、関数の戻り値型ステートメントの前に async を宣言していることに注意してください。
これは Swift コンパイラに、この関数が非同期に実行されることを伝えます。

以前のprepareIngredient関数のコードは同期でした。

私たちの同期コードを非同期に移動するために、
私たちは withCheckedContinuation 関数を使用しました。

Swiftには、同期関数と非同期関数の間のギャップを埋めるために、このような組み込み関数があります。
この関数は、continuation.resume()が一度呼び出されたかどうかをチェックします。

この時点で、各材料は非同期で準備されます。しかし、私たちは順番に、そして前の食材が完成した後に
食材を準備したい。では、どうすればそれを実現できるでしょうか?

非同期のprepareIngredient関数を呼び出す前にawaitというキーワードを使うのです。

await prepareIngredient(ingredient)

材料をひとつずつ順番にループさせて、下ごしらえが終わるまで待ちます。

そして、make関数の呼び出し元に、
その食材の準備状態(onIngrdientPreppedクロージャー)を知らせます。

for ingredient in Ingredient.allCases {
    await prepareIngredient(ingredient)
    onIngrdientPrepped(ingredient)
}

make関数が同期関数であることに注目してほしい。
同期関数の中で非同期関数を呼び出すことはできません。
make関数の中でprepareIngredientを呼ぶことはできません。

同期関数の中で非同期関数を呼び出せるようにするには、Taskを使えばいいのです。
タスクは、その中で非同期コードを実行できます。

3.サラダを作る順番は関係ない

サラダを作る順番が重要でなかったとしたら?
サラダの客が派手な演出を望まず、ただサラダを食べたいだけだったらどうでしょう?

材料1つにつき1秒の準備時間がかかるとしましょう。材料が5つあるので、サラダの準備には5秒かかります。
もしサラダの順番にこだわらなければ、すべての材料を並行して準備することができます。
したがって、(利用可能なシステム・リソースに基づいて)1秒でサラダを作れる可能性があります。

では、Swiftの並行処理でサラダの材料を並列に(または同時に)準備するにはどうすればいいのでしょうか?
2つの方法があります。

非同期
タスクグループ
async letから始めましょう。

この仕組みによって、すべての材料を並行して準備することができます。
コードはawaitの最初の呼び出しで中断します。

awaitの前にすべてのasync letが終了すると、コードは実行を再開します。

make関数本体を以下のように変更します。

func make(onIngrdientPrepped: @escaping (Ingredient) -> ()) {
    Task(priority: .userInitiated) {
        async let lettuce = prepareIngredient(.lettuce)
        async let tomatoes = prepareIngredient(.tomatoes)
        async let redOnion = prepareIngredient(.redOnion)
        async let sweetcorn = prepareIngredient(.sweetcorn)
        async let tuna = prepareIngredient(.tuna)
        let ingredients = await [lettuce, tomatoes, redOnion, sweetcorn, tuna]
        ingredients.forEach(onIngrdientPrepped)
    }
}

また、prepareIngredient関数を次のように変更しました。

private func prepareIngredient(_ ingredient: Ingredient) async -> Ingredient {
    return await withCheckedContinuation({ continuation in
        // using DispatchQueue here instead of Task due to unexpected behaviour
        DispatchQueue.global(qos: .userInitiated).async {
            let randomTime = UInt32.random(in: 1...5)
            sleep(randomTime)
            continuation.resume(returning: ingredient)
        }
    })
}

async letというキーワードを使うことで、非同期参照を保存できることに注目してください。

しかし、配列に直接格納することはできません。そのオプションは現在Swiftでは利用できないので、
それぞれのasync letを別々の変数として格納しなければなりません。

さらに、async let参照を格納できるようにするために、
prepareIngredientが結果を返すようにする必要があります。

async letにはもうひとつ注意点があります。上記のソリューションでは、
すべての材料が準備できたときだけ、SaladMaker.makeの呼び出し元に通知しています。

では、すべての非同期関数を並行して実行し、
各成分の準備が完了したら呼び出し元を更新するにはどうすればいいのでしょうか?

TaskGroupを使います。SaladMaker関数を以下のように変更してみましょう。

func make(onIngrdientPrepped: @escaping (Ingredient) -> ()) {
    Task(priority: .userInitiated) {
        await withTaskGroup(of: Ingredient.self, body: { group in
            for ingredient in Ingredient.allCases {
                group.addTask(priority: .userInitiated) {
                   await self.prepareIngredient(ingredient)
                }
            }
            for await ingredientPrepped in group {
                onIngrdientPrepped(ingredientPrepped)
            }
        })
    }
}

TaskGroupを使えば、複数の非同期関数を並行して実行できます。

for ingredient in Ingredient.allCases {
    group.addTask(priority: .userInitiated) {
        await self.prepareIngredient(ingredient)
    }
}

さらに、TaskGroupは結果が出ると知らせてくれます。

for await ingredientPrepped in group {
    onIngrdientPrepped(ingredientPrepped)
}

アプリを実行し、食材が下ごしらえされるとすぐにUIが更新されるのを確認します。

以上です!  あなたは今、Swiftにおける並行処理の基礎の知識で武装しています!

まとめ

この投稿で我々は以下の事を学びました。

並行処理とは何か
プロセスとスレッドとは何か
Swiftで非同期タスクを実行する方法
同期コードをSwiftの非同期コードに変換する方法
Swiftの並行処理を使用して並列にタスクを実行する方法

最終的な感想

この記事のソースコードは以下のGithub repoにあります:
https://github.com/anuragajwani/swift-concurrency-salad-maker

Swiftのasyncとawaitは、非同期コードの記述と読み込みを単純化します。

私は、並行コードを作るための他のソリューション(Grand Central DispatchとOperationとOperationQueues)に対するSwiftの並行性の利点をカバーしていません。これはこの投稿の範囲外でした。しかし、私は将来の投稿でソリューション間の違いをカバーするつもりです。

この投稿は、並行処理の基本をカバーすることだけを目的としています。
並行処理は非常に複雑なトピックです。
今後もこのトピックについて投稿を続けるつもりです。ご期待ください。

【翻訳元の記事】

Introduction to Concurrency Swift With Async and Await
https://betterprogramming.pub/introduction-to-concurrency-swift-with-async-and-await-1d3b03226585

Discussion