[Concurrency] Asynchronous functions

This is an interesting question that I'm not sure we have a clear answer to right now, because there's an ambiguity between a function that's "generic" about its executor (e.g. because it takes an argument that can only be safely called or otherwise used on that executor) and a function that's apathetic about its executor (e.g. because it's in an I/O library and is just kicking off work and waiting for a response).

Two things. The first is that the function is split, so there's extra low-level overhead on function suspend/resume; also, spilling values into an async frame is likely to have somewhat worse locality than spilling to the C stack. The second is that the function's frame needs to be allocated off the C stack, which will happen using the task-local allocator.

Our current implementation design for the task-local allocator (in theory, not actually implemented) is that it's a stack-discipline small-slab allocator that generally won't return memory to the general allocator until the task completes. That allocator will be fully torn down when the task completes, so a task handle that's now just a satisfied future does not pin any memory associated with running the task.

You could imagine an implementation strategy that copies the async task's frames on and off the C stack during suspends. That is not the strategy we use; async frames are allocated off the C stack to begin with, and anything that needs to survive an async suspension point is written into that frame.

It is expected to be an efficient inline check, or at least to have an efficient inline component.

Correct.

1 Like

async should not imply throws because that would effectively mean async functions would have implicit unwinding across arbitrarily nested frames, essentially turning Swift error handling into a C++-like exception system.

Having await imply try when async does not imply throws would be… odd.

4 Likes

My hope was to address the "apathetic about its executor" by having such function be defined on an "MyIOLibActor" which therefore has a place to define executor = MyIOExecutor.

This way the model becomes:

  • non-actor async functions are "generic about executor",
  • actor async functions always run on the actor's executor.

And there's no other ways to achieve the second style. With the existence of global actors even free functions can participate in this if they need to.

Would this model make sense?

I'm missing what about MyIOLibActor makes it apathetic about its executor.

Ok seems I misunderstood the meaning of apathetic. So an actor is definitely not that.

To sum up the semantics for clarity:

  • Actor async func
    • always adheres to the general actor rules and as such will execute on that actor's executor
  • A non-actor async func (those are not really formalized yet):
    • "executor generic" - the called function, if needed to suspend internally, will resume on the callers executor; say because a parameter must be only accessed from that executor.
    • "apathetic" (showing no interest to the calling tasks executor) - e.g. synchronized by other means, does not care where it resumes if it did await internally; could resume on some global pool or anything;

So the difference is between even switching over to the an actor handling all IO submissions, or not switching over anywhere and using some manual implementation (maybe lock protected, maybe otherwise) to schedule the async work. If an apathetic function needs to resume, it specifically "does not care where it resumes" which could offer some optimization space -- i.e. being called directly from where the task it kicked off completed etc.

Note: Those are not fully fleshed out out (!)

Did I get that right now?

How strict is the suspension point as a barrier. If, say, I have a code like this:

await task1()
let x = 1 + 1

can it move x instantiation (which is executor-apathetic) across await?

let x = 1 + 1
await task1()

Since we already optimize-out empty partial task, this seems about right, but then comes the question about what to do if the moved function consume a lot of time (either by being blocking, or just cpu-intensive). Maybe we need a notion of something that is executor-independent, but doesn't cross the suspension point.

(since this seems to apply to any executor model, not just actor, please feel free to move the comment if that's not the case)

At first blush, perhaps. But on reflection, this might be workable. After all, we can prefix a single try even if a chain has multiple throwing functions, and we don’t have try and retry just because we have throws and rethrows (for obvious reasons).

It seems natural to say that if you’re awaiting a task being completed, that task could fall: in real life, if I’m awaiting a delivery, that delivery could be damaged en route, lost in the postal system, returned to sender, etc.

To reiterate a point I made in a different thread about this:

7 Likes

I'd like to draw some attention towards and try to elaborate a bit on a part of this proposal that I don't think has been discussed yet: async initializers in classes.

Property Initialization

TL;DR = async property initializers should be disallowed.

While the proposal excludes the possibility that property getters and setters will be async, it does not specifically discuss default property values (i.e., property initializers) and whether those values are allowed to be evaluated asynchronously. Let's consider this example:

func databaseLookup() async -> String { /*...*/ }

class Teacher {
  var employeeID : String = await databaseLookup("DefaultID")
  // ...
}

The initializer for employeeID creates problems for all other explicit and implicit class initializers, because those initializers would have to be exclusively async. This is because the class's designated initializer is the one who will implicity invoke the async function databaseLookup to initialize the employeeID property. Any convenience initializers eventually must call a designated initializer, which is async and thus the convenience intializer must be async too.

So there's already an argument agianst async property initializers: they can lead to confusion and have significant knock-on effects when added to a class, so they're likely to be unpopular. Additionally, the reason for these knock-on effects that I just explained are likely to be confusing for programmers who are not intimately familiar with Swift's initialization procedures.

Furthermore, there is already a precedent in the language that property initializers cannot throw an error outside of its context, i.e., property initializers do not throw and thus its class initializers are not required to be throws. Instead, property initializers have to handle any errors that may arise either with a try!, try?, or do {} catch {} plus the {}() "closure application trick":

func throwingLookup() throws -> String { /*...*/ }

  var employeeID : String = {
    do {
      try databaseLookup("DefaultID")
    } catch {
      return "<error>"
    }
  }()

The closest analogue to await for error handling is try, but we cannot use the same closure application trick for await, because the closure itself will become async and applying it immediately puts us back where we started!

There are also additional problems when we consider lazy properties with an async initializer, since any use of that property might trigger an async call, though only the first use would actually do so. But, because all uses might be the first use, we would need to have all uses annotated with await, thus needlessy propagating async everywhere.

Due to these three factors, I think we can reasonably rule out all async property initializers.

Class Initialization

TL;DR = you must explicity write-out calls to an async super.init()

One of the key distinguishing features of initializers that are unlike ordinary functions is the requirement to call a super class's initializer, which in some instances is done for the programmer implicitly. Having async initializers in combination with implicit calls to them would create a conflict with the design goal of await. Specifically, that goal is to make explicit and obvious to the programmer that a suspension can occur within that expression.

Now, let's consider this set of class definitions:

struct Data {
  var x : Int = 0
  func setCurrentID(_ x : Int) { /* ... */ }
  func sendNetworkMessage() { /* ... */ }
}

class Animal {
  init () async { /* ... */}
}

class Zebra : Animal {
  var kind : Data = Data()
  var id : Int

  init(_ id : Int) async {
    kind.setCurrentID(id)
    self.id = id
    // PROBLEM: implicit async call to super.init here.
    kind.sendNetworkMessage()
  }
}

Note that Animal has a zero-argument designated initializer, so under the current rules, a call to super.init() happens just after the initialization of self.id during "Phase 1" of Zebra's init (according to my reading of the procedure). This implicit call could create problems for programmers who expect atomicity within the initializer's body to, say, send a network message immediately upon construction of a Zebra.

Thus, the simple and practical solution here is to adjust the rule to say that an implicit call to super.init will only happen if both of the following are true:

  1. The super class has a zero-argument, synchronous, designated initializer.
  2. The sub-class's initializer is declared synchronous.
7 Likes

Sounds good, no objection to the proposed rules.

Also, it's super important to allow async initializers I think, so glad we're discussing them slowly :slight_smile:

This is not addressed in the proposal, but if I'm understanding it correctly, then async functions can not be marked with @inliable or @_transparent, right?

There’s no such restriction, there doesn’t need to be, and it’s quite likely that we’ll have inlinable and transparent async functions in the standard library.

1 Like

Good point, I agree.

Good point, I agree.

-Chris

To be fully precise, this also needs to apply to global variables — really, anything except a local variable (which includes a global in a script).

Overall, these changes sound great; thanks for writing this up.

1 Like

The section Source compatibility says:

The positions of the new uses of async within the grammar (function declarations, function types, and as a prefix for let ) allows us to treat async as a contextual keyword without breaking source compatibility.

Have I overlooked something or has async as a prefix for let not been mentioned anywhere else? How can it be used and what does it do?

It's in the Structured concurrency proposal. Its thread in these forums is [Concurrency] Structured concurrency. Be sure to check out the other concurrency proposals and the concurrency roadmap too to get an overall idea of the full pitch.

2 Likes

Ah, thank you for the clarification! I was going to read the other proposals but have not had the time yet.

1 Like

My thought was that because the caller of an async function needs to save and restore its state at the suspension point, it seemed incompatible with inlining which removes the saving and restoring of a caller's state.

I think I have some fundamental misunderstanding in how inlining works.

If a function needs to save and restore state, it’s perfectly capable of doing that in the middle of its normal execution; it’s not constrained to only doing it during a call.

The main interesting interaction of inlining with the async model is that it means that our internal representation of functions can’t just assume that they run on a consistent actor, the way the language works: if it did, we wouldn’t be able to inline across executor boundaries. Instead we internally have to explicitly represent the executor-switching. But we want to do that anyway so that we can eliminate unnecessary executor-switching. All of that is just compiler technical design, though; it doesn’t shape the language.

7 Likes

Agreed. @kavon, do you mind raising these as a pull request against the proposal itself?

Doug

1 Like
Terms of Service

Privacy Policy

Cookie Policy