Skip to content

add delimited continuations #1530

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 8 commits into
base: devel
Choose a base branch
from

Conversation

zerbina
Copy link
Collaborator

@zerbina zerbina commented Apr 19, 2025

Summary

Add the suspend magic operator, which creates a reified continuation (a procedure + context object) out of the remainder of a routine.

Details

suspend is the best interface I could come up with so far, but I don't particularly like it and it doesn't have to be final.

The internal primitives that the language-level interface (currently suspend) is implemented with are fork and land:

  • land marks a resumption point
  • fork saves the current local context and returns it as an object together with the reified continuation for the forked-to resumption point

Each fork must be paired with exactly one distinct land, and the latter must not be reachable from the former.

fork and land use a backend-agnostic MIR lowering and are thus automatically supported by all MIR- and CGIR-based code generators (i.e., all current backends).


To-Do

  • implement the fork/land lowering pass
  • make the .tailcall lowering a MIR pass (so that it can happen after fork/land lowering)
  • rename land to resume (the existing mnkResume needs to be renamed to mnkUnwind)
  • implement the static semantics for suspend as described in the manual
  • add tests for suspend
  • write a proper commit message

zerbina added 8 commits April 14, 2025 23:08
It's a bit too magic, and dedicated abstract (and concrete) syntax
would be probably be better in the longer term. Nonetheless, it's
possible to make due with a magic call for now.
Most of the rules in the manual are not yet enforced.
The call expression is invoked as if it were the last expression in the
body, so a tail call is possible.
They are the MIR operators `suspend` will be implemented with.
It's easiest to lower `suspend` into `fork` + `land` directly in
`mirgen`, which also means that the proper control flow graph is
available right away.
It also acts as a test for the compiler.
@zerbina zerbina added enhancement New feature or request language-design Language design syntax, semantics, types, statics and dynamics. labels Apr 19, 2025
@zerbina zerbina marked this pull request as draft April 19, 2025 19:02
@@ -410,6 +410,100 @@ proc semPrivateAccess(c: PContext, n: PNode): PNode =
c.currentScope.allowPrivateAccess.add t.sym
result = newNodeIT(nkEmpty, n.info, getSysType(c.graph, n.info, tyVoid))

proc semSuspend(c: PContext, n: PNode, s: PSym, flags: TExprFlags): PNode =
## Analyzes a 'suspend' magic call, producing a typed AST or an error. If
## the call doesn't have the right shape, analysis fall back to overload
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
## the call doesn't have the right shape, analysis fall back to overload
## the call doesn't have the right shape, analysis falls back to overload

Possible typo.


c.openScope()
# create a let section and type that. This makes sure the symbol is properly
# registered everywhere, and retyping is also taken care. The initializer
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# registered everywhere, and retyping is also taken care. The initializer
# registered everywhere, and retyping is also taken care of. The initializer

Minor typo.

7. let `ctx` refer to the saved context (a value of type `Ctx`)
8. let `call` refer to the third argument expression

`call` is typed as if would the expression were the following:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
`call` is typed as if would the expression were the following:
`call` is typed as if the expression were the following:


If `ctx` (or a copy thereof) goes out scope without having been consumed by a
call to the continuation, cleanup happens as if unwinding would take place
right the `suspend` call, but without `finally`:idx: clauses being visited.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
right the `suspend` call, but without `finally`:idx: clauses being visited.
right after the `suspend` call, but without `finally`:idx: clauses being visited.

I think that's what was meant?

@@ -3070,6 +3070,12 @@ when defined(nimDebugUtils):
proc `not`*[T: ref or ptr](a: typedesc[T], b: typeof(nil)): typedesc {.magic: "TypeTrait", noSideEffect.}
## Constructs a `not nil` type.

when defined(nimskullHasSuspend):
proc suspend*[T](with: typedesc[T], name, call: untyped): T {.magic: "Suspend", noSideEffect.} =
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As of this moment, reading the PR, reminding myself or redex vs continuation definitions (Lexi's talk), and seeing as suspend creates a reified continuation, I'm inclined to say continuation is a better name than suspend.

Beyond, continuation is largely what would follow in a continuation(...): ... call, I have a feeling that suspend is a bit too evocative of something like async/await or coroutines.


The above is my current thing, and undoubtedly as I reread this PR as it evolves, and especially the docs, I'll end up with a different suggestion(s) and preference. I would just treat this as a data point.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's an alternative interface I made to look less magical:

proc suspend[T; Prc: proc](with: typedesc[T], name, prc: Prc, args: tupleFromArgs(Prc)): T

where tupleFromArgs() is a "magic" that convert the argument list into a tuple type


Re: the name suspend, I think it's a good one, as when viewed from the writer's PoV, you "stop" the procedure at the specified point, and then resume it elsewhere. The fact that it creates a continuation is merely an implementation detail so you can do the resuming part.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the generality of tupleFromArgs, it'd be handy for template/macro writing. I do think writing suspend directly will be less pleasant. Perhaps overloading can help there, with something like a typed macro extracting the caller from a call.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that having tupleFromArgs (though I'd name it differently) would be useful. It's possible to implement via a macro, but this is error prone and effectively requires using the unstable macro type API.


Here's an alternative interface I made to look less magical:

proc suspend[T; Prc: proc](with: typedesc[T], name, prc: Prc, args: tupleFromArgs(Prc)): T

As is, this won't work, for multiple reasons:

  1. name being untyped was deliberate, it's not a procedure: the parameter accepts an untyped identifier. However, changing name to untyped in your signature also won't work
  2. proper parameter passing cannot be emulated with a tuple. Of course, one could require the argument being an immediate tuple construction and then construct a call from it, but this a lot more "magical" - for both the compiler and user - than the current interface
  3. using overloaded routines as the receiver (i.e. prc) is not possible

There's also another internally-facing problem: there's no call of prc in the typed AST, which means that multiple places in the compiler would need special handling for suspend (which the current interface gets around).

Finally, keep in mind that suspend is a non-overloadable magic at the moment, and with its current shape, it cannot be anything else. In other words, the signature you see in system is largely irrelevant to the compiler -- it's only there for documentation purposes.

Ultimately, I believe that dedicated abstract (and concrete) syntax would be a better fit for this feature, though I'm rather reluctant to take that route.

Evaluation of `suspend`:idx: works as follows:
1. the current local context is saved
2. `local` is intialized with the tuple `(ctx, prc)`
3. all local variables (except `local`) or sink parameters of the caller used
Copy link
Collaborator

@saem saem Apr 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
3. all local variables (except `local`) or sink parameters of the caller used
3. all local variables (except `local`) and sink parameters of the caller used

I believe this was the intention

@zerbina
Copy link
Collaborator Author

zerbina commented Apr 21, 2025

Hm, there's one thing I didn't consider: address stability. The address of a local doesn't stay the same across a suspend, which would mean that code such as:

proc test() =
  var a = 0
  var b = addr a
  suspend ...
  echo b[]

is going to invoked undefined behaviour upon evaluation b[], as b would point into the memory of a dead frame at that point.

The only solution to this I can think of is disallowing taking the address of a location where the address value might be live beyond a suspend call. That is:

proc test() =
  var a = 0
  block:
    var b = 0
    discard addr(b) # allowed
  discard addr(a)   # disallowed
  suspend ...
  discard addr(a) # allowed, because there's no following `suspend`

Of course, this rule won't be able to reject code such as:

proc hiddenAddr(x: var int): ptr int =
  addr x

proc test() =
  var a = 0
  var b = hiddenAddr(a)
  suspend ...
  echo b[]

Finally, implementing this rule is going to be a nuisance: either sempass2 needs to be extended with a dedicated pass (prone to mistakes and hard to maintain), or a MIR pass has to be used, which would mean that neither nim check or nimsuggest will report an error.

@zerbina
Copy link
Collaborator Author

zerbina commented May 7, 2025

Some more fundamental changes to the MIR are in order for this PR to progress, I think. While not impossible, it's currently quite tricky to add types/procedures not based on pre-existing PTypes/PSyms; the same goes for modifying existing MIR types and procedures.

With how I'm planning to implement fork and land, modifying types is necessary for producing the final continuation context object, while modifying procedures is necessary for filling the continuation context's hooks with life.

Using enough hacks and maiming the compiler even more than the MIR-based implementation likely will could maybe allow for using a PNode-based AST transformation, but I'd rather not do that, even though it's likely (but not guaranteed) to be quicker to implement.

@zerbina
Copy link
Collaborator Author

zerbina commented May 11, 2025

There is another issue that I haven't put much thought into so far. At the moment (in a language without suspend), finally (or defer) may be used for implementing, among other things, clean up that must happen whenever control-flow leaves a scope.

break, continue, raise, etc. all enter finally sections, but suspend itself does not (nor can and should it). If the continuation is resumed exactly once, the aforementioned poses no problem, as the finally section enclosing the suspend is guaranteed to be run.

However, if the continuation is resumed more than once, the finally section would be entered more than once. Similarly, if the continuation is dismissed (i.e., never resumed), the finally section is never entered. Both cases would effectively break all finally-based cleanup/destruction logic.

Using automatic cleanup via destructors addresses this problem, as copying the continuation (necessary for resuming it multiple times) duplicates all locals part of the environment, whereas not using it / dismissing it (which calls the continuation context's destructor) automatically destroys all locals part of the environment.

Not all cleanup logic is able to use destructors, however, such as manually managed resources or ones whose cleanup requires additional state. This is problematic for composition. Consider the following:

template with(path: string, name, body: untyped) =
  block:
    var name = open(name, fmRead) # open file
    defer: name.close()
    body

without suspend, the template's author can rely on name always being closed (unless the code calls quit, of course), but with suspend, they no longer can, as the code passed to the template might suspend the running procedure and then dismiss the continuation.

There are multiple solutions that come to mind:

  1. ignore the problem and do nothing (a non-solution)
  2. disallow suspend within a local trys that have a finally clause (including the implicit try/finally created by defer)
  3. run the finally clauses that apply to the suspend call when the continuation (and its context) are dismissed

Number 3 is only a partial solution, however, as while it prevent the finallys from being not run at all, it doesn't protect against them being evaluated multiple times. It's also tricky (maybe even impossible given the current compiler) to implement.

Number 2 is the only solution that fully solves the problem, but it would significantly limit the usefulness of suspend, making it compose much worse.

If anyone has another idea for a solution, please let me know.

@zerbina
Copy link
Collaborator Author

zerbina commented May 20, 2025

Okay, after some more thinking, I've come to the conclusion that solution 2 (i.e., disallow suspend being within a try with a finally clause) is the only viable solution at the moment.

While less broadly applicable, suspend will still be useful, and further development of the language + compiler improvements might make it possible to lift the restriction again.

@saem
Copy link
Collaborator

saem commented May 21, 2025

While less broadly applicable, suspend will still be useful, and further development of the language + compiler improvements might make it possible to lift the restriction again.

Something to work and build with is better than nothing, and better than an architectural/design deadend

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request language-design Language design syntax, semantics, types, statics and dynamics.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants