Understanding how Task handles non Sendable captures

I noticed something recently that gave me pause. I'm pretty sure this is just me not fully understanding, but I wanted to ask. Here's the scenario:

class MyClass {
	private var internalState = 0

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

			// don't we hop back to the same actor we started on?
			self.internalState += value
		}
	}
}

This produces a warning that self is captured but is not Sendable. Isolating this type to a global actor can address this. But! It feels like this lack of Sendability would result in this type still always being isolated to the same actor, whichever created the type in the first place.

In short, yes.

MyClass as a class has no isolation. So your spawned task will inherit whatever the isolation context is from further up the callstack, whenever doSomeStuff is called. Which might be an actor (global or otherwise), or nothing (in which case the task will be a new top-level task with its own bespoke isolation domain).

If you isolate MyClass, whether by tying it to a global actor or making it an actor itself, then your task is (by the same inheritance rule as before) tied to that isolation domain. So then it's safe and works fine.

Note that there's only ever one isolation domain in play, for the whole thing. If you tie your class to a global actor, then doSomeStuff can only ever be called on that global actor (compiler bugs and Swift 5 limitations notwithstanding). So your task runs on that same global actor.

If you make MyClass an actor, then doSomeStuff would have to be called asynchronously from any other isolation context - which is when the isolation domain hop will happen if necessary - and doSomeStuff executes in the actor's isolation domain, and thus so does the task it spawns.

2 Likes

Huh - I would have assumed that the "nothing" case would have to be from the main entry point of the program and MainActor by definition!

This sounds like the one case where the type might actually cross isolation domains. Is that correct? Or are there other possible ways too?

Not necessarily - e.g. the code might be called on some random pthread that was spawned outside of Swift's Structured Concurrency runtime.

As far as I understand it, there's no isolation domain automatically associated with such lower-level concurrency mechanisms.

1 Like

I don’t think this is true. As far as I know, Task's actor context inheritance is strictly lexical, i.e. it only depends on the annotations of the enclosing function (and enclosing type) at the call site, not from where that function has been called.

In other words, we know that the Task { … } in @mattie’s example does not inherit any actor isolation and thus runs on the global concurrent executor, because neither the containing function nor the enclosing class are annotated with a global actor).

Source: @Douglas_Gregor in SE-0304 (4th review): Structured Concurrency - #36 by Douglas_Gregor ("It's the lexical context.") (This was during the concurrency review process, but I don't believe the rules changed later.)

6 Likes