I want to schedule a Task right off the bat from an actor's initializer, and save a reference to the Task for cancellation later. The init is non-async, so I'm having trouble figuring out how best to do this.
I can save the Task if I don't call an actor isolated method inside. I can call an actor isolated method if I don't save the task. But I can't do both unless I wrap it in an intermediary method.
Is this… correct? Why can I mutate some variables, and even sometimes mutate a Task variable?
actor Penguin {
private var announcementTask: Task<(), Never>?
private var num: Int
init(num: Int = 5) {
// ✅ I save it with an Int.
self.num = num
// ✅ I save it with a non-actor isolated method.
self.announcementTask = Task {
try! await Task.sleep(for: .seconds(5))
}
// ⚠️ Cannot access property here in non-isolated initializer;
// this is an error in Swift 6
self.announcementTask = Task {
await printSomething()
}
// ✅ I didn't try to save it!
Task {
await printSomething()
}
// ✅ I wrap the call.
Task {
await setUpInitialPrint()
}
}
private func setUpInitialPrint() {
self.announcementTask = Task {
printSomething()
}
}
private func printSomething() {
num += 1
print("I love spaghetti \(num)x!")
}
}
Swift allows initialization to happen off the actor's executor in nonisolated inits, but it doesn't allow mutation to happen there. I believe an analysis of that init would show something like this:
init(num: Int) {
// Actor is not initialized. None of its properties exist.
// Because the actor isn't initialized, you can't use its executor.
// Properties can be filled now.
self.num = num
// The actor is partially initialized: `num` exists, but `announcementTask` doesn't.
self.announcementTask = Task { ... }
// The actor is now fully initialized. Any assignment to its properties is now considered mutation.
// Unlike initialization, mutation must happen on the actor's executor.
// Since this init is synchronous, there's no chance to hop to the actor's executor.
self.announcementTask = Task { ... } // mutation on the wrong executor!
}
EDIT: I didn't realize this on first read, but one of those Tasks is capturing self and the other isn't. You can't capture self until after initialization is completed, so any assignment that references self in the task closure is necessarily a mutation and not initialization.
Thank you for the thorough answer! In that case, wouldn't commenting out/removing the first announcementTask assignment result in the warning going away? It unfortunately doesn't.
I believe part of the confusion is that optionals are implicitly initialized to nil:
init(num: Int) {
self.num = num
// This task uses self.
// It's illegal to capture a partially-initialized value like this, so self must be initialized now.
// For swift's builtin Optional, and no other type, it infers that the property is initialized to nil.
self.announcementTask = Task { await self.printSomething() }
}
Hmm, so there's no way to say "the nil is indeed the initial value, please let me manipulate it now" in the initializer in the same way calling super.init() in a class would? And the remedy is "wrapping the call" to effectively do that?
I don't believe you're allowed to manipulate properties of an actor in this context. I imagine -- though I haven't tested it myself -- that even self.num += 2 will cause the same warning/error.
Ah ha, that makes sense then. I suppose it might be more intuitive for the warning to be on the contents of the assigned Task, rather than the Task itself, because some assignments are okay, it was just the "capturing self inside the Task" that was slipping me up and I didn't realize?
(Note: SE-0327 is currently accepted but not (fully) implemented, but I believe many rules listed in the proposal reflect the current state how the implementation already works)
Initializers with nonisolated self
… Accesses to the stored properties of self is required to bootstrap an instance of an actor. Such accesses are considered to be a weaker form of isolation that relies on having exclusive access to the reference self. If self escapes the initializer, such uniqueness can no-longer be guaranteed without time-consuming analysis. Thus, the isolation of self decays (or changes) to nonisolated during any use of self that is not a direct stored-property access. That change happens once on a given control-flow path and persists through the end of the initializer. Here are some example uses of self within an initializer that cause it to decay to a nonisolated reference:
Passing self as an argument in any procedure call. This includes:
Invoking a method of self.
Accessing a computed property of self, including ones using a property wrapper.
Triggering an observed property (i.e., one with a didSet and/or willSet).
Capturing self in a closure (or autoclosure).
Storing self to memory.
So as soon as you do anything with self other than "a direct stored-property access", self becomes nonisolated, and you may no longer access the actor's state (read or write) for the remainder of the initializer.
These constructs are both OK because the await signals in both cases that the runtime will hop to the actor before executing printSomething() and setUpInitialPrint(). These are normal calls into the actor and thus the bodies of these methods have access to the actor's state.