Swift Digest
SE-0329 | Swift Evolution

Clock, Instant, and Duration

Proposal
SE-0329
Authors
Philippe Hausler
Review Manager
John McCall
Status
Implemented (Swift 5.7)

01 何が問題だったのか

「時間」に関する概念は、大きく分けて次の 3 つに分解できます。

  1. 現在時刻を取得する手段、およびある時点まで待つ手段(clock
  2. 時間軸上の 1 点(instant
  3. 経過時間の量(duration

しかし Swift には、この 3 つの概念を統一的に扱う標準的な API がありませんでした。時間を表す型は Foundation の Date / TimeInterval、Dispatch の DispatchTime / DispatchWallTime / DispatchTimeInterval、あるいは POSIX の timespec など各所に散在し、それぞれが独自の基準点・精度・単位を持っています。そのため、たとえば「タスクを 3 秒スリープする」「ネットワークリクエストのタイムアウトを設定する」「関数の実行時間を計測する」といった、どれも本質的には同じ「時間」の話題を扱う API であるにもかかわらず、呼び出し側はそのたびに別々の型変換や単位換算を強いられていました。

また、clock の種類にも違いがあります。マシンがスリープしている間も時間が進み続ける clock(いわゆる monotonic 系)と、スリープ中は時間が止まる clock(uptime 系)では、想定すべき挙動が異なります。さらに Darwin と Linux とでは monotonic / uptime の定義が逆転しているため、プラットフォームごとに微妙に意味が食い違うという問題もありました。時間軸上の 1 点(instant)も、どの clock から取得したかによって意味が変わるため、異なる clock の instant を不用意に比較してしまうとプログラマのミスにつながります。

Task.sleep(nanoseconds:) のように、ナノ秒を UInt64 で受け取る API も混在していました。こうした API は単位が型に表れないため、呼び出し側で桁を間違える余地があり、さらに「どの clock を基準に待つのか」「tolerance(許容誤差)をどう扱うのか」といった、電力効率や精度に関わる情報を表現する手段もありませんでした。

これらの課題を解決するには、clock・instant・duration を単一の型階層として整理し、用途に応じた progressive disclosure で提供する仕組みが必要でした。

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

標準ライブラリに Clock / InstantProtocol / DurationProtocol という 3 つのプロトコルと、それらをまとめる具体型 DurationContinuousClockSuspendingClock を導入します。Foundation からは UTCClock が追加され、DateInstantProtocol に適合します。

Duration

経過時間を表す具体型です。内部的には秒(Int64)とアト秒(Int64)の 2 成分で保持されており、ナノ秒精度を広い範囲で損なわず扱えます。人間が読みやすい静的メソッドで構築できます。

let d1: Duration = .seconds(3)
let d2: Duration = .milliseconds(250)
let d3: Duration = .microseconds(500)
let d4: Duration = .nanoseconds(10)

DurationEquatable / Comparable / Hashable / Codable / AdditiveArithmetic / Sendable に適合します。加減算だけでなく、IntDouble によるスカラー倍・除算、Duration 同士の除算(結果は Double)もサポートします。

let total = .seconds(1) + .milliseconds(500) // 1.5 秒
let half = total / 2                         // 0.75 秒
let ratio = total / .seconds(1)              // 1.5(Double)

既存 API との相互運用のために components プロパティで (seconds:, attoseconds:) を取り出せます。

let c = Duration.milliseconds(1).components
// c.seconds == 0, c.attoseconds == 1_000_000_000_000_000

なお DurationNumeric には適合しません。「秒 × 秒」のような duration 同士の積は意味を持たないためです。

Clock プロトコル

clock を表す基本プロトコルです。現在時刻(now)と、指定した instant まで待つ sleep(until:tolerance:) を要求します。Duration を primary associated type として持つため、some Clock<Duration: ...> のように軽量に制約できます。

public protocol Clock: Sendable {
  associatedtype Duration: DurationProtocol
  associatedtype Instant: InstantProtocol where Instant.Duration == Duration

  var now: Instant { get }
  var minimumResolution: Instant.Duration { get }

  func sleep(until deadline: Instant, tolerance: Instant.Duration?) async throws
}

minimumResolution は、その clock が扱える最小の時間粒度です。計測結果がこれを下回る場合は 0 とみなしてよいことを示します。

tolerance は deadline に対して許容する遅延の最大値です。nil を渡すと clock の実装に委ねられます。tolerance を明示すると、カーネル側が近い時刻のウェイクアップをまとめて電力効率のよい起動に調整できます。

clock には measure(_:) 拡張が用意されており、ブロックの実行時間を計測できます。

let elapsed = someClock.measure {
    someWorkToBenchmark()
}

InstantProtocol / DurationProtocol

instant は「clock から取得された時間軸上の 1 点」で、advanced(by:) で duration を加えたり、duration(to:) で他の instant との差を取れます。演算子 + / - / += / -= もサポートし、instant から instant を引くと duration になります。

public protocol InstantProtocol: Comparable, Hashable, Sendable {
  associatedtype Duration: DurationProtocol
  func advanced(by duration: Duration) -> Self
  func duration(to other: Self) -> Duration
}

DurationProtocol は clock 固有の duration を許容するためのプロトコルです。通常の時間以外にも、たとえば「フレーム数」や「ステップ数」を duration として扱う特殊な clock が定義できるよう、一般化されています。Comparable / AdditiveArithmetic / Sendable に加えて、Int による乗除算と Self 同士の除算(Double を返す)を要求します。

instant は AdditiveArithmeticStrideable には適合しない点に注意してください。instant 同士を足すことに意味はなく、また Strideable は stride が SignedNumeric であることを要求するため、duration 同士の積を許すことになってしまうためです。

具体的な clock

標準ライブラリには 2 種類の clock が用意されます。名前はプラットフォーム間の解釈のぶれを避けるために選ばれています(Darwin と Linux では monotonic / uptime の定義が食い違うため)。

  • ContinuousClock: マシンがスリープしていても時間が進み続ける clock です。Darwin の monotonic、Linux の uptime に対応します。ストップウォッチ的な計測に向きます。
  • SuspendingClock: マシンがスリープしている間は時間が止まる clock です。Darwin の uptime、Linux の monotonic に対応します。

どちらも Clock の extension から .continuous / .suspending として取得できます。

let clock = ContinuousClock()
try await clock.sleep(until: .now.advanced(by: .seconds(3)))
print("hello delayed world")

Clock を受け取るジェネリック API では、次のようにショートハンドで渡せます。

func schedule<C: Clock>(on clock: C) { /* ... */ }
schedule(on: .continuous)
schedule(on: .suspending)

Foundation 側では UTCClock が追加され、Date がその Instant 型になります。Date には、2 つの Date 間に挟まれたうるう秒の合計を返す leapSeconds(to:) と、ContinuousClock.Instant / SuspendingClock.Instant との相互変換イニシャライザが追加されます。UTC に基づいた「壁掛け時計」的なスケジューリングは用途が特殊であるため、標準ライブラリではなくカレンダー関連 API に近い Foundation に置かれています。

Task のスリープ API

Task.sleep(nanoseconds:) のようにナノ秒を整数で受ける既存 API は deprecated になり、Duration または任意の clock を取る新 API に置き換えられます。

extension Task {
  public static func sleep(for duration: Duration) async throws
  public static func sleep<C: Clock>(
    until deadline: C.Instant,
    tolerance: C.Instant.Duration? = nil,
    clock: C
  ) async throws
}

使用例は次の通りです。

// 3 秒待つ
try await Task.sleep(for: .seconds(3))

// 指定した clock の instant まで待つ
let clock = ContinuousClock()
try await Task.sleep(until: clock.now.advanced(by: .seconds(1)), clock: clock)

どちらの API も、タスクがキャンセルされた場合は例外を投げて中断します。

今後の方向性

本提案では、テスト目的で時間の進みを手動で制御できる ManualClock のようなカスタム clock の実装例が示されていますが、API として標準ライブラリに入るものではありません。将来、テスト容易性を高めるための汎用的な手動 clock や、GPU のフレーム単位など時間以外の尺度を duration として扱う特殊な clock が登場する余地が残されています(あくまで方向性であり、実現を約束するものではありません)。