Swift Digest
SE-0311 | Swift Evolution

Task Local Values

Proposal
SE-0311
Authors
Konrad 'ktoso' Malawski
Review Manager
John McCall
Status
Implemented (Swift 5.5)

01 何が問題だったのか

Swift の並行処理が async/await と Task を中心にした設計になったことで、「リクエスト ID」「トレースコンテキスト」「ロガー」「モックの設定」といった 実行に付随するメタデータ を、一連の非同期処理の流れに沿ってどう運ぶかが問題になりました。

thread local はうまく機能しない

従来こうしたメタデータの運搬には、スレッドローカル変数や DispatchQueue の specific value が使われてきましたが、これらは Swift Concurrency と相性がよくありません。

  • Swift の並行モデルは実行するスレッドについて何の保証もしません。同じ async 関数が suspend の前後で別スレッドに乗ることがあり、thread local の値はその切り替えに自動的にはついてきません。
  • thread local は「セットしたら自分で戻す」必要があり、忘れると値が残って別のワークロードに漏れたり、メモリリークになったりします。
  • thread local は子への自動的な「継承」がなく、別スレッドに処理を渡すたびに関係するすべてのライブラリが明示的にコピーしなければならず、抜け漏れが起きやすいつくりです。

DispatchQueue.setSpecific も同様で、async 関数の実行中に複数のキューをまたいで値を運ぶ仕組みがないため、Swift Concurrency には流用できません。

明示的に渡すと API がうるさくなる

安全策として、たとえば分散トレーシングでは LoggingContext のようなコンテキスト値を すべての関数の引数に明示的に引き回す というスタイルが取られてきました。

func chopVegetables(context: LoggingContext) async throws -> [Vegetable] { /* ... */ }
func marinateMeat(context: LoggingContext) async -> Meat { /* ... */ }
func preheatOven(temperature: Double, context: LoggingContext) async throws -> Oven { /* ... */ }

func makeDinner(context: LoggingContext) async throws -> Meal {
  async let veggies = chopVegetables(context: context)
  async let meat = marinateMeat(context: context)
  async let oven = preheatOven(temperature: 350, context: context)

  let dish = Dish(ingredients: try await [veggies, meat])
  return try await oven.cook(dish, duration: .hours(3), context: context)
}

ロジック上は context に意味がないのに、すべての関数シグネチャと呼び出し側に context: が現れ、非同期コードに大量のノイズを足してしまいます。ロガーやトレーサなど「呼び出しの末端でだけ参照したい付随情報」を運ぶためだけに、関数シグネチャ全体を汚すのはつらいところです。

欲しいのは「タスクに紐づくスコープ付きの値」

求められているのは、次の性質を備えた値の運び方です。

  • 非同期処理のチェーンと子タスクに沿って 自動的に継承 される。
  • スコープで区切られ、抜けたら自動で元に戻る(thread local のような「戻し忘れ」が構造的に起きない)。
  • async/sync どちらの関数からも同じように読める。
  • Task の寿命を超えて生き残らない(漏れない)。

スレッドではなく タスク に紐づく、構造化並行性に合った値のストレージが必要でした。

02 どのように解決されるのか

@TaskLocal プロパティラッパーを導入し、値を Task に紐づくスコープ付きストレージ に置けるようにします。セットは直接代入ではなく withValue(_:operation:) によるスコープ形式でのみ行え、そのスコープを抜けると値は元に戻ります。子タスクは親の task-local を継承して読み取れます。

宣言

@TaskLocalstatic stored property または global property にしか付けられません(インスタンスプロパティに付けるとコンパイルエラー)。値の型は Sendable に適合している必要があります。

enum MyLibrary {
  @TaskLocal
  static var requestID: String?
}

デフォルト値は wrappedValue の初期値として与えます。Optional にして nil を既定にするのが典型ですが、意味のある既定値があるならそれを使って構いません。

読み取り

読み取りは普通のプロパティアクセスと同じ書き方で、async / sync どちらからでもできます。

func asyncPrintRequestID() async {
  print(MyLibrary.requestID ?? "no-request-id")
}

func syncPrintRequestID() {
  print(MyLibrary.requestID ?? "no-request-id")
}

同期関数からの読み取りも可能で、Task の外(C ライブラリのコールバックなど Task が存在しない同期コンテキスト)から呼ばれた場合はデフォルト値が返ります。withUnsafeCurrentTask を使えば Task 上にいるかどうかを区別できます。

task-local の読み取りは静的プロパティの読み取りより重い処理です(内部的に親タスクをたどる線形探索が走ります)。ループの中で繰り返し読むのは避け、必要なら外に巻き上げて 1 回だけ読むようにします。

値を束縛する

値を設定するには、プロパティラッパーの projected value($ プレフィックス)を経由して withValue(_:operation:) を呼びます。operation クロージャの実行中だけその値が有効になり、スコープを抜けると自動で戻ります。

await MyLibrary.$requestID.withValue("1234-5678") {
  await asyncPrintRequestID() // prints: 1234-5678
  syncPrintRequestID()        // prints: 1234-5678
}

await asyncPrintRequestID()   // prints: no-request-id

同じキーを入れ子で withValue すると、内側のバインディングが外側を シャドウ します。これも抜ければ元に戻ります。

await MyLibrary.$requestID.withValue("1111") {
  syncPrintRequestID() // 1111

  await MyLibrary.$requestID.withValue("2222") {
    syncPrintRequestID() // 2222
  }

  syncPrintRequestID() // 1111
}

withValueoperation が同期の場合は同期で、async の場合は async で呼べる両方のオーバーロードを持っています。親タスク側で束縛された値は同じタスク内の子関数呼び出しを通じて見えるので、何段深くなってもその値を読み取れます。

子タスクへの継承

async letwithTaskGroup で作られる子タスクは、親タスクの task-local を自動的に継承します。子タスク側で改めて withValue すれば、子タスクの中だけで値を上書きできます(親には影響しません)。

await MyLibrary.$requestID.withValue("1234-5678") {
  await withTaskGroup(of: String?.self) { group in
    group.addTask {
      MyLibrary.requestID // "1234-5678"(親から継承)
    }
    return await group.next()!
  }
}

一方、detach で作る 非構造化タスク(detached task)は意図的に継承しません。detached task は呼び出し元の文脈から完全に切り離すことが目的で、task-local もプライオリティもリセットされます。必要なら呼び出し側で値を拾ってから、detached task 内で改めて withValue し直します。

なお、呼び出し元のコンテキスト(プライオリティ・エグゼキュータ・task-local)を引き継いで非同期に処理を続ける、Structured Concurrency の外に出るがコピーだけは行う別のプリミティブ(当時 async { ... } として言及されていた、のちの Task { ... })も用意されます。こちらは task-local をスナップショットとして コピー して新タスクへ引き継ぎます。

束縛はミュータブルではなく「積む」

task-local は「代入」ではなく「スタックに積む」形で扱われます。withValue はスコープの入口で現在のタスクのバインディングスタックに push し、出口で pop するだけです。子タスクは親のスタックの先頭を指すだけで、自分で追加したバインディングがあれば自分のスタックに push します。

このモデルによって、次のような性質が得られます。

  • 書き忘れによる漏れが構造的に起きない(スコープを抜ければ必ず元に戻る)。
  • 子タスクから親の値を書き換えることはできない(親のスタックには一切触れない)。
  • タスクの寿命を超えてしまうヒープ確保が要らず、スタック規律のアロケータで効率よく保持できる。

踏みやすい落とし穴: TaskGroup の addTaskwithValue で囲まない

withTaskGroup の内側で、group.addTask だけwithValue で包むのは禁止パターンで、ランタイムクラッシュとして検出されます。addTask は即座に戻って子タスクをグループに追加するだけなので、子タスクの寿命が withValue スコープより長くなってしまい、スタックに積んだ値が先に消える可能性があるためです。

withTaskGroup(of: String.self) { group in
  Trace.$name.withValue("some(func:)") { // RUNTIME CRASH!
    group.addTask {
      Trace.name
    }
  }
  return group.next()!
}

正しくは、グループ全体を withValue で囲むか、追加した子タスクの中で withValue を呼びます。

// OK: グループ全体を囲む
await Trace.$name.withValue("some(func:)") {
  await withTaskGroup(of: String?.self) { group in
    group.addTask { Trace.name }
  }
}

// OK: 子タスクの中で束縛する
await withTaskGroup(of: String?.self) { group in
  group.addTask {
    await Trace.$name.withValue("some(func:)") {
      // ...
    }
  }
}

Task のない同期コンテキストでも動く

C ライブラリのコールバックなど、Task の外にいる同期関数から withValue / 読み取りを呼んでも API はそのまま機能します。内部的にはスレッドローカルを使ってタスクスコープを擬似的に作り、同期コードの範囲ではスタック規律のセマンティクスを維持します。この同期コンテキストから Task { ... } を起動すれば、そのタスクに task-local がコピーされて引き継がれます。

使いどころ

task-local は「引数で渡したほうがよい値」の代わりに使うものではありません。ロジックの入力になる値は従来どおり引数で渡します。task-local が向くのは、関数の論理的な戻り値に影響しない、副次的な文脈情報です。たとえば以下のような用途です。

  • 分散トレーシングのトレース ID やベース情報
  • コンテキスト付きロギング(リクエスト ID など)
  • テスト時のモック(たとえば Swift System の withMockingEnabled 相当を async 対応させる)
  • 進捗モニタリングの Progress 連携
  • エグゼキュータ設定のような「このスコープだけの実行時設定」

明示的に渡すべきか task-local にすべきか迷ったら、まずは明示的な引数で渡すのが安全策です。

Future Directions

将来の拡張として、次のような方向性が示されています(いずれも今後の検討対象で、実現が約束されるものではありません)。

  • @TaskLocal ごとの 継承ポリシー の設定。たとえば「子タスクには継承しない」「detached task にもベストエフォートで運ぶ」といったモードを、特殊なキー(Progress、トレース情報)のために用意する余地があります。
  • @Logged@Traced のような function wrapper と組み合わせ、関数の入出力をトレースする仕組み。
  • withValue { ... } のネストを減らすための、スコープ付きで値を切り替える言語機能(using 相当のスコープ終了時フック)。