01 何が問題だったのか
Swift Concurrency には、現在のタスクを見るための手段がいくつかあります。withUnsafeCurrentTask { … }、Task.currentPriority、Task.name などです。ほとんどのアプリケーションコードでは、これらで十分です。
しかし、これらは 常時オンの計装(instrumentation) のために設計されたものではありません。たとえば、トレーシングや構造化ロギングのライブラリが、出力するイベントごとに「そのイベントがどのタスク上で生成されたか」を示す識別子を付けたいとします。あとから各タスクごとのタイムラインを再構成したり、レイテンシを帰属させたり、サスペンションポイントをまたいだ論理的な流れを追ったりするためです。こうした付与は、ユーザーのホットパス上でイベントごとに走るため、実質的にコストゼロ(数ナノ秒のオーダー)でなければ受け入れられません。
現状で最も近い手段は、withUnsafeCurrentTask と unsafeBitCast(数値としての識別子を取り出す)または hashValue(ハッシュ可能な識別子を得る)の組み合わせです。ランタイム内部のエントリポイント自体は数ナノ秒で済むのですが、withUnsafeCurrentTask を経由すると、そのおよそ 15 倍のコストがかかります。原因はランタイム呼び出しの遅さではなく、ジェネリックな withUnsafeCurrentTask エントリポイントにあります。クロージャの existential へのボクシング、動的なスタック確保、ウィットネステーブルの間接参照、型メタデータの探索などが、ユーザーコードが走る前に積み重なるためです。
もう 1 つの選択肢であるタスクのポインタをそのまま識別子に使う方法にも問題があります。ランタイムは破棄されたタスクのヒープアドレスを再利用することがあるため、ポインタで識別すると、長生きするサイドテーブルの中で別々のタスクを取り違えてしまう恐れがあります。
安定して等値・ハッシュ可能なタスクの識別子を、単桁ナノ秒で取得できることは、次のような幅広い用途で必要とされます。
- リアルタイム・低レイテンシなシステム(取引基盤、オーディオパイプライン、ゲームエンジンなど)。本番でも計装を常時オンにしておきたいが、プロファイル上ほぼ見えないコストであってほしい。
- プロファイラや開発ツール。観測対象のプログラムに測定可能なコストを課さずに、サンプルやイベントをタスクへ帰属させたい。
- ロックフリーなデータ構造。タスクごとの状態をキーで管理したいが、ポインタ識別は再利用の危険がある。
- カスタムエグゼキュータ。タスク識別子をキーにしたサイドテーブルを保持したい(たとえばタスクごとの集計)。
- 構造化ロギング。各行に生成元のタスク識別子をメタデータとして載せ、多くのタスクが 1 つのスレッドを共有していても、後からタスクごとの流れを再構成できるようにしたい。
02 どのように解決されるのか
Swift ランタイムが既に内部で保持しているプロセス内で一意なタスク識別子を、Task の公開APIとして露出します。ランタイムは必要な識別子をもともと持っているため、あとはそれを安定したAPIとして、呼び出し側が当てにできるコストで読み出せるようにするのが主眼です。
Task.currentID
現在実行中のタスクの識別子は、静的プロパティ Task.currentID で取得します。
extension Task where Success == Never, Failure == Never {
/// 現在実行中のタスクの識別子。このスレッドで Swift の Task が
/// 走っていない場合は nil。
public static var currentID: Task.ID? { get }
}
Success == Never, Failure == Never という制約は Task.sleep などと同じ書き方で、この静的メンバがジェネリック引数に依存しないため、呼び出し側はジェネリック引数を書かずに Task.currentID と書けます。返り値は Task.ID? で、(並行処理ランタイムの外の同期的な文脈から呼ばれたときなど)このスレッドで Swift の Task が走っていない場合は nil になります。
これがパフォーマンスの要となる入口です。設計目標はスレッドローカルの読み出しに匹敵する単桁ナノ秒で、アロケーションは行いません。
@inline(__always)
func emit(_ event: Event) {
let id = Task.currentID // 数ナノ秒、アロケーションなし
buffer.append(event, taskID: id)
}
Task.ID
識別子の型として、トップレベルに @frozen な値型 TaskID を導入し、Task 上の typealias を通じて Task.ID として公開します。
@frozen
public struct TaskID: Sendable, Hashable {
public var rawValue: UInt64 { get }
}
extension Task {
public typealias ID = TaskID
}
Task は Success と Failure でジェネリックなので、Task に直接ネストした型は Task<Success, Failure>.ID となり、インスタンスごとに別の型になってしまいます。単一の正準的な識別子型が欲しいので、トップレベルの TaskID を用意し、typealias で Task.ID という綴りを与えています。使う側では正準の綴りである Task.ID を優先します。
rawValue は、シリアライズのための唯一の「抜け道」です。バイナリのワイヤーフォーマットや共有メモリ、非 Swift のコンシューマへの橋渡しなど、明示的な 64 ビット整数表現が必要な場面で使います。逆に、整数から Task.ID を構築する公開イニシャライザは提供されません。識別子はランタイムが発行するものであり、任意の整数から作った値はこのプロセス内のどのタスクにも対応しないためです。Codable などの適合も提供されず、シリアライズしたい場合は rawValue を直接エンコードします。
識別子には次の意味的な保証があります。
- プロセス内で一意。同一プロセス内で生きている 2 つのタスクは、必ず異なる識別子を持ちます。
- 再利用されない。いったん割り当てられた識別子は、元のタスクが破棄された後でも、別のタスクに割り当て直されることはありません。これが、長生きするサイドテーブルのキーとして安全に使える理由であり、ポインタ識別より強い保証です。
- タスクの生存期間を通じて安定。識別子はタスク生成時に決まり、以後変わりません。
- プロセスをまたいでは安定しない。識別子はプロセスローカルなので、プロセスをまたいだ意味を期待して永続化・送信すべきではありません。
ランタイムは現状、識別子を単調増加で割り当てていますが、これは実装上の性質であって契約ではありません。そのため Task.ID は Comparable に適合せず、rawValue は順序付けのためではなくシリアライズのために公開されます。
インスタンスからの取得
識別子は、保持している Task の参照や UnsafeCurrentTask からも読み出せます。
extension Task {
public var id: Task.ID { get }
}
extension UnsafeCurrentTask {
public var id: Task.ID { get }
}
Task.id はタスク完了後も含め、いつでも安全に呼べます。識別子は生成時に固定され変化しないため、同期は不要です。一方 UnsafeCurrentTask.id は UnsafeCurrentTask の他のプロパティと同じ安全性契約に従い、破棄済みのタスクから読むのは未定義動作です。ただし返り値は trivial な値型なので、withUnsafeCurrentTask のクロージャ内で識別子を読み出し、その値をクロージャの外でも使い続けられます。
Task.name との関係
Task.name(SE-0469)はタスク生成時に付ける人間向けのラベルです。ここで提案する識別子はそれと補完的な関係にあり、小さく、安価で、常に存在し、プロセス内で一意です。計装システムは、ログやテーブル、サイドデータ構造の結合キーとして識別子を使い、名前は人間向けに表示するときにだけ解決することが想定されます。OS スレッドが数値の tid と任意の pthread 名の両方を持つのと同じ関係です。
意図された用途と注意点
Task.ID は、作業が行われた場所で識別子を記録し、後からログやトレース、プロファイラのサンプル、サイドテーブルで引くための「キー」です。観測結果やタスクごとの状態をタスクに紐付けるためのものであり、どのタスクが実行中かによって制御フローを分岐させるためのものではありません。タスクの識別子で分岐するコードは、スレッドの識別子で分岐するコードと同様に疑ってかかるべきだとされています。具体的には次の点に注意が必要です。
- タスクの構造は契約ではありません。 ある API が呼び出し側から渡されたクロージャを呼び出し元のタスク上で実行するのか、子タスク上で実行するのかは、その API がドキュメントで明示していない限り実装の詳細です。明示されていないのに
Task.currentIDを既知のtask.idと比較するのは、リリース間で変わりうる実装上の偶然を観測しているにすぎません。 - キャンセルは文脈ごとに観測されますが、識別子はそうではありません。 キャンセルシールド(SE-0504)の内側では、外側のスコープからキャンセルされたタスクについて、
Task.isCancelledはfalseを返す一方、そのタスクのハンドルに対するtask.isCancelledはtrueを返します。Task.IDを使えば、この 2 つの観測が同じタスクに関するものだと確認できます。これは識別子が本来の役割を果たしているだけであり、頼ったり回避したりすべき矛盾ではありません。 - 識別子はハンドルではありません。
Task.IDからタスクをキャンセルしたり、awaitしたり、タスクに到達したりすることはできません。また、親子関係やタスクグループ、エグゼキュータとの関係も一切表しません。子タスクに引き継ぐべき値は、識別子をキーにしたサイドテーブルではなく、自動的に継承される task-local(SE-0311)で受け渡します。 - サイドテーブルは所有者が掃除する必要があります。 識別子は再利用されないため、古いエントリが後のタスクによって上書き・回収されることはありません。
Task.IDをキーにしたテーブル(たとえばカスタムエグゼキュータでのタスクごとの集計)は、所有者がエントリを削除しない限り際限なく大きくなります。
タスクの識別情報自体は、デバッガや Instruments、swift-inspect、UnsafeCurrentTask のポインタ識別などを通じて従来から観測可能でした。この Proposal は、それを必要とする計装のために安価かつ安定して取得できるようにするものであり、ランタイムの挙動に対するサイドチャネルとして使うことは推奨されていません。
03 今後の見通し
元のProposalでは、今後の方向性としていくつかの構想が挙げられています。いずれも将来的な検討事項であり、この提案の一部として実現が約束されているものではありません。
Comparable 適合
Task.ID の順序付けは現時点でAPI契約に含まれておらず、Comparable にも適合していません。順序付けが必要なユースケースが現れれば、別のProposalとして適合を検討する余地があるとされています。
プロセスをまたいだ相関付け
分散トレーシングのようにプロセスやホストの境界をまたいで相関を取りたい場合、ここで提案する識別子は意図的に不十分です。プロセスローカルだからです。プロセスをまたいだ相関付けは、独自にグローバルで一意な識別子を構築する swift-distributed-tracing などのライブラリが引き続き担う領域とされています。