withDeadline
01 何が問題だったのか
非同期処理には、しばしば「ここまでに終わってほしい」という時間的な上限があります。ネットワークリクエストがサーバー不調で戻ってこない、バッチ処理の一部が全体を止めてしまう、コネクションプールが使い切られる、といった場面では、処理に時間の境界を設けないと資源を食いつぶしてしまいます。
現状の Swift でこの種のタイムアウトを書くには、withTaskGroup と Clock.sleep を組み合わせ、処理本体とタイマーを競わせて先に終わった方の結果を返す、という形を手作業で書く必要があります。キャンセルの伝播、エラーの経路、両者のレースにおけるクリーンアップをそれぞれ自前で整合させることになり、記述は冗長でミスが起きやすく、周囲の非同期コンテキスト(とくにアクター上の処理)と組み合わせたときの合成性も良くありません。
加えて、タイムアウトを「残り時間(Duration)」として受け渡すスタイルには根本的な弱点があります。呼び出しスタックを下っていくあいだに、各層で少しずつ時間が経過するため、意図した絶対時刻よりも遅れた地点を基準に再計算されてしまいます。複数の非同期処理を「同じ完了時刻」で足並みをそろえたい場面では、この drift が問題になります。
さらに、既存のタイムアウト実装の多くはクロージャに @Sendable と @escaping を要求します。これだとアクターの isolation domain を越えられず、actor のプロパティに触る処理をそのまま包むことができない、non-Sendable な値を持ち出せない、といった使いづらさが生じていました。
02 どのように解決されるのか
Concurrency モジュールに、絶対時刻としての「deadline」で非同期処理を区切るための withDeadline 関数と、専用のエラー型 DeadlineError を追加します。残り時間(duration)ではなく絶対時刻(Clock.Instant)を基準にすることで、複数の処理や複数の層にまたがっても同じ完了時刻を共有でき、drift の影響を受けません。
withDeadline の基本形
主な入口は、Clock.Instant を受け取る withDeadline です。
nonisolated(nonsending) public func withDeadline<Return, Failure: Error, C: Clock>(
_ expiration: C.Instant,
tolerance: C.Instant.Duration? = nil,
clock: C,
body: nonisolated(nonsending) () async throws(Failure) -> Return
) async throws(DeadlineError<Failure>) -> Return
where C.Instant.Duration == Swift.Duration
nonisolated(nonsending) public func withDeadline<Return, Failure: Error>(
_ expiration: ContinuousClock.Instant,
tolerance: ContinuousClock.Instant.Duration? = nil,
body: nonisolated(nonsending) () async throws(Failure) -> Return
) async throws(DeadlineError<Failure>) -> Return
clock を省略した 2 つめのオーバーロードは ContinuousClock を使います。
使い方は次のようになります。
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 {
switch error.cause {
case .deadlineExpired:
print("deadline 超過: \(error.underlyingError)")
case .operationFailed:
print("deadline 前に失敗: \(error.underlyingError)")
}
}
絶対時刻で指定するため、同じ 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)
tolerance は内部のスリープに渡される余裕で、システムがタイマーをまとめて省電力化するためのヒントです。
ショートハンド: withDeadline(in:)
毎回 clock.now.advanced(by:) を書かずに済むよう、残り時間で指定するオーバーロードも用意されます。中身は「今から timeout 後」を絶対時刻に変換しているだけで、合成性は同じです。
nonisolated(nonsending) public func withDeadline<Return, Failure: Error>(
in timeout: ContinuousClock.Instant.Duration,
tolerance: ContinuousClock.Instant.Duration? = nil,
body: nonisolated(nonsending) () async throws(Failure) -> Return
) async throws(DeadlineError<Failure>) -> Return
try await withDeadline(in: .seconds(5)) {
try await fetchDataFromServer()
}
ネストした場合は「最も早い deadline」で合成される
withDeadline はネスト可能で、入れ子になった場合は有効な deadline が「内外の最小値」にまとめられます。異なるクロックが混ざった場合は、「今からのオフセット」に換算したうえで最小値に合成されます。
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()
}
}
この「最小値で合成する」性質のために、内部では ContinuousClock を基準として取り回します。ライブラリ境界をまたいで別々に設定された deadline が、呼び出し側の期待どおりの合成結果になることを保証するためです。プロセス境界を越える通信路では、どのみち何らかのシリアライズが必要になるため、そこで適切なクロックに変換する、という住み分けになります。
DeadlineError と 2 つの失敗要因
withDeadline は、失敗時に DeadlineError<OperationError> を投げます。これは body が投げた型を typed throws として保持しつつ、「deadline で切られたのか」「それより前に本体が失敗したのか」を区別して伝えるための型です。
public struct DeadlineError<OperationError: Error>: Error, CustomStringConvertible, CustomDebugStringConvertible {
public enum Cause: Sendable, CustomStringConvertible, CustomDebugStringConvertible {
case deadlineExpired // deadline が切れてキャンセルされ、本体がエラーを投げた
case operationFailed // deadline より前に本体がエラーを投げた
}
public var cause: Cause
public var expiration: any InstantProtocol
public var underlyingError: OperationError
public init<C: Clock>(
cause: Cause,
expiration: C.Instant,
clock: C,
underlyingError: OperationError
)
}
呼び出し側は error.cause で理由を振り分け、error.underlyingError で本体が投げた具体的なエラーを取り出せます。deadline が切れても本体がエラーを投げずに値を返せば、その結果がそのまま成功として返る、という点にも注意が必要です(後述の「挙動の詳細」参照)。
実行中のタスクから現在の deadline を参照する
現在有効な deadline を、クロージャに引き渡さずに後から参照するためのアクセサも追加されます。
extension Task where Success == Never, Failure == Never {
public static var currentDeadline: (any InstantProtocol)? { get }
}
extension UnsafeCurrentTask {
public var deadline: (any InstantProtocol)? { get }
}
withDeadline のスコープに入っている間は、この値が「現在とネスト祖先の deadline の最小値」を指します。ある層が下位システムに「残り時間」を伝えたいときに、引数としての引き回しなしで参照できます。
クロージャが non-Sendable でも OK
body は nonisolated(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 の中にとどまったまま合成できます。
挙動の詳細
withDeadline の基本ルールは次のとおりです。
- 本体のクロージャと、deadline を監視するタイマーは並行に動きます。
- 先に結果を出した方がその
withDeadline呼び出しの結果を決めます。 - 片方が決着した時点で、もう片方にはキャンセルが伝播します。
- deadline が先に切れた場合でも、
withDeadlineは本体の戻りを待ちます。本体がキャンセルに即応しなければ、呼び出し全体は deadline より長くかかる可能性があります。 - 本体が成功値を返せば、たとえ deadline を過ぎていても成功として扱います。
DeadlineErrorが投げられるのは、本体側がエラーを投げた場合だけです。
したがって、deadline 超過を厳密に「時間に対するハードリミット」にしたい場合は、本体側で withTaskCancellationHandler やキャンセル検出を組み合わせ、明示的にエラーを投げる設計にする必要があります。
ネストした例では、内側で DeadlineError が投げられると、外側の withDeadline から見ればそれは「本体が投げたエラー」です。外側の deadline もすでに切れていれば cause は .deadlineExpired、まだ切れていなければ .operationFailed になり、underlyingError として内側の DeadlineError が入れ子で保持されます。これによって「どの層の deadline で切られたのか」がエラーチェーンから追えます。
Future Directions(今後の見通し)
提案では、次のような発展方向が挙げられています。いずれも speculative で、実現を約束するものではありません。
- エグゼキュータ側で deadline 付きジョブのキャンセルをより効率的に扱えるようにする拡張。
withDeadlineを支えている構造化並行のプリミティブ(他言語でraceと呼ばれるもの)を、汎用 API として切り出すこと。導入された場合、withDeadlineはその上に載せ替えられる候補になります。