I'm working with Swift concurrency and I ran into something I don’t fully understand.
Inside an actor, I create a Task like this:
Task { [weak self] in
for await message in webSocketService.stream {
switch message {
case .didReceive(let messageResponse):
NotificationCenter.default.post(
name: .didReceiveMessage,
object: messageResponse
)
case .didConnected:
await self?.syncConversation()
}
}
}
I want to capture self weakly because I don’t want the Task to retain the actor, which could create a retain cycle / memory leak.
As long as webSocketService.stream continues publishing values, the Task will keep a reference to my actor and my actor won’t be released.
However, inside the Task, if I use weak self, I end up having to call methods using await self?.....
My questions:
1. Why does using weak self inside a Task created within an actor force me to use await?
My understanding is that the moment I reference self inside the Task, even weakly, the compiler treats it as an isolated actor reference, which requires await.
But is that the real explanation?
2. Is there a proper way to run this Task without having it strongly capture the actor?
I want the Task to stop automatically if the actor is deallocated, and not keep the actor alive unintentionally.
A function cannot be isolated to an actor it only holds a weak reference to, because reliably executing on the actor requires a strong reference to be held. This function is therefore non-isolated, which is why it has to await in order to call something on the actor.
When do you close the stream that this task is iterating over?
If you're not closing it, the task is going to leak regardless of whether the actor does.
If you have a clear teardown point where you can close this stream, the task will naturally end and drop its reference to the actor at that point anyway, so the actor won't leak even if you capture it strongly.
If you're relying on doing this in the actor deinit, I have found that that is generally a poor idea and it's better to be explicit about your lifetime management.
Why does this code work when the type is annotated with a global actor, but the exact same pattern does not work when the type is an actor ?
Because a global-actor-isolated object being deallocated doesn't affect the existence of the global actor itself.
Swift's isolation model does not generally allow for isolation to dynamically change during the execution of a function. When the actor goes away, the closure can't run on the actor anymore. The closure therefore has to be non-isolated.
If I don’t use weak self , the Task keeps the actor or view model alive forever, because:
Even if you use weak self, the Task keeps itself around forever. There's no way around the fact that you need some way to tear down this task.
My point is that, regardless of how you capture self, you need the task to exit when you’re done with the actor, and the weak reference doesn’t actually help with that. If you solve that problem, you will not need the weak reference because the task’s reference will be dropped and eliminate any cycle you would otherwise be concerned about.
Tasks inside actors inherit isolation, it's useful and in most cases desired behavior. Proper way would be to keep track of Task and kill it on view model deinitialisation, something like:
actor ViewModel { // or @MainActor class ViewModel {
var notificationListener: Task<Void, Never>?
var isWorking: Bool { self.notificationListener != nil }
func check() {
// check if working when needed or just cancel and create
self.notificationListener?.cancel()
self.notificationListener = nil
self.notificationListener = Task {
for await _ in NotificationCenter.default
.publisher(for: .syncMessageServiceDidUpdate)
.buffer(size: .max, prefetch: .byRequest, whenFull: .dropOldest)
.values
{
refresh()
}
}
}
func refresh() {
// logic
}
deinit {
self.notificationListener?.cancel()
self.notificationListener = nil
}
}
Overall with streams (and Tasks) it's better to track them and manage lifecycle, regardless of retain cycle.
I am not sur but for me in your code deinit will never be called
because the notificiation will never send finish so your task will keep run on
and as your Task have a self reference with refresh()
deinit will be never called :)
It’s what I am trying to fix
Thx!
Yeah, that's the tricky part, usually you scope all the logic.
e.g. in SwiftUI views have it's own lifecycle and it's better to use structured .task modifier which will cancel child tasks. For background tasks it's better to stick to app's life. Or for backend you usually open streams inside connection's scope while it's open.
And so on.
That is a SwiftUI bug with how TabView's tasks lifecycle work. The approach is the right one for other screens outside of TabView. One work around is to use a task around the whole TabView and then use the current Tab to select which work to do combined with another set of task when the tab is changed.
thank you
May be it' will be a stupid question but there is other case where task can be call ?
I know it’s not call when pushing and pop or present and dismiss
But there is other case where task can be call multiple time ?
The task created by the task modifier is supposed to have the same lifecycle as the view. It should be started then cancelled when presenting and dismissing or pushing and poping. I think only tabs are really inconvenient to handle.