Why can actors conform to actor-ignorant protocols without warning?

@LucasVanDongen's Exploring Actors and Protocol Extensions raises an interesting question (in my mind): why can an actor implicitly conform to a 'nonisolated protocol'?

Unless I misunderstand the problem Lucas highlights, the crux of it seems to be that a protocol must inherit from the Actor protocol in order for its method [requirements] to be isolated. Otherwise they are implicitly nonisolated. And an actor can declare conformance to such a protocol without any acknowledgement of this subtle but very important aspect. Any default implementations for that protocol work on the actor even though they are nonisolated, which is - for good reason - not the 'normal' (default) behaviour for a method on an actor.

I'm sure this is not an oversight or happenstance; this must be a conscious choice. I'm curious what the rationale is? Not just for edification but also to know whether it's in the vein of "this is really the only practical approach" or "this problem might be solvable and was merely deferred"?

My immediate thought was that non-actor protocols should require special adornment to be used on an actor, e.g.:

actor ExtensionActorFormatter: nonisolated Formatting {
    …
}

That way at least there's a warning to adopters of the protocol that it behaves in certain unintuitive ways.

It also seems like a straightforward bug that an isolated actor member property or method can be accepted by the compiler for a nonisolated version in a protocol…? Or is that generally not the case but rather it's that static variables in an actor are always considered nonisolated?

4 Likes

Relevant write up with justification: swift-evolution/proposals/0313-actor-isolation-control.md at main · apple/swift-evolution · GitHub

There's definitely an open design space here for better tools to allow types (including actors and global actor isolated types) to conform to nonisolated protocols with isolated witnesses while preventing those conformances from being used outside the necessary isolation domain. It's made a little more complicated by isolation regions, because we'd need to do something like make sure that erasing a type with an isolated conformance into an existential can only happen inside that isolation domain + merge the existential value into the actor's region, but it's definitely still something worth pursuing. So to answer your question here

It's the latter.

EDIT: Sorry I think I completely misunderstood the question.

Isolated methods cannot be used to fulfill nonisolated protocol requirements. Prior to [Concurrency] Make actor-isolated witness diagnostics more discoverable. by hborla · Pull Request #70153 · apple/swift · GitHub, this was only diagnosed under -strict-concurrency=complete.

let variables in an actor are implicitly nonsiolated within the module. However, I think this rule is not correct, and it should only be the case that let variables that are Sendable are implicitly nonisolated.

Relatedly, static variables declared inside an actor (not a global actor isolated type) cannot be isolated, because there's no actor instance to isolate them to. This will be diagnosed under SE-0412.

3 Likes

So is it fair to conclude, then, that the problem at least technically lies with the code of @LucasVanDongen's example? That it is already wrong even without the protocol part, because of the use of a nonisolated static variable? That seems to be the lynchpin of the unsafety.

I'm not sure Lucas ever meant to convey otherwise; perhaps his point was that this is subtle and an all too easy mistake to make.

2 Likes

The static declaration is a bit of a Red Herring in this article, I wanted to adjust it a bit to remove these kind of side quests in the discussion:

  1. If a non-static member is accessed, the compiler will start to throw warnings that will help you correct the issue, see the first image
  2. If there is no member accessed, or a static member that is declared let you don't get any warning at all
  3. If you access a var declared static, it will show a warning, albeit not a correct one (see image 2) - it complains about the editability of the pointer, not the instance itself

The real issue is that anything at all can happen in an Actor without it actually happening within the context of the Actor. If I would pass in a value in an argument expecting it to be changed within the context of that Actor, then that would also fail without any warning.

It's really an omission on the compiler, no protocol not correctly declared for given actor should be allowed on actors.

Correct behavior when non static


Behavior when static var

1 Like

No. The function itself executes outside of the context of that actor, which I consider a problem regardless of how that specific static let attribute is used. And as I show in the static var example, it still does some rudimentary check of editability on static vars.

Yes, I think that's a correct assessment.

It's not a problem that the protocol requirement runs outside the actor. The default implementation can only access nonisolated state, and if the actor implements the requirement directly, it must be nonisolated. There's no unsafety there, and allowing actors to conform to nonisolated protocols with isolated witnesses requires fancier language features like I mentioned in my first comment.

3 Likes

What use cases do nonisolated protocols have on actors? Tightening up the compiler checks to prohibit this would only make the language safer to use, IMHO.

Conforming an actor or a global actor isolated type (e.g. a @MainActor isolated class) to any generic protocol that any type can conform to requires some interaction between nonisolated requirements with isolated types. For example: Equatable, Hashable, Comparable, Collection and friends, CustomDebugStringConvertible, or any other standard library protocol.

I agree with this statement in general, but limiting the language when there's no unsafety only makes the language harder to use. There's no unsafety here, aside from the SE proposals I mentioned that will add additional diagnostics for the specific unsafety encountered in the article. Isolated types can have nonisolated functions, which are not allowed to touch any isolated state unless they use a dynamic check like MainActor.assumeIsolated { ... }, and isolated types can conform to nonisolated protocols using nonisolated functions.

2 Likes

I'll dig into your links when I have more time, thanks for shining light on this. If I have any more questions or comments I'll ask them here or in the appropriate threads :+1:

1 Like

Please don't hesitate to ask! And if you'd like to see justification in the proposal that allowed actors to conform to nonisolated protocols, it's in the same proposal SE-0313: Improved control over actor isolation in the section on protocol conformances.

I don't quite follow - when I read the statement in question I had the idea that it was simply not correct and that it seems to be based on a misunderstanding, but I think it's entirely possible that I'm the one with the misunderstanding. I can think of two useful things that protocols that do not inherit from Actor could do when conformed to by actors:

  1. The actor can have immutable, non-isolated properties (either static or set on initialization) which the protocol can make non-trivial use of.

  2. The protocol can declare async function requirements which actor-isolated methods can satisfy.

Based on these two possibilities it seems to me like it would be a clear loss of useful functionality (with zero gain in terms of safety) to prohibit actors from conforming to protocols that do not inherit from Actor.

I know that that's not being actively proposed, so I'm not trying actively voice my opposition, I'm just trying clarify my understanding of these core Swift concepts because I found myself confused by the discussion.

1 Like

Yep, you're correct and I just wasn't clear! What I meant was: In general, I agree that tightening compiler checks to prohibit code that exhibits data-race unsafety makes the language easier to use. However, there's no unsafety when (correctly) conforming actor types to nonisolated protocols, and banning such a conformance would prohibit valuable, safe use cases. The missing diagnostics in the linked article that lead to the data-race are covered by other SE proposals, and the fundamental problem was not the fact that the actor was conforming to a nonisolated protocol.

2 Likes

@hborla @jeremyabannister I gave your arguments some thought and I kept further digging through documentation. I think you are both right because:

  • Actors only promise protection to mutable instance members
  • Forcing everything that happens in a protocol / extension pair within the actor's context even if no mutable instance members are touched could potentially block some user cases

However, I also think that Actors as a concept can be confusing to programmers as custom global actors like @MainActor at least seem to be tools for managing concurrency at first glance. Also, because access to any function on an actor, including the ones that do not touch mutable instance variables happens within its context, largely that mental model remains unchallenged to any programmer using the language feature.

So if all of a sudden simply by moving a function to a protocol / extension that function is no longer executed within the actor's context can be a big surprise.

Wouldn't it help the programmer if the compiler would expect the programmer to be explicit about this behavior? I can imagine forcing the nonisolated keyword in such cases would take any surprise away of what is happening:

extension SomeActor: NonActorProtocol { }
:no_entry_sign: Compile Error : "NonActorProtocol does not confirm to AnyActor, add 'nonisolated' or make it confirm to AnyActor"

extension SomeActor: nonisolated NonActorProtocol { }
:white_check_mark: Compiles

This both helps the programmer understand - and possibly prevent - the unexpected change to concurrency and keeps the flexibility that some programmers would like to have if they don't care about isolation in that particular case.

Any thoughts?