階層構造を持った非同期処理を可能にするTaskとは
Taskの階層構造と種類について紹介します。Taskとは非同期処理のタスクであり、非同期処理として実行できる処理の単位を表しています。このTaskは自身の子となるTaskを紐付けることによって、階層構造を持つことができます。
Taskの階層構造
Taskは下記画像中にあるような階層構造を持つことができます。画像中の四角で囲まれているものがTaskを表しており、Taskを繋ぐ白線がTask間の親子関係を表しています。Taskがこのような構造を持つことによって、便利な規則を作成できる利点が生まれます。具体的には全ての子タスクが完了している際にその親のTaskは必ず完了する、というルールです。このルールは異常終了時にも適応されるため、エラーが発生した時にも必ずTaskは完了するようになっています。つまりTaskが必ず完了するように設計しておくことで、OSが依存関係にある各Taskの完了を保証することができ、未完了Taskが永久にメモリに残り続ける心配がなくなります。

また、2つのTaskが並行処理中に一方のTaskがエラーとなった場合、何が起きるでしょうか。例えば、一方のmetadataと書かれたTaskが異常終了した場合は、もう一方のdataと書かれたTaskがキャンセルとマークされ処理は続行します。さらに、キャンセルとマークされたTaskの子孫にあたるTaskも全てキャンセルとマークされます。そして、全ての処理が完了するとエラーとしてTaskは完了します。このように、直ちに処理を終了しないのはエラーハンドルを開発者に委ねているからです。例えば、トランザクションが進行しているタスクがエラーとなった場合は、適切にトランザクションを終了する処理を記述できます。

なお、現在実行中のタスクがキャンセルされているかは下記のようにTask.isCancelledを用いて調べることができます。
func task() async -> String {
var result: String = ""
if Task.isCancelled { ... }
return result
}
Taskの構造化方法
Taskは階層構造を持つように構造化できると先述しました。構造化の方法には2種類あり、それぞれに特徴があります。ここでは、async-let tasksとTask Groupの2つの方法を紹介します。
async-let tasks
async-let tasksは最も簡潔に記述できる構造化されたタスクです。このタスクはコンパイル時に有限個と分かっているタスク群にしか適応できませんが、後述するTask Groupより簡単に記述できます。
async-let tasksを利用するためには、下記のようにletやvarではなくasync letを使って変数を宣言し非同期関数の結果を保存します。この宣言によって現在のタスクに子タスクが生成されます。なお、この宣言をasync-let bindingsと呼びます。
async let result = URLSession.shared.data(...) try await result
async-let bindingsが実行された際に何が発生するかを下図を基に説明します。まず、letやvarを使った変数への代入と異なり、式の右辺が評価され、左辺に代入される前に、仮の値placeholderが代入されます。右辺の評価は、async-let bindingsによって生成された子タスクが並行して引き受けます。ここで、右辺の評価を子タスクが引き受けたことによって、async-let bindingsの後続処理を並行して進められます。最後に、async-let bindingsを使っていた変数をtry awaitを使って参照できます。この時には変数の中身はplaceholderではなく実際の値になっている必要があるため、この、async-let bindingsの右辺の評価を行う子タスクの終了を待ちます。

Task Group
Task Groupは動的な数の子タスクを持つTaskを作ることができます。下図のfetchArticles(ids:)は、引数に受け取った複数のIDそれぞれに対応するArticleのDataを非同期で返します。関数内部では外部のエンドポイントを叩く関数fetchArticle(id:)をさらに呼び出し、非同期でArticleを取得しています。現在の実装ではTaskの構造化が行われていていないため、先述してきたようなTaskの構造化による利点を享受できていません。しかしこのようなロジックの場合、非同期処理の実行回数がfetchArticles(ids:)の引数に渡される配列の数というランタイム時に決定される値に依存するため、ランタイム前に記述するasync-let bindingsは使えません。
func fetchArticle(id: Int) async throws -> Data {
// https://codezine.jp/article/detail/15285
let request = URLRequest(url: URL(string: "https://codezine.jp/article/detail/\(id)")!)
let (data, response) = try await URLSession.shared.data(for: request)
if (response as? HTTPURLResponse)?.statusCode != 200 {
throw NetworkError.failed
}
return data
}
func fetchArticles(ids: [Int]) async throws -> [Int: Data] {
var articles: [Int: Data] = [:]
for id in ids {
articles[id] = try await fetchArticle(id: id)
}
return articles
}
このような場合はwithThrowingTaskGroup(of:returning:body:)というAPIを使ってTask Groupを作ります。Task Groupを使って書き換えたのが下記のコードです。addTask(priority:operation:)の呼び出しによって、子タスクを生成することができます。そして、子タスクたちの結果はfor try await (id, article) in groupの記述によって、順に取り出すことができます。なお、このfor文を使って非同期処理の結果を受け取る方法の詳細はMeet AsyncSequenceにて紹介されています。
func fetchArticles(ids: [Int]) async throws -> [Int: Data] {
var articles: [Int: Data] = [:]
try await withThrowingTaskGroup(of: ((Int, Data).self)) { group in
for id in ids {
group.addTask {
return (id, try await fetchArticle(id: id))
}
}
for try await (id, article) in group {
articles[id] = article
}
}
return articles
}
