SE-0461: Run nonisolated async functions on the caller's actor by default

I think nonisolated(static) and nonisolated(dynamic) are pretty confusing, to be honest. I think trying to reason about the negation makes it somewhat difficult: for isolated code, a static isolation is the stronger guarantee—that is, if some function is statically isolated then it must necessarily be dynamically isolated. But this reasoning ends up reversed for nonisolated, so that nonisolated(dynamic) ends up being the 'stronger' guarantee about the nonisolated—if some code ends up dynamically nonisolated then it must not have been statically isolated either.

I anticipate myself having to rederive this relationship every time I come across this syntax—IMO the 'caller' and 'concurrent' nomenclature was much more easily grokkable.

(This is aside from the fact that, AFAIK, we have not yet introduced static into the language surface with the colloquial 'at compile time meaning', which makes me feel like it's a bad choice even if it's paired with dynamic as the foil. I would be loathe to explain that nonisolated static func is actually something different than nonisolated(static) func. :sweat_smile:)

10 Likes

Yeah, thinking through what "statically nonisolated" means is almost similar to conformance suppression. It means the compiler does not know anything about the actor that the function will execute on at runtime. This is already the case for synchronous functions. You can have a synchronous function that is nonisolated but MainActor.preconditionIsolated() will always succeed if it's only ever called from the main actor at runtime. The way we describe that today is that the function is nonisolated at compile time, but it's dynamically isolated to the main actor.

Is it just the syntax suggestion that you find confusing, or the fundamental way that we've been describing this behavior?

I do share this concern with what I suggested:

And I didn't even think about this :face_with_tears_of_joy:

1 Like

I think the concept has a certain amount of inherent complexity: static isolation is intimately intertwined with the concept of where/how code actually runs, and both of those properties are important for understanding the overall soundness of your program—I don't see a way around having this sort of discussion at least in the docs.

But I worry that the specific spelling requires a bit too much 'reasoning' to understand the actual consequences. I suppose part of the objection here is that when I've deliberately made use of the 'nonisolated async functions run on the global executor' behavior, I've generally thought of that as a more addititve step to move code to a specific executor rather than a subtractive step of 'remove all possible isolation concepts from this code'. So to me something like @execution(concurrent) feels like a more direct expression of why I'm actually using the feature. This is reinforced in the current status quo because how I've typically accomplished this is not by creating a nonisolated async function, but by adding a new function that is lexically outside any isolation inheritance position, then 'adding' async to it with the knowledge that at the implementation level this is equivalent to telling it to execute on the global executor.

Now, I was able to learn and internalize that relatively opaque behavior of async, and so I'm sure I could learn it for nonisolated(dynamic)—but I worry that it's contrary to progressive disclosure to force the idea of 'static' and 'dynamic' isolation into the surface syntax here as the primitive (and treat the concurrent execution as the implementation detail) rather than name this with the outcome-oriented spelling as the primitive and treat static and dynamic isolation as the 'model level' implementation detail. I could probably be argued out of this position, but my gut reaction to @execution(concurrent) was much better than to nonisolated(dynamic).

5 Likes

It feels like there is some sort of dual here with @isolated(any). Recall that we in fact introduced the concept of "dynamically isolated" and "dynamically non-isolated" with respect to the runtime behavior of @isolated(any) synchronous function values in SE-0431.

Well, StaticString and StaticBigInt :slight_smile:

2 Likes

Heh, fair :slightly_smiling_face:

I'd still contend that usage in a type name and not a function modifier has a much lower chance of confusion, but at least this usage would not be totally standalone!

My brain suggested ~nonisolated func when reading your comment

How about meeting in the middle with something like:

nonisolated(caller) // the new default
nonisolated(concurrent) // the current default

Also, I wonder what happens to things like nonisolated var/let declarations when repurposing the nonisolated keyword. For things that aren't functions there is really no distinction I would assume...

Glad to see we may try to lean into the static / dynamic, however the spelling with "static" and "dynamic" and 'nonisolated' seems so confusing that even myself who works with these daily got very confused which one implies what. There's a lot of negation going on here in a nonisolated(dynamic) -- this doesn't really explain what but how to me :thinking:

I'll make some time to think about all the cases's spellings again especially with how nonisolated can be kept around. The isolated(caller) however we'd spell it with a @ or without seems like something that's the most understandable and expresses what we're trying to achieve so I'd try to keep that and see what we can do with the rest :thought_balloon:

4 Likes

I talked myself into thinking that we should spell this as if the global concurrent executor were an actor—or just make an actor for it—and mark such code as @Global (or pick your spelling) to make it run there.

I think saying “nonisolated means this function is happy to run on any (or no) actor, including the caller, thus can’t access isolated state” would be easy to teach. Similarly, using @Global to make code run on the global concurrent executor plus imply it can’t access isolated state, would also be easy to teach. Both would also be on the easy side to read and understand, IMO.

12 Likes

Not a bad idea actually!

it's not an actor per se but it definitely is one of the "well known" places to run things on -- things like the "default global executor" is something one can become familiar with intuitively and its naming is right as well... It also plays well with @al45tair's work on allowing customization for the global concurrent executor, so the word "Global" in contrast to "Main(Actor)" is actually pretty good isn't it :thinking:

I wonder if @Global is too unclear what it means, there's global properties and state as well, but perhaps @GlobalExecutor is actually close enough!? We're not saying "on an actor" but "on an executor" and it is very familiar looking to the omnipresent @MainActor in a good way. It is also clear that it's not an actor so therefore there's no static isolation to be expected here. It also does not suffer from the "concurrent" word confusion, hm.

Listing the various combinations:

  • :white_check_mark: func async() async - default, which now becomes "isolated to caller"
  • :white_check_mark: @GlobalExecutor func asyncGlobalEx() async - ok; "hop off eagerly" (to global executor)
  • :white_check_mark: @GlobalExecutor nonisolated func asyncGlobalEx() async - ok, same as above, explicit nonisolated is fine
  • :x: actor A { @GlobalExecutor func asyncGlobalEx() async } - error, isolated + global executor is not a thing
  • :white_check_mark: actor A { nonisolated func asyncGlobalEx() async } - in the new default this is the "isolated to caller" still then
  • :white_check_mark: actor A { @GlobalExecutor nonisolated func asyncGlobalEx() async } - ok, hop off quickly

Need to think through this some more but might be smart

8 Likes

Shouldn’t it be @PreferredTaskExecutor?

We could have both @GlobalExecutor and @PreferredTaskExecutor, but now we’re basically controlling execution, instead of isolation.

1 Like

Yeah there indeed is the question that if it looks like the actor annotations, should it be winning over a task executor preference or not.

These sound quite good though indeed!

I’d like to add the following option, which I think could be quite clear:

@isolated(caller)
@isolated(never)
3 Likes

Is there anything against @Andropov's original spelling? I feel like people wouldn't have to think about a lot when reading nonisolated(strict). There are no double negations and also no context specific words that would need to be learned like dynamic or static.

If we are considering the nonisolated spelling, are closures also going to have the nonisolated spelling? Will this be the first non-@ closure attribute, or would it be @nonisolated(xyz) then (or am I missing something)?

nonisolated(strict) sounds as if it has something to do with strict vs lenient diagnostic.

Being a person, who by default enables all possible warnings and sets treat-warnings-as-errors to true, my first instinct here is to always use nonisolated(strict) and avoid using nonisolated(optional) as a bad practice.

Which has nothing to do with the actual behavior, so IMO, this naming is confusing.

6 Likes

Small note on why I suggested strict: for me, a key moment in internalizing how nonisolated worked was when I (half jokingly) started referring to it as "maybeIsolated". To this day I still think it's a bit unfortunate that there's such a thing as "a nonisolated isolated function" (a dynamically isolated function with no static isolation requirements).

So one of the reasons I thought about nonisolated(strict) is because seeing it could give people pause and led them to realize that "plain" nonisolated doesn't mean that the function is never isolated, just that it has no static isolation requirements. Which feels even more important now that async functions will also default to being statically nonisolated + dynamically isolated to the caller.

The other reason is that the desired behavior naturally felt to me like some sort of "intensifier" of the existing nonisolated: does this function need actor isolation? No? Then it's nonisolated. Should this function ever run on an actor? No? Then it's nonisolated(strict).

I did think of the parallels with diagnostics, but I thought the placement would be unusual for a diagnostic-modifying keyword.

FWIW, I didn't like nonisolated(optional) at all, I think it was a terrible name, but I couldn't come up with a good opposite to strict. Maybe relaxed, at least that one isn't already taken by a completely different concept.

I like @GlobalExecutor! It's a bit too technical, but I think it doesn't force the developer to think as hard about how execution relates to isolation as execution(caller) / execution(concurrent). The leading @ provides the right intuition, and in the end it'd just end up being a new keyword to learn. Though I wonder whether that similarity with actor syntax could led people to think @GlobalExecutor stuff is somehow isolated too.

Hmmm I would have expected this to work for the same reason @MainActor works. actor A { @GlobalExecutor func asyncGlobalEx() async } could simply not inherit actor isolation due to the incompatible @GlobalExecutor annotation and be implicitly nonisolated instead.

2 Likes

I find this argument very convincing. A better spelling for a specific executor that a function runs on (which I think was suggested by somebody else upthread) is @executor(concurrent) / @executor(global).

Personally, I think it's much more important to optimize the syntax for the @execution(concurrent) attribute, because this attribute will be used long term to move functions and closures off of actors. I view @execution(caller) as necessary for the transition to this new behavior, but it's not a syntax that will stick around long term in Swift codebases; the ideal end state is that this is expressed via the default behavior for (explicitly or implicitly ) nonisolated async functions. I'm also open to spelling @execution(caller) / @executor(caller) differently from @execution(concurrent) / @executor(concurrent) / @executor(global) instead of using one base attribute if we think there's a better option.

This direction definitely requires us to standardize the term for "the generic, non-actor executor" / "the global concurrent executor" / your favorite term for this executor. That's a good thing though - we have too many terms for this executor floating around.

10 Likes

This indeed is a promising direction -- we're don't need to change that much from the original proposal:

The @execution invokes the right mental image, and the caller is fine as well (and we expect to see it very few times in the future, as the default switches.

The only thing that was bothering me with that was the "concurrent" which was confusing, so if we can lean into the executor meaning then the "where does it execute" makes a lot of sense: on the global executor.

We currently have only one codified location of that executor and it's a global property that we are going to have to deprecate and @al45tair is currently working on a proposal that would call it the Task.defaultExecutor (static property, get only). So the @execution(defaultGlobal) / @executor(global) looks really consistent then.

It also is specifically the default global executor and not just the "active executor" where the active one is taking into account the task executor preference...

We need to answer one last question the, that @orobio mentioned:

  • if there is a task preference set, does it apply to @executor(global) functions?

Both answers (yes or no) make sense... Previously there was "no executor requirement" on such methods, so the preference would apply. Now we're spelling out an actual executor in source... is it a requirement or a preference to run on this executor (@executor(global) I mean)?

Going in all the way with this is a bit too much probably... I don't think I'd want to say @executor(require: global) / @executor(prefer: global) to be honest...? Or maybe? I wonder what people think about this.

Specifically this piece of code:

await withTaskExecutorPreference(something) {
  // something
  await test()
}

@executor(global)
func test() async {
  // 'something' or 'global'
}

?

// either @executor() or @execution are fine

4 Likes

Let’s assume that that answer is “yes” - to maintain the status quo. IIRC, there haven’t been any arguments for challenging it.

But in that case syntax @execution(global) indeed can be confusing. Then we can reframe the problem as “what should the syntax be to make it clear that task executor preference is respected?”.

The title of SE-0417 is somewhat ambiguous:

  • “Task (Executor Preference)” - task has a preference for an executor.
  • “(Task Executor) Preference” - there is a preference for task executor.

If it is the latter, i.e. “task executor” is a thing, then @executor(task) sounds like a right way to express that executor preference is respected.

2 Likes

The discussion here has been really great, and I think I've changed my mind on some of the syntax in the proposal.

First, I'm in complete agreement with Holly's point about the importance of these two attributes. If we think about this, not from the perspective of where we're coming from, but from the perspective of where the language ought to be, I would summarize it like so:

  • nonisolated functions default to running with the same isolation as their caller, and
  • there's a specific feature to override that and made them run with no isolation.

@execution(caller) is a much more marginal attribute, since it merely repeats the default mode of a upcoming feature. It is basically just a transitional attribute that I wouldn't expect to still be appearing in code a few years from now. It should not really be influencing our decision about the best name for @execution(concurrent).

Second, I think it is a mistake to use @execution or @executor in these names. There are two levels at which to understand how functions are executed in Swift concurrency: the high level of actor isolation and tasks, and the low level of executors and threads. The behavior laid out in this proposal can be completely understood in terms of isolation without talking about executors at all; I explained it in those terms at the top of this post. In fact, trying to understand it in terms of executors can be misleading, both because isolation does not always map naively to executor requests and because executors are used for other things than isolation.

In particular, if you could declare a function as @executor(global), it would be very confusing to then have to explain that well actually such a function doesn't always run on the global executor because @executor(global) is really just a request to be dynamically non-isolated, and a dynamically non-isolated function will run on the current task's task executor instead of the global executor if one has been set.

As a concrete counterproposal, I think we may have been too hasty in dismissing @concurrent. I understand the objections that concurrency can arise from other things, as well as that, conversely, the execution of a @concurrent function is not concurrent from the local perspective of the current task. And yet I can't help but think that these objections shouldn't really be fatal. It's true that concurrency can only arise if there are multiple "impetuses" (such as tasks or event sources) in the program that are running with different isolation. But for the most part, we can assume that there are multiple impetuses; and while those impetuses might otherwise share isolation, @concurrent is the only isolation specification under this proposal that guarantees that they do not and therefore forces concurrency. Indeed, we expect that programmers will be reaching for @concurrent exactly for that reason: they want the current function to run concurrently with whatever else might happen in the process. So I think @concurrent really does say something meaningful, and I'm not too worried that programmers will see it and imagine that there can only possibly be concurrency if one of the functions involved is @concurrent.

As for @execution(caller), well, I'm still not completely convinced it's necessary, but if we need it, I think @isolated(caller) would be a perfectly suitable spelling. It is admittedly a little weird to see @isolated(caller) nonisolated, if we decide to allow that combination (we don't have to). But it's weird because it's explicitly stating a rule that's weird: a non-isolated function can in fact still be dynamically isolated to something. Unless we deprecate nonisolated and go in search of other names for the concept — which I agree with Holly would be a mistake — that's just the rule we have, and have always had in the case of non-async functions. For a transitional attribute that we don't expect to see much in real code in the long run, I don't think that's enough of a problem that we should try to complicate it.

21 Likes