Swift Digest
SE-0526 | Swift Evolution

withDeadline

Proposal
SE-0526
Authors
Franz Busch, Philippe Hausler, Konrad Malawski
Review Manager
Freddy Kellison-Linn
Status
Accepted with modifications

このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら

01 何が問題だったのか

非同期処理には、しばしば「ここまでに終わってほしい」という時間的な上限があります。ネットワークリクエストがサーバー不調で戻ってこない、バッチ処理の一部が全体を止めてしまう、コネクションプールが使い切られる、といった場面では、処理に時間の境界を設けないと資源を食いつぶしてしまいます。

現状の Swift でこの種のタイムアウトを書くには、withTaskGroupClock.sleep を組み合わせ、処理本体とタイマーを競わせて先に終わった方の結果を返す、という形を手作業で書く必要があります。キャンセルの伝播、エラーの経路、両者のレースにおけるクリーンアップをそれぞれ自前で整合させることになり、記述は冗長でミスが起きやすく、周囲の非同期コンテキスト(とくにアクター上の処理)と組み合わせたときの合成性も良くありません。

加えて、タイムアウトを「残り時間(Duration)」として受け渡すスタイルには根本的な弱点があります。呼び出しスタックを下っていくあいだに、各層で少しずつ時間が経過するため、意図した絶対時刻よりも遅れた地点を基準に再計算されてしまいます。複数の非同期処理を「同じ完了時刻」で足並みをそろえたい場面では、この drift が問題になります。

さらに、既存のタイムアウト実装の多くはクロージャに @Sendable@escaping を要求します。これだとアクターの isolation domain を越えられず、actor のプロパティに触る処理をそのまま包むことができない、non-Sendable な値を持ち出せない、といった使いづらさが生じていました。

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

Concurrency モジュールに、絶対時刻としての「deadline」で非同期処理を区切るための withDeadline 関数を追加します。残り時間(duration)ではなく絶対時刻(Clock.Instant)を基準にすることで、複数の処理や複数の層にまたがっても同じ完了時刻を共有でき、drift の影響を受けません。あわせて、deadline 超過によるキャンセルか通常のタスクキャンセルかを呼び出し側で見分けられるよう、CancellationError に「キャンセル理由」を持たせる拡張も同時に入ります。

withDeadline の基本形

主な入口は、Clock.Instant を受け取る withDeadline です。

nonisolated(nonsending) public func withDeadline<Return: ~Copyable, Failure: Error, C: Clock & Identifiable>(
    _ expiration: C.Instant,
    tolerance: C.Instant.Duration? = nil,
    clock: C = ContinuousClock(),
    body: nonisolated(nonsending) () async throws(Failure) -> Return
) async throws(Failure) -> Return

withDeadline が投げるエラーの型は本体クロージャの throws(Failure) と同じで、deadline 超過のために独自のラッパ型を被せたりはしません。deadline が切れたことは本体に「キャンセル」として伝わり、本体側がそのキャンセルにどう応答するか(そのままエラーを投げて戻ってくるのか、部分結果を返すのか)でそのまま withDeadline の戻り値・throw の挙動が決まります。

使い方は次のようになります。

let clock = ContinuousClock()
let deadline = clock.now.advanced(by: .seconds(5))

do {
    let result = try await withDeadline(deadline, clock: clock) {
        try await fetchDataFromServer()
    }
    print("Data received: \(result)")
} catch {
    print("Request failed: \(error)")
}

絶対時刻で指定するため、同じ deadline を複数の処理で共有できます。

let clock = ContinuousClock()
let deadline = clock.now.advanced(by: .seconds(10))

async let user = withDeadline(deadline, clock: clock) {
    try await fetchUser()
}
async let prefs = withDeadline(deadline, clock: clock) {
    try await fetchPreferences()
}

let (userData, prefsData) = try await (user, prefs)

clock 引数は ContinuousClock() がデフォルトなので、ContinuousClock を使う場合はそのまま省略できます。tolerance は内部のスリープに渡される余裕で、システムがタイマーをまとめて省電力化するためのヒントです。

withDeadline が受け取るクロックには Clock だけでなく Identifiable への適合も求められます。これは、後述する deadline の合成(同じクロックの deadline をまとめる処理)で「同じクロックかどうか」を識別するためです。標準の ContinuousClock / SuspendingClock は最初から Identifiable に適合しているのでそのまま使えますが、独自のクロックで withDeadline を使う場合は Identifiable への適合が必要になります。

ショートハンド: withDeadline(in:)

毎回 clock.now.advanced(by:) を書かずに済むよう、残り時間で指定するオーバーロードも用意されます。中身は「今から timeout 後」を絶対時刻に変換しているだけで、合成性は同じです。

nonisolated(nonsending) public func withDeadline<Return: ~Copyable, Failure: Error, C: Clock & Identifiable>(
    in timeout: C.Instant.Duration,
    tolerance: C.Instant.Duration? = nil,
    clock: C = ContinuousClock(),
    body: nonisolated(nonsending) () async throws(Failure) -> Return
) async throws(Failure) -> Return
try await withDeadline(in: .seconds(5)) {
    try await fetchDataFromServer()
}

ネストした場合は「最も早い deadline」で合成される

withDeadline はネスト可能で、入れ子になった場合は実効的に有効な deadline が「最も早い deadline」になります。

同じクロックで指定された deadline 同士は、より早い(narrow な)方にまとめられます。たとえば外側で「10 秒後」を指定したあと内側で「5 秒後」を指定すれば、内側のスコープでは 5 秒後が効きます。逆に外側が「5 秒後」、内側が「10 秒後」の場合は、内側の 10 秒後はすでに有効な 5 秒後より遅いので無視され、引き続き 5 秒後が効きます。この「同じクロックかどうか」の判定に、前述の Identifiable 適合が使われます。

let clock = ContinuousClock()
let outer = clock.now.advanced(by: .seconds(5))

try await withDeadline(outer, clock: clock) {
    let inner = clock.now.advanced(by: .seconds(10))
    try await withDeadline(inner, clock: clock) {
        // 実効的な deadline は外側の 5 秒後
        try await fetchPreferences()
    }
}

異なるクロックで指定された deadline 同士は、instant を相互に正確に比較・換算できないため、まとめられず、それぞれが独立に監視されます。この場合も最初にキャンセルを発火した deadline が本体に伝わるので、結果としては「最も早い deadline」が勝つという直感どおりに振る舞います。

CancellationError の拡張で「なぜキャンセルされたか」を区別する

deadline 超過と通常のタスクキャンセルを呼び出し側で見分けられるよう、CancellationError に「キャンセル理由」を持たせる拡張が同時に入ります。CancellationError() という従来のイニシャライザは .userRequested を意味するものとして残り、既存コードの挙動は変わりません。

public struct CancellationError: Error {
    @nonexhaustive
    public enum Reason {
        case userRequested
        case deadlineExpired
    }

    public var reason: Reason { get }
    public init(reason: Reason)
    public init() // `.userRequested` と等価
}

Reason は non-frozen な enum なので、switch で扱うときは @unknown default が必要になります。Reason は associated value を持たない単純なケースだけに限定されており(Task のサイズへの影響を避けるため)、任意の文字列などを載せることはできません。

理由付きのキャンセルを発火する側として、Task 系の API にも reason: 付きの新オーバーロードが追加されます。

extension Task {
    public func cancel(reason: CancellationError.Reason)
}

extension UnsafeCurrentTask {
    public func cancel(reason: CancellationError.Reason)
}

extension TaskGroup {
    public func cancelAll(reason: CancellationError.Reason)
}

// ThrowingTaskGroup / DiscardingTaskGroup / ThrowingDiscardingTaskGroup にも同様

withDeadline は deadline が切れたとき、本体タスクを .deadlineExpired を理由としてキャンセルします。本体が CancellationError を投げて戻ってきたら、呼び出し側は error.reason を見て「deadline 超過だったのか、外側からのキャンセルだったのか」を判別できます。

do {
    try await withDeadline(in: .seconds(5)) {
        try await fetchDataFromServer()
    }
} catch let error as CancellationError {
    switch error.reason {
    case .deadlineExpired:
        print("deadline 超過")
    case .userRequested:
        print("外側からキャンセルされた")
    @unknown default:
        print("未知のキャンセル理由")
    }
} catch {
    print("本体が別のエラーを投げた: \(error)")
}

エラーとして受け取るだけでなく、キャンセルされた時点でその理由を知る手段も追加されます。まず、onCancel ハンドラにキャンセル理由を渡す withTaskCancellationHandler のオーバーロードです。

public nonisolated(nonsending) func withTaskCancellationHandler<Return, Failure>(
    operation: nonisolated(nonsending) () async throws(Failure) -> Return,
    onCancel handler: sending (CancellationError.Reason) -> Void
) async throws(Failure) -> Return

既存の withTaskCancellationHandler と挙動は同じで、onCancel に理由が渡される点だけが違います。これを使えば、deadline 超過のときだけ後始末を変える、といった処理が書けます。

また、isCancelled で現在のタスクがキャンセルされたかを確認できるのと同じ要領で、キャンセル理由を問い合わせる cancellationReason も追加されます。キャンセルされていなければ nil を返し、いったん理由が確定すると以降は同じ値を返します。

extension Task where Success == Never, Failure == Never {
    public static var cancellationReason: CancellationError.Reason? { get }
}

extension UnsafeCurrentTask {
    public var cancellationReason: CancellationError.Reason? { get }
}

クロージャが non-Sendable でも OK

bodynonisolated(nonsending) かつエスケープしないクロージャです。そのため、アクター内部からそのまま呼び出して、actor-isolated な stored property にアクセスするような処理も withDeadline で包めます。

actor DataProcessor {
    var cache: [String: Data] = [:]

    func fetchWithDeadline(url: String) async throws {
        let data = try await withDeadline(in: .seconds(5)) {
            if let cached = cache[url] { return cached }
            return try await URLSession.shared.data(from: URL(string: url)!)
        }
        cache[url] = data
    }
}

@Sendable を要求する従来設計だと cache に触れませんでしたが、この設計なら isolation domain の中にとどまったまま合成できます。

現在有効な deadline を参照する

外部システムと連携する際などに、現在有効な deadline そのものを取り出したいことがあります。そのための問い合わせ API も追加されます。hasActiveDeadline は何らかの deadline が効いていれば true を返し、activeDeadline(for:) は指定したクロックでの deadline の instant を返します。

extension Task where Success == Never, Failure == Never {
    public static var hasActiveDeadline: Bool { get }

    public static func activeDeadline<C: Clock & Identifiable>(for clock: C) -> C.Instant?
}

deadline は instant の型がクロックごとに異なるため、activeDeadline(for:) には対象のクロックを渡す必要があります。クロックを渡すと、そのクロックで指定された deadline のうち最も narrow なものが返ります。ネストの中に同じクロックの deadline がなければ、外側のネストへとさかのぼって探します。

let continuous = ContinuousClock()
let suspending = SuspendingClock()

let inTwoSeconds = continuous.now.advanced(by: .seconds(2))
let inThreeSeconds = suspending.now.advanced(by: .seconds(3))
let inTenSeconds = continuous.now.advanced(by: .seconds(10))

try await withDeadline(inTwoSeconds, clock: continuous) {
    try await withDeadline(inThreeSeconds, clock: suspending) {
        try await withDeadline(inTenSeconds, clock: continuous) {
            // continuous での実効 deadline は最も narrow な 2 秒後
            assert(Task.activeDeadline(for: continuous) == inTwoSeconds)
            // suspending での実効 deadline は 3 秒後
            assert(Task.activeDeadline(for: suspending) == inThreeSeconds)
        }
    }
}

このように、異なるクロックの instant 同士は比較・換算できないため、問い合わせはあくまで渡したクロックの種類に対してのみ正確です。

挙動の詳細

withDeadline の基本ルールは次のとおりです。

  1. 本体のクロージャと、deadline を監視するタイマーは並行に動きます。
  2. 先に決着した方の結果がそのまま withDeadline の結果になります。
  3. 片方が決着した時点で、もう片方にはキャンセルが伝播します。
  4. deadline が先に切れた場合でも、withDeadline は本体の戻りを待ちます。本体がキャンセルに即応しなければ、呼び出し全体は deadline より長くかかる可能性があります。
  5. 本体が成功値を返せば、たとえ deadline を過ぎていても成功として扱います。エラーが投げられるのは本体側がエラーを投げた場合だけで、その型は throws(Failure) を通じてそのまま伝わります。

したがって、deadline 超過を厳密に「時間に対するハードリミット」にしたい場合は、本体側で withTaskCancellationHandler やキャンセル検出を組み合わせ、CancellationError(reason: .deadlineExpired) を明示的に投げるなど、キャンセルに即応する設計にする必要があります。

ネストしている場合も同じ枠組みです。内側の deadline が先に切れれば、内側の withDeadline が(本体がキャンセルに応じて CancellationError を投げるなら)CancellationError(reason: .deadlineExpired) を投げ、外側の withDeadline から見ればそれは本体が投げたエラーとしてそのまま伝わります。エラーチェーンに専用のラッパは挟まりません。

03 今後の見通し

提案では、次のような発展方向が示されています。いずれも将来の構想であり、実現を約束するものではありません。

  • エグゼキュータ側で deadline 付きジョブのキャンセルをより効率的に扱えるようにする拡張。現在の実装は既存のエグゼキュータに変更を要求しませんが、将来的に専用の取り扱いを追加する余地があるとされています。
  • withDeadline の基盤となる構造化並行のプリミティブを、汎用 API として切り出すこと。他言語で race と呼ばれているものに相当します。withDeadline の導入はこの方向性を妨げるものではなく、もし将来的に race 型のプリミティブが Concurrency ライブラリに追加されれば、withDeadline はその上に載せ替える有力な候補になるとされています。