Swift actors: How do they work, and what kinds of problems do they solve?

从Swift的第一个版本开始,我们就能够将各种类型定义为类、结构体或枚举。但是现在,随着Swift 5.5及其内置并发系统的发布,一个新的类型声明关键字已经添加了- actor。

因此,在本文中,让我们探讨actors的概念,以及通过在代码库中定义自定义actor类型可以解决哪些类型的问题。


1. Preventing data races

Swift的新actors类型的一个核心优势是,它们可以帮助我们防止所谓的“数据竞争”——也就是说,当两个单独的线程试图同时访问或修改相同的数据时,可能会出现内存损坏问题。

为了举例说明,让我们看一下以下UserStorage类型,它本质上为我们提供了一种通过引用而不是通过值传递User模型字典的方法:

class UserStorage {
    private var users = [User.ID: User]()

    func store(_ user: User) {
        users[user.id] = user
    }

    func user(withID id: User.ID) -> User? {
        users[id]
    }
}

就其本身而言,上述实现并没有什么问题。然而,如果我们要在多线程环境中使用该UserStorage类,那么我们可能会很快遇到各种数据竞争,因为我们的实现目前在调用它的任何线程或DispatchQueue上执行其内部突变。

换句话说,我们的UserStorage类目前不是线程安全的。

解决这一问题的一种方法是在特定的DispatchQueue上手动分派所有读写操作,这将确保这些操作始终以串行顺序进行,而不管我们的UserStorage方法将在哪个线程或队列上使用:

class UserStorage {
    private var users = [User.ID: User]()
    private let queue = DispatchQueue(label: "UserStorage.sync")

    func store(_ user: User) {
        queue.sync {
            self.users[user.id] = user
        }
    }

    func user(withID id: User.ID) -> User? {
        queue.sync {
            self.users[id]
        }
    }
}

上面的实现工作正常,现在成功地保护我们的代码不受数据竞争的影响,但它确实存在一个相当严重的缺陷。因为我们使用syncapi来分派字典访问代码,所以我们的两个方法将导致当前执行被阻止,直到那些分派调用完成。

如果我们最终执行大量并发读写,那么这可能会成为问题,因为在特定的UserStorage调用完成之前,每个调用方都会被完全阻塞,这可能导致性能低下和内存使用过度。这类问题通常被称为“数据冲突”。

解决此问题的一种方法是将我们的两个UserStorage方法完全异步,这涉及到使用异步分派方法(而不是同步),在检索用户的情况下,我们还必须使用闭包之类的东西在加载其请求的模型时通知调用方:

class UserStorage {
    private var users = [User.ID: User]()
    private let queue = DispatchQueue(label: "UserStorage.sync")

    func store(_ user: User) {
        queue.async {
            self.users[user.id] = user
        }
    }

    func loadUser(withID id: User.ID,
                  handler: @escaping (User?) -> Void) {
        queue.async {
            handler(self.users[id])
        }
    }
}

同样,上述方法确实有效,并且是在Swift 5.5之前实现线程安全数据访问代码的首选方法之一。然而,虽然闭包是一个非常好的工具,但必须将所有用户处理代码封装在一个闭包中肯定会使代码更加复杂——特别是因为我们必须使loadUser方法的逃逸闭包参数。


2. A case for an actor

这正是Swift的新actor类型非常有用的情况。actor的工作方式非常类似于类(也就是说,它们是通过引用传递的),但有两个关键的例外:

  • actor自动序列化对其属性和方法的所有访问,这确保在任何给定时间只有一个调用方可以直接与参与者交互。这反过来又为我们提供了完全的数据竞争保护,因为所有突变都将一个接一个地连续执行。

  • actor不支持子类化,因为他们实际上不是类。

因此,实际上,要将UserStorage类转换为actor,我们需要做的就是返回到其原始实现,并用新的actor关键字替换其对class关键字的使用,如下所示:

actor UserStorage {
    private var users = [User.ID: User]()

    func store(_ user: User) {
        users[user.id] = user
    }

    func user(withID id: User.ID) -> User? {
        users[id]
    }
}

只要做了一点小小的改变,我们的UserStorage类型现在就完全是线程安全的,不需要我们实现任何定制的调度逻辑。这是因为actor强制其他代码使用await关键字调用其方法,这允许actor的序列化机制在actor当前正忙于处理另一个请求时挂起此类调用。

例如,这里我们将新的UserStorage actor用作UserLoader中的缓存机制,这要求我们在加载和存储用户值时使用await

class UserLoader {
    private let storage: UserStorage
    private let urlSession: URLSession
    private let decoder = JSONDecoder()

    init(storage: UserStorage, urlSession: URLSession = .shared) {
        self.storage = storage
        self.urlSession = urlSession
    }

    func loadUser(withID id: User.ID) async throws -> User {
        if let storedUser = await storage.user(withID: id) {
            return storedUser
        }

        let url = URL.forLoadingUser(withID: id)
        let (data, _) = try await urlSession.data(from: url)
        let user = try decoder.decode(User.self, from: data)

        await storage.store(user)

        return user
    }
}

请注意,除了在与参与者交互时必须使用await之外,actors还可以像其他Swift类型一样使用。它们可以作为参数传递,使用属性存储,也可以符合协议。


3. Race conditions are still possible

然而,虽然我们的用户加载和存储代码现在保证不受低级数据竞争的影响,但这并不意味着它一定不受竞争条件的影响。虽然数据争用本质上是内存损坏问题,但争用条件是在多个操作以不可预测的顺序结束时发生的逻辑问题。

事实上,如果我们最终使用新的UserLoader在多个位置同时加载同一个用户,我们很可能会遇到竞争情况,因为最终会执行多个重复的网络调用。这是因为我们的storedUser值只有在用户完全加载后才会存在。

最初,我们可能认为解决这个问题就像让我们的用户加载器也成为actor一样简单:

actor UserLoader {
    ...
}

但事实证明,在这种情况下这样做是不够的,因为我们的问题与数据如何变异无关,而是与底层操作的执行顺序有关。

因为即使每个参与者确实序列化了对它的所有调用,当一个actor内发生await时,该执行仍然像其他任何操作一样被暂停——这意味着该actor将被解锁,并准备接受来自其他代码的新请求。一般来说,这是一件好事——因为它让我们能够编写有效执行的非阻塞代码——在这种特殊情况下,这种行为将使我们的UserLoader继续执行重复的网络调用,就像它作为类实现时一样。

为了解决这个问题,我们必须跟踪参与者当前执行给定网络呼叫的时间。这可以通过将代码封装在异步任务实例中来实现,然后我们可以将对该实例的引用存储在字典中,如下所示:

actor UserLoader {
    private let storage: UserStorage
    private let urlSession: URLSession
    private let decoder = JSONDecoder()
    private var activeTasks = [User.ID: Task<User, Error>]()

    ...

    func loadUser(withID id: User.ID) async throws -> User {
        if let existingTask = activeTasks[id] {
            return try await existingTask.value
        }

        let task = Task<User, Error> {
            if let storedUser = await storage.user(withID: id) {
                activeTasks[id] = nil
                return storedUser
            }
        
            let url = URL.forLoadingUser(withID: id)
            let (data, _) = try await urlSession.data(from: url)
            let user = try decoder.decode(User.self, from: data)

            await storage.store(user)
            activeTasks[id] = nil
            
            return user
        }

        activeTasks[id] = task

        return try await task.value
    }
}

通过上述更改,我们的UserLoader现在是完全线程安全的,并且可以从任意多的线程或队列中调用任意多次。我们的activeTasks逻辑将确保在可能的情况下正确地重用任务,并且我们的UserLoader actor的序列化机制将以可预测的串行顺序将所有突变发送到该字典。