01 何が問題だったのか
非同期処理には、しばしば「ここまでに終わってほしい」という時間的な上限があります。ネットワークリクエストがサーバー不調で戻ってこない、バッチ処理の一部が全体を止めてしまう、コネクションプールが使い切られる、といった場面では、処理に時間の境界を設けないと資源を食いつぶしてしまいます。
現状の Swift でこの種のタイムアウトを書くには、withTaskGroup と Clock.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>(
_ 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(in:)
毎回 clock.now.advanced(by:) を書かずに済むよう、残り時間で指定するオーバーロードも用意されます。中身は「今から timeout 後」を絶対時刻に変換しているだけで、合成性は同じです。
nonisolated(nonsending) public func withDeadline<Return: ~Copyable, Failure: Error, C: Clock>(
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 を比較・換算する仕掛けは要りません。結果として、たとえ異なるクロックで指定された deadline 同士でも、常に「最も早い 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()
}
}
CancellationError の拡張で「なぜキャンセルされたか」を区別する
deadline 超過と通常のタスクキャンセルを呼び出し側で見分けられるよう、CancellationError に「キャンセル理由」を持たせる拡張が同時に入ります。CancellationError() という従来のイニシャライザは .taskCancelled を意味するものとして残り、既存コードの挙動は変わりません。
public struct CancellationError: Error {
@nonexhaustive
public enum Reason {
case taskCancelled
case deadlineExpired
case custom(String)
}
public var reason: Reason { get }
public init(reason: Reason)
public init() // `.taskCancelled` と等価
}
Reason は non-frozen な enum なので、switch で扱うときは @unknown default が必要になります。.custom(String) で呼び出し側の事情に応じた理由を載せることもできます。
理由付きのキャンセルを発火する側として、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 .taskCancelled:
print("外側からキャンセルされた")
case .custom(let message):
print("カスタム理由: \(message)")
@unknown default:
print("未知のキャンセル理由")
}
} catch {
print("本体が別のエラーを投げた: \(error)")
}
クロージャが 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 を過ぎていても成功として扱います。エラーが投げられるのは本体側がエラーを投げた場合だけで、その型は
throws(Failure)を通じてそのまま伝わります。
したがって、deadline 超過を厳密に「時間に対するハードリミット」にしたい場合は、本体側で withTaskCancellationHandler やキャンセル検出を組み合わせ、CancellationError(reason: .deadlineExpired) を明示的に投げるなど、キャンセルに即応する設計にする必要があります。
ネストしている場合も同じ枠組みです。内側の deadline が先に切れれば、内側の withDeadline が(本体がキャンセルに応じて CancellationError を投げるなら)CancellationError(reason: .deadlineExpired) を投げ、外側の withDeadline から見ればそれは本体が投げたエラーとしてそのまま伝わります。エラーチェーンに専用のラッパは挟まりません。
03 今後の見通し
提案では、次のような発展方向が示されています。いずれも将来の構想であり、実現を約束するものではありません。
- エグゼキュータ側で deadline 付きジョブのキャンセルをより効率的に扱えるようにする拡張。現在の実装は既存のエグゼキュータに変更を要求しませんが、将来的に専用の取り扱いを追加する余地があるとされています。
withDeadlineの基盤となる構造化並行のプリミティブを、汎用 API として切り出すこと。他言語でraceと呼ばれているものに相当します。withDeadlineの導入はこの方向性を妨げるものではなく、もし将来的にrace型のプリミティブが Concurrency ライブラリに追加されれば、withDeadlineはその上に載せ替える有力な候補になるとされています。