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() {}
}
现在,对 uploadingFiles
和 uploadedFiles
的访问保证在特定的串行线程上(由 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
。这将导致编译器在 isValid
和 updateData
上标记错误:
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 编写。