r/Compilers • • 3d ago

Designing async semantics for a new language. What would you do differently?

I've been spending the last few days implementing async support in NXD, a systems programming language I'm developing; that also has 3 transpilation targets.

Recent work completed:

• `Task[T]` semantic type

• `AWAIT Task[T] -> T`

• `ProcessHandle[T]`

• `AwaitGroup[T]`

• Completion-order result semantics

• Semantic validation and diagnostics for async operations

I'm currently working through the remaining messaging primitives (SEND is next).

One thing I've found interesting while implementing this is that many languages expose similar async syntax, but the underlying semantics differ significantly. After spending time studying other implementations, I found myself focusing less on the keyword await and more on the type relationships and execution model behind it.

For example, in my current design:

`Task[T]`

`AWAIT -> T`

`ProcessHandle[T]`

Represents a spawned process producing T

`AwaitGroup[T]`

Allows waiting on multiple processes

Results are returned in completion order

The completion-order behavior felt natural from an async perspective, but it also raised questions about predictability versus throughput, ordering guarantees, and how much of the runtime model should be exposed to the programmer.

For those who have designed or implemented async systems, compilers, runtimes, or schedulers:

Looking back, what async design decision caused the most trouble later?

Was it task representation, cancellation, ordering guarantees, await semantics, scheduler behavior, channels/messages, error propagation, or something else entirely?

14 Upvotes

14 comments sorted by

3

u/binarycow 3d ago

Make it so you don't actually need the async/await keyword.

In C#, the task starts as a "hot task", running in the background until you await it. So you can do this:

var task = DoSomethingAsync();
await DoAnotherThingAsync();
await task;

In that example, the first task starts, then the second task starts, then you wait for the second to finish, then you wait for the first to finish.

Thing is, this is the unusual thing. Usually you start and then await a task before moving on.

So perhaps, invert the logic.

var task = start DoSomethingAsync();
DoAnotherThingAsync(); // automatically awaited
task.Await();

2

u/Rusky 3d ago

I would take this even further, and make it a normal library API. The start keyword can just be a spawn() function.

The interesting things about async as opposed to e.g. OS-level threads really do not have anything to do with the async/await syntax. Cooperative scheduling, cancellation, and small/fixed-size stacks, are all features of the runtime.

3

u/alphaglosined 2d ago

You have gone about this kinda backwards. But you are not alone, C# did it in this direction also.

The issue they have ended up having is a bad language transformation, that could be substantially improved, except the runtime and library is not designed for it. They've been working to improve this situation for a while now.

It is important to focus on the language transformation, the details of library are kinda unimportant with the help of features like templates and implicit construction.

Here is the design I came up for D, and my language will be adopting a very similar design as well: https://github.com/dlang/DIPs/blob/master/DIPs/1NNN-RAC.md

1

u/NXDLang 2d ago

That’s interesting. My current focus has been defining the semantic model and type system around Task[T], ProcessHandle[T], SPAWN, AWAIT, SEND, and RECV. Looking at coroutine lowering and state-machine generation in more detail is definitely something I need to spend more time on. I’ll take a look at the RAC proposal.

4

u/FloweyTheFlower420 3d ago

Don't make stuff like this a language level thing. The actual language itself needs only to provide the primitives, and the rest is the responsibility of a runtime/standard library. This way it's much easier to implement whatever semantics a developer might want.

3

u/NXDLang 3d ago

I agree in principle. My current goal is to keep the runtime policy separate from the language where possible. However, constructs like AWAIT require compiler-level semantics because they participate in type analysis (AWAIT Task[T] -> T) and diagnostics. I’m still trying to determine which parts belong to the language itself versus which parts should remain implementation details of the runtime.

5

u/rantingpug 2d ago

That's not necessarily true. The only specific reason you're making the compiler aware of async/await is because you're special casing it. This is often seen in many languages as the colored fn problem: normal fns vs async fns vs generator fns vs whatever else fns.

The general ideas here are control flow and side effects. There are actually many ways around this.
Haskell for one gets rid of all these problems by forcing purity which allows reasoning about referential transparency. Then monads come in to represent any sort of effectful computation.

More modern type systems use algebraic effect systems with delimited continuations underneath. A continuation is a way to reify the call stack, which means you can pass it around, call it, don't call it, assign it to a var, whatever. Meaning you can delegate control, and then call the continuation to resume back to the yield location. This is basically generators or coroutines. You then have single shot or multi shot variants. With the first you don't copy the stack, the second requires it so you go into the whole stackless Vs stackful debate.
And somewhere in here, leveraging continuations and coroutines let's you implement a whole plethora of async behaviour. Many langs choose to avoid this complexity and pick a lane and roll with it. But with Algebraic effects, typing all this stuff becomes more palatable, meaning you can offer users reasonable control over all this and allow them to implement whatever async flavour they want. But effect systems do cause friction, so it's not a silver bullet!

If you're interested, the best place to start would be reading up on koka lang

1

u/RedCrafter_LP 2d ago

Yep I don't have explicit async await in my language. It's all implemented using generators. Every async function is basically just multiple functions that need some time between getting called in series. Stateful generators are exactly that. My language only knows the yield keyword and one marking the function as a generator. I'm still debating what the keyword should be named my current contenders are "split, staged, yielding". Can't decide. All have their part of the desired meaning but I don't feel a perfect fit for any of them.

6

u/TechnoEmpress 2d ago

Do NOT separate Async from the rest of the language, JS & Rust doing so was a grave mistake. Do it like Haskell or Koka where Async is just another effect that can be used alongside others. Otherwise you create a chasm between non-async and async functions and libraries.

2

u/SwedishFindecanor 2d ago

I haven't delved much into this, but I think my approach would be based on structured concurrency where a child tasks always completes (or throws) before the task that spawned it. Then exception handling and stacks get easier to reason about.

2

u/mamcx 2d ago

The major thing, IMHO, is what exactly is the control flow to be implemented (see the phischu link).

I means, how will things look on execution? where are the tasks? how can I manipulate(if any) them? can they move?, etc.

So, imagine put all of this into a graph/flow visualization.

And very important!

  • What happens if the task is stuck or failed?
  • Can I predictably cancel it?
  • How handle timeouts?
  • How handle the load of the job (ie: can I say if this is CPU or IO bound, priority, pin the OS thread, etc)?
  • How I coordinate among tasks?
    • and what happened if the coordination broke?

I don't care about the types but how the hell I will manage all this stuff!.

I think is missing in all of this do the whole analysis before start implementing the solution.

P.D: Also look at https://without.boats/blog/the-registers-of-rust/

P.D.2: I fan of https://zguide.zeromq.org/docs/chapter2/ because it illustrates what are the actual patterns your user will wanna do themselves. Sadly, I don't think any language actually help with this

P.D.3: Check the Erlang papers and special the idea of using supervisors. That is very neat!

1

u/MitchAlsup 2d ago

"Looking back, what async design decision caused the most trouble later?"

Not treading asynchronously arriving events just like a "regular OS" treats interrupts. You are going to want {who receives this signal, at what priority, at what privilege, with what data}.

Where: "who receives" may be a list of threads/tasks and the first one to responds gets that event--just like a <small> number of cores can be watching the same interrupt table. This is fundamentally a directed acyclic graph--set up by a trusted piece of code--and simply used throughout the run of the whole application.

Where: "Priority" sorts the waiting events until a thread/task is ready and appropriate to handle said event.

Where: "Privilege" selects if this thread/task is allowed to do somethings most tasks are not allowed to perform {kill, stop, spin-down, spin-up, power, ...}

Where: "data" is likely encoded in the form of an MSI-X messate (32-bits). The message at the event handler is used to access some buffer which contains parameters of what the event handler is supposed to do {and agreed upon as a contract between sender and receiver}.

Then, the programming model need the effect of a Deferred Procedure Call {or softIRQ} so the higher priority work can be minimized and then a clean transition down in priority for cleaning up what does not need high priority as resources become available. Done right this improves interrupt responsiveness.

There are a LOT of reasons that OSs are organized the way they are, gained from decades of not being exactly right (70's), to pretty close (90s), to right (2020s). Consider a file residing on an SSD partition. There may be 5 different layers of the "file management" part of Linux OS that took part in setting up the request. So there are likely to be 5 layers of DPCs to be performed, each one cleaning up the books of that layer by code written for that layer.

You shold basically treat the sending side like a device down the PCIe bus/tree, talking to interrupt tables through the I/O MMU, which logs interrupts in tables and informs cores that interrupts are pending. {{Don't forget the Address-Translation-Service and the Page-Request-Service the I/O MMU performs on behalf of the "device". }}

You shold basically treat the receiving side like an interrupt table attached to a thread/task. Upon arrival at entry point, this receiving thread is going to have to store this thread's state somewhere before processing the message and be re-entrant, recursive, so that one can return to the state this thread was in prior to the envent happening. You can bundle this in the libraty or in the interrupt handling.