Hello,
I have been using Xcode 16 for a few weeks now and noticed a behavior regarding actor
deinitialization that surprises me. I have read several proposals regarding this topic without finding an explanation so I would like to ask if it's an intended behavior, or maybe something I have missed.
In our app, we use a few actor
s that are stored as constant static properties and thus live as long as the app is not killed. Compiling with Xcode 16, we have noticed that capturing an actor
as unowned
in somes closure such as a Task
initialization is an error as the actor
might be deinitialized before the Task
completes. Reading more attentively the documentation, I have realized it was unneeded to capture the actor as unowned
in most cases even though the Task
instance is owned by the actor
(self
).
That said, we noticed that capturing the actor
as unowned
lead to a crash in other cases, although the actor
captured as unowned
is still stored as a constant static property.
To exemplify this, here are two versions of similar blocks of code for a command-line tool. In the first block, the reference type is a final class
.
// MARK: - Static
enum Storage {
static let shared = SharedClass()
}
// MARK: - SharedClass
final class SharedClass {
// MARK: Properties
var task: Task<Void, Error>?
var value = 1
// MARK: Init
deinit {
print ("DEINIT")
}
// MARK: Test
func test() {
task = Task { [unowned self] in
print ("Task execution")
print (value)
}
}
}
// MARK: - Run
Storage.shared.test()
try await Task.sleep(for: .seconds(1))
print("Bye-Bye")
There are some warnings regarding concurrency with the complete concurrency setting on. But everything behaves as I imagine it should. Here is what this code prints.
Task execution
1
Bye-Bye
Now, let's replace the final class
by an actor
.
// MARK: - Static
enum Storage {
static let shared = SharedActor()
}
// MARK: - SharedActor
actor SharedActor {
// MARK: Properties
var task: Task<Void, Error>?
var value = 1
// MARK: Init
deinit {
print ("DEINIT")
}
// MARK: Test
func test() {
task = Task { [unowned self] in
print ("Task execution")
print (value)
}
}
}
// MARK: - Run
await Storage.shared.test()
try await Task.sleep(for: .seconds(1))
print("Bye-Bye")
Here's what is printed in the console.
Task execution
1
DEINIT
Bye-Bye
I don't understand why the deinit
block is called. From my understanding, all reference types should behave similarly here, and storing them as static constants should prevent deinitialization as long as the app (or command line-tool) live.
Those are examples with a Task
, and as I pointed above, it's not necessary to capture self
as unowned
as stated in the documentation. But we are noticing similar behaviors with other closure initializations such as AVAudioSourceNode
where the owning actor
is deinitialized although it lives with the application. Even though in many cases I realized there was no strong reference cycle as I wrongly assumed, I would like to know why the deinitalization behavior I mention is happening.
If anyone could shed some light to this matter, I would appreciate!