Swift Concurrency 最初の一歩
はじめに
こんにちは、株式会社アイスタイルで@cosmeアプリのiOSエンジニアをしている上野初仁(うえのはつひと)と申します。
前回も書いた通り、アイスタイルには外部委託として参画しています。今回もこのようにテックブログを書く機会を頂きました。最近は個人プロジェクトとしてバックエンドにLaravel、フロントエンドにReactを使ったSPAによるサイトを運営したりしています。
先日の WWDC 2024 でSwift 6.0におけるSwift Concurrency対応強化、対応推奨などが発表されました。今後はSwift Concurrencyによるアプリ開発がデファクトスタンダードとなりそうなので、勉強もかねてSwift Concurrencyについてまとめます。
Swift Concurrency : iOS / macOS アプリ開発における非同期処理
Swift ConcurrencyはSwift 5.5 で導入されました。従来の非同期処理に比べシンプルに非同期処理を記述できるようになったのは、iOS や macOS アプリケーションの開発者にとって大きな進化でした。今回はSwift Concurrencyの基本的な概念、使用方法、実例を解説します。
1. Concurrency の必要性
現代のアプリケーションは、様々な処理において多くのタスクを平行して実行し、ユーザーのアプリケーションにおける体験を向上させる必要があります。例えば、データのフェッチ、画像の処理、ユーザーインターフェースの更新などです。これを効率的に行うためには、Concurrency(並行処理)が重要です。
辞典を調べてみると「コンカレンシー (Concurrency)」とは「(主にコンピューター分野で)並行性」と出てきます。逆に並行性とは「計算機科学において、時間的にオーバーラップして実行される計算を伴うシステムの属性であり、そのような計算ではリソースを共有することがある。」とあります。特にConcurrencyとはSwift特有の概念で無い事がわかります。
2. Swift Concurrency の基本概念
async/await
Swift Concurrency を調べると、まず最初に async(エィシンク)
/ await(アウェイト)
というキーワードが出てきます。これは非同期処理を簡潔に書くためのものです。
func fetchData() async throws -> Data {
let url = URL(string: "https://example.com/data")!
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
async
キーワードは関数が非同期であることを示し、await
は非同期の結果を待つことを示します。これにより、コールバックベースの非同期処理よりも直感的で読みやすいコードが書けます。
Task
Task
は非同期タスクを作成するために使用します。Task
は、同期処理を行っているコンテキスト(非同期コンテキスト外)から非同期関数を呼び出す場合にも利用されます。
Apple Developer Documentation: Task
Task {
do {
let data = try await fetchData()
print("Data received: \(data)")
} catch {
print("Error: \(error)")
}
}
Actor
Actor
は状態をスレッドセーフに管理するための新しいコンセプトです。Actor
を使用することで、データ競合を避けることができます。
Apple Developer Documentation: Actor
actor DataManager {
private var data: [String] = []
func addData(_ item: String) {
data.append(item)
}
func fetchData() -> [String] {
return data
}
}
上記の例では、DataManager
という actor
が、内部状態 data
をスレッドセーフに管理しています。このように、actor
を使用することで、複数のスレッドからのアクセスを安全に制御できます。
Actor
は Class
や Struct
, Enum
などの記述方法で作成でき、それらの多くの機能を利用できます。
3. Task の具体的な使い方
Task と Task.init 、Task.detached の差異と利用箇所の違い
Task
Task
は、Swift の非同期コンテキスト内で非同期タスクを実行するための基本です。
親タスクや現在のコンテキストを継承するため、優先度やアクター内でのコンテキスト、キャンセル状態などを利用出来ます。
Task {
// 非同期コード
let result = await someAsyncFunction()
print(result)
}
- 利用箇所
- メインスレッドや現在のコンテキスト内で非同期タスクを実行する場合
- 非同期関数を呼び出して、その結果を利用したい場合
Task.init
Task.init
は、Task
のイニシャライザを用いてタスクを作成する方法です。
この方法は、より制御が必要な場合に使用されます。
例えば、優先度を指定したり、タスクがキャンセル可能であるか指定する場合などです。
注意)ここでは説明のために項を分けていますが、基本的に Task.init
と明示的に記述する必要はなく、Task
と記述方法および利用方法は同じになります。
let task = Task(priority: .high) {
// 非同期コード
let result = await someAsyncFunction()
print(result)
}
// タスクの結果を待つ
let result = await task.value
// or タスクのキャンセル
task.cancel()
- 利用箇所
- 特定の優先度でタスクを実行する必要がある場合
- タスクのキャンセルやその他の詳細な制御が必要な場合
- タスクの結果を明示的に待つ必要がある場合
Task.detached
Task.detached
は、親タスクや現在のコンテキストに依存しない独立したタスクを作成するために使用されます。
この方法で作成されたタスクは、現在のスコープ外で実行されるため、グローバルなコンテキストで非同期作業をするのに適しています。
Task
と違い、親タスクや現在のコンテキストを継承しないため、それらの持っている優先度やアクターのコンテキスト、キャンセル状態などと連動する処理には不向きです。
Task.detached {
// 非同期コード
let result = await someAsyncFunction()
print(result)
}
- 利用箇所
- 親タスクやコンテキストに依存しない独立した非同期タスクを作成する場合
- グローバル実行必須な非同期作業がある場合
- 親タスクのキャンセルが子タスクに影響を与えないようにしたい場合
Task.detached
は親タスクや現在のコンテキストに依存しない独立したタスクを作成するために使用されますが、利用する際には注意が必要な場合もあります。
ここでは、Task.detached
を利用しない方が良いケースとその具体例を紹介します。
-
Task.detached
を利用しない方がよい場合- 親タスクとの連係が必要な場合
-
Task.detached
は親タスクから独立しているため、親タスクのキャンセルやエラーの影響を受けません。このため、親タスクと連係して動作させる必要がある場合には、Task.detached
は適していません。
-
- タスクのキャンセルを管理したい場合
- 親タスクがキャンセルされると、通常の
Task
はキャンセルされますが、Task.detached
はキャンセルされません。そのため親タスクと子タスクのキャンセル状態を連動させたいときは、Task.detached
の使用は避けるべきです。
- 親タスクがキャンセルされると、通常の
- 親タスクのコンテキストが重要な場合
-
Task.detaeched
は現在のコンテキストを継承しないため、特定の優先度や実行環境が重要な場合には適していません。
-
- 親タスクとの連係が必要な場合
// 親タスクとの連係が必要な場合
Task {
do {
let data = try await downloadData()
let processedData = try await processData(data)
await MainActor.run {
updateUI(with: processedData)
}
} catch {
print("An error occurred: \(error)")
}
}
// タスクのキャンセルを管理したい場合
let task = Task {
do {
let data = try await downloadData()
let processedData = try await processData(data)
await MainActor.run {
updateUI(with: processedData)
}
} catch {
print("An error occurred: \(error)")
}
}
// 何らかの条件でタスクをキャンセル
task.cancel()
Task.detached
は強力なツールですが、親タスクとの連携やキャンセル管理が必要な場合には適していません。
これらのケースでは、通常の Task
や Task.init
を使用する方が適切です。
具体的なユースケースに応じて適切なタスク作成方法を選択することが、Swift Concurrencyを効果的に利用する鍵となります。
[weak self]
が不要な理由
Task に Swift Concurrency では、Task
内で [weak self]
を使用しない場合でも、タスクのライフサイクルが適切に管理されます。
これらのタスクは、非同期コンテキスト内で独立して実行されるため、通常のクロージャと異なり、循環参照(retain cycle)を引き起こすリスクが低減されています。
特に、タスクが完了すると自動的に解放されるため、メモリリークの心配が少ないです。
そのため [weak self]
を使用しなくても問題ない場合は、冗長なコードを避けるために省略できます。
// 冗長なコード(書いても問題ないが不要)
Task { [weak self] in
guard let self = self else { return }
// 非同期コード
let result = await self.someAsyncFunction()
print(result)
}
// 省略したコード
Task {
// 非同期コード
let result = await someAsyncFunction()
print(result)
}
参考文献:Task.initのクロージャーに[weak self]はいらない。Task.detachedとTaskGroup.addTaskも同様
Task まとめ
Task
、Task.init
、および Task.detached
は、それぞれ異なるユースケースに適した非同期タスク作成の方法を提供します。
Task
は簡単な非同期タスクに適し、Task.init
は優先度やキャンセルなどの詳細な制御が必要な場合に役立ちます。
Task.detached
は、親タスクやコンテキストに依存しない独立したタスクを作成するのに最適です。
これらを適切に使い分けることで、Swiftでの非同期プログラミングをより効果的に行うことができます。
さらに、これらのタスクは通常、循環参照を引き起こしにくいため、[weak self]
を使用しなくても安全に利用できる点も大きな利点です。
4. Actor の具体的な使い方
具体的な例として、actor
を使った銀行口座の管理を考えてみます。この銀行口座は複数のタスクから安全にアクセスされ、預金や引き出しの操作が行われます。
actor BankAccount {
private var balance: Int = 0
func deposit(amount: Int) {
balance += amount
}
func withdraw(amount: Int) throws {
if balance >= amount {
balance -= amount
} else {
throw NSError(domain: "Insufficient funds", code: 1, userInfo: nil)
}
}
func getBalance() -> Int {
return balance
}
}
次に、この BankAccount
を使って複数のタスクから預金の実行と引き出しの実行をする例を見てみましょう。
let account = BankAccount()
Task {
await account.deposit(amount: 100)
print("Deposited 100")
}
Task {
do {
try await account.withdraw(amount: 50)
print("Withdrew 50")
} catch {
print("Failed to withdraw: \(error)")
}
}
Task {
let balance = await account.getBalance()
print("Current balance: \(balance)")
}
出力:
Deposited 100
Withdrew 50
Current balance: 50
この例では、Task
内で await
を使って BankAccount
のメソッドにアクセスしています。deposit
メソッドと withdraw
メソッドを呼び出してから、現在の残高を取得して表示しています。actor
により、これらの操作はスレッドセーフに行われます。
5. 競合が発生する問題の解説
では、actor
を使わない場合、どのようにデータ競合が発生するかを見てみます。以下の例では、UnsafeBankAccount
クラスがスレッドセーフでないため、複数のタスクから同時にアクセスされた場合にデータ競合(問題発生)する可能性があります。
import Foundation
class UnsafeBankAccount {
private var balance: Int = 0
func deposit(amount: Int) {
balance += amount
}
func withdraw(amount: Int) throws {
if balance >= amount {
balance -= amount
} else {
throw NSError(domain: "Insufficient funds", code: 1, userInfo: nil)
}
}
func getBalance() -> Int {
return balance
}
}
let unsafeAccount = UnsafeBankAccount()
Task {
for _ in 0..<1000 {
unsafeAccount.deposit(amount: 1)
}
}
Task {
for _ in 0..<1000 {
do {
try unsafeAccount.withdraw(amount: 1)
} catch {
print("Failed to withdraw: \(error)")
}
}
}
Task {
let balance = unsafeAccount.getBalance()
print("Final balance: \(balance)")
}
出力例:
Failed to withdraw: Error Domain=Insufficient funds Code=1 "(null)"
Failed to withdraw: Error Domain=Insufficient funds Code=1 "(null)"
Failed to withdraw: Error Domain=Insufficient funds Code=1 "(null)"
(中略)
Failed to withdraw: Error Domain=Insufficient funds Code=1 "(null)"
Failed to withdraw: Error Domain=Insufficient funds Code=1 "(null)"
Failed to withdraw: Error Domain=Insufficient funds Code=1 "(null)"
Final balance: 205
この例では、2つのタスクが同時に UnsafeBankAccount
の deposit
および withdraw
メソッドを呼び出しているため、データ競合を起こす可能性があります。結果として、期待されるバランスではなく、不正確なバランスが表示される可能性を含んでいます。最後のタスクで unsafeAccount.getBalance()
を呼び出しても、正確な値を得られない場合があります。
6. 実例:非同期データフェッチと UI 更新
以下は、非同期でデータをフェッチし、UI を更新する例です。
import SwiftUI
struct ContentView: View {
@State private var data: String = "Loading..."
var body: some View {
Text(data)
.padding()
.task {
await loadData()
}
}
func loadData() async {
do {
let fetchedData = try await fetchData()
data = String(data: fetchedData, encoding: .utf8) ?? "Failed to load data"
} catch {
data = "Error: \(error.localizedDescription)"
}
}
}
この例では、@State
プロパティを使用して UI の状態を管理し、Swift Concurrency の task
を使って非同期処理を実行しています。
今回の例では顕在化していませんが、UI の状態変更は必ずメインスレッドで行う必要があります。バックグラウンドで UI の状態変更するとクラッシュの原因となります。
バックグラウンドで UI の状態変更などメインスレッドで行う必要のある処理をしようとすると、多くの場合でXcodeがワーニングを出してくれます。
出力例:
Foo.bar() must be used from main thread only.
7. エラーハンドリング
Swift Concurrency は、非同期処理におけるエラーハンドリングもサポートしています。関数宣言時に func fooBar() async throws -> {}
とすることでエラーが発生する関数であることを明示します。
エラーが発生する非同期関数を利用する際のエラーハンドリングの方法は、do
/ catch
構文です。
func fetchData() async throws -> Data {
let url = URL(string: "https://example.com/data")!
let (data, response) = try await URLSession.shared.data(from: url)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw URLError(.badServerResponse)
}
return data
}
do {
let fetchData = try await fetchData()
} catch {
print(error.localizedDescription)
}
8. 結論
Swift Concurrency は、非同期処理をシンプルかつ直感的に記述するための強力なツールセットを提供します。async
/await
、Task
、Actor
などのコンセプトを理解し活用することで、より効率的で保守性の高いコードを書くことができます。
先に述べたとおり、Swift 6.0 では Swift Concurrency で記述することが推奨されており、今後は iOS / macOS アプリ開発の大きな根幹となると思われます。私も今のうちにもっと勉強し慣れておきたいと考えています。
おわりに
今回は Swift Concurrency の基本的な言語仕様についてまとめました。いかがだったでしょうか。先述したとおり今後は iOS / macOS アプリ開発で Swift Concurrency を触らない日は無くなってきそうです。この記事が皆さんの開発の一助になれば幸いです。
また、前回の続編として Swift / Kotlin の言語仕様の差分についてもまとめたいと考えています。
Discussion