Swift actors:工作原理及作用介绍

1,229 阅读8分钟

从Swift的第一个版本开始,我们就可以将我们的各种类型定义为类、结构体或枚举。但现在,随着Swift 5.5及其内置的并发系统的推出,一个新的类型声明关键字被添加到组合中--actor

因此,在这篇文章中,让我们来探讨演员的概念,以及我们可以通过在代码库中定义自定义演员类型来解决什么样的问题。

防止数据竞赛

Swift新行为体类型的核心优势之一是,它们可以帮助我们防止所谓的 "数据竞赛"--也就是说,当两个不同的线程试图同时访问或变异相同的数据时,就会出现内存损坏问题。

为了说明这一点,让我们看一下下面的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]
        }
    }
}

上述实现是有效的,现在成功地保护了我们的代码免受数据竞赛的影响,但它确实有一个相当大的缺陷。因为我们正在使用sync API 来调度我们的字典访问代码,我们的两个方法将导致当前的执行被阻塞,直到这些调度调用完成。

如果我们最终执行了大量的并发读写,这就会成为问题,因为每个调用者都会被完全阻塞,直到那个特定的UserStorage 调用完成,这可能会导致性能不佳和内存占用过多。这种类型的问题通常被称为 "数据争夺"。

解决这个问题的一个方法是让我们的两个UserStorage 方法完全异步化,这涉及到使用async 派遣方法(而不是sync ),在检索用户的情况下,我们还必须使用类似于闭包的东西来通知调用者其请求的模型被加载。

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之前一直是实现线程安全的数据访问代码的首选方式之一。然而,虽然闭包是一个非常棒的工具,但要把我们所有的User 处理代码都包在一个闭包里,肯定会让代码变得更加复杂--尤其是我们不得不让我们的loadUser 方法的闭包参数 转义

一个演员的案例

这正是Swift新的actor类型可以发挥巨大作用的情况。行为体的工作方式很像类(也就是说,它们是通过引用传递的),但有两个关键的例外:

  • 行为体自动序列化对其属性和方法的所有访问,这确保了在任何时候只有一个调用者可以直接与行为体进行交互。这反过来又为我们提供了对数据竞赛的完全保护,因为所有的突变都将以串行方式进行,一个接一个。
  • 行为体不支持子类,因为,它们实际上不是类。

所以,实际上,我们所要做的就是把我们的UserStorage 类转换为一个行为体,回到它的原始实现,并简单地用新的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 类型现在是完全线程安全的,不需要我们实现任何类型的自定义调度逻辑。这是因为行为体使用await 关键字强迫其他代码调用它们的方法,这让行为体的序列化机制在行为体目前正忙于处理另一个请求的情况下暂停这种调用。

例如,在这里我们使用我们新的UserStorage 行为体作为UserLoader 中的一个缓存机制--这要求我们在加载和存储User 的值时都使用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 ,actor的使用就像任何其他Swift类型。它们可以作为参数传递,使用属性存储,也可以符合协议。

竞赛条件仍然是可能的

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

事实上,如果我们最终使用我们的新UserLoader ,在多个地方同时加载同一个用户,我们将非常有可能遇到一个竞赛条件,因为几个重复的网络调用最终将被执行。这是因为我们的storedUser 值只有在该用户被完全加载后才会存在。

最初,我们可能认为解决这个问题就像让我们的UserLoader 也成为一个角色一样简单。

actor UserLoader {
    ...
}

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

因为即使每个角色确实对它的所有调用进行了序列化,当一个角色内发生await ,该执行仍然像其他的一样被暂停--这意味着该角色将被解禁,并准备接受来自其他代码的新请求。虽然这在一般情况下是件好事--因为它可以让我们写出高效执行的非阻塞代码--但在这种特殊情况下,这种行为会使我们的UserLoader ,就像它作为一个类实现时一样,不断进行重复的网络调用。

为了解决这个问题,我们将不得不跟踪行为体当前执行特定网络调用的时间。这可以通过将代码封装在异步Task 实例中来实现,然后我们可以在一个字典中存储对它的引用--像这样:

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
    }
}

有趣的是,上述解决方案在概念上与我们在"使用Combine的共享操作符来避免重复工作 "中使用Combine解决类似的竞赛条件的方法几乎相同这是一个这样的领域,即使我们调用的API可能不同,但技术和基本原则仍然或多或少是通用的。

随着上述变化的到位,我们的UserLoader ,现在是完全线程安全的,可以从我们想要的线程或队列中任意调用。我们的activeTasks 逻辑将确保任务在可能的情况下被适当地重复使用,我们的UserLoader 行为体的序列化机制将以可预测的、序列的顺序向该字典派发所有的突变。

结论

所以,总的来说,当我们想实现一个支持以非常安全的方式并发访问其底层状态的类型时,行为体是一个非常好的工具。然而,同样重要的是要记住,将一个类型变成一个行为体将要求我们以异步的方式与之交互,这通常会使这种调用的执行变得更加复杂(和缓慢)--即使有像async/await这样的工具供我们使用。

当涉及到Swift的行为体模式的实现时,当然还有更多需要探索的地方(这肯定是我们将来要重新讨论的话题),以及使Swift代码线程安全的其他方法,我希望这篇文章能给你一些想法,让你在你工作的代码库中使用行为体。

谢谢你的阅读!