Skip to content

Instantly share code, notes, and snippets.

@nathanhosselton
Last active January 10, 2025 02:03
Show Gist options
  • Save nathanhosselton/98b7851b624eae927a82b924235f5dcd to your computer and use it in GitHub Desktop.
Save nathanhosselton/98b7851b624eae927a82b924235f5dcd to your computer and use it in GitHub Desktop.
@MainActor
protocol NonSendableThing: AnyObject {
func doSomething() async -> Int
}
@MainActor
final class SomeThing: NonSendableThing {
static let sharedExistential: any NonSendableThing = SomeThing()
init() {}
func doSomething() async -> Int {
return 0
}
}
@MainActor
func someOperation() async {
// Why does this error with "sharedExistential cannot cross actor
// boundary"? Shouldn't this be thread safe via its accessor? What does
// it matter that the type isn't Sendable if both its access and all
// of its operations are actor-bound?
async let _ = SomeThing.sharedExistential.doSomething()
// This also errors but instead with "Sending `shared` risks causing
// data races". This is actually a less surprising diagnostic than the
// former, but again, shouldn't this be thread safe since `shared` is
// still actor-bound?
let shared = SomeThing.sharedExistential
async let _ = shared.doSomething()
// This does not error at all, even when using and awaiting the async
// variable. Why not? Isn't this still a non-sendable type crossing an
// actor boundary?
var existentialThing: any NonSendableThing = SomeThing()
async let _ = existentialThing.doSomething()
// Using `SomeThing` directly or declaring `NonSendableThing` to be
// Sendable works as expected since the captured objects are known to
// be Sendable. So I've omitted those examples. This issue isn't a
// blocker in my actual project, I just want to understand what the
// thread safety issue is here, because this seems safe to me.
}
@mattmassicotte
Copy link

func case1() async {
	let _ = await SomeThing.sharedExistential.doSomething()
}

func case2() async {
	// we are try to move sharedExistential from MainActor to non-isolated, but being a
	// NonSendableThing, that cannot be done, and I think this explains case1 as well.
	let shared = await SomeThing.sharedExistential

	let _ = await shared.doSomething()
}

But I'm still struggling with that third case...

@jamieQ
Copy link

jamieQ commented Jan 8, 2025

re: the 3rd case:

    // This does not error at all, even when using and awaiting the async
    // variable. Why not? Isn't this still a non-sendable type crossing an
    // actor boundary?
    var existentialThing: any NonSendableThing = SomeThing()
    async let _ = existentialThing.doSomething()

i believe in this case the local variable existentialThing is in a 'disconnected region' (i.e. it's somewhat analogous to a sending parameter) when initially created, and so is allowed to cross the implicit isolation boundary into the concurrent async let context1. this is allowed because there are no potentially concurrent uses of existentialThing that could race with the use within the async let. if you were to add another local use after the async let (and before its value is await-ed), this would no longer be true and the compiler will diagnose the issue.

additionally, if that variable ever becomes 'merged' with an actor's region, you cannot 'detach' it again, and you'll get similar errors to the other cases. e.g. once you do something like this, you cannot again use it in the async-let binding:

    let existentialThing: any NonSendableThing = SomeThing()

    // local variable is in a disconnected region here
    async let value = existentialThing.doSomething()
    _ = await value
    // still in a disconnected region here

    // combining in a datastructure with main actor state 'merges' the
    // previously disconnected value into the main actor's region
    let merged = (existentialThing, SomeThing.sharedExistential)
    _ = merged

    // existentialThing is now (and forever) main actor-isolated
    async let _ = existentialThing.doSomething() // πŸ›‘
//                |- error: sending 'existentialThing' risks causing data races
//                `- note: sending main actor-isolated 'existentialThing' into async let risks causing data races between nonisolated and main actor-isolated uses

Footnotes

  1. this is outlined in the region based isolation proposal here ↩

@nathanhosselton
Copy link
Author

@jamieQ this is heavy stuff, but I think I follow. thanks for such a thorough response.

I think you've also indirectly answered my broader question of why any of these cases are errors in the first place, given that everything is actor-bound. And it sounds like the answer is: because even though all access/operations must be queued onto the actor, they could be queued in an indeterminate order, which could create a race condition? Therefore, it must be known to also be Sendable, else these actions are not allowed.

Am I on the right track here?

@jamieQ
Copy link

jamieQ commented Jan 9, 2025

looking at the first 2 cases, i think the issue there is that that values being passed across actor boundaries are existentials which are not Sendable. i think this is what Matt was pointing out above, but to reinforce that point, consider the following:

func useSendable(_ as: any Sendable) {}

@MainActor
func doStuff(
  _ existential: any NonSendableThing,
  _ concrete: SomeThing
) async {
  useSendable(existential) // πŸ›‘ the protocol existential is _not_ `Sendable`
  useSendable(concrete) // βœ… the concrete conformance is (b/c it is `@MainActor` and is a non-protocol type)

  async let _ = existential.doSomething() // πŸ›‘
  async let _ = concrete.doSomething() // βœ…
}

the fact that the NonSendableThing protocol has the @MainActor attribute does not implicitly confer a Sendable conformance to the protocol existential, so it can't be passed across isolation domains. this is covered in the global actors evolution doc (though it's possible some stuff in there is out of date).

because even though all access/operations must be queued onto the actor, they could be queued in an indeterminate order, which could create a race condition? Therefore, it must be known to also be Sendable, else these actions are not allowed.

i would suggest a somewhat different framing – Swift's concurrency model does not aim to protect you from race conditions, but rather data races1. to that end, the compiler tends to be quite conservative in its checks for how non-Sendable values can be moved around. in your example there's clearly no actual potential for a data race with the code as-written, since the protocol has only one conforming type, and that type has no stored properties. but the compiler checks generally operate on far more limited information, like what is known from type signatures and local data flow within a function.

Footnotes

  1. and to roughly define the terms, by 'data race' i mean a race on underlying program memory that can corrupt an application during execution, and 'race condition' is a logical program error due to events occurring in different, possibly non-deterministic orderings. both can lead to quite bad things, but data races are generally considered 'worse' b/c of potential security vulnerabilities, crashes, etc. ↩

@nathanhosselton
Copy link
Author

nathanhosselton commented Jan 9, 2025

@jamieQ thank you for the proposal ref! from that:

A non-protocol type that is annotated with a global actor implicitly conforms to Sendable.

so I was correct in assuming that being actor-bound implies Sendable conformance (which also answers where SomeThing was getting its implicit Sendable conformance), it's just the specific case of the protocol itself not implicitly getting Sendable conformance, and I guess then, by extension, existentials of that protocol.

feels like this functionality could maybe maybe be added(?), but for my part I am unbothered. it's easy to mark the protocol Sendable and move on. my worry was that I was misunderstanding the implicit sendability of actor-bound types in some fundamental way. which I only arrived at because of this very specific and probably uncommon use case. so thank you so much for taking the time to clear this up given that this was basically self-inflicted, hah.

Swift's concurrency model does not aim to protect you from race conditions, but rather data races.

this callout is also very much appreciated. I conflated the two somewhere along the way. and while I don't understand data races quite as well, now I at least have better framing to start from for understanding Swift concurrency as a whole.

@mattmassicotte
Copy link

Tiny bit of coverage of that here: https://www.swift.org/migration/documentation/swift-6-concurrency-migration-guide/dataracesafety:

More formally, a data race occurs when one thread accesses memory while the same memory is being mutated by another thread.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment