迁移至 Swift Actors

English Version

Actors 作为 Swift 并发模型的重要组成部分,于 WWDC21 上推出,并在 iOS 13 以上可用。它们通过确保串行访问,提供了在并发环境中安全管理状态的方法。已有多篇优秀文章介绍了 Actors 的概念和基本用法(Swift 新并发框架之 actor),我们假设你已熟悉这些内容。本文将重点分享在现有代码库中集成 Actors的经验和解决方案。

将数据模型类重构为 Actor

Actors 最常见且强大的用例之一是管理从多个线程访问的(串行)数据模型。在这个场景中,我们有一个 Uploader 类需要处理并发操作。以下是 Uploader 的简化版本:

swift 复制代码
protocol Uploader {
    func upload(file: String)
    func retry(file: String)
    func cancelUpload(file: String)
}

final class UploaderImp: Uploader {

    private var uploadingFiles = Set<String>()
    private let uploadService = UploadService()

    func upload(file: String) {}
    func retry(file: String) {}
    func cancelUpload(file: String) {}
}
class UploadService {
    func upload(_ file: String, completion: @escaping (Result<(), Error>) -> Void) {}
}

注意我们有一个 uploadingFiles 私有属性,需要在其他函数中更新。此外,其他函数可能会在多线程中被调用。

与传统方法相比的优势

传统上,多线程问题通过锁或队列解决。虽然简单直接,但这种方法要求开发人员手动确保对 uploadingFiles 等共享资源的每次访问都受到保护。这很容易出错,因为很容易遗漏某个情况,而且编译器不会帮助捕获这些错误。忽略必要的锁或队列可能导致竞态条件和其他并发相关的错误。

传统的基于队列的解决方案如下所示:

swift 复制代码
final class UploaderImp: Uploader {

    private var uploadingFiles = Set<String>()
    private let uploadService = UploadService()
    private let queue: DispatchQueue

    func upload(file: String) {
        queue.async {
            uploadingFiles.insert(file)
            uploadService.upload(file) { [weak self] in
                //  completion 可能在其他队列上执行,
                // 我们可能会忘记用 `queue.async` 包装,
                // 尤其是在更复杂的代码中。
                // 然后 `uploadingFiles` 可能会在其他队列中被访问,
                // 在这种情况下编译器无法提供任何帮助。
                uploadingFiles.remove(file)
            }
        }
    }
    func retry(file: String) {
        queue.async {
            uploadingFiles.insert(file)
        }
    }
    func cancelUpload(file: String) {
        queue.async {
            uploadingFiles.insert(file)
        }
    }
}

然而,很容易忘记用队列包装所有属性访问,从而导致潜在的数据竞争。

另一种传统方法是使用锁。但是,当处理多个属性时,确保原子操作可能具有挑战性。考虑使用(WWDC24 中引入的新) Mutex 作为示例:

swift 复制代码
import Synchronization

final class UploaderImp: Uploader {

    private var uploadingFiles = Mutex<Set<String>>()
    private var uploadedFiles = Mutex<Set<String>>()
    private let uploadService = UploadService()

    func upload(file: String) {
        uploadingFiles.withLock {
            $0.insert(file)
        }
        uploadService.upload(file) { [weak self] _ in
            // 将文件从 uploadingFiles 移动到 uploadedFiles
            // 不是原子操作,可能导致多线程问题
            // 如竞态条件或脏读。
            self?.uploadingFiles.withLock {
                $0.remove(file)
            }
            self?.uploadedFiles.withLock {
                $0.insert(file)
            }
        }
    }
    func retry(file: String) {}
    func cancelUpload(file: String) {}
    init() {}
}

如果数据模型包含多个属性,为每个属性单独包装锁并不能保证所有属性之间的原子操作。对所有属性使用单个锁与使用队列具有相同的缺点。

这就是 actor 的用武之地。以下是如何将 Uploader 重构为 actor:

swift 复制代码
actor UploaderImp: Uploader {

    private var uploadingFiles = Set<String>()
    private var uploadedFiles = Set<String>()
    private let uploadService = UploadService()

    nonisolated func upload(file: String) {}
    private func _upload(file: String) {
        uploadingFiles.insert(file)
        uploadService.upload(file) { [weak self] _ in
            self?.uploadingFiles.remove(file) // ERROR: Actor-isolated property 'uploadingFiles' can not be mutated from a nonisolated context
            self?.uploadedFiles.insert(file) // ERROR: Actor-isolated property 'uploadedFiles' can not be mutated from a nonisolated context
        }
    }
    nonisolated func retry(file: String) {}
    nonisolated func cancelUpload(file: String) {}
    init() {}
}

你可能已经注意到某些函数上的 nonisolated 关键字;我们稍后会讨论这一点。

编译器生成了一些错误,但这些错误可以解决。我们可以通过使用像 performInIsolation 这样的辅助方法(可以使用 sending 而不是 Sendable 进行优化)来修复编译错误并确保原子性:

swift 复制代码
public extension Actor {
    /// 为任何 actor 添加通用的 `perform` 方法,以访问其隔离域,使用闭包一次性执行多个操作。
    func performInIsolation<T>(_ block: sending (_ actor: isolated Self) throws -> sending T) rethrows -> sending T {
        try block(self)
    }
}
swift 复制代码
actor UploaderImp: Uploader {

    private var uploadingFiles = Set<String>()
    private var uploadedFiles = Set<String>()
    private let uploadService = UploadService()

    nonisolated func upload(file: String) {}
    private func _upload(file: String) {
        uploadingFiles.insert(file)
        uploadService.upload(file) { [weak self] _ in
            Task {
                await self?.performInIsolation { `self` in
                    self.uploadingFiles.remove(file)
                    self.uploadedFiles.insert(file)
                }
            }
        }
    }
    nonisolated func retry(file: String) {}
    nonisolated func cancelUpload(file: String) {}
    init() {}
}

现在,对 uploadingFilesuploadedFiles 的访问保证在特定的串行线程上(由 actor 保证),并且上传完成中的修改是原子的。

你可能认为代码量与传统方法相似。然而,关键优势在于编译器现在保证 actor 属性仅在其隔离环境中被访问。这种编译时安全性防止了数据竞争和其他并发问题,减轻了开发人员的认知负担,并在代码运行前就防止了错误。

从同步环境桥接到隔离环境

我们的上传器现在内部是串行和隔离的。然而,代码库的其余部分可能不知道 actors 或隔离。它可能存在于传统的同步世界中。因此,我们需要在这两个环境之间创建一个桥接。

我们向代码库的其余部分公开 Uploader 协议。如果可能,我们可以用 async 关键字标记 Uploader 协议中的函数,因为非隔离环境必须异步访问隔离环境。

swift 复制代码
protocol Uploader {
    func upload(file: String) async
    func retry(file: String) async
    func cancelUpload(file: String) /*async*/ // 如果我们不标记 async
}
actor UploaderImp: Uploader {
    func upload(file: String) {}
    func retry(file: String) {}
    func cancelUpload(file: String) {} // ERROR: Actor-isolated instance method 'cancelUpload(file:)' cannot be used to satisfy nonisolated protocol requirement
}

然而,要桥接到不使用 async 的世界,我们必须在 UploaderImp actor 中将协议的方法标记为 nonisolated。然后在 actor 内部,我们可以使用 Task 桥接到异步的隔离环境:

swift 复制代码
protocol Uploader {
    func upload(file: String)
    func retry(file: String)
    func cancelUpload(file: String)
}
actor UploaderImp: Uploader {
    nonisolated func upload(file: String) {
        Task { [weak self] in await self?._upload(file: file) }
    }
    private func _upload(file: String) {
        // 在这里执行我们的串行逻辑
    }
    // retry 和 cancelUpload 类似

这样,Uploader 协议可以在同步上下文中使用,而其实现则能充分利用现代并发特性。

如果你的方法需要返回值,它们必须是异步的。你可以通过使用闭包回调来实现,该回调在异步操作完成时被调用并返回结果。

注意:Task 不保证执行顺序。如果操作顺序很重要,请考虑使用像 swift-async-queue 这样的库。

使用 @MainActor 保证主线程执行

Actors 的另一个重要用途是 actor 属性(如 @MainActor),它可以应用于函数、类和其他声明,以确保它们在特定 Actor(如 MainActor)上执行。你甚至可以定义自己的自定义 actors。

长期以来,确保 API 在特定线程(通常是主线程)上调用的唯一方法是在文档中说明。作为开发人员,我们通过经验了解哪些 API 只能在主线程上使用(如 UIView),哪些可以在后台线程上使用(如 UIImage)。然而,在深层调用堆栈中很容易出错,这可能导致运行时崩溃。

另一方面,作为 API 提供者,你可能会在代码中添加保护措施以切换到正确的线程,使用锁或信号量来防止误用。这通常导致每个公共 API 中都有样板代码,并可能引入其自身的一系列线程问题。

@MainActor 属性解决了这个问题,允许我们指定 API 必须在主线程上调用。让我们看一个视图模型的示例:

swift 复制代码
import UIKit
class ViewModel {
    var isValid: Bool = false {
        didSet {
            updateViewHidden(isValid)
        }
    }

    private weak var view: UIView?
    private func updateViewHidden(_ isHidden: Bool) {
        view?.isHidden = isHidden
    }
}

这段代码看起来很熟悉,乍一看似乎是正确的,对吧?

现在,让我们看看 ViewModel 可能的用法:

swift 复制代码
class DataModel {
    let vm = ViewModel()

    func updateData(_ isValid: Bool) {
        vm.isValid = isValid
    }
}

我们很容易意外地从后台线程调用 updateData,这将尝试从后台线程更新 UIView 属性,导致崩溃。

现在,让我们将 @MainActor 属性添加到 updateViewHidden。这将导致编译器在 isValidupdateData 上标记错误:

swift 复制代码
class ViewModel {
    var isValid: Bool = false {
        didSet {
            updateViewHidden(isValid) // ERROR: Call to main actor-isolated instance method 'updateViewHidden' in a synchronous nonisolated context
        }
    }

    private weak var view: UIView?
    @MainActor
    private func updateViewHidden(_ isHidden: Bool) {
        view?.isHidden = isHidden
    }
}
swift 复制代码
class ViewModel {
    @MainActor
    var isValid: Bool = false {
        didSet {
            updateViewHidden(isValid)
        }
    }

    private weak var view: UIView?
    @MainActor
    private func updateViewHidden(_ isHidden: Bool) {
        view?.isHidden = isHidden
    }
}

class DataModel {
    let vm = ViewModel()

    func updateData(_ isValid: Bool) {
        vm.isValid = isValid // ERROR: Main actor-isolated property 'isValid' can not be mutated from a nonisolated context
    }
}
swift 复制代码
class DataModel {
    let vm = ViewModel()

    func updateData(_ isValid: Bool) {
        Task { @MainActor in
            self.vm.isValid = isValid
        }
        // 或者以传统方式,只要编译器知道
        // 当前环境在 MainActor 中:
        DispatchQueue.main.async {
            self.vm.isValid = isValid
        }
    }
}

如果你想知道为什么我们可以使用 DispatchQueue.main 来提供 MainActor 环境,请参阅:Swift 编译器如何知道 DispatchQueue.main 意味着 @MainActor -- Ole Begemann

这些错误最终会引导你用 @MainActor 标记调用链中的每个函数和属性,确保整个链从一开始就在正确的线程上执行,并具有编译时检查。这类似于 Swift 在编译时提供的类型安全,与其他动态类型语言相较而言。@MainActor 属性赋予了我们从源头上修复线程问题,而不是在别的地方打补丁的能力。

Actor 属性基本概念

@MainActor 这样的 Actor 属性不仅可以应用于函数和属性,还可以应用于类、结构体、枚举和协议。

当应用于类时,类中的所有属性和方法默认继承 @MainActor 属性。你可以通过用 nonisolated 关键字标记特定函数不继承属性。

swift 复制代码
// 在类声明上:
@MainActor
class V {
    var a: Int = 1 // 类中的所有属性和函数
    func b() {} // 默认继承 `@MainActor`。

    // 你可以显式标记 `nonisolated` 以放弃 actor 继承。
    nonisolated func c() {}
}

// 在结构体和枚举声明上:
// 所有属性和函数默认也继承 `@MainActor`。
@MainActor
struct S {}
@MainActor
enum E {}

继承也适用于协议及其实现:

swift 复制代码
// 在协议属性和函数上:
protocol P {
    @MainActor
    var a: Int { get }
}
class C: P {
    var a: Int = 0 // C.a 继承 `@MainActor`
}

func b() {
    let c = C()
    c.a // ERROR: Main actor-isolated property 'a' can not be referenced from a nonisolated context
}

// 在协议本身上:
@MainActor
protocol P2 {}

class C2: P2 {} // C2 继承 `@MainActor`

func b2() {
    let c = C2() // ERROR: Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context
}

你可以充分利用 actor 属性来保证不仅是线程安全,以及我们可能称之为"线程正确性"------确保代码在正确的线程上运行。

关于 UIView 的情况

你可能已经注意到,虽然 UIView 被标记为 @MainActor,但从后台线程(在非隔离上下文中)调用 UIView 及其方法不会产生任何错误或警告。这是为什么呢?

swift 复制代码
import UIKit

@MainActor
class MyView {}

func a() {
    UIView() // 没有错误或警告!
    MyView() // ERROR: Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context
}

秘密在于 @preconcurrency。即使你在 UIView.swiftinterface 文件中没有明确看到 @preconcurrency,它也会被隐式标记,因为 UIView 是从 Objective-C 导入的。Objective-C 的声明在被导入时,编译器会自动给它们标记上 preconcurrency 属性。

如果代码不在并发环境中,@preconcurrency 属性会抑制编译器本应发出的警告:

swift 复制代码
import UIKit

@preconcurrency
@MainActor
class MyView {}

func a() {
    UIView() // 没有错误或警告!
    MyView() // 也没有错误或警告!
}

然而,如果你尝试在不在 MainActor 上的并发环境中调用与 UIView 相关的 API,编译器将发出警告(即使使用最小并发检查):

swift 复制代码
actor A {
    func a() {
        UIView() // WARN: Call to main actor-isolated initializer 'init()' in a synchronous actor-isolated context; this is an error in the Swift 6 language mode
        MyView() // 同上警告
    }
    nonisolated func b() {
        UIView() // WARN: Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context; this is an error in the Swift 6 language mode
        MyView() // 同上警告
    }
}

在这种情况下,你应该使用 Task { @MainActor in } 或调度到主线程,以确保编译器的线程正确性。

重要的是要注意,在某些情况下,编译器可能不会提供错误或警告,尤其是在非并发环境中。因此,你应该意识到这些情况,如果你不在完整的并发环境中,不要仅仅依赖编译器错误。

Actors 内部的并发

虽然 actors 串行执行代码,但你仍然可以通过使用前面提到的 nonisolated 关键字在 actor内部并发运行代码。这意味着如果一个函数不隔离到其封闭的 actor,它就可以并发运行。

例如,如果你收到需要在传递给视图之前进行处理的数据,你可以将 nonisolated 函数与 async let(或 TaskGroup,或任何其他异步任务)结合使用,以在 actor 的串行执行上下文之外执行繁重的处理:

swift 复制代码
import UIKit

@MainActor
class ViewModel {
    private weak var view: UIView?
    // 此函数从封闭类继承 @MainActor
    func didReceiveData(_ data: Int) {
        // Task.init 从环境继承 @MainActor
        // 但是,如果我们使用 Task.detached,将会出错
        Task {
            print(Thread.isMainThread) // true
            async let processedData = processData(data)
            print(Thread.isMainThread) // true
            // 由编译器保证在主线程上
            view?.isHidden = await processedData > 0
        }
    }

    nonisolated private func processData(_ data: Int) -> Int {
        // 在这里模拟你的繁重处理逻辑
        Thread.sleep(forTimeInterval: 1)
        print(Thread.isMainThread) // false,意味着繁重的逻辑在主线程之外运行
        return data
    }
}

然而,编写异步代码会引入重入问题。如果 didReceiveData 被快速连续调用多次,你可能会有多个处理任务同时运行。为了解决这个问题,你可以存储最后一个运行的任务,在开始新任务之前取消它,并在更新视图之前检查取消:

swift 复制代码
import UIKit

@MainActor
class ViewModel {
    private weak var view: UIView?
    private var calculatingTask: Task<Void, Error>?
    // 此函数从封闭类继承 @MainActor
    func didReceiveData(_ data: Int) {
        // Task.init 从环境继承 @MainActor
        // 但是,如果我们使用 Task.detached,将会出错
        calculatingTask?.cancel()
        calculatingTask = Task {
            print(Thread.isMainThread) // true
            async let processedData = processData(data)
            let processedResult = await processedData
            print(Thread.isMainThread) // true
            try Task.checkCancellation()
            view?.isHidden = processedResult > 0
        }
    }

    nonisolated private func processData(_ data: Int) -> Int {
        // 在这里模拟你的繁重处理逻辑
        Thread.sleep(forTimeInterval: 1)
        print(Thread.isMainThread) // false
        return data
    }
}

结语

虽然 actors 尚未达到最终形态,但 Swift 语言及其并发模型正在不断发展。最近的 Swift 6.2 版本 引入了几个新特性,使得使用 actors 和并发比以往任何时候都更容易,例如"控制默认 actor 隔离推断"、"全局 actor 隔离一致性"以及"默认在调用者的 actor 上运行非隔离异步函数"的能力。这些改进与官方 Swift 文档中概述的 易用并发愿景 一致。随着 Swift 的不断成熟,我们可以期待更多增强功能,使并发编程更安全、直观和易用。

与此同时,我们现在就可以开始将 actor 和其他 Swift 并发特性引入到我们的项目中,以提升开发体验。

本文使用 Xcode 16.0 和 Swift 6.0 编写。

相关推荐
游戏开发爱好者81 小时前
iOS App 电池消耗管理与优化 提升用户体验的完整指南
android·ios·小程序·https·uni-app·iphone·webview
神策技术社区7 小时前
iOS 全埋点点击事件采集白皮书
大数据·ios·app
wuyoula8 小时前
iOS V2签名网站系统源码/IPA在线签名/全开源版本/亲测
ios
2501_915918419 小时前
iOS 性能监控工具全解析 选择合适的调试方案提升 App 性能
android·ios·小程序·https·uni-app·iphone·webview
fishycx9 小时前
iOS 构建配置与 AdHoc 打包说明
ios
90后的晨仔10 小时前
ios 集成阿里云的AI智能体报错CocoaPods could not find compatible versions for pod "AUIAICal
ios
Keya10 小时前
lipo 命令行指南
ios·xcode·swift
zhangmeng10 小时前
SwiftUI中如何实现子视图向父视图传递数据?
ios·swiftui·swift