在Swift中使用async/await时的内存管理
在异步代码的上下文中,管理应用程序的内存往往特别棘手,因为随着时间的推移,通常需要捕获和保留各种对象和值,以便执行和处理我们的异步调用。
虽然Swift相对较新的async/await
语法确实使许多异步操作更容易编写,但在管理此类异步代码所涉及的各种任务和对象的内存时,它仍然需要我们非常小心。
隐性捕获
async/await
(以及我们从同步上下文调用此类代码时需要用于包装此类代码Task
类型)的一个有趣方面是,当我们的异步代码执行时,对象和值通常如何被隐式捕获。
例如,假设我们正在开发一个DocumentViewController
,它下载并显示从给定URL下载的Document
。为了在视图控制器即将显示给用户时懒洋洋地执行我们的下载,我们在视图控制器的viewWillAppear
方法中启动该操作,然后我们要么渲染下载的文档,要么显示遇到的任何错误——像这样:
class DocumentViewController: UIViewController {
private let documentURL: URL
private let urlSession: URLSession
...
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
Task {
do {
let (data, _) = try await urlSession.data(from: documentURL)
let decoder = JSONDecoder()
let document = try decoder.decode(Document.self, from: data)
renderDocument(document)
} catch {
showErrorView(for: error)
}
}
}
private func renderDocument(_ document: Document) {
...
}
private func showErrorView(for error: Error) {
...
}
}
现在,如果我们快速查看上面的代码,似乎没有任何对象捕获。毕竟,异步捕获传统上只发生在转义闭包中,这反过来又要求我们在访问此类闭包中的本地属性或方法时始终明确引用self
(当self
引用类实例时)。
因此,我们可能会期望,如果我们开始显示我们的DocumentViewController
,但在下载完成之前离开它,一旦没有外部代码(例如其parentUINavigationController)保持对它的强烈引用,它将被成功重新分配。但事实并非如此。
这是因为上述隐式捕获发生在我们创建Task
或使用await
等待异步调用结果时。Task
中使用的任何对象将自动保留,直到该任务完成(或失败),包括我们引用其任何成员时,就像我们上面所做的那样。
在许多情况下,这种行为实际上可能不是问题,并且可能不会导致任何实际的内存泄漏,因为所有捕获的对象在捕获任务完成后最终都会被释放。然而,假设我们预计DocumentViewController
下载的文档可能相当大,如果用户在不同屏幕之间快速导航,我们不希望多个视图控制器(及其下载操作)保留在内存中。
解决这类问题的经典方法是执行weak self
捕获,该捕获通常在捕获闭包本身中伴随着guard-let self
表达式,以便将弱引用转换为强引用,然后可以在闭包的代码中使用:
class DocumentViewController: UIViewController {
...
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
Task { [weak self] in
guard let self = self else { return }
do {
let (data, _) = try await self.urlSession.data(
from: self.documentURL
)
let decoder = JSONDecoder()
let document = try decoder.decode(Document.self, from: data)
self.renderDocument(document)
} catch {
self.showErrorView(for: error)
}
}
}
...
}
不幸的是,在这种情况下,这行不通,因为当我们的异步URLSession
调用暂停时,我们的本地self
引用仍将被保留,直到我们所有闭包的代码完成运行(就像函数中的局部变量被保留到该范围退出为止)。
因此,如果我们真的想弱地捕捉自我,那么我们必须在整个封闭过程中始终使用这种弱的self
参考。为了更简单地使用我们的urlSession
和documentURL
属性,我们可以单独捕获这些属性,因为这样做不会阻止我们的视图控制器本身被释放:
class DocumentViewController: UIViewController {
...
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
Task { [weak self, urlSession, documentURL] in
do {
let (data, _) = try await urlSession.data(from: documentURL)
let decoder = JSONDecoder()
let document = try decoder.decode(Document.self, from: data)
self?.renderDocument(document)
} catch {
self?.showErrorView(for: error)
}
}
}
...
}
好消息是,随着上述内容的到位,如果在下载完成之前最终被解雇,我们的视图控制器现在将成功分配。
然而,这并不意味着其任务将自动取消。在这种情况下,这可能不是问题,但如果我们的网络调用导致某种副作用(如数据库更新),那么即使在我们的视图控制器被释放后,该代码仍将运行,这可能会导致错误或意外行为。
取消任务
一旦我们的DocumentViewController
内存不足,确保任何正在进行的下载任务确实会被取消的一种方法是存储对该任务的引用,然后在我们的视图控制器被解除分配时调用其cancel
方法:
class DocumentViewController: UIViewController {
private let documentURL: URL
private let urlSession: URLSession
private var loadingTask: Task<Void, Never>?
...
deinit {
loadingTask?.cancel()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
loadingTask = Task { [weak self, urlSession, documentURL] in
...
}
}
...
}
现在一切都按预期工作,一旦被关闭,我们所有视图控制器的内存和异步状态都会自动清理——但我们的代码在这个过程中也变得相当复杂。必须为每个执行异步任务的视图控制器编写所有内存管理代码将相当繁琐,这甚至可能让我们怀疑async/await
是否真的比组合、委托或闭包等技术给我们带来任何真正的好处。
谢天谢地,还有另一种方法可以实现上述模式,它不涉及那么多的代码和复杂性。由于该惯例是长期运行的async
方法在被取消时抛出错误,一旦我们的视图控制器即将被关闭,我们可以简单地取消loadingTask
——这将使我们的任务抛出错误,退出并释放其所有捕获的对象(包括self
)。这样,我们不再需要弱地捕获self
,或做任何其他类型的手动内存管理工作——给我们以下实现:
class DocumentViewController: UIViewController {
private let documentURL: URL
private let urlSession: URLSession
private var loadingTask: Task<Void, Never>?
...
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
loadingTask = Task {
do {
let (data, _) = try await urlSession.data(from: documentURL)
let decoder = JSONDecoder()
let document = try decoder.decode(Document.self, from: data)
renderDocument(document)
} catch {
showErrorView(for: error)
}
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
loadingTask?.cancel()
}
...
}
请注意,当我们的任务被取消时,我们的showErrorView
方法现在仍将被调用(因为将抛出错误,并且此时self
仍保留在内存中)。然而,就性能而言,额外的方法调用应该完全可以忽略不计。
长期观察
一旦我们开始使用async/await
来设置某种异步序列或流的长期运行观察,上述内存管理技术应该变得更加重要。例如,在这里,我们让UserListViewController
观察UserList
类,以便在更改User
模型数组后重新加载其表视图数据:
class UserList: ObservableObject {
@Published private(set) var users: [User]
...
}
class UserListViewController: UIViewController {
private let list: UserList
private lazy var tableView = UITableView()
...
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
Task {
for await users in list.$users.values {
updateTableView(withUsers: users)
}
}
}
private func updateTableView(withUsers users: [User]) {
...
}
}
请注意,上述实现目前不包括我们之前在DocumentViewController
中实现的任何任务取消逻辑,在这种情况下,这实际上会导致内存泄漏。原因是(与我们之前的Document
加载任务不同),我们的UserList
观察任务将无限期地运行,因为它正在迭代基于Publisher
的异步序列,该序列无法抛出错误或以任何其他方式完成。
好消息是,我们可以使用与之前完全相同的技术轻松修复上述内存泄漏,以防止我们的DocumentViewController
保留在内存中——也就是说,一旦我们的视图控制器即将消失,就可以取消我们的观察任务:
class UserListViewController: UIViewController {
private let list: UserList
private lazy var tableView = UITableView()
private var observationTask: Task<Void, Never>?
...
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
observationTask = Task {
for await users in list.$users.values {
updateTableView(withUsers: users)
}
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
observationTask?.cancel()
}
...
}
请注意,在这种情况下,在deinit
中执行上述取消是行不通的,因为我们正在处理实际的内存泄漏——这意味着除非我们打破观察任务的无休止循环,否则永远不会调用deinit
。
结论
起初,Task
和async/await
等技术似乎使异步、与内存相关的问题成为过去,但不幸的是,在执行各种async
标记调用时,我们仍然必须小心如何捕获和保留对象。虽然实际的内存泄漏和保留周期可能不像使用组合或闭包之类的东西时那么容易遇到,但我们仍然必须确保我们的对象和任务的管理方式使我们的代码健壮且易于维护。