Today, I realized that self wasn't implicitly captured when it's within an "unowned" block as below.
func f1() {
runBlock { [unowned self] in
// self is safe here since runBlock won't run this block if not.
Task {
await slow() // self may be released while awaiting.
self.val = 0 // self is implicitly unowned; can trap!
}
}
}
Therefore I needed to fix this crash bug as below.
func f2() {
runBlock { [unowned self] in
Task { [self] in
await slow()
self.val = 0 // self is explicitly captured; no trap.
}
}
}
But I found it a bit surprising since the code below works fine.
To me, this seems like expected behavior; if an outer closure captures self unowned, it would be surprising to make it strong because another closure inside it also captured self (and in fact, we had that exact bug with weak self until recently, and IIRC we fixed it). But also, if runBlock immediately executes the block without storing it indefinitely, as the name suggests, then you have no reason to use [unowned self] or [weak self] here, because there's no chance for a reference cycle to form. You should only use one of those modifiers if there's an actual reference cycle to break, and there isn't a better way to avoid it without weak references.
Hm, I see. Perhaps my expectation is wrong. I assumed that Task would capture self strongly unless it's explicitly captured weakly. This should probably be better mentioned in the language spec, no?
BTW, a better example I ran into today was like this.
Yeah, that shouldn't blow up unless the subscriptions lifetime isn't tied to self, which it normally is, if that's where you stored the cancellation token.
Edit: Thinking about it more, it seems possible in that example that the sink is executed while self is still alive but is deinitialized before the Task is able to run, leaving the unowned reference in there.
Closures automatically capture the references they contain. At the point where you’re forming the closure passed to Task.init, self refers to an unowned reference, not a strong reference, and so it’s the unowned reference that gets captured. Put differently, there’s no “closures capture references strongly by default” rule—rather, the rule is that references are strong by default and so closures are usually capturing strong references.
As an aside, in the f2 fix you call out above:
What you’re doing in the Task closure is forming a new (strong) reference to self before the capture, semantically equivalent to something like:
/*strong*/ let self = self // will crash if self not available here!
Task {
await slow()
self.val = 0
}
It’s invalid for a reference to self to escape deinit, closure capture or no. It’s not that self is unowned (which would actually be safe to access post-deinit since safe unowned is the default) but that the reference to self is entirely invalid after the deinit finishes. We should definitely improve the diagnostics in this area.
In top-of-tree Swift, the runtime will now dynamically trap if an object is still being kept alive after deinit completes. Maybe we could tighten up the static constraints here as well, but that might be difficult, since there are existing APIs that do legitimately temporarily escape self during deinit while still letting go of it before deinit completes.