迁移至 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 编写。

相关推荐
AI2中文网10 小时前
别再说AppInventor2只能开发安卓了!苹果iOS现已支持!
android·ios·跨平台·苹果·appstore·app inventor 2·appinventor
咕噜签名分发冰淇淋11 小时前
苹果ipa应用安装包ios系统闪退问题
macos·ios·cocoa
wsxlgg11 小时前
IOS打包上传 出现 You do not have required contracts to perform an operation 的解决办法
ios
HarderCoder12 小时前
深入理解 SwiftUI 中的 @ViewBuilder:从语法糖到实战
swift
HarderCoder14 小时前
Swift 中的可调用类型:彻底搞懂 `callAsFunction`、`@dynamicCallable` 与 `@dynamicMemberLookup`
swift
CuiXg14 小时前
iOS XML 处理利器:CNXMLParser 与 CNXMLDocument 深度解析
ios·swift
HarderCoder15 小时前
Swift 中 Enum 与 Struct:如何为状态建模选择最合适的工具
swift
大熊猫侯佩17 小时前
韦爵爷闯荡 Swift 6 江湖:单例秘籍新解(上)
swift·编程语言·apple
大熊猫侯佩17 小时前
韦爵爷闯荡 Swift 6 江湖:单例秘籍新解(中)
swift·敏捷开发·apple