[Pitch] Inherit isolation by default for async functions

This is a bit of a worry of mine under this pitch, that people will take it as a reason to worry/reason less about reentrancy in ways that are fragile. Yes, stickiness means that the window of reentrancy may be shrunk, but except for the narrow case of an async function which contains no suspension points (which this pitch would obviate the need for), it would still be necessary to reason about what will happen in the suspension case. Users who rely on the stickiness property to avoid reasoning about reentrancy may find themselves surprised, even if this pitch makes some additional cases function as expected. And users who are relying on certain behavior happening before any suspension/actor hop are putting themselves in IMO a more fragile position by relying on the implementation details of an async function rather than on the local isolation guarantees.

5 Likes

I should've probably given an example, but what I had in mind is far away from stateless types: I'm simply talking about sendable classes that use locking, atomics or other means of synchronization, for which it could be reasonable to choose to "always run me on the generic executor" as a type-level declaration. Database handles or message brokers come to mind, for instance.

1 Like

Right, improving reasoning about actor re-entrancy is not the motivation of this pitch. This pitch is about eliminating places where programmers need to confront data-race safety errors when they weren't actively trying to share values between isolation domains. I think that people should still reason about actor re-entrancy in the same way they did under SE-0338, because you'd otherwise have to rely on implementation details of the async function to know exactly when the suspension happens. Of course, people still will fall into patterns where they rely on such implementation details, but I don't think this pitch makes that phenomenon any worse than it is right now. Right now, people resort to expressing this explicitly using isolated parameters, or they just can't write the code they need to write at all if they aren't in control of the async function declaration.

From my perspective, the biggest problem is it makes a semantics-and-ABI-preserving migration impossible. We can debate when conceptually it makes sense to apply an attribute like @concurrent on the library side versus explicitly running code on the generic executor from the caller using tools like child tasks and Task.detached, but I don't think we can pursue this idea at all unless we have a way to preserve the semantics of existing code.

(Edited to quote a more descriptive part of Andrew's comment)

I tried to make this clear in the proposal, but this is not possible without passing down the actor value into the function as a parameter. Without the actor value in the callee, we'll admit data-race safety holes. This has to be something that is specified in the function declaration itself (even if implicitly as proposed here).

6 Likes

Isn't this possible to implement with the current syntax?

func myFunc(isolation: isolated (any Actor)?) async {
}

actor I {
    func isolatedMethod() async {
        await myFunc(isolation: self)
    }
}

nonisolated func anotherFunc() async {
    await myFunc(isolation: nil)
}

Then what @Andrew_Hoos suggested can be achieved with the same implicit isolation parameter just different syntax, if I get this right?

Fair enough, but it is significantly more valuable for the purposes of isolation, where it's hands down a better approach.

people reach for Task and then run synchronous functions inside it

This does seem like misuse.

I interpreted Andrew's suggestion as a caller-side syntax that works with existing nonisolated async functions that do not accept an isolated parameter. Otherwise yes, you can certainly write an async function that allows the caller to decide the isolation using an isolated parameter.

1 Like

IIUC under this proposal nonisolated async functions will have implicit isolated parameter, which then allows to achieve the same as code snipped above just with caller-side syntax? In that case we can have

isolated await myFunc()  // implicitly passing current isolation

to inherit isolation, and

await myFunc()  // implicitly passing nil isolation

will behave the same way as of right now. Or am I just understood this wrong?

1 Like

I thought the same, but on re-read I think the suggestion is actually:

  • Adopt the portion of this pitch which gives all nonisolated async functions an implicit isolation: parameter.
  • Don't adopt the portion of this pitch which defaults the argument to #isolation, and instead introduce language syntax for calling a nonisolated async function with the current isolation.
4 Likes

Ah I understand, thank you. I think that's an option, but I don't think it solves this problem

You'd still get a data-race safety error when you start to write async code on a non-Sendable type and call it from the main actor. You'd have an easier way to fix it, but I still think that starting to write async code is not the right point of the progressive disclosure curve to surface data-race safety errors. I think ultimately this is a tradeoff between the ideal long-term programming model for data-race safety at the cost of invalidating mental models, educational material, etc, that people have today, versus avoiding those challenges and having a suboptimal default that makes the learning curve harder in the long term.

6 Likes

I really like this.

I'm still convinced that the callee decides is the best default for longterm simply for local reasoning in the same way that value types make Swift so great. Though I understand the need for stickiness. If @Jumhyn above comment were to be applied, a await(sticky) function() like syntax would give us both, fixing your goal I think.

What if callee resides in a public library (like Foundation), and you have control over caller only? How can you reason about your code then?

Edit: Or it should "just work" in any case?

async let doesn't allow you to run synchronous functions off the actor, only async ones. I do like that defer-like await of your example, if it were possible, I'd allow me to write synchronous functions that can optionally be called async in the global executor easily, whenever it's needed.

let result = try await {
  // decode wasn't declared async by the library author
  decode()
}

That would make it not necessary to declare an otherwise synchronous function (no awaits in its body) as "async" just to run them off the actor.

It does!

import Foundation

@MainActor
func f() async {
    async let x: Void = g()
    await x
}

func g() {
    print(Thread.isMainThread) // false
}

await f()
9 Likes

thank you I learned something today! I'd be nice if it were documented, this is such a useful feature Documentation

3 Likes

If you want a better name for @concurrent, it might just be @globalExecutor. Which seems to me is a clearer statement about what this does. As @hborla mentioned, I guess something like this is a necessity for migration.

Otherwise developers could define their own isolation domains (a.k.a. global actors) and tag methods with those instead. This is also one other annoyance of mine that Swift Concurrency makes it easy to reach for new actors rather than making it easy to reach for new isolation domains, which you could use to tag an group many things together (and that seems more interesting to me).

4 Likes

My assumption, but I guess I don't actually know for sure, is that the global executor can run more than one sychnronous task at a time. Custom global actors cannot do this, but I agree it comes close.

Indeed but they have other advantages, for example a nonisolated async function that was used for background work can now be made synchronous and can make synchronous calls to other functions from the same isolation domain. That's quite something already. Parallelism is a more complex subject I think, could still be achieve with more isolation domains, or maybe still the global executor.

2 Likes

I wonder whether adding a parameter to nonisolated (e.g. nonisolated(concurrent) / nonisolated(pool)) is something that would work for this?

IIRC the global executor defaults to using up to 1 thread per core per QoS level, so yes. It is not required to be able to execute work concurrently, but in practice it does.

1 Like

Obviously there’s a challenge here around making a change after people have started adopting concurrency. But I think it will be mostly safe. Running a JSON or image decoder on main isn’t ideal, but it won’t cause an app to crash.

My experience after explaining Swift concurrency to a lot of my coworkers over the last year is that the existing behavior is a big point of confusion and bugs. As stated in the proposal, the fact that sync and async function behavior is essentially opposite is confusing.

5 Likes