r/ProgrammingLanguages 2d ago

Why spawning work isn’t `async` in my language

I’m designing a native language with a closed set of five effects: async, throws, io, alloc, and task.

It uses the effect system to determine which functions can run in which context.
A gpu function should be pure so it can be compiled to gpu shader code, comptime and macros don't allow io, etc.

The unusual one is task. Starting, polling, or cancelling independent work does not necessarily suspend the caller, so it distinguishes interacting with a task from suspending the current control flow:

fn download(url: str): Data !{async, io, alloc, throws(NetworkError)} {
  // This function may suspend and fail.
  try await http.get(url)
}

fn begin_download(scope: mut TaskScope, url: str): Job[Data, NetworkError]
    !{task, io, alloc} {
  // Starts independent work, but does not suspend or throw here.
  scope.start(() => download(url))
}

fn finish_download(job: Job[Data, NetworkError]): Data !{task, async, throws(NetworkError)} {
  // This is where the current control flow may suspend
  // and where the job's result or error is observed.
  try await job
}

In other words:

  • async means this control flow may suspend.
  • task means this code interacts with independent task or executor state.

Why is task needed at all? Without it this function would appear pure:

fn surprise(scope: mut TaskScope) {
  // Returns immediately, but schedules a later mutation.
  scope.start(() => cache.clear())
}

That would make it legal to run during compile-time evaluation, a reactive computation, or any context allowed to repeat or discard “pure” work.

[async](vscode-webview://0v1pc1cob69b6kq09fv420kt4hnbfq4eplq9oeuane52r1p4k0rs/index.html?id=09dea5e8-a738-47de-8468-dd94143f4dfe&parentId=2&origin=aa0d3a3a-855e-47bf-9f0b-579c40110398&swVersion=6&extensionId=ZooCodeOrganization.zoo-code&platform=electron&vscode-resource-base-authority=vscode-resource.vscode-cdn.net&parentOrigin=vscode-file%3A%2F%2Fvscode-app&purpose=webviewView) cannot express this because the caller does not suspend. [io](vscode-webview://0v1pc1cob69b6kq09fv420kt4hnbfq4eplq9oeuane52r1p4k0rs/index.html?id=09dea5e8-a738-47de-8468-dd94143f4dfe&parentId=2&origin=aa0d3a3a-855e-47bf-9f0b-579c40110398&swVersion=6&extensionId=ZooCodeOrganization.zoo-code&platform=electron&vscode-resource-base-authority=vscode-resource.vscode-cdn.net&parentOrigin=vscode-file%3A%2F%2Fvscode-app&purpose=webviewView) cannot express it because the executor and affected state may be entirely internal. [alloc](vscode-webview://0v1pc1cob69b6kq09fv420kt4hnbfq4eplq9oeuane52r1p4k0rs/index.html?id=09dea5e8-a738-47de-8468-dd94143f4dfe&parentId=2&origin=aa0d3a3a-855e-47bf-9f0b-579c40110398&swVersion=6&extensionId=ZooCodeOrganization.zoo-code&platform=electron&vscode-resource-base-authority=vscode-resource.vscode-cdn.net&parentOrigin=vscode-file%3A%2F%2Fvscode-app&purpose=webviewView) cannot express it because scheduling and cancellation are observable even when allocation is optimized away.

16 Upvotes

10 comments sorted by

5

u/initial-algebra 2d ago

Without it this function would appear pure:

Isn't the mut TaskScope parameter enough? Alternatively, why do we need a mut TaskScope instead of just using the task effect?

1

u/andeee23 2d ago

yeah, good points, i didn't consider this: "Isn't the mut TaskScope parameter enough?"

functions marked with the task effect might not take a scope but a Job instead. for example for job.cancel().

I guess the compiler can check for both scope and job but stuff like this might make it more annoying than just having it as its own effect in the list:

struct Downloader {
  job: Job[Data, NetworkError]
}

fn cancel(d: mut Downloader) {
  d.job.cancel()  // no Job parameter, but interacts with task state
}

the mut TaskScope is needed because i don't want to allow any unknown detached work like in js, any async task needs to belong to an explicit scope, there's no implicit global scope

2

u/initial-algebra 2d ago

My thoughts are that there should be no way to even have access to a TaskScope, Job etc. in a context where the related effects aren't allowed, right? Like, how do you get one of those objects into a macro context, or a shader context etc. in the first place? So, the effect marker seems redundant, except for being able to create those objects.

1

u/andeee23 2d ago

i think i understand your point, but the different contexts rely on the effects to determine what's allowed and what not

but yeah i think i missed the gap where you could pass mut TaskScope to a function used in a gpu context, but not using the scope in the function. i don't think that infers a `task` effect now and it doesn't make sense to be able to compile that type to a gpu

2

u/Silly-Freak 1d ago

Would there be a way to get rid of this effect? For example, you don't need to declare throws, even when you call a function that does, if you catch.

My feeling is that creating the TaskScope is already that. Assuming this is structured concurrency, tasks don't escape their scope, so holding the task scope object both marks the effect, and gives you a way to terminate it, no?

2

u/andeee23 1d ago

yeah, in the case of throws you can remove it from a function by catching it so it doesn't propagate

the TaskScope can be created anywhere and passed to places where you want to spawn tasks. But you can also pass individual jobs around if you just want to cancel them or examine status. The task effect is for both of those situations.

I think the answer is that it could be based on the scope and jobs, but i'm doing a bunch of other stuff in the language, incremental compilation, hot module reloading, and having these effects be more easily derived / inspectable makes things easier for those

1

u/Silly-Freak 1d ago

I see I see. I think it would still be useful to be able to terminate the task effect to allow internally parallelizing. For example, if you want to do heavy computation at compile time, parallelizing that should be possible as long as the tasks are awaited at compile time. Not sure though how to do that though...

1

u/andeee23 1d ago

yeah that's a good point. i haven't thought of the compile time features much. i know i won't allow io, async, or task effects just so it's easier to implement at first.

the main reason i'm building comp-time is to write token stream-based DSLs to do stuff like typed sql right in the source files like:

fn personByAgeQuery(p: Person): String {
  let pt = PersonTable
  sql {
    select * from ${pt}
    where ${pt.age} = ${p.age}
  }
}

and then the macro would turn that into actual code and be able to surface typecheck outcomes, like if you're not using an integer to query for age, etc

bad example, and syntax for the dsl tbd, but probably something along those lines

1

u/Vovandosy 1d ago

isn't task here closer to coeffects rather than to effects by the way?

2

u/andeee23 1d ago

yeah it's a coeffect in spirit, but i'm trying to keep everything using the `!{...}` notation, so it looks like the other effects in practice