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()
}
コードの処理の順番は次の通りです。
- ①:URLSession.shared.dataTaskを呼び出し、返り値であるtask変数を取得する
- ②:resumeメソッドを呼び出しリクエストを開始する
- ③:リクエストが完了したら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()
メソッドのbodyTask
やTask.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 withCheckedThrowingContinuation
でwithCheckedThrowingContinuation
を呼び出します。
コールバックから取得できる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を使えるようになる機能です。また別の機会で解説する予定です。
参考文献
- Meet async/await in Swift - WWDC21 - Videos - Apple Developer
- Concurrency — The Swift Programming Language (Swift 5.5)
- swift-evolution/0296-async-await.md at main · apple/swift-evolution
- Swift Concurrency チートシート
- swift-evolution/0300-continuation.md at main · apple/swift-evolution
宣伝
BOOTHより、同人版「Swift Concurrency入門」発売中です。
Swift Concurrencyを網羅的に学べ、さらに既存アプリへの適応方法も解説しています。
日本語で体系的に学べる解説本は他になかなかありません。
1章、2章が立ち読みできるおためし版もありますので、ぜひチェックしてください!