0%

Swift 并发中 Task 隐式捕获 self 的问题

隐式捕获

Swift Concurrency 的 Task 有两个常用的初始化方法

1
2
3
public init(priority: TaskPriority? = nil, operation: @escaping @Sendable () async -> Success)

public static func detached(priority: TaskPriority? = nil, operation: @escaping @Sendable () async -> Success) -> Task<Success, Failure>

可以看到,两个方法的 operation 参数都是用 @escaping 修饰的,意味着 operation 是一个逃逸闭包,逃逸闭包会在声明后延长生命周期再执行,因此如果在一个类当中使用 Task,Task 内部又调用了类的方法,就会造成对 self 的隐式捕获。

这种 self 的隐式捕获是一个很常见的问题,例如下面的代码

1
2
3
4
5
6
7
8
9
class TestView: UIView {
func test() {
DispatchQueue.main.async {
handle()
}
}

func handle() {}
}

这里我们用到了 GCD 调用 handle 方法,如果直接这样写就会触发 Xcode 的编译错误

1
Call to method 'handle' in closure requires explicit use of 'self' to make capture semantics explicit

这是因为 Xcode 希望开发者通过显式调用 self.handle 的方式,提醒开发者这里发生了隐式捕获。需要注意的是,隐式捕获并不一定会造成内存泄漏,例如在这里,隐式捕获仅仅会因为捕获 self,导致 self 的生命周期延长到了下一个 runloop,一旦 GCD 执行结束,self 就可以正常释放,所以这里可以简单改为

1
2
3
4
5
6
7
8
9
class TestView: UIView {
func test() {
DispatchQueue.main.async {
self.handle()
}
}

func handle() {}
}

但是在 Task 这里,情况有些不同,对于下面的代码,Xcode 是不会有任何提示的。

1
2
3
4
5
6
7
8
9
class TestView: UIView {
func test() {
Task {
handle()
}
}

func handle() {}
}

这是因为前面提到的 Task 的两个初始化方法,在源码内部实际上会用 _implicitSelfCapture 关键词修饰 operation,_implicitSelfCapture 关键词可以让被修饰的 block 捕获 self 而不会触发显式捕获的错误,因为 Swift Concurrency 的设计者认为,显式捕获 self 的意图是为了发现隐藏的循环引用,而 Task 的闭包会被 立即执行,所以看起来并不需要传递这种意图。

The intent behind requiring self. when capturing self in an escaping closure is to warn the developer about potential reference cycles. The closure passed to Task is executed immediately, and the only reference to self is what occurs in the body. Therefore, the explicit self. isn’t communicating useful information and should not be required.

隐式捕获的问题

看起来确实是这样,Task 通常情况下,包括在前面提到的场景下,都是会立即执行并结束的,它最多可能只会延长 self 的生命周期,并不会发生循环引用。

但在一些特殊情况下,这种预想就失效了,比如如果遇到了无限的异步序列 (AsyncSequence)。

1
2
3
4
5
6
7
8
9
10
11
12
13
class TestView: UIView {
func test() {
Task {
let notifications = NotificationCenter.default.notifications(named: UIApplication.didEnterBackgroundNotification)
for await notification in notifications {
print(notification)
handle()
}
}
}

func handle() {}
}

这里 Task 内部的 operation 监听了应用进入后台的通知,并通过 for await 对 notifications 异步序列进行了监听,也没有增加监听停止的条件,这样的代码就会造成 self 无法被释放。进一步分析会发现,这里真正捕获 self 的并不是 最外层的 Task,之所以这样说是因为如果我们将代码改为下面的形式,内存泄漏依然存在。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class TestView: UIView {
func test() {
Task { [weak self] in
await self?.observeAppEnterBackground()
}
}

func handle() {}

func observeAppEnterBackground() async {
let notifications = NotificationCenter.default.notifications(named: UIApplication.didEnterBackgroundNotification)
for await notification in notifications {
print(notification)
handle()
}
}
}

可以看出,在初始化 Task 时,我们通过捕获列表已经将 self 弱引用捕获了,但是执行代码后, TestView 仍然无法释放。

实际上真正引发泄漏的是下面的代码,我们通过 for await 遍历无限异步序列 notifications,相当于代码陷入了死循环,而每一次 await 操作又会隐式捕获 self,造成了 self 无法被释放,可以简单认为,每一次 await 操作都会隐式生成一个新的 Task,而新的 Task 又会捕获 self,这才是造成循环引用的原因。

1
2
3
4
for await notification in notifications {
print(notification)
handle()
}

如何解决

弱捕获 self

既然明确了在监听无限序列时因为捕获 self 导致了循环引用,解决思路也就有了,最传统的方式就是类似 GCD 和闭包引用一样,我们通过捕获列表将 self 引用变为弱引用,从而避免 Task 持有 self。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class TestView: UIView {
func test() {
Task { [weak self] in
let notifications = NotificationCenter.default.notifications(named: UIApplication.didEnterBackgroundNotification)
for await notification in notifications {
guard let self = self else { return }
print(notification)
self.handle()
}
}
}

func handle() {}
}

需要注意的是,由于真正发生捕获行为的地方是在 for await 的地方,因此如果像下面这样弱引用 self,是不能解决内存泄漏问题。

1
2
3
4
5
6
7
8
Task { [weak self] in
guard let self = self else { return }
let notifications = NotificationCenter.default.notifications(named: UIApplication.didEnterBackgroundNotification)
for await notification in notifications {
print(notification)
self.handle()
}
}

虽然通过 weakself 捕获, TestView 此时确实可以正常释放了,但是 Task 本身其实是脱离 TestView 的生命周期存在的,所以代码对 notifications 的监听并没有停止,验证这一点也很简单,我们将打印语句移动一下,就会发现即使 TestView 释放了,打印语句仍然会被执行。

1
2
3
4
5
6
7
8
Task { [weak self] in
let notifications = NotificationCenter.default.notifications(named: UIApplication.didEnterBackgroundNotification)
for await notification in notifications {
print(notification)
guard let self = self else { return }
self.handle()
}
}

因此正确的思路应该是取消 Task 执行。

取消 Task

实际上 Swift Concurrency 并不推荐我们直接初始化一个 Task,因为这样初始化的 Task 是一个独立的根 Task,没有所属的父 Task,也就意味着 Task 的生命周期是独立存在的。Swift Concurrency 建议所有的 Task 都应该形成一个任务树,从而实现结构化编程。

结构化编程:并发操作保证控制流路径的单一入口和单一出口

所以最合理的方式是这样修复。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class TestView: UIView {
var task: Task<Void, Never>?

deinit {
task?.cancel()
print("deinit")
}

func test() {
task = Task { [weak self] in
let notifications = NotificationCenter.default.notifications(named: UIApplication.didEnterBackgroundNotification)
for await notification in notifications {
guard let self = self else { return }
print(notification)
self.handle()
}
}
}

func handle() {}
}

这样,当 TestView 被释放时,对 UIApplication.didEnterBackgroundNotification 的监听也能正常取消。

相关链接