I was reading the section about References and actor isolation in SE-0306 Actors, and I came across this code and its annotations.[1]
actor MyActor {
let name: String
var counter: Int = 0
func f()
}
extension MyActor {
func g(other: MyActor) async {
print(name) // 1 okay, name is non-isolated
print(other.name) // 2 okay, name is non-isolated
print(counter) // 3 okay, g() is isolated to MyActor
print(other.counter) // 4 error: g() is isolated to "self", not "other"
f() // 5 okay, g() is isolated to MyActor
await other.f() // 6 okay, other is not isolated to "self" but asynchronous access is permitted
}
}
Earlier on, the proposal says:
All declarations on an instance of an actor, including stored and computed instance properties (like balance), instance methods (like transfer(amount:to:)), and instance subscripts, are all actor-isolated by default.
My questions are:
1: How is name non-isolated if it is declared as a stored property of MyActor? This seems to contradict what is stated earlier on in the proposal about all declarations on an instance of an actor being actor-isolated by default.
3: Why is it relevant that g() is isolated to MyActor? counter is a stored property just like name. Shouldn’t counter be actor-isolated by default?
4: What does this mean: g() is isolated to self not other?
5: Shouldn’t this read: “okay, f() is isolated to MyActor?” Again, why is it relevant that g() is isolated to MyActor for calling f() inside g() to be okay?
Whether each of these accesses is allowed or not allowed is accurate, but otherwise these comments are not well worded and--I think--misleading. My advice would be to ignore them entirely, and perhaps we should make an editorial amendment to the proposal.
For (1) and (2), name and other.name are both actor-isolated, but access is allowed because of this:
A reference to an actor-isolated declaration from outside that actor is called a cross-actor reference . Such references are permissible in one of two ways. 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.
By contrast, since counter is mutable state, such access is not allowed unless you're in the same actor isolation context. Therefore, you can get self.count or call self.f() synchronously inside the body of g(), but you must use await when calling other.f(). This is because two instances of the same actor type each isolate their own state.
I refer you to an earlier quote in that same proposal (emphasis added):
But name is immutable and therefore is not actor-isolated.
[Edit: While the code snippet in the proposal clearly states that name is not actor-isolated, that assertion is arguably too strong (or perhaps even technically incorrect?); the issue is more subtle than that: If you access this immutable Sendable property from within the current module, it behaves like a nonisolated property, one that you can access synchronously. But if you access it from a different module, you can see that it clearly is isolated, available only offering asynchronous cross-actor access (i.e., one must await it). All of this is discussed in the Cross-actor references and Sendable types section of the proposal, as well as in the Alternatives Considered: Cross-actor lets.]
Yes, counter is actor-isolated because it is a mutable property of the actor. That’s why g(), which is also actor-isolated, is free to access properties of its own instance.
Because g() is actor-isolated, it is free to access its own self.counter (as in 3), but not to other’s (as in 4). An actor-isolated method can access its own instance’s properties, but not to reach in an access another instance’s property (or at least not without an await).
In short, the isolation of self (e.g., within g()) should not be conflated with the separate isolation of other.
The point is to note that, unlike 4, where it (incorrectly) attempted to reach into other and directly access one of its actor-isolated properties, in 5 you are obviously free to call actor-isolated methods of other (such as other.f()). But the key is that g must await the call to other.f(), even though f() is not an async method, because other has a separate isolation context than self.g().
Interesting. I'm concerned that this proposal presents two diametrically opposite user-facing models as to whether an immutable property is considered actor-isolated. Ultimately, it does not matter for the behavior because cross-actor references are explicitly permitted, but it is certainly a barrier to learning that we have a self-contradictory text here.
I need to take a closer look at this tomorrow morning, but isn't accountNumber, which is also an immutable property, actor-isolated, in the very first example in this proposal? I’m confused.
Later on the proposal also states:
Instance declarations on an actor type implicitly have an isolated self. However, one can disable this implicit behavior using the nonisolated keyword
Then it goes on to declare nonisolated let accountNumber: Int using the nonisolated keyword.
It is confusing, but it was a conscious decision. As the proposal says:
So, within the module, it behaves like a nonisolated property, allowing synchronous cross-actor access to these constants.
But across modules, it definitely is an isolated property (obviously, only relevant if public). The proposal outlines this in the “Cross-actor lets” discussion:
So, in short, within the module, it behaves like nonisolated property, satisfying the “if it is a constant, it is inherently thread-safe” intuition, but across modules, it is isolated, offering framework authors greater flexibility for non-breaking changes should they choose to make the property mutable in the future.
Yes, but immediately following that example, they explain the rationale for why they thought it was imprudent to have us sprinkle our code with nonisolated qualifiers for every constant, namely:
Sometimes I can not help but think that some SE proposals, such as SE-0306, are written by Swift experts for Swift experts, with little or no consideration for learners.
Fortunately, however, there are some souls willing to step in to help. In this regard, I really admire the energy and generosity of @robert.ryan, who is very helpful and illuminating.
So if I understand this correctly, then non-isolated = safe to access synchronously from self and from cross-actor references. If I access an immutable property from within the current module, the property behaves as if it is non-isolated. Therefore, it is okay to synchronously access the immutable property from self or from cross-actor references. If I access the property from a different module, it behaves as if it is isolated. The only way then to cross-reference the property is using async await and awaiting the property. Synchronous cross-actor reference is not okay for mutable properties (only okay for immutable properties).
Therefore, rather than say name is actor-isolated because it is a stored property on MyActor, it is more correct to say, because I can synchronously access name on self and other, then name is non-isolated (if and only if we access name within the same module)?
Whereas isolated = unsafe to access synchronously from cross-actor references (but okay if I access asynchronously using async await).
Thanks to Jumhyn's clarification in another thread, it is clear to me that the comment about other.name in this code snippet is not accurate. Formally speaking, name is not nonisolated, that comment just reflected an early-days mental model.
The way I understand it from other people’s replies in this thread, including Robert Ryan’s, is that name is indeed non-isolated because it is being talked about in the context of it being accessed in the same module, where since I am able to synchronously access the property on self and to cross-reference it on other, then it is properly referred to as non-isolated. Were it an isolated property, we would not be able to synchronously access it on self and other. I could be wrong, since I am still slightly confused about this terminology .
@robert.ryan How can you conclude that immutable instance properties are not actor-isolated based on this quote (which isn’t saying anything about immutable instance properties):
A mutable instance property … is actor-isolated if it is defined on an actor type.
The proposal is clear (emphasis added):
All declarations on an instance of an actor, including stored and computed instance properties (like balance), instance methods (like transfer(amount:to:)), and instance subscripts, are all actor-isolated by default.
In [SE-0306], all instance methods, instance properties, and instance subscripts on an actor type are actor-isolated, and they can synchronously use those declarations on self.
All of the limitations described above stem from the fact that instance methods (and properties, and subscripts) on an actor type are always (sic) actor-isolated
So what is the proposal really saying when it is saying “1okay, name is non-isolated” (assuming the authors are not incorrect)? How is name defying the rule that all properties on an actor type are always actor-isolated? I don’t think the discussion about inside the module or outside the module is particularly relevant here.[1]
I asked Gemini about this inconsistency and it states that the compiler treats name as if it were non-isolated because it is immutable and String is Sendable which means its safe to send across concurrency domains, so this becomes an exception to the rule. This isn’t explicitly stated in the proposal but it can be inferred that there are exceptions to the rule that all declarations on an actor are actor-isolated by default. (I wonder if the term by default is the culprit here, because it sort of implies that there are exceptions).
If the compiler treats immutable properties as non-isolated, then why does the compiler require the use of async await to access myRandomText in this explanatory video about actors unless it’s marked non-isolated.
i believe if you replicate that particular example you'll find that the async is not required, and indeed will not work since the context in which it was used is synchronous.
import SwiftUI
actor A {
static let shared = A()
let randomText = "abcdabcd"
}
struct V: View {
let a = A.shared
var body: some View {
Text("hi")
.onAppear {
_ = a.randomText // âś…
_ = await a.randomText // 🛑 causes closure to be inferred as async
}
}
}
@asaadjaber – My point is merely that they explicitly qualified this statement to “mutable” properties for a reason, namely that this statement only applies categorically to mutable properties. Immutable properties require a more fulsome explanation, which they cover later in the proposal (namely that synchronous cross-actor access only applies to (a) Sendable immutable properties; which are (b) accessed from within the same module).
Bottom line, an actor’s immutable Sendable constants allow synchronous cross-actor access (within that module), with no context hops to the actor. I believe that this is why this code snippet declares that “name is non-isolated.” I’m sympathetic to SE-0306’s framing of this, as that seems like the very definition of “non-isolated.”
But, I get your point: You are trying to square the notion of “all properties are actor-isolated” with the fact that access to immutable Sendable properties within the module do not require any context hop. It is a little confusing on an initial read.
To my eye, it seems to be to be a semantic question: If you do not require actor-isolation for access an immutable property from within that module, is it really incorrect to characterize that property as “non-isolated”? Or is better to frame it as “all properties are actor-isolated, but not all actor-isolated properties require a context hop to the actor”? I’m not sure that’s better.
Personally, I don’t have any problem with SE-0306’s framing. I agree that the language could be tightened up, but I think a full reading of the entire proposal makes the intent reasonably clear.