When a Sendable ~Copyable value (e.g.: a Atomic) is captured by a Task closure, how does the compiler know the lifetime of this value and where to insert the deinit call?
For example:
let task = {
let v = Atomic<Int>(42)
let task = Task.detached {
try await Task.sleep(for: .seconds(2))
print("value: \(v.load(ordering: .relaxed))")
}
v.store(43, ordering: .relaxed)
return task
}()
_ = try await task.value
This code works fine, but how does it correctly keep v alive when the task is running even after leaving the current scope (the outer closure)?
In Swift, a closure is a bit like a struct containing two stored properties:
struct Closure {
/// A reference to the closure’s compiled code
let body: OpaquePointer
/// Any values captured by the closure
let captures: Captures
/// Not actually a class in real life,
/// but it behaves very similarly to one.
class Captures {
...
}
}
In your case, the Captures “class” would have a let v: Atomic<Int> property, and the captured v is moved into that property when the closure is created. Then, the Task initializer takes the closure and stores it somewhere inside the task instance.
Because the captures property contains a class instance, Swift uses automatic reference counting to keep the class alive as long as there is a reference to it. So the Task will keep the captures around as long as there is a reference to the task. In turn, the Swift runtime keeps a reference to the task until it finishes running.
So in your example, where there are no other references to the closure you passed to Task.detached, the runtime will remove its reference to the task after it finishes, which will cause the task to be deinitialized, which will in turn cause the closure’s capture class to be deinitialized, which will finally deinitialize the Atomic<Int>.
@j-f1's explanation makes sense to me. But SE-0390 says:
For a local var or let binding, or consuming function parameter, that is not itself consumed, deinit runs at the end of the binding's lexical scope.
It appears that SE-0390 missed the situation where a noncopyable value is captured by an escaping closure (although it has a section discussing about it).
Yes, and this is also true for copyable values captured by an escaping closure.
There is an optimization, however, that if you capture just one value and that value is a class, the class is used directly as the captures property and there is no separate allocation.
You could argue that the binding is consumed by creating the closure (which is true at at least one level of abstraction, but not exactly in the surface language).
Just curious, is this true for an immutable copyable value captured by an escaping closure? IIUC the value is borrowed by the closure in such a case. Is my understanding correct that it's captured as a reference at concept level but as a copy in implementation?
Hmm...but this seems to conflict with the common understanding that the value is borrowed by the closure.
A captured binding (as opposed to an explicitly written capture, inside square brackets) formally captures the storage by reference, which you can observe by referring to the same non-Copyable binding in multiple closures. So while the contents are only borrowed, they have to be moved into storage that can (potentially) outlive the current scope, and indeed live as long as any capturing closure is alive, and that's implemented with a consume of the original stack storage. (Of course, "the stack" is already an implementation detail in Swift, and in many cases the optimizer may skip that step and just construct the binding as a capture to begin with. And that's to say nothing of non-copyable types that are nonetheless passed by value instead of by reference. Which is why I only said "one level of abstraction".)