Swift:构建forEach和map的异步和并发版本的教程

514 阅读8分钟

如果我们仔细想想,我们每天写的那么多代码基本上都是由一系列的数据转换组成的。我们以某种形式获取数据--无论是实际的模型数据、网络响应,还是像用户输入或其他事件--然后在其上运行我们的逻辑,最终得到某种形式的输出数据,然后保存、发送或显示给用户。

在这篇文章中,让我们来看看在使用forEachmap 等函数进行此类数据转换时,我们如何利用 Swift 内置的并发系统。

同步转换

让我们先来看看一个例子,在这个例子中,我们建立了一个MovieListViewController ,使用户能够将某些电影标记为收藏夹。当这种情况发生时,我们的视图控制器使用标准库的forEach API来遍历用户选择的索引路径,然后调用一个FavoritesManager ,以便将这些索引路径所对应的电影标记为收藏夹。

class MovieListViewController: UIViewController {
    private var movies: [Movie]
    private let favoritesManager: FavoritesManager
    private lazy var tableView = UITableView()
    ...

    func markSelectedMoviesAsFavorites() {
        tableView.indexPathsForSelectedRows?.forEach { indexPath in
            let movie = movies[indexPath.row]
            favoritesManager.markMovieAsFavorite(movie)
        }
    }
}

除了使电影被标记为收藏夹,我们的FavoritesManager 也有一个方法,让我们加载所有用户先前添加到他们的收藏夹的Movie 模型--它使用内置的map 方法,通过从Database 检索,将电影ID的Set 转化为实际模型。

class FavoritesManager {
    private let database: Database
    private var favoriteIDs = Set<Movie.ID>()
    ...
    
    func loadFavorites() throws -> [Movie] {
        try favoriteIDs.map { id in
            try database.loadMovie(withID: id)
        }
    }

    func markMovieAsFavorite(_ movie: Movie) {
        ...
    }
}

到目前为止还不错。但现在让我们来看看,如果我们想开始使用Swift的新并发系统,以异步、非阻塞的方式执行上述操作,会发生什么。

走向异步

为了开始,我们假设我们想让我们的FavoritesManagerDatabase 类型异步执行它们的工作,例如像这样:

class FavoritesManager {
    ...
    
    func markMovieAsFavorite(_ movie: Movie) async {
        ...
    }

    func loadFavorites() async throws -> [Movie] {
        try await favoriteIDs.map { id in
            try await database.loadMovie(withID: id)
        }
    }
}

然而,事实证明,上述实现实际上并不能编译,因为标准库中的map 版本(还)不支持async 闭包。值得庆幸的是,由于Swift扩展的强大功能,我们可以自己添加支持。

在 Swift 中,我们通常对各种集合(包括像forEachmapflatMap API)进行的大多数操作都是通过Sequence 协议扩展实现的--而这又是所有内置集合(像ArraySetDictionary )所符合的协议。它也是用来驱动Swift的for 循环的协议。

因此,要建立一个异步版本的map ,我们所要做的就是自己扩展Sequence 协议,以便添加新的方法:

extension Sequence {
    func asyncMap<T>(
        _ transform: (Element) async throws -> T
    ) async rethrows -> [T] {
        var values = [T]()

        for element in self {
            try await values.append(transform(element))
        }

        return values
    }
}

注意上面的方法是如何用rethrows 关键字标记的。这告诉 Swift 编译器,只有当传递给它的闭包也抛出时,才会将我们的新方法视为抛出,这让我们在需要时可以灵活地抛出错误,而不要求我们总是用try 来调用我们的新方法,即使其闭包没有抛出。

有了以上这些,我们现在可以回去用asyncMap 替换我们的loadFavorites 方法对map 的使用,我们新的异步的FavoritesManager 实现现在已经成功编译并可以使用了:

class FavoritesManager {
    ...

    func loadFavorites() async throws -> [Movie] {
        try await favoriteIDs.asyncMap { id in
            try await database.loadMovie(withID: id)
        }
    }
}

MovieListViewController 接下来,让我们假设我们也想把forEach 中的迭代也变成异步的,因为这将让我们的UI保持完全的响应,而我们的FavoritesManager 正忙着把用户选择的电影标记为收藏夹。

要做到这一点,我们还需要一个异步版本的forEach ,它的实现方式与我们构建asyncMap 的方式非常相似:

extension Sequence {
    func asyncForEach(
        _ operation: (Element) async throws -> Void
    ) async rethrows {
        for element in self {
            try await operation(element)
        }
    }
}

然而,在我们开始在我们的视图控制器中使用我们新的asyncForEach 方法之前,有一件事我们需要考虑--因为我们将用户选择的电影标记为收藏夹的调用现在将以异步方式执行,这意味着我们的电影数组有可能在我们的迭代执行过程中发生变化。

因此,为了防止在这种情况下发生bug或崩溃,让我们在调用asyncForEach按值捕获我们的movies 数组,这样我们在整个迭代过程中就会一直在同一个数组上操作:

class MovieListViewController: UIViewController {
    ...

    func markSelectedMoviesAsFavorites() {
        Task {
            await tableView.indexPathsForSelectedRows?.asyncForEach { 
                [movies] indexPath in

                let movie = movies[indexPath.row]
                await favoritesManager.markMovieAsFavorite(movie)
            }
        }
    }
}

有了这些改变,我们现在已经成功地将两个潜在的繁重的数据转换操作变成了异步的、非阻塞的调用--这反过来将使我们的用户界面保持响应,无论这些调用需要多长时间来完成。

并发

不过,我们还可以做一件事来加快上述操作的速度,那就是让它们并行执行,而不是按顺序执行。因为即使我们的操作现在与其他代码相比是异步的,但就它们的执行方式而言,它们实际上仍然是完全顺序的--鉴于asyncForEachasyncMap 总是在继续下一个操作之前await 一个操作的结果。

因此,为了使我们能够以一种平行执行所有操作的方式来执行一个给定的forEach 调用,让我们编写一个新的、并发的版本,利用内置的TaskGroup API:

extension Sequence {
    func concurrentForEach(
        _ operation: @escaping (Element) async -> Void
    ) async {
        // A task group automatically waits for all of its
        // sub-tasks to complete, while also performing those
        // tasks in parallel:
        await withTaskGroup(of: Void.self) { group in
            for element in self {
                group.addTask {
                    await operation(element)
                }
            }
        }
    }
}

现在,我们肯定不会想要并发地执行我们所有forEach 调用(因为我们必须记住,将事情分散到多个并发任务和线程中也会带来自己的开销和延迟)。然而,在我们的MovieListViewController ,将电影标记为收藏夹的情况下,如果用户最终选择了大量的电影,我们也许能够实现速度的提升,现在我们就可以并发地迭代这些电影。

因此,让我们用调用新的concurrentForEach 方法来取代之前的asyncForEach -- 像这样:

class MovieListViewController: UIViewController {
    ...

    func markSelectedMoviesAsFavorites() {
        Task {
            await tableView.indexPathsForSelectedRows?.concurrentForEach {
                [movies, favoritesManager] indexPath in

                let movie = movies[indexPath.row]
                await favoritesManager.markMovieAsFavorite(movie)
            }
        }
    }
}

注意我们现在如何在我们的闭包中捕捉到对我们的FavoritesManager 的引用。这是因为我们现在处理的是一个转义闭包,它要求我们将所有与self 有关的捕获显式化。因此,我们不需要捕获self ,而是只捕获我们需要执行操作的实际对象。

有了上面的改变,我们最喜欢的标记逻辑现在将在需要时对多个电影进行并行操作,这可能会给我们带来显著的速度提升。当然(就像大多数与性能有关的事情一样),我们是否要使用asyncForEachconcurrentForEach ,我们必须根据每个特定情况下的实际性能指标来决定,因为对于某些事情是否可以从并发执行中受益,并没有普遍的规则。

在让一组特定的操作并发执行之前,另一件重要的事情是我们的底层数据是否支持并发访问。我们将在接下来的文章中仔细研究这个话题--包括我们如何使用Swift的行为体类型来强制执行对可变状态的串行访问。

在我们的FavoritesManager 中继续进行映射操作,现在让我们来看看我们如何能让迭代也能并发地进行。这就需要一个新的、并发的map ,我们称之为concurrentMap

然而,与concurrentForEach 不同的是,这个新方法不会使用TaskGroup ,因为该API在完成顺序方面没有给我们任何保证,这意味着我们最终可能会得到一个与输入顺序不同的输出数组。因此,我们将为每个元素创建一个异步Task ,然后我们将使用之前的asyncMap 方法,按顺序等待每个任务的结果。

extension Sequence {
    func concurrentMap<T>(
        _ transform: @escaping (Element) async throws -> T
    ) async throws -> [T] {
        let tasks = map { element in
            Task {
                try await transform(element)
            }
        }

        return try await tasks.asyncMap { task in
            try await task.value
        }
    }
}

上述的另一种方法是使用一个TaskGroup ,并结合一个字典来跟踪我们的输出顺序。然而,这段代码不仅会更复杂,而且与我们目前的实现相比,也不会给我们带来任何显著的性能提升。

使用我们新的concurrentMap 方法,我们现在能够让我们的FavoritesManager 以完全并发的方式加载所有的收藏夹--这将使该操作在用户将越来越多的电影添加到他们的收藏夹列表中时得到很好的扩展。

class FavoritesManager {
    ...

    func loadFavorites() async throws -> [Movie] {
        try await favoriteIDs.concurrentMap { [database] id in
            try await database.loadMovie(withID: id)
        }
    }
}

有了这最后一块,我们现在不仅给我们的代码进行了异步改造,而且还让它以并发的方式执行--在这个过程中,我们建立了四个完全可重用的Sequence API--asyncForEachconcurrentForEachasyncMapconcurrentMap

总结

我希望这篇文章能让你对某些Swift迭代如何利用Swift的新并发系统变成异步或并发操作有一些新的认识。同样,考虑什么时候(和什么时候)部署并发性总是很重要的,并尝试将这些决定建立在现实生活的性能指标之上。毕竟,完全同步的代码可能始终是最简单的实现、调试和维护。

谢谢你的阅读!