Swift 5.5からのSwift Concurrencyのasync/awaitの使い方

非同期処理、並行処理を不具合なく実装することはとても難しいです。
クロージャーはどんどんネストされ読みにくくなり、複数のスレッドが同じデータを書き込めばデータ競合が起こります。

Swift 5.5からはSwiftの言語機能としてConcurrencyが登場しました。
これは非同期処理、並行処理のコードを簡潔かつ安全に記述できる機能です。
async/awaitを使えば、同期処理と同じような書き方で非同期処理を記述できます。
またactor型を使えばデータ競合を防ぐことができます。

actor型に関してはSwift 5.5から登場したActorについてをご覧ください。

この記事ではSwift 5.5からのSwift Concurrencyの機能の一つasync/awaitの使い方を解説します。

サンプルコード

https://github.com/SatoTakeshiX/first-step-swift-concurrency/tree/main/try-concurrency.playground
対応するサンプルコードにはページ名をコメントに記載します。
記載がなければ対応するコードはないことを意味します。

検証環境

  • Xcode 13.2.1
  • Swift 5.5
  • iOS 15.0

クロージャーによる非同期処理の問題点

非同期処理をクロージャーで実装することは開発者が日常的に行っていることですが、簡単に可読性が下がり不注意によるバグも発生しやすいです。

例えば、URLSessionでHTTPリクエストを行う関数を考えてみましょう。

// Page: 1-1-request-with-closure
func request(with urlString: String, completionHandler: @escaping (Result<String, APIClientError>) -> ()) {
    guard let url = URL(string: urlString) else {
        // completionHandlerを呼ぶの忘れがち
        completionHandler(.failure(.invalidURL)) 
        return
    }

    // 処理の流れ
    // ①
    let task = URLSession.shared.dataTask(with: url) { data, response, error in
        // ③
        // レスポンスをハンドリング
    }
    // ②
    task.resume()
}

コードの処理の順番は次の通りです。

  1. ①:URLSession.shared.dataTaskを呼び出し、返り値であるtask変数を取得する
  2. ②:resumeメソッドを呼び出しリクエストを開始する
  3. ③:リクエストが完了したらdataTaskメソッドのcompletionHandlerが呼ばれる

同期的なコードは上から順番に実行されますが、上、下、真ん中と読み進めなければいけません。
さらにネストが深くなり、条件分岐も複雑になればどんな順番でコードが実行するのかを追うのは非常に難しくなります。

またcompletionHandlerを引数とした場合、呼び出し元はすべてのパスで確実にcompletionHandlerが呼ばれることを想定していますが、実際にcompletionHandlerを呼び出すかは開発者の責任です。ちょっとした不注意でcompletionHandlerを呼び忘れた場合、不具合の原因になります。

// Page: 1-1-request-with-closure
let task = URLSession.shared.dataTask(with: url) { data, response, error in
    if let error = error {
        // completionHandlerを呼ぶの忘れがち
        completionHandler(.failure(.serverError(error)))
    } else {
        guard let httpStatus = response as? HTTPURLResponse else {
            // completionHandlerを呼ぶの忘れがち
            completionHandler(.failure(.responseError)) 
            return
        }

例えば、リクエストの処理中には画面にローディングViewを表示する場合を考えます。
呼び出し元でリクエスト前にローディングViewを出し、処理が終わったらローディングViewを非表示しましょう。
completionHandlerが呼ばれない条件があった場合、ローディングViewがいつまでも表示されてしまいます。

// Page: 1-1-request-with-closure
// Viewにローディング画面を出すためのフラグ
var isLoading = true
request(with: urlString) { result in
    // completionHandlerの呼び出しを忘れるとisLoadingがfalseにならず、もしかしてViewのローディングがずっと表示したままになるかもしれない
    isLoading = false
    switch result {
        case .success(let responseString):
            print(responseString)
        case .failure(let error):
            print(error)
    }
}

クロージャーの呼び出しはSwiftコンパイラーはチェックをしないので開発者は注意深くすべてのパスでハンドラーが呼ばれるかどうかをチェックする必要があります。

async/awaitで解決する

Swift Concurrencyのasync/awaitはこの問題を解決します。
先ほどのrequest関数をasync関数として実装し直します。

// Page: 1-2-request-with-async
func request(with urlString: String) async throws -> String {
    guard let url = URL(string: urlString) else {
        throw APIClientError.invalidURL
    }
    do {
        // ①リクエスト
        let (data, urlResponse) = try await URLSession.shared.data(from: url, delegate: nil)
        guard let httpStatus = urlResponse as? HTTPURLResponse else {
            throw APIClientError.responseError
        }

        // ②ステータスコードによって処理を分ける
        switch httpStatus.statusCode {
            case 200 ..< 400:
                guard let response = String(data: data, encoding: .utf8) else {
                    throw APIClientError.noData
                }
                return response
            case 400... :
                throw APIClientError.badStatus(statusCode: httpStatus.statusCode)
            default:
                fatalError()
                break
        }
    } catch {
        throw APIClientError.serverError(error)
    }
}

->の前にasync throwsのキーワードがつけられており、「エラーをスローするasync関数」ということがわかります。
返り値がStringなため、すべてのパスでreturnをするかエラーをスローしなければコンパイルエラーとなります。
つまり、completionHandlerを使用していた時とは異なり、正常系も異常系も実装忘れがないことをコンパイルが保証してくれるのです。

処理は同期的なコードと同じように上から下に流れていくため読みやすいコードとなっています。

asyncの関数を呼び出す際はawaitキーワードが必要です。
awaitキーワードをつけることで、システムにプログラムが中断可能性があること伝えます。
asyncの関数やメソッドは並行処理のための特別なコンテキストで実行が必要です。
最も簡単にそのコンテキストを作る方法はTask.detachedを使う方法です。

// Page: 1-2-request-with-async
Task.detached {
    do {
        let urlString = "https://api.github.com/search/repositories?q=swift"
        let response = try await request(with: urlString)
        print(response)
    } catch {
        print(error.localizedDescription)
    }
}

requestメソッドの前にtry awaitをつけて呼び出しています。
エラーが発生すればcatchブロックが呼ばれます。
このようにasync/awaitを使うと非同期処理のコードを同期的なコードと同じように実装が可能です。

ちなみに、awaitキーワードで実行した後のスレッドはその前で実行されたものと同じとは限りません。

let urlString = "https://api.github.com/search/repositories?q=swift" // A
let response = try await request(with: urlString)
print(response) // Aと同じスレッドで実行されるとは限らない

今回の例で言えば、urlStringが実行されたスレッドとprint関数が実行されるスレッドが必ず同じとは限りません。request(with:)メソッドの実行でスレッドが変わる可能性があります。

プログラムを中断/再開とは?

awaitキーワードはプログラムにそのメソッドやプロパティが待機可能であることを伝えるものです。
awaitがつけられるとプログラムは待機状態となります。
スレッドはブロックを解除し、他の作業を行います。
システムがそのプログラムを再開すると、awaitキーワードのつけられたメソッドやプロパティが評価され、結果を得ることができます。

図で説明しましょう。
先ほどの例で示したrequest(with:)メソッドの呼び出しをみてみます。

awaitをつけることでシステムにrequest(with:)メソッドが待機可能であることを伝えることができます。
実際にコードが実行された時、request(with:)メソッドは中断されます。

スレッドはブロックを解除し他のタスクを行います。
例えば、もしかしたらユーザーがボタンタップしたり、スクロールをするなどのUIインベントが発火されるかもしれません。Timerなどのグローバルな通知イベントが発火するかもしれません。
その場合でもスレッドはブロックされず、他のタスクを実行します。

そしてrequest(with:)メソッドが再開されると、結果が左辺のresponse変数に代入されます。

このようにSwift Concurrencyではプログラムを中断、再開して非同期処理を行います。
開発者はスレッドの管理を気にすることなく同期的なコードと同じような書き方で、非同期処理を実行できます。

async/awaitの文法

async/awaitの文法を見ていきます。

エラーのない関数・メソッド

関数・メソッドを非同期にするには引数の閉じカッコ)の後ろ、戻り値の矢印->の前にasyncをつけます。

// Page: 1-3-async-await-syntax
// async関数
func a() async {
    print(#function)
}
Task.detached {
    await a()
}

// 戻り値のあるasync関数
func b() async -> String {
    return "result"
}
Task.detached {
    let result = await b()
    print(result)
}

エラーが発生する関数・メソッド

エラーが発生する関数にはthrowsの前にasyncをつけます。

// Page: 1-3-async-await-syntax
func c(showError: Bool) async throws {
    if showError {
        throw AsyncError(message: "error")
    } else {
        print("no error")
    }
}

呼び出すさいは、tryの後にawaitをつけます。

// Page: 1-3-async-await-syntax
Task.detached {
    do {
        try await c(showError: true)
    } catch {
        print(error.localizedDescription)
    }
}

イニシャライザー

イニシャライザーにもasyncはつけられます。
インスタンス生成時にawaitをつけます。

class D {
    init(label: String) async {
        print("イニシャライザーでasync")
    }
}

Task.detached {
    _ = await D(label: "")
}

awaitなしでコンパイルエラー

async関数をawaitなしで呼び出すとコンパイルエラーが発生します。

Task.detached {
   // awaitをつけないでasync関数実行
    a() // Expression is 'async' but is not marked with 'await'
}

またasync関数をTask.detachedの外で実行してもエラーになります。

a() // 'async' call in a function that does not support concurrency

awaitキーワードが使える場所

awaitキーワードはシステムに「プログラムが中断する」ことを伝えるものなので、どこでも利用できるわけではありません。次の3つが主にawaitが使える場所です。

  • async関数・メソッド、プロパティのbody
  • @main属性が付けられた型のmain()メソッドのbody
  • TaskTask.detachedのクロージャー内

複数のasync関数を一つのawaitにまとめる

await式は複数async関数やプロパティがある場合一つにまとめられます。
例えば、

Task.detached {
    let result = await b()
    let d = await D(label: result)
    print(d)
}

b関数とDクラスのイニシャライザーはasync関数なのでそれぞれにawaitをつけています。
これを次のようにまとめることができます。

Task.detached {
    let d = await D(label: b())
    print(d)
}

async letバインディングで並列実行

この章のみサンプルコードURLはこちらです。
https://github.com/SatoTakeshiX/first-step-swift-concurrency/tree/main/swift-concurrency-tutorial
Playgroundではまだasync letバインディングは利用できないのでApplicationプロジェクトで作成しました。

async関数やメソッドを複数で順番に実行したい場合は、単純にawaitをつけて実行すればよいです。

次のコードは1秒待つメソッドを3回順番に実行する例です。

func runAsSequence() async {
    await waitOneSecond() // 1秒待つ
    await waitOneSecond() // 1秒待つ
    await waitOneSecond() // 1秒待つ
}

プログラムは最初のwaitOneSecondメソッドの完了した後に2番目のwaitOneSecondメソッドを実行し、それが終われば3番目を実行します。

実行順はこの図のようになります。

一方で、並列的に処理を行いたい場合もあります。
その場合、async letバインディングが使えます。
先ほどのwaitOneSecondメソッドを並列で呼び出すコードは次のとおりです。

func runAsParallel() async {
    async let first: Void = waitOneSecond()
    async let second: Void = waitOneSecond()
    async let third: Void = waitOneSecond()

    await first
    await second
    await third
}

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

実行順はこの図のようになります。

この並列実行は強力です。
例えばUserIDを元に画像をフェッチする処理を考えます。
画像のダウンロードは比較的時間のかかる処理です。複数回実行しなければいけない場合に長時間ユーザーを待たせてしまうかもしれません。
そこで並列的にダウンロードを開始できればトータルの実行時間を少なくできます。

// 一つずつダウンロードすると時間がかかる
func fetchImages() async -> [UIImage] {
    let image1 = await fetchImage(userID: "1")
    let image2 = await fetchImage(userID: "2")
    let image3 = await fetchImage(userID: "3")

    return await [image1, image2, image3]
}

// 並列で処理をすればトータルの時間が少なくなる
func fetchImages() async -> [UIImage] {
    async let image1 = fetchImage(userID: "1")
    async let image2 = fetchImage(userID: "2")
    async let image3 = fetchImage(userID: "3")
  
   // awaitはまとめられる
    return await [image1, image2, image3]
}

並列で実行できる箇所があればasync letバインディングを使いましょう。

コンプリーションハンドラー形式のメソッドをasyncメソッドに変換

サンプルコードURLは以下に戻ります。
https://github.com/SatoTakeshiX/first-step-swift-concurrency/tree/main/try-concurrency.playground

すでに実装されたコンプリーションハンドラー形式のメソッドをasyncメソッドに変換できると便利です。Swiftは標準ライブラリーにマニュアルでasync関数に適応する方法を用意しています。

withCheckedThrowingContinuation関数とwithCheckedContinuation関数です。
これらの関数を使うと通常の関数をasync関数にラップができます。
一つずつ見ていきましょう。

withCheckedThrowingContinuation

通常の関数をエラーをスローするasync関数・メソッドを作成したい場合はwithCheckedThrowingContinuation関数を使います。
使い方は、ラップしたasync関数のreturnにwithCheckedThrowingContinuationを返します。

// Page: 1-4-withCheckedContinuation
// コンプリーションハンドラー形式の関数
func request(with urlString: String, completionHandler: @escaping (Result<String, APIClientError>) -> ()) {} 

// ラップしたasync関数
func newAsyncRequest(with urlString: String) async throws -> String {
    return try await withCheckedThrowingContinuation { continuation in
        // コンプリーションハンドラー形式の関数を呼び出す
        request(with: urlString) { result in
            continuation.resume(with: result)
        }
    }
}

newAsyncRequestがラップするasync関数です。
return try await withCheckedThrowingContinuationwithCheckedThrowingContinuationを呼び出します。
コールバックから取得できるcontinuationインスタンスはCheckedContinuation型で、通常の関数とasync関数を橋渡しするものです。
クロージャー内でコンプリーションハンドラー形式の関数を呼び出して、そのコールバック内でcontinuation.resumeメソッドを実行します。resumeメソッドにはコンプリーションハンドラーでのクロージャーの引数を渡せます。
これをするだけで通常の関数をasync関数にラップができます。

// Page: 1-4-withCheckedContinuation
Task.detached {
    let urlString = "https://api.github.com/search/repositories?q=swift"
    // ラップした関数で呼び出す
    let result = try await newAsyncRequest(with: urlString)
    print(result)
}

「プログラムを中断/再開とは?」 の章で解説したとおり、awaitキーワードでプログラムを中断、再開しますが、この「再開」をマニュアルで行うのがresumeメソッドです。

withCheckedContinuation

通常の関数をラップする際に、エラーをスローしないasync関数・メソッドを作成したい場合はwithCheckedContinuation関数を使います。
使い方はwithCheckedThrowingContinuationと同じくラップするasync関数のreturnにwithCheckedContinuationを指定します。

// Page: 1-4-withCheckedContinuation
struct User {}
// コンプリーションハンドラー形式の関数
func fetchUser(userID: String, completionHandler: @escaping ((User?) -> ())) {
    if userID.isEmpty {
        completionHandler(nil)
    } else {
        completionHandler(User())
    }
}

// ラップするasync関数。エラーを返さない
func newAsyncFetchUser(userID: String) async -> User? {
   // 
    return await withCheckedContinuation { continuation in
        fetchUser(userID: userID) { user in
            continuation.resume(returning: user)
        }
    }
}

newAsyncFetchUser関数がラップするasync関数です。
returnの後にawait withCheckedContinuationを実行します。
コールバックのcontinuationはwithCheckedThrowingContinuationと同じくCheckedContinuation型のインスタンスです。
コールバック中にラップ対象のfetchUser関数を呼び出し、そのコンプリーションハンドラー内でcontinuation.resumeメソッドを呼び出します。

newAsyncFetchUser関数の実行例はこちらです。

// Page: 1-4-withCheckedContinuation
Task.detached {
    let userID = "1234"
    let user = await newAsyncFetchUser(userID: userID)
    print(user ?? "")

    let noUser = await newAsyncFetchUser(userID: "")
    print(noUser ?? "no user")
}

このように実装することで、エラーをスローしない関数・メソッドもasync関数・メソッドに変換ができました。

resumeメソッドは必ず1回実行する

また注意点として、resumeメソッドはwithCheckedThrowingContinuationのコールバックで確実に1回実行しなければならないということです。

もしもresumeメソッドの呼び出しを忘れるとランタイムで次のようなワーニングが出ます。

func newAsyncRequest(with urlString: String) async throws -> String {
    return try await withCheckedThrowingContinuation { continuation in
        request(with: urlString) { result in
           // resumeメソッドを一回も実行しない場合
            //continuation.resume(with: result) 
        }
    }
}
// ランタイムのワーニングが出る。
SWIFT TASK CONTINUATION MISUSE: newAsyncRequest(with:) leaked its continuation!

また、resumeを2回以上実行するとランタイムエラーが発生します。
次のnewAsyncFetchUser関数でresumeメソッドを2回呼び出してみましょう。

func newAsyncFetchUser(userID: String) async -> User? {
    return await withCheckedContinuation { continuation in
        fetchUser(userID: userID) { user in
            continuation.resume(returning: user)
            continuation.resume(returning: user)
        }
    }
}

このようなランタイムエラーが表示され、プログラムが停止します。

// ランタイムのエラーが出る。
Fatal error: SWIFT TASK CONTINUATION MISUSE: newAsyncFetchUser(userID:) tried to resume its continuation more than once, returning Optional(__lldb_expr_3.User())!

resumeメソッドは必ず1回実行するように注意しましょう。

まとめ

async/awaitで非同期処理を書く方法を解説しました。
コンプリーションハンドラーを使う方法では、読みにくいコードやバグが容易に混入しやすいですが、awaitを使うことで同期的コードと同じように非同期処理コードを記述できます。
またその仕組みであるプログラムの中断と再開も解説しました。
async letバインディングで並列処理を行うこともでき、コンプリーションハンドラー形式のメソッドをasyncメソッドに変換する方法もあります。

async/awaitを使うことで、読みやすく、バグの少ない非同期処理を実装できます。
ぜひ皆さんも利用してください。

本記事にAsyncSequenceの説明がまだありません。for in文でawaitを使えるようになる機能です。また別の機会で解説する予定です。

参考文献

宣伝

BOOTHより、同人版「Swift Concurrency入門」発売中です。
Swift Concurrencyを網羅的に学べ、さらに既存アプリへの適応方法も解説しています。
日本語で体系的に学べる解説本は他になかなかありません。
1章、2章が立ち読みできるおためし版もありますので、ぜひチェックしてください!





https://personal-factory.booth.pm/items/3888109