Swift Digest

ProgressManager: Swift Concurrency における進捗報告

ProgressManager: Progress Reporting in Swift Concurrency

Proposal
SF-0023
Authors
Chloe Yeo
Review Manager
Charles Hu
Status
Accepted

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

01 何が問題だったのか

Foundation には、長く使われてきた進捗報告の仕組みとして Progress クラスがあります。Progress は、親子関係を持つツリー構造で進捗を集約できる汎用的な API ですが、その推奨される使い方は async/await を前提とした Swift Concurrency と相性がよくありません。

Progress を返り値で受け渡すパターンが async/await と噛み合わない

Progress の典型的な使い方は、進捗を報告したい関数が新しい Progress インスタンスを返し、それを呼び出し側の親 Progress に子として追加する、というものです。完了ハンドラベースの非同期 API なら、関数を呼び出した直後に Progress を受け取って親へ追加できるので問題ありません。

public func makeSalad() {
    let progress = Progress(totalUnitCount: 3)
    let chopSubprogress = chopFruits { result in
        // ...
    }
    progress.addChild(chopSubprogress, withPendingUnitCount: 1)
}

public func chopFruits(completionHandler: @escaping (Result<Progress, Error>) -> Void) -> Progress { ... }

ところが、これを async/await に置き換えると同じパターンが成立しなくなります。

public func makeSalad() async {
    let progress = Progress(totalUnitCount: 3)
    let chopSubprogress = await chopFruits()
    progress.addChild(chopSubprogress, withPendingUnitCount: 1)
}

public func chopFruits() async -> Progress { ... }

await chopFruits() は関数の処理が完了してから戻るので、返ってきた Progress はすでに completedUnitCount == totalUnitCount の状態です。これを親に追加しても、途中経過としての進捗は何も観測できません。

引数で Progress を渡すパターンは誤りやすい

代替策として、Progress を引数として渡し、関数の中で totalUnitCount を設定して進捗を進める書き方もあります。しかしこれは、誰がいつ Progress を作って誰が消費するのかを言語が強制してくれないため、簡単に誤用が生まれます。

public func makeSalad() async {
    let progress = Progress(totalUnitCount: 2)

    let chopSubprogress = Progress()
    progress.addChild(chopSubprogress, withPendingUnitCount: 1)

    await chopFruits(progress: chopSubprogress)
    await chopVegetables(progress: chopSubprogress) // Author's mistake: 同じ subprogress を使い回している
}

public func chopFruits(progress: Progress) async {
    progress.totalUnitCount = Int64(fruits.count)
    for fruit in fruits {
        await fruit.chop()
        progress.completedUnitCount += 1
    }
}

public func chopVegetables(progress: Progress) async {
    progress.totalUnitCount = Int64(vegetables.count) // Author's mistake: chopFruits の進捗を上書きしてしまう
    // ...
}

Progress インスタンスをひとつ渡してしまうと、受け取った関数側はその totalUnitCount を上書きし、completedUnitCount を進めることができてしまいます。同じ Progress を二度使うと、片方で進めた進捗を相手に上書きされたり、完了済みのものをさらに「完了」させてしまったりといった、レースや過剰完了が起きやすい状態になります。

ProgressReporting プロトコル経由でも mutable な状態が漏れる

クラス側に ProgressReporting プロトコルを適合させ、progress: Progress を公開するパターンもあります。こちらは「関数の途中で完了してしまう」問題は避けられるものの、Progress のすべての mutable な状態が観測者に晒されてしまい、外部から completedUnitCount を勝手に進められてしまうといった問題があります。

Swift Concurrency と整合した進捗報告 API が欲しい

これらの背景から、

  • async/await の関数で途中経過を含めて進捗を観測できる
  • 進捗を「組み立てる側」と「観測する側」を型で分離し、誤用を防ぐ
  • 完了の取り扱い(タスクのキャンセルなど)を破綻なく一貫させる
  • Observation フレームワークと連携して UI に直接バインドできる
  • ファイル数・バイト数といった付加情報も型安全に扱える
  • ひとつのインスタンスを複数の親ツリーに組み込めるようにする

といった要求を満たす、Swift Concurrency 前提の新しい進捗報告 API が必要とされていました。

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

Foundation に、Swift Concurrency と整合した新しい進捗報告 API として ProgressManager が追加されます。ProgressManager 単独ではなく、役割の異なる 3 つの型がセットで導入され、それぞれ「進捗を進める」「子に進捗を委譲する」「進捗を読み取る・他のツリーに組み込む」という責務に分かれています。

  • ProgressManager: 進捗を実際に進めたり、子へ count を割り当てたりする中心の型。@ObservableSendable クラス
  • Subprogress: 親 ProgressManager から「この分は別の関数で報告してね」と切り出して渡される ~Copyable な値型。受け取った関数で 1 回だけ start(totalCount:) を呼んで、自分用の ProgressManager に変換する
  • ProgressReporter: ProgressManager の読み取り専用ビュー。@Observable で、複数の親 ProgressManager に組み込むこともできる

進捗の合成は、整数の totalCountcompletedCount をベースにツリー(ProgressReporter 経由で組むときは多重親も許す非循環グラフ)として行います。fractionCompleted は、子の進捗状況も加味して 0.0〜1.0 の範囲で算出されます。

Subprogress を使った関数間での進捗報告

進捗を報告したい async 関数は、引数として consuming Subprogress? を受け取ります。これは「この関数が進捗報告に対応している」ということをシグネチャだけで読者に伝えるための、半ば慣習的な書き方です。

public class FoodProcessor {
    public func process(
        ingredients: [Ingredient],
        subprogress: consuming Subprogress? = nil
    ) async {
        let manager = subprogress?.start(totalCount: ingredients.count + 1)

        var choppedIngredients: [Ingredient] = []
        for ingredient in ingredients {
            let chopped = await Self.chop(
                ingredient,
                subprogress: manager?.subprogress(assigningCount: 1)
            )
            choppedIngredients.append(chopped)
        }

        // 進捗報告に対応していない関数の呼び出しは、自分で complete(count:) する
        await blender.blend(choppedIngredients)
        manager?.complete(count: 1)
    }

    static func chop(_ ingredient: Ingredient, subprogress: consuming Subprogress? = nil) -> Ingredient { ... }
}

呼び出し側は、自分の ProgressManager から subprogress(assigningCount:)Subprogress を切り出し、それを引数として渡します。

let overallManager = ProgressManager(totalCount: 2)

await withTaskGroup(of: Void.self) { group in
    group.addTask {
        await foodProcessor.process(
            ingredients: mainCourse,
            subprogress: overallManager.subprogress(assigningCount: 1)
        )
    }
    group.addTask {
        await juicer.makeJuice(
            ingredients: beverage,
            subprogress: overallManager.subprogress(assigningCount: 1)
        )
    }
}

Subprogress~Copyable なので、コピーや複数回の start(totalCount:) 呼び出しはコンパイル時にエラーになります。これにより、ひとつの「進捗の枠」を複数のサブタスクで使い回す、という典型的な誤用が型レベルで防げます。

let subprogressOne = overall.subprogress(assigningCount: 1)
let managerOne = subprogressOne.start(totalCount: 10)

// COMPILER ERROR: 'subprogressOne' consumed more than once
let managerTwo = subprogressOne.start(totalCount: 8)

また、Subprogress 自体には complete(count:) のような進捗を進めるメソッドは存在せず、必ず一度 start(totalCount:)ProgressManager に変換しなければ進捗を進められません。逆に、Subprogress を受け取ったまま start(totalCount:) を呼ばずに関数を抜けた場合(例外や早期 return など)には、その分の count は親の ProgressManager 側で自動的に完了扱いになり、進捗が止まってしまうことを防ぎます。

ProgressManager のメソッド

ProgressManager の主な API は次のとおりです。

@available(FoundationPreview 6.4, *)
@dynamicMemberLookup
@Observable
public final class ProgressManager: Sendable, Hashable, Equatable,
    CustomStringConvertible, CustomDebugStringConvertible {

    public var totalCount: Int? { get }
    public var completedCount: Int { get }
    public var fractionCompleted: Double { get }
    public var isIndeterminate: Bool { get }
    public var isFinished: Bool { get }

    public var reporter: ProgressReporter { get }

    public convenience init(totalCount: Int?)

    public func subprogress(assigningCount count: Int) -> Subprogress
    public func assign(count: Int, to reporter: ProgressReporter)

    public func complete(count: Int)
    public func setCounts(_ counts: (_ completed: inout Int, _ total: inout Int?) -> Void)
}

totalCountnil で初期化すると isIndeterminate == true の不確定状態になり、後から setCounts でまとめて設定できます。totalCount は読み取り専用で、Sendable なクラスでありながらレースを起こさずに書き換えるために、completedCount との同時更新も含めて setCounts クロージャ経由でしか変更できないようになっています。

let manager = ProgressManager(totalCount: nil)
// 後から件数が分かったタイミングでまとめて設定
manager.setCounts { completed, total in
    total = 100
    completed = 0
}

ProgressReporter で読み取り専用ビューを公開する

クラスのプロパティとして進捗を公開したい場合は、ProgressManager.reporter から得られる ProgressReporter を使います。ProgressReporterProgressManager のすべての値を読み取りできる @Observable なクラスですが、進捗を進めるメソッドは持たないため、観測者から勝手に進捗を進められてしまうことがありません。

public class ExamCountdown {
    public var progressReporter: ProgressReporter { ... }
}

let examCountdown = ExamCountdown()
let observedProgress = examCountdown.progressReporter

さらに ProgressReporter は、複数の親 ProgressManager に対して assign(count:to:) で子として追加することもできます。これにより、ひとつのオブジェクトが「ダウンロード進捗ツリー」と「全体の作業進捗ツリー」のように、複数のツリーに同時に組み込まれた状態を表現できます。サイクル(自分が自分の祖先になる構造)を作ろうとした場合は実行時にクラッシュさせて検出します。

let overall = ProgressManager(totalCount: 5)
overall.assign(count: 3, to: examCountdown.progressReporter)

let deadlineTracker = ProgressManager(totalCount: 2)
deadlineTracker.assign(count: 1, to: examCountdown.progressReporter)

Observation フレームワークとの連携

ProgressManagerProgressReporter はどちらも @Observable な final クラスなので、SE-0475 で導入される Observations を通じて AsyncSequence として観測できます。SwiftUI などの UI フレームワークから fractionCompleted などをそのまま購読できることが想定されています。

let manager = ProgressManager(totalCount: 2)
let managerFractionStream = Observations { manager.fractionCompleted }

let reporter = manager.reporter
let reporterFractionStream = Observations { reporter.fractionCompleted }

型安全な追加プロパティ

totalCount / completedCount だけでは表現しきれない情報(処理中のファイル名、バイト数、推定残り時間など)を、@dynamicMemberLookup を介した型安全な追加プロパティとして扱えます。よく使うものは ProgressManager.Properties にあらかじめ定義されています。

  • totalFileCount / completedFileCount (Int)
  • totalByteCount / completedByteCount (UInt64)
  • throughput (UInt64、サマリは [UInt64])
  • estimatedTimeRemaining (Duration)

利用側は通常のプロパティのように読み書きできます。

let manager = ProgressManager(totalCount: 2)
manager.complete(count: 1)
manager.totalByteCount = 1_000_000

独自プロパティを追加するには、ProgressManager.Properties を拡張し、ProgressManager.Property プロトコルに適合する型を定義します。Value(個々の値)と Summary(サブツリー全体の集計値)の組み合わせは、現時点で次のものがサポートされます。

  • (Int, Int)
  • (UInt64, UInt64)
  • (Double, Double)
  • (String?, [String?])
  • (URL?, [URL?])
  • (UInt64, [UInt64])
  • (Duration, Duration)

reduce(into:value:) で個別の値を Summary に取り込み、merge(_:_:) でサブツリーから集まった Summary を結合し、finalSummary(_:_:)ProgressManager がデイニシャライズされたときに親の集計へどう反映するかを定義します。

extension ProgressManager.Properties {
    var filename: Filename.Type { Filename.self }

    enum Filename: Sendable, ProgressManager.Property {
        typealias Value = String?
        typealias Summary = [String?]

        static var key: String { "ExampleApp.Filename" }
        static var defaultValue: String? { nil }
        static var defaultSummary: [String?] { [] }

        static func reduce(into summary: inout [String?], value: String?) {
            summary.append(value)
        }
        static func merge(_ summary1: [String?], _ summary2: [String?]) -> [String?] {
            summary1 + summary2
        }
        static func finalSummary(_ parentSummary: [String?], _ selfSummary: [String?]) -> [String?] {
            parentSummary + selfSummary
        }
    }
}

サブツリー全体の集計値は summary(of:) で取得できます。

manager.filename = "Capybara.jpg"
await doSomething(subprogress: manager.subprogress(assigningCount: 1))

func doSomething(subprogress: consuming Subprogress? = nil) async {
    let manager = subprogress?.start(totalCount: 1)
    manager?.filename = "Snail.jpg"
}

let filenames = manager.summary(of: \.filename) // ["Capybara.jpg", "Snail.jpg"]

キャンセル時の挙動

ProgressManager 自身はタスクの制御(キャンセルや一時停止)を担当しません。タスクの取り扱いは Swift Concurrency の Task.cancel() などに委ねる、という方針です。一方で、進捗の状態が中途半端なまま放置されないように、ProgressManager がデイニシャライズされる際には残った count を完了済みとして処理します。Subprogress を受け取ったまま start(totalCount:) を呼ばずに破棄した場合も同様で、親 ProgressManager 側でその分の count が完了扱いになります。これにより、fractionCompleted は最終的に必ず 1.0 に向かって進む、という一貫した挙動になります。

キャンセル理由などをクライアントに伝えたい場合は、独自の追加プロパティを定義してそこに情報を載せる、という運用が想定されています。

既存の Progress との相互運用

既存の Foundation.Progress を使うコードと、新しい ProgressManager を使うコードを混在させて使えるよう、相互運用 API も用意されます。ここでは、既存 Progress がツリー構造であることに合わせて、相互運用の場面では多重親は使えないという前提が置かれています。

ProgressManager を親に、Foundation.Progress を子にしたい場合は、ProgressManager 側で assign(count:to:)Foundation.Progress 受け取りオーバーロードを使います。

extension ProgressManager {
    public func assign(count: Int, to progress: Foundation.Progress)
}

let overall = ProgressManager(totalCount: 2)
let subprogressOne = overall.subprogress(assigningCount: 1)
let result = await doSomethingWithManager(subprogress: subprogressOne)

let legacyChild = doSomethingWithProgress() // 既存の Progress を返す関数
overall.assign(count: 1, to: legacyChild)

逆に、Foundation.Progress を親に、ProgressManager を子にしたい場合は、Progress 側に追加された subprogress(assigningCount:)addChild(_:withPendingUnitCount:) オーバーロードを使います。

extension Progress {
    public func subprogress(assigningCount count: Int) -> Subprogress
    public func addChild(_ reporter: ProgressReporter, withPendingUnitCount count: Int)
}

let overall = Progress(totalUnitCount: 3)

let subprogressOne = doSomethingWithProgress()
overall.addChild(subprogressOne, withPendingUnitCount: 1)

let subprogressTwo = overall.subprogress(assigningCount: 1)
await doSomethingWithManager(subprogress: subprogressTwo)

let downloadReporter = DownloadManager().progressReporter
overall.addChild(downloadReporter, withPendingUnitCount: 1)

これらにより、フレームワークが新旧どちらの API で進捗を返してきても、利用側は最終的にひとつのツリーへ集約できます。

03 今後の見通し

ProgressManager のさらなる拡張として、いくつかの方向性が示されています。いずれも将来の構想であり、その実現を約束するものではありません。

FormatStyle の導入

ProgressManagerProgressReporter 用の FormatStyle を追加し、進捗の文字列表現を簡潔に組み立てられるようにすることが構想されています。

UI フレームワーク側 API への対応

SwiftUI の ProgressView のように、これまで Foundation.Progress を受け取っていた UI フレームワーク側の API に ProgressManager 用のオーバーロードを追加し、アプリ開発者が UI への接続をそのまま ProgressManager で完結できるようにする方向性も挙げられています。

分散 ProgressManager

既存の Progress がプロセスをまたいで進捗を伝えられるのと同じように、distributed actor を活用してプロセス間で進捗を報告する distributed ProgressManager を導入することが構想されています。

count 管理の自動化

ネストが深い進捗ツリーで count の割り当てと消費が複雑になる問題に対して、マクロなどを用いて count の集計を自動化する利便機能を導入する案が挙げられています。

整数以外の進捗表現

外部からは Double などの整数以外で進捗が渡ってくる場面に対応するため、ProgressManager を整数以外のフォーマットで初期化する、あるいは整数以外で進捗を扱うピア型を追加する、という方向性も示されています。

サブツリーの子の表示サポート

ProgressManagerProgressReporter のサブツリーに含まれる子の一覧を表示したい、というニーズが高まれば、それを観測するための追加 API を導入する構想も挙げられています。