従来並行処理は難しいものでした。ネストが深いコールバック関数を読むのが辛くなり、コールバックの呼び忘れがないように開発者は注意深く実装をしなければいけません。スレッドの管理も開発者の責務です。Appleが提供しているDispatchQueueを用いて、順次実行したり並行実行する処理を実装することはできます。しかし、不注意でスレッドをデッドロックしたり、大量のスレッドを生成してパフォーマンスを落としてしまうことはよくあります。
Swift 5.5からSwift Concurrencyが導入されました。並行処理を簡潔に、安全に記述できる機能です。今までの記述方法と同じように並行処理を書け、コンパイラが並行処理の安全性をチェックするので、開発者はスレッドをマニュアルで管理する必要がなくなりました。とても素晴らしい機能ですが、新しい文法や型が数多く導入されました。非同期関数を作成するasyncキーワードや、それを呼び出すawaitキーワード、データ競合を防ぐ新しい型の種類であるActor、そして並行処理の実行単位であるTask型などです。
並行処理は、どの開発者も避けては通れない処理です。Swiftが言語機能として並行処理を導入したことで、以前よりも不具合の少ないコードを実装できるようになります。ただし、どのように機能しているのかを理解しないことには、十分にその機能を発揮できないでしょう。本書ではSwift Concurrencyがどのようなもので、どう使えばいいかを解説します。従来の記述方法とSwift Concurrencyの記述方法を比較し、どんな問題を解決しているのかを丁寧に解説します。本書があなたの開発の手助けとなることを願います。
・第1章「async/await」
─非同期処理をasync/awaitで記述できるようになりました。従来クロージャーによるコールバックと比べてどのように簡潔、安全になったのかを解説します。
・第2章「Actor/データ競合を守る新しい型」
─マルチスレッドプログラミングにおいて、データ競合(data race)は典型的な不具合のひとつです。Swift Concurrencyではデータ競合を防ぐ新しい型、Actorが導入されました。どのような特徴があるのかを解説します。
・第3章「AsyncSequence」
─繰り返し処理でお馴染みのfor文を非同期で書きましょう。for await inループとそれを実現するAsyncSequenceプロトコルを学びます。
・第4章「Task」
─Swift Concurrencyの並行処理はTaskという単位で行われます。Taskの特徴を解説します。
・第5章「Sendable」
─Actorを始め、並行コードにおいて、データ競合なしにデータを同時並行処理間で渡せるかどうかを表す新しいプロトコルSendableが登場しました。Sendableを解説し、コンパイラがエラーを出力した場合の対処方法を探ります。
・第6章「既存のプロジェクトにSwift Concurrencyを導入」
─既存のプロジェクトにSwift Concurrencyを導入する方法を解説します。async/await、@MainActorだけでなく、Swift 5.6の対応も行います。
本書は、次の読者を対象としています。
・Swift言語の基本文法を学んだ方
・UIKit/SwiftUIの基本動作を理解している方
Swift ConcurrencyはSwiftの言語機能ですが、サンプルコードはiOS開発に向けたものを作成しています。Swift言語自体の解説やUIKit/SwiftUI自体の解説は行いません。あらかじめご了承ください。
本書は次の環境で検証しています。
・iOS 14.5、iOS 15.0
・Xcode 13.4.1
・Swift 5.6
・macOS 12.4
・MacBook Pro(14インチ、2021)
─チップ: Apple M1 Max
次のリポジトリーに本書のサンプルコードが掲載されています。
・https://github.com/SatoTakeshiX/first-step-swift-concurrency
各章のサンプルプロジェクトがディレクトリーでまとめられています。どのプロジェクトをサンプルとして利用するのかは、各章でお伝えします。本文で解説するコードには、ファイル名をコメントに記載しています。サンプルプロジェクトで前後のコードを確認する際の目安にしてください。また、記載がなければ対応するコードはないことを意味します。あらかじめご了承ください。
本書に記載されている会社名、製品名などは、一般に各社の登録商標または商標、商品名です。会社名、製品名については、本文中では©、®、™マークなどは表示していません。
・サンプルコード
・https://github.com/SatoTakeshiX/first-step-swift-concurrency/tree/main/try-concurrency.playground
非同期処理、並行処理を不具合なく実装することはとても難しいです。クロージャーはどんどんネストされ読みにくくなり、複数のスレッドが同じデータを書き込めばデータ競合が起こります。
Swift 5.5からは、Swiftの言語機能としてConcurrencyが登場しました。これは非同期処理、並行処理のコードを簡潔かつ安全に記述できる機能です。async/awaitを使えば、同期処理と同じような書き方で非同期処理を記述できます。この章では、Swift 5.5からのSwift Concurrencyの機能のひとつasync/awaitの使い方を解説します。
非同期処理をクロージャーのコールバックで実装することは開発者が日常的に行っていることですが、簡単に可読性が下がり不注意によるバグも発生しやすいです。たとえば、URLSessionでHTTPリクエストを行う関数を考えてみましょう。HTTPリクエストを行い、そのレスポンスのURLからさらに画像を取得し、その画像をリサイズすることを想定しています。
// Page: 1-1-request-with-closure
func request(url: URL,
completionHandler: @escaping (Result<UIImage, Error>) -> ()) {
// ①taskインスタンス取得
let task = URLSession.shared
.dataTask(with: url) { data, response, error in
// ③URLSession.shared.dataTaskのコールバック
guard error == nil else { return }
downloadImage(data: data) { result in
// ④downloadImageのコールバック
let image = try? result.get()
resizeImage(image: image) { result in
// ⑤resizeImageのコールバック
completionHandler(result)
}
}
}
// ②リクエストの実行
task.resume()
}
3つのコールバックが順々に呼ばれており、ネストが深くなり読みづらいコードになっています。コードの実行順は①でURLSessionのdataTaskメソッドの戻り値taskインスタンスを取得し、②のresumeメソッドでリクエストを実行します。そして③、④、⑤のコールバックが順に呼ばれるという流れです。処理の実行順が上、下、真ん中と分かれるので注意して読み進めないと、どの順番でコードが実行されるかを間違えてしまうでしょう。さらにネストが深くなったり、コールバックの実行に条件分岐が加わるなどがあれば、さらにコードの可読性はさらに下がります。
また、コールバックの呼び出し元はすべてのパスで確実に呼ばれることを想定していますが、実際に呼び出すかどうかは開発者の責任です。呼び忘れた場合は不具合の原因になるでしょう。
リスト1.1ではエラーがある場合、guard文で早期リターンをするのみで、コールバックを呼んでいません。
// Page: 1-1-request-with-closure
func request(url: URL,
completionHandler: @escaping (Result<UIImage, Error>) -> ()) {
let task = URLSession.shared
.dataTask(with: url) { data, response, error in
// エラーがある場合にコールバックを呼んでいない
guard error == nil else { return }
すると、呼び出し元で不具合が発生する可能性があります。たとえば、リクエストの処理中には画面のローディングViewを表示する場合を考えましょう。呼び出し元でリクエスト前にローディングViewを出し、処理が終わったらローディングViewを非表示にするとします。コールバックが呼ばれないパスがある場合、ローディングViewがいつまでも表示されてしまいます。
// Page: 1-1-request-with-closure
let url = URL(string: "https://example.com")!
request(url: url) { result in
// コールバックが呼ばれないとisLoadingがfalseにならず、
// ローディングViewが表示しっぱなしになるかもしれない
isLoading = false
switch result {
case .success(let image):
print(image)
case .failure(let error):
print(error.localizedDescription)
}
}
コールバックの呼び出しはコンパイラはチェックをしないので、開発者は注意深くすべてのパスで呼ばれるかどうかをチェックする必要があります。
Swift Concurrencyのasync/awaitは、この問題を解決します。関数の定義にasyncをつけることで、その関数を非同期関数1として定義ができます。非同期関数を呼び出すためには、awaitが必要です。awaitをつけると、その式をシステムに待機可能なプログラムということを伝えます。待機可能なプログラムの意味は後述します。
ひとまず、先ほどのrequest関数をasync関数として実装し直し、記述がどう変わるのかを見てみましょう。
// Page: 1-2-request-with-async
func request(url: URL) async throws -> UIImage {
let (data, response) = try await URLSession
.shared
.data(from: url, delegate: nil)
let image = try await downloadImage(data: data)
let resizedImage = try await resizeImage(image: image)
return resizedImage
}
たった6行ほどで、非同期処理を記述できるようになりました。戻り値の矢印->の前にasync throwsのキーワードをつけて、エラーをスローする非同期関数として定義します。戻り値が定義されているため、すべてのパスでreturnをするかエラーをスローしなければコンパイルエラーとなります。つまり、コールバックを使用していたときとは異なり、正常系も異常系も実装忘れがないことをコンパイラが保証してくれています。処理は同期的なコードと同じように上から下に流れていくため、読みやすいコードとなっています。
非同期関数を呼び出す際は、awaitキーワードが必要です。awaitキーワードをつけることで、システムにプログラムが待機可能性があること伝えます。また、非同期関数やメソッドは並行処理のための特別なコンテキストで、実行が必要です。本書では、この特別なコンテキストを非同期コンテキストと呼ぶことにします。ここではTask.detachedを利用しましょう。Task.detachedについての詳細は第4章「Task」で解説しますが、今回は非同期コンテキストを作成してくれるものとして考えていただければ大丈夫です。
// Page: 1-2-request-with-async
var isLoading = true
Task.detached {
do {
let url = URL(string:
"https://api.github.com/search/repositories?q=swift")!
// 非同期関数を呼び出す
let response = try await request(url: url)
// エラーがなければ必ず通る
isLoading = false
print(response)
} catch {
// エラーの場合でも必ず通る
isLoading = false
print(error.localizedDescription)
}
}
requestメソッドの前に、try awaitをつけてメソッドを呼び出しています。戻り値はresponse変数に代入され、通常の変数と同じように利用できます。ここでは、print関数で変数の中身を出力しています。また、エラーが発生すればcatchブロックが呼ばれます。コールバック形式の場合とは異なり、処理の流れは上から順に実行されて読みやすいです。リクエスト中に表示するローディングViewもこの場合ではエラーある場合、ない場合、どちらでも必ずisLoadingをfalseにできます。ローディングViewがずっと表示されたままになる不具合は、非同期関数の場合はなくなります。
awaitキーワードは、プログラムにそのメソッドやプロパティーが待機可能であることを伝えるものです。awaitがつけられると、実行中のメソッドやプロパティーは待機状態となります。そのメソッドやプロパティーを実行していたスレッドはブロックを解除し、他の作業を行います。システムがそのメソッドやプロパティーを再開すると、処理が完了し、戻り値があれば左辺に変数が代入されます。
図で説明しましょう。リスト1.5で示したrequest(url:)メソッドの呼び出しを見てみます。
awaitキーワードをつけることで、システムにrequest(url:)メソッドが待機可能であることを伝えます。実際にコードが実行されたとき、request(url:)メソッドは中断されます。
スレッドはブロックを解除し、他のタスクを行います。たとえば、もしかしたらユーザーがボタンタップしたり、スクロールをするなどのUIイベントが発火されるかもしれません。Timerなどのグローバルな通知イベントが発火するかもしれません。その場合でもスレッドはブロックされず、他のタスクを実行します。
そしてrequest(url:)メソッドが再開されると、結果が左辺のresponse変数に代入されます。
このようにSwift Concurrencyでは、実行中のメソッドやプロパティーを中断、再開して非同期処理を行います。開発者はスレッドの管理を気にすることなく、同期的なコードと同じような書き方で、非同期処理を実行できます。
ちなみに、awaitキーワードで実行した後のスレッドは、その前で実行されたものと同じとは限りません。
// Page: 1-2-request-with-async
let url = URL(string:
"https://api.github.com/search/repositories?q=swift")! // A
let response = try await request(url: url)
isLoading = false
print(response) // Aと同じスレッドで実行されるとは限らない
今回の例でいえば、Aが実行された行とprint関数が実行される行のスレッドが必ず同じとは限りません。request(url:)でスレッドが変わる可能性があります。