Non-suspending alternative to await?

Part of my ongoing struggle with adopting structured concurrency includes deciding how I should branch out of synchronous execution when I have an async operation that I do not want to await. Currently, the best (only?) option seems to be to create a new Task.

Has a non-suspending alternative keyword to await ever been considered or proposed? Or is the lack of a language-level feature for this use case by design?

As an example for what I mean, consider a new concurrent keyword (term stolen from this pitch):

await someAsyncOperation() // suspends
concurrent someAsyncOperation() // does not suspend

The second call would effectively behave like wrapping the first call in a new Task. Which is also similar to how async let behaves (I believe), so actually this could be nothing more than syntactic sugar for that. I think it's sugar that would make intent much clearer, however. A more novel implementation could maybe even allow it to be used from non-concurrency supporting locations.

This seems like the kind of thing that has to have already been considered at some point, but it often feels like one of the most painful omissions from the implementation of concurrency, so even if the answer is "yes, and we decided against it", that will at least help me come to terms with using Task every time.

Looks interesting at first, but a few issues become apparent immediately:

  • because concurrent implies fire and forget, it has to be an implicit closure which is already a bad idea for multiple reasons
  • implies a single expression and as such will have a limited use (unless you want to allow a block in which case it would be no different from Task { }
  • doesn't support prioritization and other parameters Task constructors can take
  • exception semantics: what happens to throws? It swallows them like Task does? If so, I think it would be a bit strange for a built-in construct to just "lose" exceptions

Maybe there is a better way, but it seems to me that something that is inherently a closure should have curly brackets in which case it doesn't matter whether the entire construct starts with a built-in keyword or a constructor.

Unless I'm missing something here of course...

All good points. It may turn out that the reason this feature doesn't exist is because of one of these, which I hadn't fully considered.

I know that async let already behaves like an implicit closure. Which is part of why I felt this change could have some legs. But async let distinctly propagates thrown errors back up when the variable is later accessed. This keyword could not (or I see no obvious way that it could).

In which case, the keyword would behave almost exactly like a default Task and swallow errors, which is behavior I already don't appreciate, so I'm not keen on proposing something that perpetuates it. It could instead only be applied to non-throwing functions, but that feels too specific to be worth the effort.

2 Likes

Undermining my prior comment a bit though, upon thinking back to the examples for why I was proposing this in the first place, they all are cases where no error is thrown or even where I am not interested in the thrown error. For example, kicking off some sort of background refresh or other task that isn't directly user initiated and should not block surrounding execution nor present an error to the user. Basically: asynchronous side effects.

Not arguing that this is necessarily enough to warrant the new language feature/sugar. Just that I think I dismissed my own case a little stronger than I should have.

1 Like

Not directly related, but I did want to note that I'm hopeful the silent throwing behavior Task is addressed soon!

2 Likes

I'm not sure about this and I don't think it's equivalent to what you are proposing.

With async let you create something known in other environments as a promise. Nothing is executed until you actually call the promise with await where you are back to square one, it's just a plain await call in the end.

Your proposal is a fire-and-forget kind of an async call where you are not interested in the result and therefore you want to continue execution of the current context without awaiting. It's a totally different thing.

My proposal is a fire-and-forget strategy, and that is distinct from async let. However, the original proposal as well as the current documentation for async let does describe its behavior as I mentioned. The core part of async let that I was referencing as similar was specifically the asynchronous background execution of the captured operation. The difference is that operations using this hypothetical keyword would never be awaited (explicitly or implicitly) from the current context.

1 Like

Still looking forward to this! And actually I'm happy you mentioned it here, because this implementation will impact the error-dropping use case.

Currently, I can do this to kick an async operation to the background when I don't care about the result/error:

Task.detached {
  try await someOperation()
}

But with that implementation, it will (happily) be this:

let _ = Task.detached {
  try await someOperation()
}

Not egregious. And I'd much rather write a little more code that makes it clear that I'm dropping an error. But, this is still added noise for a not-totally-uncommon nor invalid use case.

My goal here is definitely not to introduce another way to hide a dropped error/result. I could imagine a hypothetical implementation that maybe requires something like

try? concurrent someAsyncThrowingOperation()

when a thrown error would be consumed by the implicit task.

Maybe this still isn't good or worthwhile. It is a new keyword for a non-general use case that essentially just saves a Task/closure. But, arguably, the same could be said of async let. And I just know that, currently, trying to fire-and-forget an async API from any context feels more arduous and less obvious than I'd expect given the breadth of language-level features we have for concurrency. Like, saying "run this, but somewhere else" seems like it could have a more clear and direct syntax. Even if it's not via a new keyword.

1 Like

Heck, the implementation could simply spawn and return a Task that follows the same discarding rules of Task.detached. let task = concurrent someOperation() when you want to respond to the task or a thrown error, concurrent someOperation() when you don't. And when the discarding rules are (hopefully) updated, you must let _ = concurrent someThrowingOperation() when you want to drop the error.

This would also allow the keyword to be used from any context, just like Task.

If you're looking for an answer from a "strict" structured concurrency perspective, one of the core ideas of structured concurrency is that potential concurrency should be well-scoped. The canonical way to do things simultaneously is to use a task group:

await withTaskGroup(of: Void.self) { group in
  group.addTask { await someAsyncOperation() }
  group.addTask { await someOtherAsyncOperation() }
}

which allows for someAsyncOperation and someOtherAsyncOperation to both run independently of each other. However, the outer task will not make progress past the withTaskGroup until both child tasks complete, which keeps the extent of the potential concurrency scoped to the withTaskGroup. Swift doesn't force you to be completely strict, since it also allows for unstructured Task {}s to be started, but if you're looking to stay within the lines of structured concurrency, then it may help to instead think about the extent of "non-suspending" operations as potential concurrency scopes, think about how long you want the operation to really run for and what other sibling operations can truly run in parallel with it, and then model that group of operations as a task group.

7 Likes

Ah, this is good stuff. Admittedly, I skipped over task groups when I started learning, I think because the concept of groups was imposing while I was just trying to understand single Tasks. But I should have returned to it by now. So thank you for bringing it up.

For most of my cases as they relate to this post, I think my unstructured tasks would still be children, rather than siblings, as they're typically still dependent upon completion of a prior task, even if its own completion is ignored.

I think I could, however, do something like this in a group:

await withTaskGroup(of: Void.self) { group in
  group.addTask { await someAsyncOperation() }
  group.addTask { await someOtherAsyncOperation() }

  await group.waitForAll()

  Task {
    await someFinalOperation()
  }
}

But absent that second, parallel task, there'd be no advantage to using a group in this situation, correct?

Even if not, I suspect that part of the intention of your message was actually to get me to think more carefully about whether unstructured concurrency is actually what I want in these situations. Which I appreciate. Because you'd be right that, in most cases, there would be value in my putting in the extra effort to completely model and control all of the concurrency such that nothing is left running indeterminately out in the ether, even if I have no action to take as a response to its completion.

To reiterate what @nathanhosselton already said, this is incorrect. The child task you create with async let starts running immediately – it won’t wait until someone awaits it.

You can verify this for yourself with this code:

import Foundation

func f() async throws {
    print("Start: ", Date.now)
    async let x = Task.sleep(for: .seconds(5))
    try await Task.sleep(for: .seconds(5))
    _ = try await x
    print("End: ", Date.now)
}

This function will take a total of just 5 seconds to execute because the two 5-second sleeps are executing concurrently, even though the async-let-sleep is only awaited after the sleep in the parent task has completed.

3 Likes

I had a wrong idea about async let then, good to know, thanks!

Also interesting, if you remove the await on the promise, it's still executed and practically does what the OP wanted to achieve essentially?

I.e.

func f() async throws {
    print("Start: ", Date.now)
    async let _ = Task.sleep(for: .seconds(5))
    try await Task.sleep(for: .seconds(5))
    print("End: ", Date.now)
}

No, because async let follows the fundamental rule of structured concurrency that a child task can never outlive its parent task. If you don't await an async let explicitly, the compiler will insert code to implicitly await it at the end of the scope.

In other words, the function f() cannot return until the async let has completed. This is different with unstructured concurrency (Task { … } and Task.detached { … }), which create new unrelated tasks with independent lifetimes.

4 Likes

Today, you can do something like this:

Task { try? await someAsyncThrowingOperation() }

I just removed the use of detached here, for two reasons. First, in addition to not inheriting isolation, it doesn't inherit other context as well. But much more importantly, since the callee here is async, it doesn't really have any affect (unless an isolated parameter is involved, and in that case, is probably needed).

But I also do want to point out that there is yet another proposal pending that could influence this further.

With that in place, you could remove only isolation. Again, because someAsyncThrowingOperation is async and establishes its own isolation, I'm not sure this is actually useful for async functions, but I think it could be for synchronous ones.

Task { nonisolated in try? someSyncThrowingOperation() }

Now, in my opinion, using Task here is the best option. First, users of concurrency have to understand it regardless. Second, fire-and-forget operations don't actually come up that often, in my experience. And when they do, you usually have to think hard about them, so making them easier may not be an advantage. Finally, the concurrency system is already complex. Introducing a new keyword here would need a lot of justification.

3 Likes

Setting aside the fact that the suggestion here is just meaningless sugar over creating Task, it's also an anti-pattern. The post starts with "adopting structured concurrency", but a "fire-and-forget" task is opposite to everything structured concurrency stands for. You're not supposed to leak tasks.

If the current scope is insufficient for the runtime of the task you want to execute, what you need to do is accept a TaskGroup as a parameter to the function from which you are spawning the new task. Then, a parent scope can create said TaskGroup, in a context where the task you're creating should reasonably be expected to finish executing.

At the most extreme case, you can just wrap the entire main function (or top block) in a withTaskGroup, and just pass it all the way down. Although, tasks that are allowed to run literally until the process exits, should be extremely rare. In general, you should know in advance how long a task should be valid for, and when you can afford to wait for it to finish (or cancel it if it hasn't).

That's all I can say without a specific use case, however.

2 Likes

I do not think that characterizing any suggestion as "meaningless sugar" is appropriate.

But further, I'm not sure I think that forcing a caller to wait for internal operations always make sense. There's nothing wrong with incorporating unstructured concurrency into a system, even if it is largely structured.

2 Likes

It replaces

Task { await someAsyncOperation() }

with

concurrent someAsyncOperation()

Changing nothing about the semantics of it. It literally saves exactly one character (4 if you count spaces, although you can just delete those). How is "meaningless sugar" anything less than factually accurate in this case?

Maybe (It's a matter of opinion, and can easily be a topic of a long discussion in and of itself), but then it's just unstructured. i.e. There's no reason to use a keyword to imply that it is structured when it is not.

The moment you take such an action, you are no longer "adopting structured concurrency", in which case there's no reason to write it any differently than you would write unstructured concurrency currently.

I think it is an unkind way to phase it.

5 Likes

Makes it less cluttered and easier on the eyes for one thing. Even better with a shorter keyword, say:

async someAsyncOperation()

Probably not a great choice but just to illustrate the point.

Not defending the idea, some problems with it have been mentioned already, but yes calling it meaningless is a bit inappropriate in any case.