Allow closure to be statically isolated to a weakly captured actor

Right now, you can write an API that allows an actor to call it and pass in a closure that is statically isolated to the calling actor:

final class Foo {
    func onWrite(_ callback: @escaping () async -> ()) {
        
    }
}

actor Bar {
    var count = 0
    
    func start(with foo: Foo) {
        foo.onWrite {
            self.count += 1 // Synchronous access
        }
    }
}

This breaks down, however, if we need to capture the calling actor weakly:

actor Bar {
    var count = 0
    
    func start(with foo: Foo) {
        foo.onWrite { [weak self] in
            self?.count += 1 // Error: Actor-isolated property 'count' can not be mutated from a nonisolated context
        }
    }
}

In this case we get the error:

Actor-isolated property 'count' can not be mutated from a nonisolated context

when we try to synchronously access the actor's protected state.

My loose understanding is that we currently can't statically isolate the closure to the weakly captured actor because the actor might cease to exist, and this poses a problem for the way that static isolation analysis is currently implemented. But at a conceptual level it seems like the second code example is perfectly coherent and should be permissible. Is everyone in agreement about this and it is purely a matter of someone taking the time to implement it?

Or are there some dragons I'm not aware of?

1 Like

I'm not sure the expected behavior is self-evident, at least in the general case. Suppose the compiler was updated to treat this closure as statically isolated to the actor instance. What should actually happen at runtime if you call it but the actor no longer exists? Silently doing nothing doesn't necessarily seem desirable. You essentially have the same question/problem after every potential suspension point in a closure like this too, since you'd potentially need to switch back to the actor's executor from wherever an async callee ran. What should the runtime do if the actor "goes away" during such a call?

3 Likes

This is what seemed to me like the obvious behavior. Why is this not desirable?

This is where my ignorance of how these things are implemented under the hood limits my ability to contribute confidently. Conceptually I’m thinking of isolation as basically just the right to access certain properties and methods synchronously, so if the actor disappears then there are no more properties and methods to access, so it’s meaningless to debate whether we’ve lost the right or not. We can say that our isolation hasn’t changed, because we still have the right to attempt to access that actor synchronously. But it might not exist which we are forced to deal with by virtue of it being optional.
Is there some concept that I’m missing? Or is this just too different from the reality of how isolation is currently implemented that it becomes a real issue?

Running on an actor means executing on the actor’s SerialExecutor, so when the actor is deallocated, we must hop to another executor, since in most cases the actor’s underlying SerialExecutor no longer exists. Therefore, we would likely need to hop back to the GCE. If we were to implement a feature like this, we could hardly consider such contexts statically isolated. It would also make everything much more confusing, in my opinion.

2 Likes

Maybe it could be an option that when an actor is weakly retained its serial executor is strongly retained, such that we can keep executing on it until the closure is discarded, even if the actor is discarded sooner

May I ask you about this @hborla or @John_McCall? Could the closure simply retain and always execute on the weakly captured actor's executor? Does this resolve the supposedly tricky question regarding the formal isolation of such a closure?

The language is being conservative. It's probably possible to allow some useful things here, but we'd have to do a bunch of design and implementation work around figuring out what's safe, and it would introduce a lot of corner-case complexity. A function being actor-isolated normally tells us (at least) three things:

  • we can access isolated properties of the actor,
  • we can access non-Sendable values that are known to be in the actor's region, and
  • the function cannot run concurrently with itself.

The actor having been deallocated trivializes the first point but not the second or third; in fact, because the function would be essentially @concurrent, anything relying on the second or third point would become unsafe to do.

1 Like

My question is whether it is possible to keep running on the deallocated actor’s executor, because if so it sounds to me like that would solve the second and third points as well.

The third one at least seems clearly solved by this just going off the name “SerialExecutor”, without me needing to be well-read on the topic of executors. As far as the second one, I’m making the assumption that each region has exactly one executor associated with it, but maybe I’m ignorant of something there?

The actor’s executor is not reliably an independently managed object from the actor, I’m afraid.

We could, theoretically, change the language to do so. But again, it would add unnecessary complexity and confusion. Currently, we pass the actor around, not its executor.

Also, if we do such change, I would be strongly against this being part of static isolation.

You say "unnecessary", but this is a real limitation. Currently one has to choose between retain cycles or extra suspension points, neither of which is a trivial problem.

Why? It's possible I'm not totally sure what you mean by this. Could you clarify what this would mean practically?

Also, you say it would cause confusion, but I don't see why. What would the confusion be? What other behavior could possibly be expected?

While we would continue executing on the same executor as before, it would now simply be a nonisolated context with a specific executor (sounds familiar), not an actor isolated context. From a static perspective, this is more akin to dynamic isolation. And from a reasoning standpoint, it is confusing to keep the value of unownedExecutor alive while the enclosing instance has been deallocated. This raises the question: what is an actor? Is an actor defined by its serial executor? It’s like cutting the strings of a string puppet while continuing to move the strings.

1 Like

I've run into this exact problem before. In this very specific case (which I suspect was a simplification), a solution is to use a function instead of a property. This is because property assignment is special-cased to not be awaitable.

final class Foo {
  func onWrite(_ callback: @escaping () async -> ()) {
  }
}

actor Bar {
  var count = 0

  func increment() {
    count += 1
  }

  func start(with foo: Foo) {
    foo.onWrite { [weak self] in
      await self?.increment() // <- this can address the immediate problem
    }
  }
}

However, this is of course not the same as the closure being statically-isolated to the actor, and that can really matter.

If you can truly tolerate the actor being spontaneously deallocated during the execution of the function, I think encapsulating the work in terms of actor functions that execute with a self?., like above, is a reasonable option.

If, on the other hand, you need the closure's body to be isolated to the actor, in my opinion you have a situation that is fundamentally-incompatible with a weak capture.

foo.onWrite { [weak self] in
// extremely long function that does a lot of stuff ...

// ... yet the very last executable line must still be guaranteed isolated to the "weak" capture?
}


I've been reading and thinking about this thread with interest. A closure's isolation being conditional on its captures has been bothering me, both practically and conceptually, for a while now. I'm glad to be reminded of the weak-capture case, because I think it's a really interesting one. But, I'm fairly sure that this specific behavior makes sense as-is.

1 Like

Can you quantify the non-triviality? How much excess memory or CPU time does retaining the actor until the task completes cost in your application?

The non-triviality is that, if you're in a situation where you cannot strongly capture because a memory leak is not acceptable (e.g., hot path, long-lived server instance, etc.), then the semantics you're looking to achieve may not be possible unless you rearchitect your system. This could leave you choosing between, for example, deterministic+fast tests vs rearchitecting your system, neither of which is a trivial loss.

In my case, the closure is something like this:

actor SyncEngine {
    init(database: Database) {
        let callbackHandle = database.onWrite { [weak self] in
            Task.immediate {
                await self?.markChangesForUpload()
            }
        }
    }
}

If the closure were statically isolated to the SyncEngine then markChangesForUpload() will be entered synchronously, without needing to first hop over to the SyncEngine, and this gives me a guarantee that certain changes to the SyncEngine's state will occur before the onWrite callback returns, whereas if the closure is not isolated to the SyncEngine then markChangesForUpload() will not have started running by the time the callback finishes running. In my case, having such guarantees would be helpful in writing deterministic+fast sync tests, by which I mean that they do not resort to using Task.sleep(for: .seconds(...)), which makes local test runs unnecessarily slow and CI test runs fail spuriously.

I was hopeful you could work around this with assumeIsolated() but alas, assumeIsolated() is not available in async contexts:

actor Foo {
  var prop: String?
  func doThing() async {
    Task.immediate { [weak self] in
      guard let self else { return }
      self.assumeIsolated { (_ self: Foo) in
      // warning: - warning: instance method 'assumeIsolated' is unavailable from asynchronous contexts; express the closure as an explicit function declared on the specified 'actor' instead; this is an error in the Swift 6 language mode
        self.prop = "what"
        //  error: actor-isolated property 'prop' can not be mutated from a nonisolated context
      }
    }
  }
}