Diagnostics for global isolation and Sendable conformance

Consider the following code, which today produces no diagnostics other than the one commented:

class NonSendable {
}

@MainActor
class MyClass: Sendable {
	var value = NonSendable()

	func doThing() {}
}

actor MyActor {
	var value: MyClass

	init(value: MyClass) {
		self.value = value
	}

	func accessValue() {
		value.doThing() // ERROR: Call to main actor-isolated...
	}
}

I believe what is happening is here is the global isolation of MyClass is taking precedence over the explicit Sendable. Yet this type actually isn't Sendable in the same way, which itself takes a moment to understand. This is fundamentally inconsistent in a way that seems like it at least deserves a warning.

Anecdotally, I have seen a lot of confusion about the implicitly-Sendable nature of global isolation. Part of that is related to the fact that, today, sometimes both are required to use closures (something I'm hopeful we'll address: Implicitly Sendable Functions).

I'd like to propose a diagnostic when type is both globally-isolated and Sendable, with a fix-it to remove the Sendable conformance. This should apply to all types except closures and protocols.

1 Like

Here is a draft proposal:

I think there's a bit of a disconnect here. MyClass is Sendable which can be observed by the fact that you're able to call doThing() asynchronously:

	func accessValue() async {
		await value.doThing()
	}

MyClass achieves sendability by virtue of the fact that it is @MainActor isolated. Being isolated to the main actor ensures that access to all mutable state is synchronized.

It seems like the misunderstanding here is an interpretation of Sendable as meaning something like "free to be shared across isolation domains with no change in behavior" which is roughly what is achieved by, say, Int, where you can copy it freely between isolation domains and everywhere it goes it's 'just' an Int. But Sendable is more broad than that. Sendable also encompasses types which can be moved across isolation domains because they internally manage any access to their mutable state. This is why global-actor-isolated types (as well as actors) may be shared across isolation domains: the language enforces that any calls 'into' their members will actually hop into the corresponding isolation domain. I really recommend reading the full docs for the Sendable protocol, they're pretty thorough.

Now, I think we could still potentially warn when a global-actor-isolated or actor type notes a Sendable conformance (since it's redundant), but I'm not totally sold. I think it would be reasonable for programmers to adopt a style where all conformances are noted explicitly, even if they'd otherwise be implicit (such as with Sendable).

Nonetheless:

I do think this is good feedback about how we teach Sendable, as well as a very good argument for adopting the proposal you've linked. You're right that not all Sendable types are treated equally by the language. There is one such confusion about Sendable's interaction with global actor isolation that went up just a few hours ago!

3 Likes

I understand what you saying! And thank you for the link to the docs!

But, I want to submit the code sample with only the @MainActor annotation removed to be 100% sure it does not change how you feel.

class NonSendable {
}

//@MainActor
class MyClass: Sendable { // WARNING: not final class cannot be Sendable
	var value = NonSendable() // WARNING: Stored property 'value' of 'Sendable'-conforming class 'MyClass' is mutable
	
	func doThing() {}
}

actor MyActor {
	var value: MyClass
	
	init(value: MyClass) {
		self.value = value
	}
	
	func accessValue() {
		value.doThing() // synchronous call now fine
	}
}

That first warning was a surprise! I'd forgotten to mark this class final. But it kinda makes sense when you see that second warning. It looks like a GAIT that also conforms to Sendable just turns off all of the type-internal checks.

Edit: upon thinking about this more, I guess it isn't necessarily that the checks are off, but that they are being satisfied by the isolation (final notwithstanding).

I hadn't thought of this. And based on it, perhaps I'm just mis-interpreting the compiler's behavior?

Yup, precisely. The global actor isolation guarantees that any access to the type's internal state is mediated by the main actor, so access cannot happen concurrently and cause a data race. Callers outside the main actor will need to await calls into MyClass so that we can hop to the main actor for execution. Attempts to move non-sendable state out of MyClass across isolation domains will also fail:

func f(c: MyClass) async {
  let ns = c.value // error, 'value' is not sendable so can't cross 
}                  // into a non-main-actor domain here
2 Likes

Ok, fine :sweat_smile:

So, how about the missing warning about final? I guess even that is not a bug, technically, because the type is in fact Sendable in that configuration?

I took the time to write this up. So I think it's clear what my opinion is on this. But, with the feedback, which I deeply appreciate, I also think it is clear why things work the way they do.

1 Like

Yeah, global actor isolation is inherited by subclasses so there's no issue here.

It's very possible for something to be both logically sound and still confusing. :slightly_smiling_face: The concurrency system and sendability rules are complex and compose in ways that are not always obvious at first glance. This kind of feedback is still helpful even if it's not a 'bug' as such--knowing which corners are feeling sharp to users can inform future features, diagnostics, or sugar to help make things clearer.

2 Likes

I guess this example is further support for leaving things as-is. It isn't the same thing, but it is kinda close.

// no warnings
struct A: Hashable, Equatable {}

Rats.

Not sure I totally follow, why would you expect warnings with the above?

Oh, sorry I don't! Wasn't nearly clear enough.

Hashable already conforms to Equatable, so I was using it as an example of a guaranteed-redundant conformance that does not produce a warning. This doesn't have nearly the same semantic implications of a globally-isolated type vs a plain-old Sendable type, but I thought it felt related.

1 Like