async letが導入された背景
async letが導入される前、非同期処理を並列で実行するにはTaskGroup
を使う必要がありました。
let result = await withTaskGroup(of: Int.self) { group -> Int in
// 複数のタスクを追加
group.addTask { await fetchNumber(1) }
group.addTask { await fetchNumber(2) }
group.addTask { await fetchNumber(3) }
// ...
}
しかしTaskGroupにはいくつかの問題点がありました。
- 異なる型の結果を扱う際コードが冗長になる
- ただ子タスクが親タスクに単一の値を返せばいいだけの場合でも、冗長なボイラーコードが必要
その問題を解決するため、簡単に非同期の並列処理を行えるasync let
が導入されました。
async letの使い方
async let
は宣言の左部分に付属させるだけで使用できます。
async let value = fetchData()
この式で宣言された時点で、子タスクとしてfetchData
が非同期で処理されるようになります。
このvalue
の値を使いたい時は、await
を使用する必要があります。逆にawait
を使うまでは、処理は中断されません。
print(await value)
もしthrows
がついた関数を並列で処理したい場合は、結果を取得する際にtry
をつける必要があります。
async let result = fetchData() // fetchDataはthrowsがついた関数
print(try await result)
TaskGroupが抱えていた問題を解決できたのか?
先述した通りTaskGroupには、異なる型の結果を扱う際コードが冗長になるという問題点がありました。
例えば以下のコードのような場合です。
// 異なる型の結果を扱うための列挙型
enum FetchResult {
case number(Int)
case text(String)
}
func fetchNumber() async throws -> Int {
try await Task.sleep(for: .seconds(1))
return 42
}
func fetchText() async throws -> String {
try await Task.sleep(for: .seconds(1))
return "Hello, World!"
}
// TaskGroup を使用して異なる型の結果を取得
func fetchAllWithTaskGroup() async throws -> (Int, String) {
var number: Int?
var text: String?
try await withThrowingTaskGroup(of: FetchResult.self) { group in
group.addTask {
return .number(try await fetchNumber())
}
group.addTask {
return .text(try await fetchText())
}
for try await result in group {
switch result {
case .number(let fetchedNumber):
number = fetchedNumber
case .text(let fetchedText):
text = fetchedText
}
}
}
return (number!, text!)
}
Task {
do {
let (number, text) = try await fetchAllWithTaskGroup()
print("Number: \(number)")
print("Text: \(text)")
} catch {
print("エラーが発生しました: \(error)")
}
}
Stringが返り値のfetchText
と、Intが返り値のfetchNumber
を並列で処理するために、FetchResult
を定義しています。ぱっと見かなり複雑で読みづらいコードだと思います。
こちらのコードをasync let
を使って描き直したのがこちらです。
func fetchNumber() async throws -> Int {
try await Task.sleep(for: .seconds(1))
return 42
}
func fetchText() async throws -> String {
try await Task.sleep(for: .seconds(1))
return "Hello, World!"
}
// async let を使用して異なる型の結果を取得
func fetchAllWithAsyncLet() async throws -> (Int, String) {
// 並行してタスクを開始
async let number = fetchNumber()
async let text = fetchText()
// 結果を待機して取得
let finalNumber = try await number
let finalText = try await text
return (finalNumber, finalText)
}
// 使用例
Task {
do {
let (number, text) = try await fetchAllWithAsyncLet()
print("Number: \(number)")
print("Text: \(text)")
} catch {
print("エラーが発生しました: \(error)")
}
}
かなりシンプルで読みやすくなったと思います。
- 並列処理したい関数の戻り値が異なる型の場合
- 並列で処理したいタスクの個数が決まっている場合
これらの場合は、async let
を使って記述するとシンプルに書けるようになると思います。
async let
で生成されたタスクのキャンセルについて
async let
で生成されるタスクは呼び出し元の親タスクの子タスクとして生成されます。
そのため親タスクがキャンセルされると、async let
の処理もキャンセルされることになります。
以下は並列処理中に親タスクがキャンセルされる例です。
func longRunningTask(label: String) async -> Int {
do {
for i in 1...5 {
try Task.checkCancellation() // キャンセルをチェック
print("\(label): Running step \(i)")
try await Task.sleep(for: .seconds(1)) // 1秒待機
}
print("\(label): Completed")
return 42
} catch {
print("\(label): Cancelled")
return -1
}
}
func main() async {
async let taskA = longRunningTask(label: "Task A")
async let taskB = longRunningTask(label: "Task B")
print("Parent task: Starting child tasks...")
let values = await(taskA, taskB)
print(values)
}
let parent = Task {
await main()
}
// 親タスクを2.5秒後にキャンセル
Task {
try? await Task.sleep(for: .seconds(2.5)) // 2.5秒待機
parent.cancel()
print("Parent task: Cancelled")
}
実行結果(順番は前後する可能性がある)
Parent task: Starting child tasks...
Task A: Running step 1
Task B: Running step 1
Task A: Running step 2
Task B: Running step 2
Task B: Running step 3
Task A: Running step 3
Task B: Cancelled
Parent task: Cancelled
Task A: Cancelled
(-1, -1)
実行結果を見るとわかる通り、親タスクがキャンセルされると、async let
で定義した処理もキャンセルされています。