While I agree that lazy doesn't quite seem the most intuitive on its own, I also don't know that this is any better. In fact, I'm struggling to come up with an alternative suggestion because I can't even tell if this is supposed to be "lazy" or "eager"!
I almost want to say that nonisolated could keep its current form and we could add nonisolated(eager), but it seems like maybe that's not what you want. If you want nonisolated to be eager-by-default, the "lazy" behavior could be isolated(caller) (with isolated by itself being shorthand for isolated(self))?
The nonisolated(none) was just a throwaway suggestion really. I fully understand the difficulties this proposal is trying to balance between changing the default while still providing control to users. I'm personally open to most spellings that reuse nonisolated or isolated. The only thing that I feel the current proposal is mixing is execution and isolation which I personally think we should avoid.
I think the specialized nonisolated spelling (not yet sure about eager and lazy) is a good compromise between keeping the isolation terminology but also not creating ambiguity or confusion around combining @isolated and nonisolated which I think is what is meant by this:
Another possibility is to use isolation terminology instead of @execution for the syntax. This direction does not accomplish the goal of having a section to have a consistent meaning for nonisolated across synchronous and async functions. If the attribute were spelled @isolated(caller) and @isolated(concurrent) , presumably that attribute would not work together with nonisolated ; it would instead be an alternative kind of actor isolation. @isolated(concurrent) also doesn't make much sense because the concurrent executor does not provide isolation at all - isolation is only provided by actors and tasks.
What about nonisolated(strict) —as in, this function is strictly nonisolated and will never run in an actor's executor— and plain nonisolated (or maybe nonisolated(optional), idk) —where the code itself does not require actor isolation, but may have it if called from an actor's executor— which would behave just like synchronous nonisolated functions?
I am fundamentally against any spelling that uses @isolated for methods that are today nonisolated because it gives the impression that there is some serialization mechanism that allows the method to access protected state. All local variables in any method are isolated by the task. This is not something that is specific to nonisolated methods, and it's not really something that people need to understand in terms of formal isolation. It's a local variable, so of course nothing except the function it's in has access to it.
In any case, I am strongly against any direction that deprecates nonisolated for the reasons I outlined in the proposal.
I understand what you mean here and currently all local variables in asynchronous methods are task isolated; however, with the proposed change this isn't the case anymore right? Since methods inherit the caller's isolation by default local variables are either disconnected or isolated to the caller's isolation. If a local variable is derived from the state of an input parameter the regions are merged right? Just as a small example show casing how local variables can be isolated to the caller's isolation today:
protocol StateActor: Actor {
var counter : Int { get set }
}
func isolated(isolation: isolated (any StateActor) = #isolation) async {
var localCounter = 0
await Task {
// This task is isolated to the isolation parameter
localCounter += 1
}.value
isolation.counter = localCounter
}
I see how this makes sense for concurrent case, but not for caller case.
The caller case is exactly equivalent to what today can be achieved using isolated (any Actor)? = #isolation, and we consider such functions to be isolated. Actually using nonisolated on such function is an error:
nonisolated func foo(actor: isolated (any Actor)? = #isolation) {
`- error: global function with 'isolated' parameter cannot be 'nonisolated'
}
Does the concept of “isolation” exist at the Actor level, the SerialExecutor level, or the Executor level? Executor.withTaskExecutorPreference() takes an isolation argument, so if something running on a non-serial Executor can be isolated, then isolation in and of itself does not confer any sense of serialization.
How I see it is that isolation exists at a higher level than any of the things you mention. Isolation is a concept that provides separate regions in your code where state lives. There are various kinds of isolation regions such as actors, tasks, mutexes and others that can be constructed via mechanisms such as @Sendable closures.
Executors are related but orthogonal to it. In the end, code has to run somewhere. The current isolation of a piece of code is one factor to where it is executed in the end but there are many more such as what is the global executor, what is the main executor, is there task executor preference and more.
SerialExecutor specifically are tied to actors but a type can implement both a serial and task executor and can be used as both.
Executor.withTaskExecutorPreference() takes an isolation argument
That's just for making it compose. You might want to set an executor preference while in the scope of an actor and still access actor local state inside the withTaskExecutor closure. When calling out to a currently nonisolated method you want to the task executor to apply though.
I wouldn't say a function using isolated (any Actor)? = #isolation is isolated when called from a nonisolated region. The most common explanation of isolated (any Actor)? = #isolation is something along the lines of "it inherits the isolation of its caller", which is not the same thing as saying it's isolated.
func funcTakingIsolatedClosure(@inheritIsolation _ body: () -> Void) {
let closureIsolation = body.isolation
Task { [isolated isolation] in
// isolated to isolation here
body() // no need for await here
}
}
@isolated(any): the caller supplies the isolation the closure will use @isolated(any)
func funcTakingIsolatedClosure(isolatedClos: isolated(any) () -> Void) {
Task { [isolated isolation] in
// isolated to isolation here
isolatedClos() // no need for await here
....
}
}
Then any closure without any of the above is implicitly nonisolated where one must explicitly opt-in through methods 2, 3 or 4 if they want an isolated closure
The following is a hard error
let nonisolatedClosure: isolated(nil) (sending NonSendable) async -> Void = { [isolated self] (object: sending NonSendable) async in ... } // ❌️ Error: can't isolate a nonisolated closure
The isolation context of the caller cannot influence overload resolution. I think the right way to support both forms is by having only one function that runs on the caller's actor, and then in the Concurrency library we add an API for running a given function on the concurrent executor. E.g. something like this
@execution(concurrent)
public func runConcurrently<R>(
_ operation: @execution(concurrent) () async -> R
) async -> R {
await operation()
}
This allows people to explicitly move a specific function or closure off of an actor without having to wrap the code in their own @execution(concurrent) function. I've been wanting this sort of API independent of this change, because I think too many people reach for await Task.detached { ... }.value for this purpose, when the unstructured task isn't actually necessary.
Yes!
Just to be clear, the initial semantics of async functions in Swift 5.5 were not data-race safe. Async functions used to use the unsafe inherit executor model, which means they would not switch back to the original executor after any calls to other async functions in the implementation. SE-0338 was motivated by changing the semantics to be safe in a way that did not change the ABI of async functions. I agree that the decision made in SE-0338 to accomplish a sound rule was ultimately the wrong tradeoff, but as you can see in this proposal, the complexity of staging in an ABI change also has real, significant tradeoffs. The benefit of hindsight and years of real world experience with SE-0338 makes the impact of the tradeoffs much more clear, and I don't think we could have easily foreseen these consequences at the time.
I'm glad you brought this up, because it reminded me that there can be a subtle difference between the parameter region and the caller's actor region. And, I think there's a way in which @execution(caller) nonisolated functions can be meaningfully different from methods with an isolated parameter. Yes, you're right that any local variables derived values in the parameter region are also in the parameter region. For methods with an isolated parameter, the parameter region is always the actor's region. However, for nonisolated methods that run on the caller's actor, I don't think there's a way to actually get at the actor and assign into its state unless the parameters are already part of the actor's region before the call happens. If I'm right about that, then we don't have to always merge parameters and results into the actor's region. This allows us to have the same region rules for nonisolated synchronous functions and @execution(caller) nonisolated functions.
Let's make this more concrete. Consider the following code:
class NotSendable {}
nonisolated func identity<T>(_ t: T) -> T {
return t
}
actor MyActor {
func isolatedToSelf() -> sending NotSendable {
let ns = NotSendable()
return identity(ns)
}
}
The above code is valid under -langauge-mode 6. The local ns variable is in a disconnected region, and the result of identity(ns) is in the same disconnected region as ns. So, returning that value as a sending result is fine. Even though identity ran on the actor, the values were not merged into the actor's region.
If you give identity a value already in the actor's region, then the code is invalid:
class NotSendable {}
nonisolated func identity<T>(_ t: T) -> T {
return t
}
actor MyActor {
let ns = NotSendable()
func isolatedToSelf() -> sending NotSendable {
return identity(ns) // error: actor isolated value cannot be sent
}
}
I think these region rules are still valid if identity is async:
class NotSendable {}
@execution(caller)
nonisolated func identity<T>(_ t: T) async -> T {
return t
}
actor MyActor {
func isolatedToSelf() async -> sending NotSendable {
let ns = NotSendable()
return await identity(ns)
}
}
I think the above code should be valid. The implementation of identity can't actually access the actor's state unless isolated state is passed in via one of the parameters. Yes, the proposal allows you to access #isolation for the purpose of forwarding it along to some method that accepts an isolated (any Actor)? parameter, but rule is meant to help with the transition off of isolated parameters, and I think it's still safe; I don't think there's a way to get at the actor's state, because the Actor protocol does not give you any way to get isolated state out (you can if the isolated parameter has a concrete type).
Took me quite a while to consider all the cases I wanted to make sure to consider before replying, but finally made it, so here's some of the notes I took throughout that research over the last few days:
Overall, to me, the general change of the default isolation behavior or these functions is something we really have to do, from an understandability, performance as well as maintenance of libraries point of view; so I'm thrilled we're attempting to fix the default!
#isolation special cases and composition with existing APIs
This took me a very long time to verify this isn't a bug and expected behavior so I think we need to clarify it much more in the proposal with some examples:
The proposed @execution(caller) effectively adds the caller actor as a parameter to functions annotated with it:
This behavior is accomplished by implicitly passing an optional actor parameter to the async function. The function will run on this actor's executor.
Further along, we're discussing #isolation expansion:
Uses of the #isolation macro will expand to the implicit isolated parameter.
...
Unstructured tasks created in nonisolated functions never run on an actor unless explicitly specified.
Quickly skimming the proposal, one might expect this means that the implicit parameter is isolated and @execution(caller) is sugar for the isolated #isolation pattern, but it's not. At least it's not proposed this way. We can observe this here:
Because the added implicit parameter is not isolated, the first isolation inline will return nil here, as designed:
- ✅ DYNAMIC kappa isolated
- #isolation inline = ❗️ nil <<< so the implicit parameter was NOT isolated
- Isolation IN PARAM = ✅ Optional(t6esting.Kappa)
Which, if you read between the lines of the proposal, seems intentional but feels quite off.
@hborla makes a great point that we can pass the #isolation into such function, without breaking safety:
So we can add #isolation just like I did during my experimenting around and indeed it'll give us the any Actor and it'll be fine region wise and we can't access the caller's state - this is all fine.
However... If this is meant to facilitate composition with "legacy" APIs which use the isolated #isolation pattern, I think we do have a composition problem here?
So... because the #isolationinside the @execution(caller) func async is looking for an isolated parameter, and does not find one -- since the implicit parameter added by @execution(caller) is notisolated -- we pass nil to the legacy API, and we lose isolation
This feels like a problem we should address? Shouldn't we be able to have new libraries start adopting the caller isolation/execution and be able to call the "old style" of APIs which expect the #isolation to be passed!
What could we do about it?
Calling downstream isolated (any Actor)? = #isolation methods works as expected IF we made the implicit parameter added to caller isolated functions isolated, then the #isolation works as usual and picks it up for forwarding.
This does not risk all Task {} created inside a @execution(caller) to suddenly also be isolated to it; because we'd still need to refer to the caller inside the Task for that to happen, but that's hard to by accident since the caller isn't a variable/parameter accessible unless I explicitly spell #isolation and pass it around.
This does mean forwarding the isolation if I wanted an task on the same context doesn't need the above trickery but would be some form of let caller = #isolation; Task { _ = caller } to ensure this "continue on caller"
This would scale to structured tasks as well in the future with closure isolation, with group.addTask { [isolated caller] in }
#isolation and recovering isolation with assumeIsolated
This may be a bit of a tangent, but it got me thinking if assumeIsolated should lose it's noasync:
The below snippet works around the #isolation returning null in this @execution(caller) nonisolated func async but I think (above section) this is a bug and we actually want it to return the caller, right?
@execution(caller)
nonisolated func async(
_ kappa: Kappa,
callerIsolationParam caller: (any Actor)? = #isolation
) async {
kappa.assertIsolated(); caller?.assertIsolated();
print(" - ✅ DYNAMIC kappa isolated")
print(" - #isolation inline = ❗️ \(String(describing: #isolation))") // nil
print(" - Isolation IN PARAM = ✅ \(String(describing: caller))") // Optional(t6esting.Kappa)
guard let caller else { return }
print(" Recover isolation [\(caller)]...")
await caller.assumeIsolated { isoCaller in // ❗️warning: Instance method 'assumeIsolated' is unavailable from asynchronous contexts;
// express the closure as an explicit function declared on the specified 'actor' instead; this is an error in the Swift 6 language mode
Task {
_ = isoCaller
isoCaller.assertIsolated() // OK
print(" - kappa.assumeIsolated { isoCaller in Task { isoCaller } } isolation: ✅ caller isolated")
}
}.value
}
actor Kappa {
func callAsync() async { await async(self) }
}
@MainActor
func test() async {
let kappa = Kappa()
await kappa.callAsync()
}
await test()
This snippet illustrates that technically one can make use of the assumeIsolated in an async context and have it make sense... Regardless of the caller isolation tbh, but it's related (discovered this during my exploration now).
Currently we're banning the use of caller.assumeIsolated via noasync and we'll get a "warning: Instance method 'assumeIsolated' is unavailable from asynchronous contexts; express the closure as an explicit function declared on the specified 'actor' instead; this is an error in the Swift 6 language mode" for its use.
I'm thinking that perhaps we should remove somewhat arbitrary noasync from assumeIsolated along with this proposal since now it actually can be used for some patterns.
This does not break any of the state isolation of the caller since type wise we can't access it anyway, even though we became isolated to the actor; so it's safe, just as @hborla just explained
Where do things execute decision tree
The proposal spells it right here I think, but it can be difficult to follow unless one is deeply into this.
The complete decision tree is documented on withTaskExecutorPreference, and if we were to amend it to include this proposal, I believe the intended semantics are this:
// My interpretation of the proposed rules:
/* where should it execute? */
|
NEW: /* @execution(caller)? */ - yes (nonisolated) --+
| |
no, or caller == nil |
| |
+--- no -- /* is isolated? */ - yes -> /* actor has unownedExecutor */
| | |
(nonisolated) yes no
| | |
| v v
| +=======================+ /* task executor preference? */
| | on specified executor | | |
| +=======================+ yes no
| | |
| | v
| | +==========================+
| | | default (actor) executor |
| v +==========================+
v +==============================+
/* task executor preference? */ ---- yes ----> | on Task's preferred executor |
| +==============================+
no
|
v
+===============================+
| on global concurrent executor |
+===============================+
Yeah maybe, this might be worth revisiting as we discuss the global executor hooking proposals. Currently it is named in source (public) as globalConcurrentExecutor but of course we can choose to rename it. The word "concurrent" I'm not too married to in this context.
What's in a name: execution / isolation?
Lastly, I did spend a very long time thinking about the naming implications here.
@execution(concurrent) is a clear improvement over @concurrent. It is much less confusing and signals the intent better. The "caller" makes sense, I love that bit. And "concurrent" kinda works well enough, although it is not entirely precise.
However, the concern of adding yet-another word to understand Concurrency is problematic and challanges our stated goal of progressive disclosure and "simple basics". Historically, wording we have been using was all centered around isolation which is either static (compiler enforced), or dynamic ("the runtime knows about it").
Dynamic isolation can be recovered into static isolation using runtime checks, and assumeIsolated. Previously, isolation always implied execution requirements, but this proposal kinda flips how we use those words on its head. The proposed attribute name is problematic, to me, because we inverse the wording here: "execution" has an effect on isolation now, but it's not really static isolation... I'm worried about understanding and teaching this relationship of those two new words.
The type of the isolated to parameter will always be erased to Actor, so we cannot access any of it's state. (This is great!) But still, the function is, genuinely, dynamically isolated to the caller. We can still call caller.assertIsolated() if we pass it (even not isolated) to the function, and we can even recover the isolation using caller.assumeIsolated{}, proving that the function was actually dynamically isolated to the caller.
I'm having a difficult time justifying introducing a new word to day-to-day Swift Concurrency parlance when this seems like a form of dynamic isolation, which we already have and make use of. I have re-read the Alternatives Considered and really tried getting into it deeply, but I still don't feel we explored this direction enough. Especially given the above code examples how we need to pass isolated isolation to be able to call downstream isolation accepting functions, and the semantics discussion here.
Potential naming ideas could include
@isolated(to: caller) which is a nice way to stage in the potential future @isolated(to: someParam).
And since isolated(concurrent) indeed is a bit weird, we could lean into other existing terminology: isolated + nil.
Since this @isolated is related to #isolation, and caller is using #isolation to carry the caller, we could say @isolated(to: nil) to express just that, it's as if replacing the #isolation in the isolation = isolated Actor = #isolation with a nil: isolation = isolated Actor = #isolation which tied with the semantic meaning of @isolation(to: nil) (equal to the proposal's @execution(concurrent) but without the "concurrent" which can be confusing) would give us the expected behavior of always eagerly hopping off.
If you're still reading, thank you
I genuinely am very excited to see this proposal done and it overall it is already quite fantastic!
I think this is "just" a compiler bug. The implementation of the #isolation should handle the case where it's inside an @execution(caller) function. This has to work for composition with APIs that get this behavior via the isolation: isolated (any Actor)? = #isolation dance, like you note.
Implementation details for the curious
The reason for this bug is that currently, the builtin #isolation macro is expanded before the implicit actor parameter is added to @execution(caller) functions. Today, #isolation is expanded in the actor isolation checker unless it's used inside an initializer, in which case it's expanded after SILGen because actor initializers can change isolation after self is initialized. @execution(caller) functions do not have a parameter as far as the type checker is concerned; the parameter is added in SILGen. So, that means that #isolation is expanded before there's an actor value to expand to. The solution is to simply move #isolation expansion to SILGen consistently for everything.
This is a great point! Yes, I agree that assumeIsolated now becomes useful in an async context with this proposal, and removing the noasync annotation alongside this proposal makes sense to me.
Thank you for the detailed commentary here, and thank you to everyone who took the time to write out how you're thinking about static isolation, dynamic isolation, and executors. I'm still against removing/deprecating nonisolated, and I'd like a syntax that makes sense together with nonisolated, but I'm still thinking through all of the naming options surfaced in this thread. And I'm enjoying reading about everybody's mental model, so please continue to discuss it!
IIUC, caller and someParam live in different namespaces. caller is a contextual keyword, while someParam is an identifier, accessible inside the function body. You've used caller as parameter name in your examples, but that is a coincidence. This parameter can be removed, and @execution(caller) still could be used.
Being a keyword, caller case should be similar to @isolated(any) case - i.e. no argument label.
But @isolated(to: nil) and @isolated(to: someParam) both take an expression, so it makes sense for them to have a similar syntax. And argument label comes handy here, helping to disambiguate between contextual keywords and identifiers. In hypothetical future, both @isolated(caller) and @isolated(to: caller) can co-exist, while meaning different things.
It has been a while now that I have been convinced this is the right direction.
That said, and it has pretty much already been said, but to weigh in, I have some doubts on the attributes as they are proposed. It doesn't feel totally neat to me.
the use of execution instead of isolat{ion,ed} seems a bit inconsistent to me
nonisolated and @execution(caller) seem a bit redundant
I've been mulling over this discussion all week! I'm most convinced by the argument that the way people should be thinking about this behavior is in terms of static and dynamic isolation. Konrad describes this well here:
Using these terms, this proposal provides the ability to specify the dynamic isolation of a nonisolated async function.
If a function is statically nonisolated, then it could be called from any isolation domain, so the implementation cannot access any actor isolated state. The dynamic isolation can still be some specific actor, which is why this proposal makes assumeIsolated a useful operation on async functions. If a function is statically nonisolated, then there's no isolation boundary at the call-site; the function is dynamically isolated to the caller's actor, so passing in isolated state through the parameters is okay because access to isolated state is still serialized with the actor. If actor-isolated state is passed in through the parameters, then all non-Sendable parameter and result values will be merged into the actor's region to make sure that isolated state cannot later be sent to another isolation domain.
An async function can also be dynamically nonisolated, meaning it is never dynamically isolated to an actor and therefore never runs on an actor's executor. Dynamically nonisolated functions always run on the generic/global/concurrent executor. Calling a dynamically nonisolated function does cross an isolation boundary when called from an actor-isolated context; they're not serialized with actors, so it's not safe to pass in isolated state to a dynamically nonisolated function through the parameters.
Given all of that, I like the direction of @Andropov 's suggestion here for the syntax to specify which kind of nonisolated we're talking about:
Similarly, we could literally use "static" and "dynamic" in the syntax:
Under the AsyncCallerExecution upcoming feature (which could also be renamed in light of the mental model discussion), the default of nonisolated on async functions simply changes from (dynamic) to (static). Down the road, nonisolated(static) should be increasingly rare to see in code, and nonisolated(dynamic) should be used only when you want an async function to never run on an actor's executor.
This direction makes more sense to me than any of the alternatives that use some form of @isolated. This approach definitely de-emphasizes the "caller's actor" aspect of the behavior, but I don't mind that. I don't think people will need to frequently reason about the fact that an async function is dynamically isolated to the caller's actor by default unless they're trying to deeply understand how the code works, e.g. they're trying to understand why the data-race safety rules aren't enforcing sendable checking on arguments and results.