PAYATHON 2026

如何在 Swift 中实现线程安全的进度上报?

技术老齐

可以保留 ProgressGroup 的 @MainActor 和 @Observable,将传给耗时函数的对象换成可跨隔离域传递的 Sendable 上报器。上报器提供同步方法,内部通过 Task { @MainActor in ... } 提交更新,调用方就不必为了上报进度而声明为 async。

setProgress() 返回时,进度更新可能还没执行。如果要求返回前完成共享状态写入,可以用 Swift 提供的 Synchronization.Mutex 保护短临界区。使用锁也符合 Swift concurrency 的设计。SE-0433:Mutex

保留主 actor 上的可观察模型

@Observable 负责追踪属性变化,不会自动保护业务属性的并发读写。即使内部的 ObservationRegistrar 是线程安全的,修改 childProgress 和计算总进度仍然需要同步。SE-0395:Observation

供 UI 使用的进度模型可以继续由 MainActor 管理所有状态。各个任务只持有上报器,不直接访问数组。

下面的实现要求每个子任务的进度只增不减,适用于支持 Observation 的 Swift 6 环境,且模块的默认 actor 隔离为 nonisolated。

import Observation

@MainActor
@Observable
final class ProgressGroup {
    private(set) var totalProgress: Float = 0

    @ObservationIgnored
    private var childProgress: [Float]

    init(numChildren: Int) {
        precondition(numChildren > 0)
        childProgress = Array(repeating: 0, count: numChildren)
    }

    func reporter(for index: Int) -> ProgressReporter {
        precondition(childProgress.indices.contains(index))
        return ProgressReporter(group: self, index: index)
    }

    fileprivate func setChildProgress(
        idx: Int,
        progress: Float
    ) {
        guard progress.isFinite else { return }

        let value = min(max(progress, 0), 1)

        // 同一轮操作中,只接受更大的进度值。
        guard value > childProgress[idx] else { return }

        childProgress[idx] = value
        totalProgress =
            childProgress.reduce(Float.zero, +)
            / Float(childProgress.count)
    }
}

struct ProgressReporter: Sendable {
    fileprivate let group: ProgressGroup
    fileprivate let index: Int

    nonisolated func setProgress(_ value: Float) {
        Task { @MainActor [group, index] in
            group.setChildProgress(idx: index, progress: value)
        }
    }

    // 需要确认某次更新已经完成时,显式等待。
    nonisolated func setProgressAndWait(_ value: Float) async {
        await group.setChildProgress(idx: index, progress: value)
    }
}

ProgressReporter 的成员都是不可变的,它引用的 ProgressGroup 受全局 actor 隔离保护,因此可以安全地跨隔离域传递。调用方拿到引用后,仍须遵守隔离规则,不能直接修改其状态。SE-0316:Global actors

在主 actor 上创建模型并取得上报器:

@MainActor
func makeProgress() -> (ProgressGroup, ProgressReporter) {
    let group = ProgressGroup(numChildren: 3)
    return (group, group.reporter(for: 1))
}

耗时函数可以保持同步,由其他 actor 调用:

func performWork(progress: ProgressReporter) {
    // 执行一部分耗时工作……
    progress.setProgress(0.5)

    // 继续执行……
    progress.setProgress(1)
}

actor Worker {
    func run(progress: ProgressReporter) {
        performWork(progress: progress)
    }
}

跨 actor 调用 Worker.run() 仍然需要 await,但 performWork() 不必为了上报进度而声明为 async。耗时工作应在适合它的执行上下文中运行,不能将整段工作放进更新 UI 的主 actor 闭包。

UI 继续观察 group.totalProgress 即可。三个等权子任务的进度为 0、0.5、0 时,总进度是 0.5 / 3 ≈ 0.1667,即约 16.67%。

提交更新与完成更新有区别

上报器在同步方法内部提交异步更新,跨 actor 的更新仍然是异步的。使用时要注意:

  • 调用 setProgress(0.5) 后立即读取模型,可能仍会读到旧值。需要确认最终进度已经写入时,可以调用 await reporter.setProgressAndWait(1)。
  • 独立 Task 没有 FIFO 保证。连续上报 0.5 和 0.6,不能依赖两个更新任务严格按创建顺序执行。示例通过拒绝较小值来防止进度倒退。SE-0306:Actor 调度
  • 这个实现以进度单调递增为前提。如果允许回退、重试或重置,应使用轮次标识和序号判断更新是否过期,不能简单取最大值。最直接的做法是每轮操作都创建新的 ProgressGroup 和上报器。
  • 这里创建的是非结构化任务,不会自动成为工作任务的子任务。工作取消后,已经提交的更新仍可能执行。如果取消后必须停止展示,应增加操作状态检查。

setProgressAndWait(1) 只保证这一次更新完成,不能用作等待此前所有上报任务完成的通用屏障。在示例的单调递增规则下,较小的旧更新即使随后执行,也不会覆盖最终值。

父对象没有保存上报器,因此也避免了原始设计中“父对象强引用子对象,子对象又强引用父对象”的引用循环。

必须同步写入时,可以使用 Mutex

如果要求上报方法返回前内部进度已经更新,可以将同步存储和 UI 模型分开:

  1. 用 Mutex 保护进度数组,提供同步读写。
  2. 用 @MainActor @Observable 保存供 UI 观察的快照。
  3. UI 刷新时读取存储,并更新可观察属性。

以下代码需要 Swift 6 的 Synchronization 模块,SDK 和部署目标也必须支持 Mutex。

import Synchronization
import Observation

final class ProgressStore: Sendable {
    private let values: Mutex<[Float]>

    init(numChildren: Int) {
        precondition(numChildren > 0)
        values = Mutex(Array(repeating: 0, count: numChildren))
    }

    func setProgress(_ value: Float, for index: Int) {
        guard value.isFinite else { return }
        let clamped = min(max(value, 0), 1)

        values.withLock { progress in
            precondition(progress.indices.contains(index))
            progress[index] = clamped
        }
    }

    func totalProgress() -> Float {
        values.withLock { progress in
            progress.reduce(Float.zero, +)
                / Float(progress.count)
        }
    }
}

@MainActor
@Observable
final class ProgressViewState {
    private(set) var totalProgress: Float = 0

    func refresh(from store: ProgressStore) {
        totalProgress = store.totalProgress()
    }
}

同步写入后,可以提交一次主 actor 刷新:

func report(
    _ value: Float,
    index: Int,
    store: ProgressStore,
    viewState: ProgressViewState
) {
    store.setProgress(value, for: index)

    Task { @MainActor in
        viewState.refresh(from: store)
    }
}

report 返回前会完成 store 的写入,UI 则稍后异步更新。刷新任务在执行时读取最新状态,可以避免提前捕获的旧快照晚到,覆盖较新的显示值。

这个存储允许进度回退。并发写入同一个索引时,以取得锁的顺序为准;如果业务要求更严格的先后关系,还需要轮次或序号。

所有相关读写都必须经过同一把锁,不能只保护写入。临界区内不要执行耗时工作、调用观察者或等待异步任务。withLock 的闭包是同步的,适合这种短小的状态操作。锁竞争会阻塞线程,因此临界区应尽量短。Mutex 文档

actor 的数量不是主要选择依据

普通 actor 实例通常不会独占一条线程。默认情况下,actor 使用共享的并发线程池,因此创建很多 actor 不等于创建很多线程。Actor 文档

可以根据调用语义选择实现:

需求合适的实现
主要用于 UI,允许稍后更新@MainActor @Observable 加同步上报器
返回前必须写入,操作短且同步Mutex 保护存储,主 actor 发布快照
状态操作包含异步步骤,调用方需要等待结果普通 actor,加独立的 UI 可观察模型

采用普通 actor 时,仍应在主 actor 上维护供 UI 使用的可观察模型。不能假定将原来的类直接改成 @Observable actor 后,还能保留相同的观察方式。

上报频率很高时,应合并更新或让 UI 定期读取快照。即使数据访问完全安全,每次上报都创建一个 Task,也可能积累大量待处理任务。进度展示通常只需要最新状态;操作完成、失败或取消,应通过工作任务的结果明确表达。

备注:内容仅供参考。