[Concurrency] Asynchronous functions

I have to say that I'm really excited about this feature overall and think that the API for composing async functions looks great. Even in the full proposal, I don't see anything about how async functions interact with other times of asynchronous code, though.

How can an async function be made that interacts with closure-based APIs or Future based APIs like SwiftNIO? In other languages with async / await, at least ones that I'm familiar with, they all have some kind of Future type that is returned from async functions, which has a clear way of being created from a closure or other future / promise types, but I'm having trouble imagining how it would look when the async nature is part of the function signature but not the return type.

I'm particularly interested in how async / await works with NIO's EventLoopFuture, because server side swift currently suffers from the same pyramid of death problems noted in the proposal.

Hi Marcus,

This is covered in the full proposal on structured concurrency, the last section in Structured concurrency: Low-level code and integrating with legacy APis with UnsafeContinuation, please have a look.

Please refer to this thread on NIO adoption of the concurrency features: Future of Swift-NIO in light of Concurrency Roadmap

Long story short: there are a few "steps" in adoption and yes this proposal should solve the "pyramid of doom" issues you allude to in server-side frameworks.

Future values which are free to "pass around" undermine structured-concurrency and thus are a bit discouraged by this design. Please have a look at the structured concurrency design to get a feeling for this.

"Futures" still exist though -- they are called Task.Handle but should be used rarely because they miss out on some cool automatic context/priority/deadline propagation as well as bounded concurrency features.

5 Likes

Asynchronous programs that need to do intense computation should generally run it in a separate context.

It would be helpful to see an example of how to implement this. Do you mean to suggest using a detached task? I think having guidelines about when those are appropriate would be useful. I'm especially concerned about scenarios where they should be used and people don't use them because they are less convenient.

When that’s not feasible, there will be library facilities to artificially suspend and allow other operations to be interleaved.

In most cases, wouldn't a well-behaved long-running computation periodically check for cancellation? I imagine a convenient way to implement both cancellation checking and suspension would be something like func throwIfCancelled() async throws. Calling this function periodically would both introduce a suspension point and abort if the result is no longer required.

Further, it is necessary if we choose implicit task cancellation at suspension points (something I think is a good idea).

The compiler is working as the proposal states it should. The shortest trick if you want to implement your asynchronous function in terms of a synchronous one with the same signature---which I'm honestly a bit dubious about in the first place---is to wrap the call in a (non-async) closure, e.g.,

{ foo() }()

We could probably come up with a clearer way.

Doug

This exists and is called checkCancellation(), please refer to the structured concurrency proposal: https://github.com/DougGregor/swift-evolution/blob/structured-concurrency/proposals/nnnn-structured-concurrency.md#cancellation

Rationale: This order restriction is arbitrary, but it's not harmful, and it eliminates the potential for stylistic debates.

I'm not sure about this for two reasons. Firstly, I believe it's unprecedented in the language to require that a semantically unordered list be placed in an arbitrary order. These concerns are currently, and probably rightfully, left to a linter to enforce. e.g. There's no enforced order for attributes like these:

class C {
  @discardableResult @inlinable @objc func f() -> Int { 0 }
  @objc @inlinable @discardableResult func g() -> Int { 0 }
  // … etc
}

Secondly, there's no proposed general principle here, just “the order is async throws”, which makes it unclear if/how to apply it to other parts of the language (like the above example), hard to remember, and hard to extend in future. What do you do if there's a new word that can go in this position, like a new keyword or a user-defined token (e.g. I think this is where a general effects system would live)? Would the rule be “alphabetical order”? Does this apply to both the definition and the call site? What if the call site words alphabetise in a different order than the definition site words?

I would prefer that these semantically unordered lists of attributes/keywords remain unordered for simplicity.

3 Likes

Protocol compositions are an example of where the language already requires an order purely for style reasons. Protocols can go in any order, but if it includes a class, the class must go first. Class & Protocol is allowed, but Protocol & Class is not.

8 Likes

Good point. A counterpoint is that you can only inherit from a single class but can conform to multiple protocols, so it might be unordered but there are two clear groups of things to distinguish. You don't have to specify all the protocols in any particular order there, which is perhaps more comparable.

3 Likes

I think it also just makes sense. try await could be read to imply that the await operation itself can throw, which is not the case, so from that perspective await try is just more accurate.

6 Likes

In additional modifiers that start with @ have to go before non-@ modifiers.

It seems like cancellation is talked about in the Structured Concurrency proposal. But I'm having a hard time putting the concepts in that proposal and the concepts in this proposal together. I'm wondering how a consumer of an asynchronous method would cancel a call to func processImageData2() async throws -> Image. Is it by wrapping it in a Task and then cancelling the task?

Yes, exactly. Note that async functions are always running as part of a task.

Relatedly to tourultimate's question, I think it would be helpful to have some of the simpler examples also expressed in a way where the Task and "nursery" entities are visible. It would help bridge the gap between this and the structured-concurrency proposals.

Thank you for such a well written and thorough guide!

I agree with everything in it, I just have some questions concerning the performance implications of async functions outside of actors. Are such functions executor agnostic, that is they can run on the executor of an async calling function without needing a context switch? Would there be a suspension point between two actor-less async function calls, an actor to actor-less call, vice-versa, etc?

asynchronous functions are able to completely give up that stack and use their own, separate storage . This additional power given to asynchronous functions has some implementation cost

What kinds of costs are we talking about and in what situations? E.g. register copies vs early-exit calls into the Swift runtime vs heap allocations? Where is this separate storage and when is it allocated/initialized/deinitialized/freed?

“ (In practice, asynchronous functions are compiled to not depend on the thread during an asynchronous call, so that only the innermost function needs to do any extra work.)”

What is this saying? Could you give an example?

“If the callee’s executor is different from the caller’s executor, a suspension occurs and the partial task to resume execution in the callee is enqueued on the callee’s executor.”

Is this determination statically known, or do we have to consult the runtime?

Asynchronous function types are distinct from their synchronous counterparts

Another benefit of allowing overloading on async is it works around the situation where a type wants to implement two protocols with similarly named requirements that differ in asynchronicity.

A suspension point must not occur within a defer block.

Does that mean that an await cannot occur in a defer block, even if the function is async? Or just an await that explicitly hops executors?


A situation I'm concerned about is having an asynchronous source of data from which I fill up a buffer, and then synchronously vend many Ts (until I need to refill the buffer).

struct TStream {
  var (buffer, source): (Array<UInt8>, DataSource)

  func next() async -> T? {
    // Only happens 1/1000 times
    if buffer.count < threshhold { await source.refill(&buffer) }
    ... // Synchronous code
  }
}

struct ArduousTSteam {
  var (buffer, source): (Array<UInt8>, DataSource)

  /// Make sure to call this if `next` tells you to refill,
  /// otherwise you'll get a premature `nil` result the
  /// next time you call `next`.
  func refill() async { await source.refill(&buffer) }

  func next() -> (T, callRefill: Bool)? {
    ... // Synchronous code
  }
}

TStream is a much nicer API, but what is the overhead of next() being async over burdening the user with the buffering concern ala ArduousTStream?

If the caller of next() is async anyways (whether actor or actor-less), is there overhead for TStream vs ArduousTSteam?

1 Like

I currently have no opinion on this debate, but I'm very interested in hearing rationale for why async/await shouldn't also imply throws/try.

Maybe I missed it but how can we "convert" an async func to synchronous?

I think the biggest problem is probably that people reading await aren’t going to think about it being an unwind point.

5 Likes

Yeah, this. Being able to say "ok there's a catch here, ALL I need to do is look for try to see what might cause it to run" is very powerful for readers.

2 Likes

Do you mean "what might cause it to throw?" I think people would learn that they also need to look for await pretty quickly. Requiring people to learn that seems like it might be worth it. Requiring await try everywhere seems like something we might regret someday. That said, this is a change that could be phased in later without breaking code so it could be considered later.

People will learn and adapt to almost any internally consistent set of rules, so ultimately I agree it doesn't matter that much. But the 1:1 correspondence is nice and simple.

2 Likes