Inline @concurrent functions

I was trying to write a light-weight inline function that runs on the global executor. It's possible, but requires a full function signature. Is that a bug, a limitation, or something else? I see no way the function should be interpreted as synchronous, but I might be missing something.

await { @concurrent () async -> Void in
  print("i am on the global executor")
}()

// error: Cannot use @concurrent on non-async closure
await { @concurrent in
  print("i am not async for some reason?")
}()

Now, of course, I find it irresistible to not mention my long-standing desire for await blocks. I know that async let can get you close, but running one little bit of code off the current actor is just a common requirement that I can't seem to let it go. Anyone else like this?

let result = await {
  // on global
  nonisolatedSyncWork()
}

Edit: perhaps async let is just a better option of the first form?

async let value = {
  print("global executor")
}()
2 Likes

As far as I’m aware, we currently don’t support async inference in that way. You either have to explicitly specify it as async or include an await inside the closure. But I think that would be a neat improvement worth considering, at least, for @concurrent.

1 Like

I started writing out a long-winded explanation, but it's maybe more instructive/efficient to just read the PR description that changed this behavior. IIUC, basically the inference rule here was changed to reduce the likelihood of future problems if/when @concurrent is allowed on synchronous functions.

Given the above, that @concurrent does not imply async, then I think it makes sense that the closure is not inferred to be async, since it does no asynchronous work. E.g. if you were to remove the annotation entirely, we'd expect the closure to be synchronous, and produce a warning like "no async operations in await expression".

IIUC that's not equivalent to your original code because the RHS of the async let runs concurrently with the caller. You'd have to await the async binding immediately to preserve the semantics you're going for I think (though maybe you just left that out for brevity). Also, it might be a bit less efficient to use async let if you don't actually need concurrent execution (i.e. the caller is just going to immediately await the result), because it has to make a child task and run it (though that's mostly speculation on my part).

2 Likes

Yeah absolutely. I think the async let-await value dance can work just fine for stuff that produces a result. And since that is often the case it can be a pretty nice tool.

Ah, right of course. I do not believe that @concurrent synchronous functions make sense, but I also understand why closing the door to them would be undesirable. Darn.

1 Like

To push on this point a little bit – why not? Assuming you think actor-isolated synchronous functions make sense, wouldn't these be a fairly natural analog?

1 Like

Yeah, but for the sake of consistency, I think it should be possible, or maybe not. It could add confusion as well.

Edit: @jamieQ Didn’t see you posted first :D

1 Like

This post was what really convinced me: [Pitch] Inherit isolation by default for async functions - #99 by gwendal.roue

The core issue is that concurrent functions are just less-useful than nonisolated synchronous functions. For an asynchronous function, it makes sense because the await is already required. But for a synchronous function, I do not think the author of the function should be able to artificially disallow synchronous execution on behalf of all clients.

Now, I might hear another argument in favor of them that sways me. But I'm pretty sure that callers should be in control here.

(which itself is another good argument for ergonomic means of shifting work to the global executor)

1 Like

Interesting, thanks for sharing that. I guess I would assume that it would still be possible to call such functions synchronously. Presumably if it's statically known that you're in a "matching" execution context, it would be allowed (same way things work with global actor isolation). I agree the utility may be rather niche, but it still seems like there's some potential value in being able to explicitly codify "function that does not run on an actor and does not suspend". Admittedly, I might just be over-weighting how it would nicely "fill in the table" of possible variations here though. At any rate... thoughts to save for a hypothetical future evolution proposal!

2 Likes

Ah interesting. So you are making a distinction from a nonisolated synchronous function, in that it would be impossible to run the function on an actor. Is this purely theoretical or do you have a use case for something like this in mind?

Yes, this is something you can't express today, which I think in some (again, maybe niche) cases you may want to do.

Mostly theoretical, but I think you can come up with some not-overly-contrived situations where it might be desirable. E.g. say you're processing a large image or something in parallel. Each "chunk" of work is synchronous, but you want to make sure they all definitely run on the concurrent/default executor. Something like the concurrentPerform dispatch API I guess is where my mind goes.

1 Like

Ok so here's my question. Why should this function's author be in the position to decide that running synchronously on some actor should be impossible? Does it not make more sense to give the callers the option and allow them to decide to shift the work off to the global executor if they'd like?

Interesting arguments for both perspectives. I am still torn about what my preference would be.

IIUC, @concurrent doesn’t force the function to run on the GCE, but respects TaskExecutor preference.

Edit: SE-0461 states this as a reason against using the name @executor:

For example, an @executor(global) function could end up running on some executor other than the global executor via task executor preferences.

1 Like

If this is true and a strong guarantee of no actor is actually impossible, I think if anything this weakens the argument for a synchronous @concurrent functions even further. Exactly what problem could the author of such of a function be solving, other than making a presumption of the intended needs of callers?

@concurrent should represent the execution semantics of nonisolated asynchronous functions as defined by SE-0338. These functions respect the TaskExecutor preference, as succinctly described in my favorite piece of Swift documentation: Asynchronous function execution semantics in presence of task executor preferences.

If you want to have absolute certainty that a function executes on a specific executor, I think an actor with a custom executor is still the best approach.

1 Like

This got me thinking, as a possible future direction, we could introduce something like @concurrent(SpecificTaskExecutor), which would be orthogonal to the @isolated attribute if we ever decide to introduce something like @isolated(SpecificActor).

Hmmm interesting. I'm certainly not opposed! I'm just not sure I understand the specific kinds of problems that this would help solve. Also, can this be statically checked?