1. はじめに
キッチンに入ったら、そこに料理人が10人いたと想像してください。1人は、目の前で待っている人のためにオムレツを作っています。2人目は「明日のため」のスープを煮ています。3人目は皿を洗っています。理想の世界では、チームはオムレツが「フォーク磨き」より重要だと自分で理解しています。そうでないと、客は空腹で機嫌が悪いまま帰ってしまいます(しかもレビューまで残します)。Swiftにおけるタスク優先度は、だいたいこの話です。つまり、ランタイムに対するヒントであり、今、ユーザーにとって何がより重要かを伝えるものです。
優先度を上げても、タスクが「10倍速く」なるわけではありませんし、悪いアルゴリズムが良いものに変わるわけでもありません。100万要素をバブルソートしているなら、高い優先度が与えてくれるのは、なぜ人生の選択としてバブルソートにたどり着いたのかを少し早く悟る機会くらいです。優先度はスケジューラの注意をどう配分するかの仕組みであって、「計算を高速化する」仕組みではありません。
もう1つ大事な心構えがあります。学習用のコードや、多くの実運用シナリオですら、優先度はまったく触らなくてもよいことがほとんどです。デフォルトでも、structured concurrencyのおかげで十分に妥当な挙動が得られます。優先度は継承されることがありますし、高優先度のタスクが低優先度のタスクを待っているなら、システムはその優先度を一時的に「引き上げる」こともできます(これについては少し後で見ます)。
2. TaskPriority: 実際には何なのか
「タスクの優先度」と聞くと、「これを最優先で実行せよ」という厳格な命令を想像したくなります。しかし、Swiftでは(そして一般に現代的なシステムでは)、そうは動きません。優先度はスケジューラへのシグナルであり、リソースが全タスクに同時には足りないとき、どのタスクを先に実行させるかの判断を助けます。executorは、スケジューリングの判断材料の1つとして優先度を使います。
優先度は実行順序を保証しません。高い優先度でタスクを作っても、それが他のどのタスクよりも先に完了しなければならない、という意味ではありません。そのタスクがawaitで停止してしまうこともあります(たとえばネットワーク待ち)。その間は、他のタスクが普通に実行されます。
優先度について、実務的に覚えておきたい「魔法ではない点」が3つあります。
第一に、優先度があるからといって、効率的なコードを書く必要がなくなるわけではありません。CPU負荷の高い処理なら、高い優先度でも救ってはくれません。単に「より強く」CPUを占有するだけです。
第二に、優先度はキャンセルの代わりにはなりません。タスクがもう不要なら、キャンセルすべきです。「そのうちバックグラウンドで勝手に終わるだろう」と期待してはいけません。
第三に、優先度はタイムアウトの代わりにもなりません。タイムアウトは「Nより長く待たない」と言い、優先度は「何を実行するか選ぶなら、これを少し早めに選んで」と言います。挙動を制御する別々の仕組みです。
3. TaskPriorityのレベルとタスク作成
優先度の尺度とエイリアス
優先度をプラットフォーム間で移植しやすくするために、Swiftはかなり「汎用的」なレベルを使っています。通常は、高いものから低いものへという優先度の並びと、Appleの用語に沿ったエイリアス(たとえばuserInitiated)に出会います。
表で固定しておくと便利です(これは「丸暗記」ではなく、地図のようなものです)。
| レベル(一般名) | Appleエイリアス | 考え方(人間の言葉で) | このCLIで使いどころがありそうな場面 |
|---|---|---|---|
|
|
ユーザーが今まさに待っており、遅延が目立つ | 人が実際に返答を待っているときのfetch/fetch-manyコマンド |
|
— | 「普通の重要度」、特にドラマはない | もうすぐ必要になるバックグラウンド計算 |
|
|
役には立つが緊急ではない | インデックス準備、キャッシュ再構築、軽い分析 |
|
|
完全に裏方、「時間があるときに」 | 一時ファイルの掃除、まれなhousekeeping操作 |
もう1つの注意点として、ランタイムには公開APIより高い/低い内部優先度が存在する場合があります(たとえばUIスレッドは、より「インタラクティブ」なものとして振る舞い、そこから作られたタスクはuserInitiatedのように見えることがあります)。診断結果や「なぜこうなるのか」という感覚に驚かないために、これは知っておくと役に立ちます。
実践上の主なポイントは単純です。ユーザーが画面を見て待っているなら、それはuserInitiated寄りです。システムのメンテナンスで、結果がすぐには不要なら、utilityまたはbackgroundです。
優先度の指定方法: Task(priority:)
この講座の段階では、基本となるやり方を1つ押さえれば十分です。タスク作成時に優先度を指定することです。structured concurrencyのAPIでは、優先度はまさにタスク作成時に設定します(たとえばTask.detached、Task { ... }、あるいはグループにタスクを追加するとき)し、priority:パラメータで渡せます。
まずは小さく、しかも完全にコンパイル可能な断片から始めましょう。これは「手で触ってみる」ためのもので、「本番ではこうすべき」という話ではありません。
import Foundation
func demoPriority() async {
let task = Task(priority: .background) {
return "バックグラウンドで実行中"
}
let value = await task.value
print(value) // バックグラウンドで実行中
}
ここで重要なのは、.backgroundが「速くする」または「遅くする」ということではありません。重要なのは、あなたが明示的に伝えたことです。つまり、この仕事はユーザーへの即時応答にとってクリティカルではない、ということです。
次は、私たちのLibraryCLIに少し近い例です。IDで本をダウンロードするコマンドがあるとしましょう。ユーザーがたった今コマンドを入力したのなら、いま待っているわけですから、通常は.userInitiatedが妥当です。
import Foundation
func fetchBookID(_ id: Int) async -> String {
// ダミー。ここではネットワーク呼び出しだと仮定する
try? await Task.sleep(nanoseconds: 200_000_000)
return "Book#\(id)"
}
func userRequestedFetch(id: Int) async {
let t = Task(priority: .userInitiated) {
await fetchBookID(id)
}
let result = await t.value
print("Fetched:", result) // Fetched: Book#10(例)
}
ここで小さいけれど大事な考え方があります。私たちは優先度を「ビジネスロジックの一部」にはしていません。コマンドは、優先度がなくても動作します。優先度は正しさのルールではなく、実行ポリシーです。
4. 優先度の確認方法: Task.currentPriority
優先度をいじり始めると、「本当に適用されているのか?」を確かめたくなります。そのための簡単な診断ツールがTask.currentPriorityです。これは現在実行中のタスクの優先度を返します。また、タスクの文脈外で呼ぶと、現在のスレッドの優先度に対する「最善の近似値」を返します。
ミニ例:
import Foundation
func showPriority(label: String) {
print("\(label):", Task.currentPriority)
}
func priorityProbe() async {
showPriority(label: "Before Task") // 例: Before Task: TaskPriority(...)
let t = Task(priority: .utility) {
showPriority(label: "Inside Task") // Inside Task: utility(イメージ)
}
await t.value
}
ここで覚えておきたいのは、優先度の出力は開発者向けの診断であって、ユーザーに対する契約ではないということです。ですから、printによるデバッグの一種として扱いましょう。便利ではありますが、コードを「全200巻のログ小説」にしてはいけません。
前の日までに作った独自のLoggerをすでに使っているなら、タスクを作る場所で優先度を記録してもよいでしょう。そうすると、負荷がかかったときに、なぜあるシナリオは「応答性が高く」、別のシナリオは「考え込んでいるように見える」のかを後から理解しやすくなります。
5. 優先度の継承とTask.detached
structured concurrencyが考案された理由の1つは、コンテキスト(優先度を含む)がタスクツリー全体に自然に流れるようにするためです。GUIやそれに類するシステムでは、子タスクが親の優先度を継承し、待機時に優先度を正しくエスカレーションできることが重要です。
ここで重要な分岐があります。Task { ... } とTask.detached { ... } は、「コンテキスト」という意味では同じではありません。
Task { ... } はunstructuredなタスクを作りますが、作成元の場所から優先度を伝播します。単純化して言えば、コードがタスクの中で実行されているならその優先度を使い、UIコンテキストならしばしば.userInitiated相当になり、それ以外ではランタイムが周囲の「妥当な」優先度を推定しようとします。
一方でTask.detachedは「完全に独立したタスク」です。思想的にも、作成時のコンテキストから最大限独立しているべきものです。そのため、detachedタスクはコンテキスト情報を自動的には継承しません(優先度も、そのようなコンテキストの1例です)。
ここから今日の実践ルールが導けます。もし Task.detachedを使う非常に明確な理由がないなら、まずは Task { ... }から始めること。 Detachedは規律への要求が上がります。コンテキストとのつながりを失いやすく、優先度の喪失はその症状の1つにすぎません。
小さな「手で触ってみる」例:
import Foundation
func comparePriorities() async {
let parent = Task(priority: .userInitiated) {
print("Parent:", Task.currentPriority)
let child = Task {
print("Child Task {}:", Task.currentPriority)
}
let detached = Task.detached {
print("Detached:", Task.currentPriority)
}
await child.value
await detached.value
}
await parent.value
}
出力を「自然法則の証明」として受け取ろうとしないでください。ただ、考え方の図解としては有用です。通常のTask {} は親のコンテキストに近く、detachedはより自律的に動きます(そのため、優先度が重要なら明示的に指定したほうがよいです)。
6. Priority escalation: 「重要な」タスクが「重要でない」タスクを待つとき
実際には、「重要な」処理が「重要でない」処理の終了を待たないと終われないことがよくあります。たとえば、ユーザーがfetch-manyコマンドの結果を待っているのに、内部で処理の一部を低優先度で作ってしまったとします。もしシステムが律儀に「lowなんだから1時間くらい待たせよう」と言ったら、ユーザーは苦しむことになります。
そこでstructured concurrencyには、優先度エスカレーションという考え方があります。高優先度のタスクが低優先度のタスクを待つとき、低優先度側を一時的に引き上げて、処理全体が妥当な時間で終わるようにできます。このエスカレーションは、子タスクにも及ぶことがあり、タスクがawaitで一時停止している間も維持されることがあります。
ここで初心者にとって重要なのは、実装の細部ではなく次の考え方です。タスク階層を正しく組むと、システムは「理想的ではない」優先度を自力で補正しやすくなります。 これも、必要もないのにTask.detachedを乱用しない理由の1つです。構造を壊すと、待機時の丁寧なエスカレーションのような自動メカニズムも一緒に壊してしまうからです。
とはいえ、これを理由に「とりあえず」で.backgroundを付けてよいわけではありません。エスカレーションは救済であって、免罪符ではありません。処理がユーザー向けで重要だと分かっているなら、.userInitiatedでそう伝えるほうがよいです。
7. LibraryCLIでの実践: 優先度が必要な場所と不要な場所
では、プロジェクトに降ろして考えてみましょう。私たちには、コマンドをパースする → ネットワークへ行く → DTOを受け取る → ドメインモデルに変換する → リポジトリを更新する → ファイルを保存する、という流れがあります。この流れの中で優先度が意味を持つのは、実行リソースを奪い合う場所、つまり通常は非同期な部分です。ネットワーク、デコード、並列リクエストなどです。
ApplicationServiceに「本を取得して保存する」メソッドがあるとしましょう。そこでは「優先度ポリシー」を引数として受け取れます。大事なのは、これはドメインの一部ではなく、あくまで実行ポリシーだということです。ですから、TaskPriorityを渡してもよいですが、慎重に扱いましょう。プロジェクト全体に「神聖なアーティファクト」のように貫通させるべきではありません。
import Foundation
struct LibraryAppService {
let api: ApiClient
let repo: BookRepository
func fetchAndStore(id: Int, priority: TaskPriority) async throws {
let task = Task(priority: priority) {
try await api.fetchBook(id: id)
}
let book = try await task.value
try repo.upsert(book) // 状態更新は順番に行う
try repo.save()
}
}
ここには、いくつかの「正しいけれど地味な」判断があります。repo.upsertとrepo.saveを並列化しようとはしていません。これらは共有状態の変更であり、私たちはsingle-writerルールで生きることにしたからです。
次に、fetch-manyのシナリオを考えましょう。ここでは複数のネットワークリクエストをasync letで並列に実行します。優先度は「ラッパー」のレベル、つまりネットワークを起動するタスクに対して指定できます。ユーザーが明確に待っているなら.userInitiatedです。「事前にデータを温めておく」(CLIではまれですが)なら、.utilityまたは.backgroundです。
import Foundation
func fetchTwoBooks(api: ApiClient, a: Int, b: Int) async throws -> (Book, Book) {
async let first: Book = Task(priority: .userInitiated) { try await api.fetchBook(id: a) }.value
async let second: Book = Task(priority: .userInitiated) { try await api.fetchBook(id: b) }.value
return try await (first, second)
}
たしかに、少し「冗長」に見えます。実際のコードならもっときれいに書くでしょう。ただ、教材としてはこの断片で十分です。優先度は競合する仕事を作る瞬間に指定するのだ、という考え方を示しています。
では、どこで優先度が有害な飾りになるのでしょうか。競合も、本当の意味でのスケジューリングもない場所です。たとえば、並列性のない1つの逐次処理しかしておらず、しかも時間の大半をネットワーク待ちに使っているなら、優先度で変わることはほとんどありません。あるいは、「そのほうが速そうだから」と何十ものタスクに.userInitiatedを付けてしまうと、尺度そのものの意味を壊します。都市のすべての車が突然「自分は救急車だ」とサイレンを鳴らし始めたら、結局誰も速くはなれないのと同じです。
8. 実践的な適用ルール
優先度は、気をつけないと「アーキテクチャに貼る絆創膏」に変わってしまう道具です。だからこそ、ありふれて聞こえても効くルールをいくつか持っておくと役立ちます。
第1のルール: この仕事が別の仕事より重要な理由を人間の言葉で説明できないなら、おそらく優先度は不要です。優先度はUXと重要性の話であって、「最適化のための最適化」の話ではありません。
第2のルール: 高い優先度を「どこにでも」付けないこと。そうするとスケジューラに「全部重要だ」と言っているのと同じで、それは「何も重要ではない」のと変わりません。システムは有益な情報を受け取れなくなります。
第3のルール: 優先度は結果の正しさに影響してはいけません。結果は.backgroundでも.userInitiatedでも同じであるべきです。変わってよいのは、システムがその仕事にどれだけ積極的に実行時間を割り当てるかだけです。
第4のルール: 優先度はキャンセルやタイムアウトの代わりにはなりません。タイムアウトは「もうこれ以上待てない」です。キャンセルは「もう必要ない」です。優先度は「CPUを誰に与えるか選ぶなら、先に自分へ」です。レバーは別々で、互いに置き換えられません。
そして第5のルールは、structured concurrencyに関係します。タスク構造を失わないように努めてください。まさにその構造があるからこそ、コンテキストの継承や、待機時のエスカレーションのような有益な仕組みが働きます。
9. タスク優先度を扱うときの典型的な誤り
誤り1: 優先度を「アルゴリズムの加速装置」だと受け取ること。
「.userInitiatedを付ければ速くなる」と考え始めるのはとても簡単です。しかし、優先度は計算を安くしませんし、計算量も変えません。問題が、O(n)で済むところをO(n²)でやっていることにあるなら、優先度は助けになりません。システムが誰に先に時間を与えるかを選びやすくするだけで、魔法のCPUを増やしてはくれません。
誤り2: 「念のため」に何にでも高い優先度を付けること。
それでは優先度の尺度を自分で無価値にしてしまいます。スケジューラが受け取る信号は「全部同じくらい急ぎだ」です。そうなると、もはや判断材料がありません。結果としてUXは改善せず、コードだけが複雑になり、負荷時に奇妙な挙動を引き起こす可能性が高くなります。
誤り3: 優先度とタイムアウトを混同すること(「backgroundなら、より長く待ってよいはず」)。
これは論理的な落とし穴です。優先度はスケジューリングに影響し、タイムアウトは待機ポリシーに影響します。.userInitiatedでタイムアウト1秒(ユーザーは待っているが、長くは待たせない)もできますし、.utilityでタイムアウト30秒(急ぎではないが、待つのは構わない)もできます。これは独立した判断です。
誤り4: 影響を明確に理解しないままTask.detachedを使うこと。
detachedは独立したタスクであり、コンテキスト(優先度を含む)を継承する義務はありません。全体として、キャプチャやライフタイムの扱いにも、より厳密な規律が必要になります。単に「非同期で起動して待ちたい」だけなら、多くの場合はTask { ... } のほうが適しています。構造化モデルとコンテキスト継承に、より自然に収まるからです。
誤り5: 優先度をビジネスロジックの一部にして、ドメイン全体に引き回すこと。
優先度は実行ポリシーであって、ドメインルールではありません。TaskPriorityをBook、Repository、あるいはドメインエラーの中に保存し始めると、責務のレイヤーが混ざってしまいます。数週間もすると、そのコードは読みにくくなります。どこまでが「問題領域の意味」で、どこからが「スケジューラ設定」なのかが分からなくなるからです。
GO TO FULL VERSION