【翻訳】Swift Concurrency Deep Dive [4] — Task
この投稿は、 Swift の並行処理、つまり async/await をより深く理解するために書かれています。
Apple Developer's Document、Swift-evolutionリポジトリ、 Swift Language Guide のような信頼できる情報源から可能な限り情報を集めましたが、間違った情報が含まれているかもしれません。その場合は、コメントで教えてください。
私の以前のシリーズを読むことを強くお勧めします
Swift Concurrency Deep Dive [1] — GCD vs async/await (https://towardsdev.com/swift-concurrency-deep-dive-1-gcd-vs-async-await-280ac5df7c76)
Swift Concurrency Deep Dive [2] — Continuation (https://enebin.medium.com/swift-concurrency-deep-dive-2-continuation-c2e385b11a10)
Swift Concurrency Deep Dive [3] — Structured concurrency (https://enebin.medium.com/swift-concurrency-deep-dive-3-structured-concurrency-bcfa7c68b0ba)
Task
Task の概念
from proposal document
Task はシステムにおける並行処理の基本単位です。すべての非同期関数は Task の中で実行されます。言い換えれば、 Task は非同期関数にとっての Task であり、同期関数にとってのスレッドです。
Task は、提案文書で定義されているように、 Swift の並行処理における非同期ジョブの基本単位であり、全ての非同期関数は Task 内で実行されなければなりません。
Task の作成
from Language Guide
前のセクションで説明した同時実行への構造化されたアプローチに加えて、 Swift は非構造化された同時実行もサポートします。Task グループの一部である Task とは異なり、非構造化 Task は親 Task を持ちません。... 現在の actor 上で実行する非構造化 Task を作成するには、 Task.init(...) イニシャライザーを呼び出します。
Task は、提供されている API、Task.init と Task.detached によって作成され、管理されます。 Swift の言語ガイドに書かれているように、 Task を使えば、非構造化並行処理を作成することができます。
構造化された同時実行ではなく、非構造化された同時実行を作成することができると言うのは、気が引けるかもしれません。なぜなら、 Swift の同時実行は構造化プログラミングの実装だと言ったからです。では、"非構造化並行処理を作成する "とは、実際にはどういう意味なのでしょうか?
非構造化並行性?
手短に言えば、それ以外の意味はありません。 Task はただそのために設計されているのです。これを理解するには、まず、なぜ Task が必要なのかを知る必要があります。
Task は Swift で非構造化並行処理を行う唯一の方法であり、残りのコードをサスペンド状態にして Task の終了を待つことはできません。それが、 Task 自体を待ち受けることができない理由です。
親 Task が存在しない構造化された方法で、 Swift の並行 Task をディスパッチすることを想像してみてください。それは不可能です。
そのため、非構造化という特徴により、外部から Swift の並行処理のコンテキストに入ることが可能になりました。この場合、 Swift の並行処理のエントリーポイントのように振る舞います。
実際、それ以外にも、 Task を実行するために「現在の actor 」から抜け出す必要があるときに、 Task を使うことができます。この記事の後半で確認します。
await for Task
import Foundation
var currentSeconds: String {
"CURRENT_SEC - " + String(Int64(Date().timeIntervalSince1970 * 1000) / 1000 % 1000) + ": "
}
func doSomethingAsync() async throws -> Int {
print(currentSeconds, "doSomethingAsync")
try await Task.sleep(nanoseconds: 2_000_000_000)
return 100
}
func doAnotherAsync() async throws {
print(currentSeconds, "doAnotherAsync")
try await Task.sleep(nanoseconds: 1_000_000_000)
}
let task = Task {
let result = try await doSomethingAsync()
return result
}
print(currentSeconds, "running")
Task {
try await doAnotherAsync()
let result = try await task.value
print(currentSeconds, "Done: ", result)
}
上のコードのように、 Task で return を使い、 await でタスクの return を取得するコードを見かけることがあります。 Task に await は使えないと言ったはずです。では、これは一体何でしょうか?
しかし、このコードを実行してみると、 Task が宣言された時点で result を await するわけではないことに気づくでしょう。代わりに、 await がセットされた変数 result が、 Task の return を待ちます。重要なのは、 await がどこにあるかということであり、 Task が作られたかどうかということではありません。
例のログはこんな感じ。
CURRENT_SEC - 317: doSomethingAsync
CURRENT_SEC - 317: running
CURRENT_SEC - 317: doAnotherAsync
CURRENT_SEC - 319: Done: 100
doSomethingAsync と doAnotherAsync がほぼ同時に開始されたことが分かります。しかし、doAnotherAsync の結果を待っていたため、最後のログが出力されたのは開始から2秒後でした。
子 Task を構造的に作りたい場合は、 TaskGroup か async let を使うべきでしょう。
Detached Task
From Language guide
現在の actor の一部ではない非構造化 Task を作成するには,
Task.detached(priority:operation:) クラス・メソッドを呼び出します。
現在の actor から抜け出したいときに Task が使えることは述べました。そのためには、Task.detached(priority:operation:) を使ってデタッチされたタスクを作ることができます。
ドキュメントを参照すると、ほとんどの場合、これを使用する必要はありません。また、 Apple は detached Task の直接使用をできるだけ避けることを推奨しています。
Actor については後で詳しく説明します。今は、異なるスレッドで Task を実行するための単なる方法であることを知っていれば十分です。
デッドロック問題を見直す
Swift の並行処理のコンテキストで起こりうるデッドロックの問題については、以前この記事の最後で説明しました。今、私たちはなぜデッドロックが起こったのかを調査するのに十分な準備ができています。
import Foundation
let lock = NSLock()
func doSomething(_ num: Int) async throws {
print("Ready to do:", num)
Task {
try await Task.sleep(nanoseconds: 1_000_000_000)
print("Job: \\(num)", Thread.current)
lock.unlock()
}
print("Returned: ", num)
}
// main
Task {
for i in 0..<100 {
print("Entered: ", i)
lock.lock()
try await doSomething(i)
}
}
内部で何が起こっているかを視覚化するために、 print を追加しました。では、コードがどのように実行されるかを見てみましょう。
-
メイン・パートでは、 Task の内部で for ループが実行されています。
-
各ループでは、 doSomething を実行する前に共有ロックがロックされます。
-
従って、ロックが解除できなかった場合、2回目のループ(i == 1)の後、doSomethingは実行できません。
-
doSomething の内部では、現在のスレッドを出力する非構造化 Task が作成され、ディスパッチされます。
-
タスクは構造化されずに作成されたので、関数の残りの部分はその返事を待ちません。
関数を待機させるには、その Task が属する親 Task が必要ですが、この場合は存在しません。
上記のコードを実行すると、次のようなログが出力されます。
Entered: 0
Ready to do: 0
Returned: 0
Entered: 1
順を追って見ていきましょう。
-
Entered 0: 最初のループに入りました。(i == 0)
-
Ready to do 0: 最初のループから呼び出された doSomething に入りました。
-
Returned 0: doSomething が返されました。
-
Entered 1: 2番目のループ(i == 1)に入りました。ロックがロックされているので、次のジョブが呼び出される直前の時点でロックが解除されるまで、無期限に一時停止されます。
-
Swift の並行処理はシリアルキューを使用するので、ロックを観察する最初のジョブが終了するまで、ロックを解除する2番目の Task が実行される可能性はありません。
DispatchQueue を使えば、このように表現できます。
import Foundation
let lock = NSLock()
let queue = DispatchQueue(label: "test")
func doSomething(_ num: Int) {
print("Ready to do:", num)
queue.async {
print("Job: \\(num)", Thread.current)
lock.unlock()
}
print("Returned: ", num)
}
// main
queue.async {
for i in 0..<100 {
print("Entered: ", i)
lock.lock()
doSomething(i)
}
}
では、この問題を解決するにはどうすればいいのでしょうか?
1. NSLock を NSRecursiveLock に変更して、ロックがすでにロックされている状態を無視するようにすればいいです。
・ しかし、この場合、 Task は並列に実行され、前の Task を待つことはありません。意図した通りではありません。
2. Task. の代わりに Task.detached を使えばよい。
・ これは、別のスレッドでロックを解除して、止まっている作業を続行する仕組みです。
・ この場合、前の Task は次の Task を待っているので、意図した目的は達成されたと言えます。
・ しかし、切り離された Task は、文書が警告しているように、そのアウトオブアクターの特性により、予期せぬエラーを引き起こす可能性があります。
3.実は、ロックを使う必要はありません。
import Foundation
func doSomething(_ num: Int) async throws {
try await Task.sleep(nanoseconds: 1_000_000_000)
print("Job: \\(num)", Thread.current)
}
// main
Task {
for i in 0..<100 {
try await doSomething(i)
}
}
この場合、ロックなしでデッドロックのないコードを書くことができます。
actor について簡単に触れました。 actor は、Swift の並行処理の最も重要な概念の一つでもあり、意図しないデータ競合を管理するのに役立ちます。次の投稿でそれを見ていきましょう。
【翻訳元の記事】
Swift Concurrency Deep Dive [4] — Task
Discussion