Isolation Assumptions

OK, so I want to preface this by saying that I really have no idea what I'm doing here. This idea has been bouncing around in my head for a while, and I think it could be useful. But I do not understand enough about the compiler, concurrency, or even the evolution process to do a good job here. Think of this like a pitch pitch!

I am willing to put in the work here. But, I really wanted to do a first-pass first to see if this is a) possible and b) a good idea.

So here's the problem, which I actually originally posted as a question:

class NonSendableType {
	private var internalState = 0

	func doSomeStuff() {
		// must be on some actor here right???
		Task {
			let value = await otherType.getAValue()

			// aren't I back on the actor that called me???
			self.internalState += value // non-Sendable self captured here
		}
	}
}

The important bits: this type is not Sendable and has internal mutable state that must be protected. However, it also wants to do some async stuff. And it absolutely feels like this should be possible. I have ran into a number of situations where it would be really useful to have this kind of isolation guarantee.

A solution which I regularly use is to slap on a global actor annotation to this type. This works, but its very limiting. This type doesn't need to be statically tied to a global actor. It just needs to be used in some isolated context - the one that created it.

I would like to express this to the compiler!

Imaginary solution 1:

isolated class NonSendableType {
}

This would tell the compiler to disallow whatever situations prevent it from making the assumption that there may not be an isolation context. I'm just going to assume such a thing is possible. And that leads me to an even more wild idea:

I kinda think this should be the default behavior for all non-Sendable types!

My intuition says that non-Sendable types that are also ok to use without an isolation context of any kind are rare. And because such behavior would really help both flexibility and just general intuition how about requiring explicit opt-in:

Imaginary solution 2:

nonisolated class NonSendableType {
}

This would tell the compiler that it cannot assume there is isolation. This is how (I think) things work today.

I fear that this may actually be impossible, a bad idea, or both. But I thought it was at least worth a pitch pitch.

5 Likes

Is it possible that you're looking for something like generic global actor isolation?

Global actor-constrained generic parameters

A generic parameter that is constrained to GlobalActor could potentially be used as a global actor. For example:

@T
class X<T: GlobalActor> {
  func f() { ... } // constrained to the global actor T
}

@MainActor func g(x: X<MainActor>, y: X<OtherGlobalActor>) async {
  x.f() // okay, on the main actor
  await y.f() // okay, but requires asynchronous call because y.f() is on OtherGlobalActor
}

Since values of this type would be actor-isolated, they could be Sendable.

It is possible that the "global" part of this is too limiting. We may want to express that T can be any isolation domain -- whether that domain is a global actor, or a non-global domain stemming from an actor value or other context.

2 Likes

Let's start here. This is not guaranteed to be on an actor. An instance of your type can be created in an arbitrary task and be isolated to that task. Right now it cannot be transferred to another isolation domain but region based isolation lays the foundation for transferring values across domains safely.

Now to your actual problem, you want to have a type that is not Sendable but the type should have async methods. You can achieve this by passing an isolated parameter to the async methods which will allow dynamic isolation enforcement.

An example of this:

class NonSendableType {
	private var internalState = 0

	func doSomeStuff(isolatedTo actor: isolated any Actor) async {
           try await Task.sleep(for: .seconds(1))
           print(self.internalState)
	}
}
3 Likes

Here's what I think is needed to support something like this:

  1. All async methods on NonSendableType need to have a value of the actor that's isolating the NonSendableType in order to hop back to the actor after any async calls in the function implementation. This means that either the actor value needs to be stored in NonSendableType, or it needs to be passed as an argument to every async function call on NonSendableType.
  2. The actor isolation checker needs to understand what the actor value actually is, so that it knows that async calls on NonSendableType do not cross an isolation boundary.
  3. If the isolated value ever crosses an isolation boundary (which is fine as long as all access outside the original actor is async; isolation typically implies Sendable), then the isolation probably needs to decay into nonisolated / dynamically isolated because of 2. above

The way to accomplish this today is with isolated parameters like @FranzBusch suggests, but I can understand that's pretty annoying if you want every single method to be isolated to the calling actor.

I don't think it's rare -- I've seen use cases where people basically want an isolated, non-Sendable class everywhere except for their unit tests, which are nonisolated. But this also is not a problem; calling a nonisolated async function from a nonisolated async context does not cross an isolation boundary, so you won't get Sendable warnings in this case anyway.

4 Likes

Could we model this by having a non-sendable class accept in its initializer and then hold a reference to an isolated any Actor rather than re-pass the parameter to every method?

4 Likes

Is this safe? What happens if doSomeStuff (or some other method) mutates internalState, and you call those functions with different actor values?

My understanding is that example function is another way of writing this:

extension Actor {

  // implicitly isolated to the enclosing actor.
  func doSomeStuff(_ val: NonSendableType) {
    try await Task.sleep(for: .seconds(1))
    print(val.internalState)
  }
}

Is that not the case?

And the problem with doing that is you're passing around shared mutable state (the instance of the NonSendableType class), which has no isolation, to various actors.

In order to do this you'd need to obtain a reference to the same NonSendableType in different isolation domains, which should be impossible since the type isn't sendable!

3 Likes

Well, it's not impossible...

actor ActorOne {}
actor ActorTwo {}

func test() async {
    let x = NonSendable()
    let actorOne = ActorOne()
    let actorTwo = ActorTwo()
    await x.doSomeStuff(isolatedTo: actorOne)
    await x.doSomeStuff(isolatedTo: actorTwo)
}

But the compiler does warn that this is not safe:

passing argument of non-sendable type 'NonSendable' into actor-isolated context may introduce data races

The thing that it catches is not that you're passing it in to two different actor isolation contexts, it's that you're passing it in to any isolation context at all. So yeah I don't think isolated parameters are actually a solution.

I built with -swift-version 6 and it was a warning. I don't know if it'll be upgraded to an error.

Yeah, that should be read as ‘impossible without ignoring sendability diagnostics’.

Right, because you’ve formed it in a non-isolated async function, so passing to any explicitly isolated domain (without region-based isolation) is a violation of sendability.

1 Like

All of the Sendable diagnostics still need to be audited in the compiler implementation; many of them are unconditionally warnings. They will be errors in Swift 6.

1 Like

Thanks so much for all of this feedback, especially how graciously it was given.

Thank you for pointing this out. I agree that this improves the situation, and I look forward to it. But I also agree it is still limiting. In practice, I would put money on virtually all users still just reaching for @MainActor here.

Dynamic isolation is really powerful, and I will admit that I don't have enough practice with it. However, I disagree that it is the desirable or even feasible solution. Making the method async fundamentally changes the semantics. My method is synchronous and that is essential to its function. Maybe a solution is to adopt the tricks that withCheckedContinuation and friends use, as they face a similar problem. Perhaps I could pass in both an actor as well as a synchronous block? I'm not inclined to go down this route, but please correct me if I am wrong!

Am I understanding correctly that this means it would be impossible to do this with a synchronous method?

This is really what my proposal is about. I want to tell the actor isolation checker "if you cannot figure out what isolation boundary a value of this type is being used on without additional information, throw an error".

I will totally concede that you all know better than me about how reasonable it would be to make this opt-in vs opt-out. So, opt-in seems like the only way.

Based on all this I feel like I need to check in again. Does this proposal (now limited to opt-in only) seem reasonable and implementable? Does anyone want this?

1 Like

Well your method is kicking off an unstructured task so is it really synchronous? At best you should avoid the usage of any unstructured task if possible and if you have to get an async value from somewhere then your method should be async as well.

1 Like

Maybe? I'm not sure how we'd preserve what that actor value was, because the initializer invocation for the non-Sendable type is almost certainly not in the same place as the code that needs to check the isolation of a function call on the non-Sendable value. If we take the approach of suppressing the Sendable conformance (despite the guaranteed isolation) or representing the "isolation decay" in the type of the value when passed across isolation boundaries (e.g. isolated(any) NonSendableType to represent "this NonSendableType value is isolated to something, but I don't know what, and all access has to be asynchronous"), then maybe that's fine, because you'd get the guarantee that NonSendableType never leaves the isolation domain it was formed in.

Ah, no! It's not impossible with a synchronous method, but the hop to the "isolated by" actor happens at the call site instead of in the method implementation (where it cannot happen, because the method is synchronous) if the call is made from outside the actor. However, in your code example at the beginning of this thread, you actually do need the "isolated by" actor to be passed as an argument to doSomeStuff in order for the task you kick off to inherit the same isolation, which it does by capturing the actor value for the same reason I mentioned about hopping back to it after any async calls in the Task. So, I think what I said about async methods still applies to synchronous methods of the non-Sendable type.

I do think it's reasonable to want to isolate a non-Sendable value to an actor value. Isolating an otherwise non-Sendable type to a global actor is a natural way to make that type Sendable when you only ever use it on the global actor, and I think the same use cases apply to actor values.

This is good advice in general, but sometimes it's really difficult to make a synchronous method async in the context of an existing code base using frameworks that predate concurrency. Unstructured tasks are there for a reason, because they're sometimes the right tool to reach for, especially to help facilitate incremental concurrency adoption. I also don't want the discussion to become fixated on the use of an unstructured task in a contrived code example; I think the idea of isolating a type to an actor value is useful even if unstructured tasks didn't exist.

3 Likes

I very much agree with this in general. And I want to stress that I appreciate how one option here is to change the semantics. It's possible I'm not understanding, but it seems to me like if I were to use an isolated parameter + async method I'd be making big trade-offs.

Before: callable in a synchronous context, where the also caller does not need to wait for internal work to complete or even be aware work is happening

After: only callable from an async context, caller forced to await internal work it does not depend on

It took me a moment but I think I get it now, and it makes sense.

Fantastic!

1 Like

Ok hang on. What am I missing here?

class NonSendableType {
	func doSomeStuff(isolatedTo actor: isolated any Actor) {
		// doesn't actor have to be whatever created this value?
	}
}

With region-based isolation you could create the value in another isolation context and then pass it off to the actor across domains to call the method.

1 Like

Huh.

I know zero about how region-based isolation works. But I am surprised to learn that this it would result in the object crossing domains in a way that would be unsafe.

Edit: As in actor here should always be the owner of the value, but not necessarily the creator?

As I understand it, the isolated modifier for method parameters is meant to allow the method to run in a target actor - whatever actor is explicitly specified as that argument. It has no direct or implicit connection to whatever actor (if any) instantiated the object (self).

Again, I definitely could be wrong!

But it seems like in order for that to happen, the method must become async if not already isolated to that actor, to get the target actor's isolation domain. And in order to do that, the object would need to be Sendable, which is is not.

Edit: the compiler seems to disagree with my assumption here. I find that surprising enough that I filed a bug about it. isolated parameters for non-Sendable seems to be missing warnings ¡ Issue #71020 ¡ apple/swift ¡ GitHub

A method needs to be marked async when its execution can be suspended. Being isolated to a specific actor doesn’t mean that execution of the method can suspend. The caller could suspend as it hops to the actor to which the method is isolated. I’m not sure what state the pitch is in now, but as originally pitched the default value for an isolated argument was a special token that meant “the caller’s current actor”, which means no hop was necessary.

If it’s possible to pass a non-default value for such an argument, then I think the compiler should require an await at the call site because it cannot prove the actor argument is equal to the caller’s isolation.