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.

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