Why do I need await when using weak self inside a Task in an actor, and how can I avoid the Task retaining self?

Hi everyone,

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.

thank you :slight_smile:

2 Likes

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.
7 Likes

Thanks for your answer! I still don’t fully understand one thing.

Why does this code work when the type is annotated with a global actor:

@MainActor
class WebSocketService_test {

    init() {
        Task { [weak self] in
            self?.ok()
        }
    }

    func ok() {}
}

…but the exact same pattern does not work when the type is an actor:

actor WebSocketService_test {

    init() {
        Task { [weak self] in
            self?.ok()
        }
    }

    func ok() {}
}

With a regular actor, the compiler forces me to await any access to self, even if it’s weak.
With @MainActor, the code is fine.


Why this matters for my use case

In my real code, I have something like:

Task {
    for await _ in NotificationCenter.default
        .publisher(for: .syncMessageServiceDidUpdate)
        .buffer(size: .max, prefetch: .byRequest, whenFull: .dropOldest)
        .values
    {
        refresh()
    }
}

If I don’t use weak self, the Task keeps the actor or view model alive forever, because:

as long as the notification publisher doesn’t finish, the Task still retains the actor.

This leads to memory leaks or objects that never deinitialize.

So I’m trying to understand: What is the correct pattern to consume a Notification as an AsyncStream without causing memory leaks?

I’m trying to avoid having long-lived Tasks strongly capture my actors, especially when observing notifications or streams.

1 Like

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.

3 Likes

thank you for you answer
Even if you use weak self, the Task keeps itself around forever: agree with you
but are we agree it’s not true for “self”? :)

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.

2 Likes

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.

1 Like

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

1 Like

Ah, true, I'm cancelling it explicitly usually :sweat_smile: (just been sick with corona, so brain is melted), having separate function like:

  func cancel() {
    self.notificationListener?.cancel()
    self.notificationListener = nil
  }

Though reply is still valid — all the streams better be managed.

1 Like

Yes I agree with cancel method but when you have a ViewModel I don’t know when to call it :’(
get well soon :slight_smile:

1 Like

Thx! :slight_smile:
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.

1 Like

thank you for the tips =D
Good Idea to use Task from Swiftui

I have may be another question :slight_smile:

struct ContentView: View {
    var body: some View {
        TabView {
            
            HomeView()
                .tabItem {
                    Image(systemName: "house.fill")
                    Text("Accueil")
                }
            
            SettingsView()
                .tabItem {
                    Image(systemName: "gearshape.fill")
                    Text("Réglages")
                }
        }
    }
}

struct HomeView: View {
    var body: some View {
        VStack {
            Text("Écran d'accueil")
                .font(.largeTitle)
        }.task {
          print("HomeView")
          
          do {
            try await Task.sleep(nanoseconds: 7_000_000)
          } catch {
            print("error")
          }
        }
    }
}

struct SettingsView: View {
    var body: some View {
        VStack {
            Text("Écran des réglages")
                .font(.largeTitle)
        }
    }
}

in these cases task we call each time I switch from other tabItem
and it’s seem that that my task won’t be cancel when a new use one is set :’(

1 Like

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.

1 Like

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 :slight_smile: ?

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.

thank you ::slight_smile:

@NicolasL I get your intentions. Combining Task lifecycle especially with never ending AsyncStream with „observer/subscriber” (e.g. ViewModel) is no more as handy as it was with Combine/RxSwift etc. You might be interested in GitHub - nonameplum/AsyncLifetime: Automatic lifetime management for Swift async sequences. Prevents retain cycles and ensures proper cleanup. .