A minor suggestion to simplify non-isolated related rules

I'd like to suggest changing inferred isolation of actor properties accessbile to non-isolated functions from actor-isolated (the current behavior) to non-isolated. The change wll cause almost no behavior change, but may make it more natural for user to reason about whether an actor property can be accessed by non-isolated functions.

The Problem

Let's see this example, which works by design:

actor MyActor {
    let value: Int = 1

    nonisolated func foo() {
        print(value) // This is OK
    }
}

There are three rules involved to understand why it works:

  • General rule: Actor's properties are actor-isolated by default (unless their isolation are declared explicitly)
  • General rule: it's OK for nonisolated function to access nonisolated values
  • Excpetion rule: it's OK for nonisolated to access immutable actor property of value type

The exception rule isn't difficult to understand in above case, until we have more such rules. Let's see another example:

class C: @unchecked Sendable {
    var value: Int = 1  // let's assume the value is protected by mutex
}

actor MyActor {
    let c = C()

    nonisolated func foo() {
        print(c.value) // This is OK
    }
}

We need a new exception rule to understand why it works:

  • Exception rule 2: it's OK for nonisolated function to access mutable actor property of reference type which is Sendable

Not sure about others, but I find it's difficult to document and remember these rules. The approach I personally take is to not do it. Instead, when I'm not sure, I first think if it's possible to cause data race and then do experiments to verify it. That's not an efficient approach though.

The Proposal

So I'd like to propose the following changes:

  1. Actor property isolation inference: if an actor property doesn't have an explicit isolation declaration, it's inferred as either actor-isolated (the default) or non-isolated if a) it's immutable and of value type, or b) it's mutable and of a Sendable reference type. The rule applies to properties of global actor too.

  2. Accessibility by non-isolation function: non-isolation function can only access non-isolated values. No exceptions.

One may argue that the change doesn't get rid of the complexity but rather moves it from one place to another place. That's true. The complexity is still there, but by moving it to a proper place it becomes more natural. IMO the current rules are unnatural because they don't fit isolation's semantics. What do we mean when we say a value is actor-isolated? I think it means the value should be accessed from the actor's executor only. Unfortunately that's not true in the above examples because of those exception rules. Or think like this, since those values can be accessed from any domain, shouldn't they be non-isolated in the first place? That's the foremost reason why I propose the change.

Source compatibility

For actor properties with inferred isolation the new rules won't change their behavior, because only those that used to be accessible by non-isolated functions will be inferred as non-isolated.

For actor properties with explicit isolation declaration the new rules may change their behavior. For example, the following code compiles currently but will fail under the new rule:

actor MyActor {
    @MainActor let value: Int = 1

    nonisolated func foo() {
        print(value) // This doesn't compile under the new rule
    }
}

I think it's actually good to break code like the above because the use of @MainActor in it is meaningless. It does nothing other than confuse users.

I think your “exception rule 2” is misleading, because there is a race there and the compiler does call you out on it, but you silenced the compiler with @unchecked. I know you said “pretend this is protected by a mutex”, but if it actually were protected by a mutex, it wouldn’t be an exception. You access c, which is an immutable binding to a class instance; since that instance is Sendable, it’s assumed that it can be accessed from multiple threads without a problem. Then you use a member of c, which by the Sendable rule ought to be safe, and it turns out it isn’t. The fact that that member is a mutable stored property is beside the point; it could be a computed property, or a method with some kind of side effect.

This might sound like quibbling when it was just an example, but without motivation for the proposed rule change, the proposal is of course a lot weaker. I’m no async expert, but it remains unclear to me what code would be improved by the new rule.

4 Likes

Isn’t this just generalisation of the first exception?

Thanks for comments.

I'm afraid you probably misunderstood the purpose of the example. It's not to demonstrate that there is flaw in the current rules and the new rules can catch it. That's not the case because the new rules brings almost no behavior changes. The purpose is to demonstrate the current rules are complex and hopefully the new rules will be more easier to use.

It doens't improve code. Its purpose is to make it easier to understand/determine if it's Ok to access an actor's property from a non-isolated function.

I can't see why it's a generalization. Can you ellobrate it? Note exception rule 1 requires an immutable value. If you consider c in example 2 as an immutable value because its reference can't be modified, I'm afraid I can't agree.

FWIW, I also considered another rule but I didn't include it in the original post for simplicity:

  1. An actor's property can be declared as non-isolated explicitly only if it can be inferred as non-isolated.

IMO the following two features are related. It would be great to implement and understand them in a consistent way:

  • Whether an actor property can be accessed by non-isolated function
  • Whether an actor property can be declared as non-isolated explicitly

If it's feasible (I don't know), I think rule 3 might be a simple way to achieve that.

Why do you focus on immutability rather than sendability? You can add class property to a value type, with value type remaining immutable on the declaration side, but it’s class-based property can be modified. Of course, immutability within the actor is important — by making those properties on actor mutable, we create shared mutable state on an actor and it cannot be accessed outside on the isolation safely. But until then, all sendable types are OK, and all non-sendable aren’t.

This is better to illustrate with example, so I extended it to sendable and non-sendable cases:

import Synchronization

struct SendableValueType: Sendable {
    let message: String
}

struct NonSendableValueType {
    let backing: NonSendableReferenceType

    init(message: String) {
        backing = NonSendableReferenceType(message: message)
    }
}

class NonSendableReferenceType {
    let message: Mutex<String>

    init(message: String) {
        self.message = Mutex(message)
    }
}

final class SendableReferenceType: Sendable {
    let message: Mutex<String>

    init(message: String) {
        self.message = Mutex(message)
    }
}

actor A {
    let sendableValueType = SendableValueType(message: "I am sendable VALUE type")
    let nonSendableValueType = NonSendableValueType(message: "I am NON sendable VALUE type")
    let sendableReferenceType = SendableReferenceType(message: "I am sendable REFERENCE type")
    let nonSendableReferenceType = NonSendableReferenceType(message: "I am NON sendable REFERENCE type")

    nonisolated func test() {
        print(sendableValueType)
        print(nonSendableValueType)
        print(sendableReferenceType)
        print(nonSendableReferenceType)
    }
}

If you run this, you’ll get errors in Swift 6 on both non-sendable properties. Note that I’ve even made NonSendableReferenceType technically Sendable, just didn’t marked as one, so it’s only difference from the sendable reference type is missing marker protocol, and still get the same result on access from nonisolated code. I hope this helps to illustrate, that the rule includes being Sendable always and can be formulated as "accessing immutable and sendable property" with immutable referring to the actor’s property mutability of course.

1 Like

You seem to suggest the object c in my example 2 is an immutable object? I don't think that's correct. I have never seen that people treat an object's content and its reference separately when considering the object's mutability. That's why I find the way how you understand it weird.

You are confusing immutable object and immutable property. That’s two distinct concepts. Similarly how sendable type is thread-safe, but property of that type might not be such. And I am referring only to property, which is relevant for actor’s context.

Property c is immutable from actors perspective — it cannot be mutated. Furthermore, it is Sendable, meaning accessing instance of it is safe from any isolation. The fact that type C contains mutable state isn’t related to that fact. I get what you are saying and in terms of finding shared mutable state in general that’s a valid and I guess commonly used analysis. However, this is no longer applicable for Swift 6 concurrency model — at least, not in that way. With sendability checks you don’t need to consider if C has mutable state within it, just that if it is Sendable or not.

The fact that C has its own mutable properties is now important when we take a look at this object: if we want it to be sendable, we need to protect mutable state it contains. But this is somewhat parallel to the actor’s point of view.

4 Likes

Thanks for the explanation, but I'm not convinced. Below is how I think about it.

  • All my examples access actor property in function body rather than as parameters. That's intentional. If the function is non-isolated, it means the value is accessed in place concurrently.

  • Sendable was designed to pass value across isolation boundary rather than access value in place. For reference type, they are the same thing. For value type, however, they are different. That's why for value type just being Sendable isn't sufficient.

  • For reference type, while I'm not sure, I suspect the immutable requirement of object reference (in other words, actor property) is probably more of a design decision than something strictly required, because IIUC mutex can protect both object content and object reference. In this sence, the fact that actor property of both types are immutable is a coincidence.

That's why I still hold my viewpoint that the rules about value type and reference type are so different in their natures that it's hard to summarize them into a single rule in a meaningful way. But thanks for the discussion. It have been very helpful.

I'm not trying to convince you, just point at how compiler reasons about concurrency safety.

What makes you think your example doesn't pass anything across isolation boundary? You have actor-isolated property that you access from non-isolated method – that's exactly boundary crossing.

You are mixing quality of a type and quality of a property of that type. Consider thread-safety: any Sendable type itself is thread-safe by definition (defined by the Swift docs); however, variable of that type isn't inherently so, because it now part of another type and we have to consider thread-safety of that type now.

How does value or reference semantics influences this?

Being let on actor has same significance for either value or reference type. Property of thread-safe type isn't thread-safe. That means, either instance declared on actor as variable is unsafe to be accessed from non-isolated context as-is, because property itself can be changed: in that scenario you can assign to c new instance.

To put it in other words, becoming mutable property on an actor (despite any other detail) automatically means that for concurrency safety it should be isolated.

I don't know what do you mean, but mutex protects only resources you explicitly protect using it, it cannot magically expand to instances. So just to be clear:

// perfectly thread safe
final class ThreadSafe {
    var value: Int {
        lock.withLock { _value }
    }
    private var _value: Int = 0
    private let lock = NSLock()
    
    func update(newValue: Int) {
        lock.withLock { _value = newValue }
    }
}

var instance = ThreadSafe()
let queue = DispatchQueue()
queue.async {
    // NOT safe
    print(instance.value)
}

// meanwhile, over-write instance
instance = ThreadSafe()

Because now instance represents its own shared mutable state, distinct from value inside type.

You happen to ignore example I've made earlier that explicitly demonstrates that this is simply the way things are. It's up to you of course, but fighting the way language itself reasons for me seems unproductive.

2 Likes

Others responses have gotten at this too but just to be very direct: I think you're starting with a too-specific rule here and then causing yourself some confusion by attempting to special-case other instances of the actual underlying rule. Rather than "...of value type" this rule should read:

  • Exception rule: it's OK for nonisolated code to access immutable actor property of Sendable type.

Many (but not all) value types satisfy this requirement because they are inherently Sendable by virtue of their value semantics. But this also holds for types with reference semantics which are nonetheless Sendable by managing their data-race-safety internally, as you've seen. The original Actors proposal gets at this, though it could probably use a more direct statement that this only applies to Sendable values:

First, a cross-actor reference to immutable state is allowed from anywhere in the same module as the actor is defined because, once initialized, that state can never be modified (either from inside the actor or outside it), so there are no data races by definition.
[...]
A let of Sendable type is conceptually safe to reference from concurrency code, and works in other contexts (e.g., local variables).


I see what you're getting at but I think you're thinking about this slightly backwards—reference types still have a notional 'value' which is stored 'in place' where the property is declared: namely, the reference value itself. If this storage is mutable, it admits all the same potential data-race issues you'd have with e.g. a mutable property of type Int. That's what the actor protects you from, and why it's safe to access immutable state from a nonisolated context.

The Sendable requirements comes up because once the concurrent access is completed, you're then holding onto a copy of the original value in a different isolation domain. Even for reference types, you're holding onto a notional copy of the reference value and potentially accessing it, mutating it, etc., from the nonisolated context. Again, for types with value semantics we are safe because our copy is 'independent' of the value still stored in the actor, but for reference types you might need additional synchronization to make sure that any mutations of the instance's properties happen safely—and this is precisely what Sendable asks conformers to guarantee!

5 Likes

I fail to see the point of simplifying the rules, isn't the role of the compiler to think about them and guiding you to follow those rules via diagnostics? I never have to think about those rules, the explanations from the diagnostics are (with Swift 6.2 and the diagnostic docs) usually enough to understand the issue and fix it.

I think if you're encountering hard-to-fix concurrency issues the solution would be to improve the diagnostic's output and suggestions, not change the rules.

1 Like

Thank @Jumhyn, that nails it. Let me rephrase it a bit differently: Sendable is about value (including class instances), not property.

Proprety can be isolated. Below are two typical scenarios of accessing a sendable value saved in an isolated property:

A) Pass it as function parameters (or return it as function return value)

This just works because callee (foo) doesn't perform cross-domain access. It's caller (test) that accesses the property and put a copy of the value to callee's stack. Since caller and the property are in same domain, there is no chance for data race.

nonisolated func foo(_ value: Int) {
    print(value)
}
    
actor MyActor {
    var value: Int = 0
    
    func test() {
        foo(self.value)
    }
}

B) Access it in place

The standard approach to perform cross-domain access is doing it asynchronously:

actor MyActor {
    var value: Int = 0

    nonisolated func foo() async {
        print(await self.value)
    }
}

If the isolated part of value in the property is immutable, however, it can be performed synchronously:

class C: @unchecked Sendable {
}

actor MyActor {
    let value1 = 0
    let value2 = C()
    
    nonisolated func foo() async {
        print(self.value1)
        print(self.value2)
    }
}

I coin the term "isolated part of value" to abstract away the difference between value type and reference type. For value type, it's the value; for reference type, it's object's reference.

The above description is pretty much the same as what you said. I just organize and phrase it in a different way and I find this works best for me. Thanks.

Why I coin the term "isolated part of value"

The reason why I don't like "immutable actor property" of Sendable type, which you suggested, is that it feels unnatural to me to use the term "property" to refer to an object's reference. Suppose you ask Swift programmers you meet on the streets what's the c property of struct S in the following code, I think most, if not all, of them would say it's an object, instead of an object's reference. That's how one typically think about it. I could be nitpicking though, because there are other scenarios (e.g. COW) where one needs to be aware it's a reference.

class C {
    var value: Int = 0
}

struct S {
    let c = C()
}

There is also another reason. How would you answer if one asks why it's sufficient to make object reference, instead of entire object, immutable? The typical answer is that it's up to user to make sure object content safety. If using my term, however, the answer could be that object reference is isolated but object content isn't. IMO this anwer reflects its nature better.

You might want to coin a different term than "the isolated part of value" if that can help you internalize the rules. Because technically speaking, in your last example, both value1 and value2 are actually non-isolated -- because they can be accessed from any isolation domain.

1 Like

A quick recap of the discussion so far:

  1. I proposed in my original post to change inferred isolation of actor properties accessbile to non-isolated functions from actor-isolated to non-isolated for two reasons: a) the current rules are a bit complex, and more importantly b) the current rules don't fit isolation's semantics.

  2. So far, there haven't been any comments on the proposed change itself. All the discussion have been focused on the first reason instead. It was suggested by others that the two exception rules in my original post can be generalized as "immutable actor property of Sendable type can be accessed by non-isolated functions".

  3. I personally don't like that description, so I came up with a new idea to abstract away the difference between value type and reference type. The idea is that a value may have isolated part and non-isolated part.

    • For reference type, its value have two parts: a) object content is always non-isolated (because its access isn't controlled by actor), and b) object reference can be isolated or non-isolated, depending on the context in which it's defined.

    • For value type, its value has only one part (the entire value).

    An example:

    class S: @unchecked Sendable {}
    
    class NS  {}
    
    actor MyActor {
        var v1 = S()
        let v2 = S()
        var v3 = NS()
        let v4 = NS()
    
        nonisolated func foo() {
            print(self.v1) // Not OK: v1 has mutable isolated part (object reference)
            print(self.v2) // OK: v2 has immutable isolated part
            print(self.v3) // Not OK: v3 has mutable isolated part
            print(self.v4) // Not OK: v4 isn't Sendable
        }
    }
    

    With this model, the two exception rules in my original post can be generalized as "a senable value whose isolated part is immutable can be accessed by non-isolated functions". For details please see my last post.

  4. The change I proposed in item 1 and the idea I described in item 3 are orthogonal. They can work separately or together.


The term is general and is applicable to other cases. Please see my recap above.

In addition, your assumption that value1 and value2 are non-isolated isn't correct. I believe they are inferred as actor-isolated in current implementation. What I proposed is to infer them as non-isolated. I'm glad to know my proposal makes sense to you :)

Incorrect statement

I'm afraid they are non-isolated already . There're multiple sources about this, for example, you can check this section in the first proposal about actors: swift-evolution/proposals/0306-actors.md at main · swiftlang/swift-evolution · GitHub.

Note that it is a different story for nonsendable lets, which this first proposal didn't discuss much about.

Correction: this is not true, see Jumhyn's post below.

2 Likes

Yes, the text implies it indeed. What about global actor? SE-0343 has the following:

Top-level global variables are implicitly assigned a @MainActor global actor isolation to prevent data races.

And SE-0412 (it obsoletes SE-0343, I think) has the following:

Under strict concurrency checking, require every global variable to either be isolated to a global actor or be both immutable and of Sendable type. Global variables that are immutable and Sendable can be safely accessed from any context, and otherwise, isolation is required.

It's not clear to me if the description is about behavior or if the implementation actually infers a property that is "both immutable and of Sendable type" as non-isolated. Why does it matter? I think it matters for code like the following. If the current implementation infers property as non-isolated, what's the point to support code like this? IMO it should also implement rule 2 in my proposal (non-isolated function should only access non-isolated variables) and make this example fail.

actor MyActor {
    @MainActor let value: Int = 1

    nonisolated func foo() {
        print(value) 
    }
}

I'd appreciate it if someone in Swift team can confirm what's the actual behavior. I considered doing experiments before proposing the change, but didn't figure out how to verify it.

The rule for accessing immutable, sendable properties is actually kind of subtle—it is not the case that such properties are nonisolated. Indeed, they carry the relevant isolation for the (possibly global) actor. But as a pragmatic usability improvement, within the same module you're allowed to access immutable properties of Sendable type, because the compiler knows it's safe. From outside the module, though, for a public property, access remains isolated to the actor and must be awaited.

6 Likes

Thanks for the information. So the unnecessary inconsistency is another reason why the current behavior isn't ideal.