From c9bc2f59a3f0040e33942a550250fded0e37f874 Mon Sep 17 00:00:00 2001 From: "Edward A. Lee" Date: Sun, 11 Oct 2026 17:14:51 -0700 Subject: [PATCH 1/5] Added doc page for async-await --- docs/assets/code/c/src/AsyncUserInput.lf | 64 ++++++++ docs/assets/code/c/src/AwaitLET.lf | 40 +++++ .../assets/images/diagrams/AsyncUserInput.svg | 1 + docs/assets/images/diagrams/AwaitLET.svg | 1 + docs/sidebars.ts | 4 + docs/writing-reactors/async-await.mdx | 142 ++++++++++++++++++ 6 files changed, 252 insertions(+) create mode 100644 docs/assets/code/c/src/AsyncUserInput.lf create mode 100644 docs/assets/code/c/src/AwaitLET.lf create mode 100644 docs/assets/images/diagrams/AsyncUserInput.svg create mode 100644 docs/assets/images/diagrams/AwaitLET.svg create mode 100644 docs/writing-reactors/async-await.mdx diff --git a/docs/assets/code/c/src/AsyncUserInput.lf b/docs/assets/code/c/src/AsyncUserInput.lf new file mode 100644 index 000000000..6652d0137 --- /dev/null +++ b/docs/assets/code/c/src/AsyncUserInput.lf @@ -0,0 +1,64 @@ +target C { + keepalive: true +} + +reactor UserInput { + preamble {= + #include + #include + + // Read a line from stdin into dynamically allocated memory, dropping the + // trailing newline. The caller must free the result. Returns 0 on EOF or error. + size_t read_string(char** result) { + char* buffer = NULL; + size_t capacity = 0; + ssize_t nread = getline(&buffer, &capacity, stdin); + if (nread < 0) { + free(buffer); + return 0; + } + if (nread > 0 && buffer[nread - 1] == '\n') { + buffer[nread - 1] = '\0'; + } + *result = buffer; + return nread; + } + =} + timer t(0, 5 s) + output out: char* + state count: int + physical action awake + + // This reaction must precede the reaction that calls lf_async on the physical action. + // It causes that reaction to resume at the logical time of the physical action. + reaction(awake) {= + lf_async_resume(awake); + =} + + reaction(startup) -> out, awake {= + while (true) { + char* line = NULL; + int nread = lf_async(awake, read_string, &line); + if (nread == 0) { + lf_request_stop(); + break; + } + // The logical time is now the physical time at which the line was read. + lf_set(out, line); + } + =} + + reaction(t) -> out {= + char* prompt = NULL; + asprintf(&prompt, "%d: Waiting for input...", self->count++); + lf_set(out, prompt); + =} +} + +main reactor { + user_input = new UserInput() + + reaction(user_input.out) {= + lf_print("At logical time " PRINTF_TIME ", Line: %s", lf_time_logical_elapsed(), user_input.out->value); + =} +} diff --git a/docs/assets/code/c/src/AwaitLET.lf b/docs/assets/code/c/src/AwaitLET.lf new file mode 100644 index 000000000..1b9f6f0be --- /dev/null +++ b/docs/assets/code/c/src/AwaitLET.lf @@ -0,0 +1,40 @@ +target C { + timeout: 1 s +} + +reactor RequestWork { + input result: int + output request: int + timer t(0, 200 ms) + state count: int = 1 + + // This reaction must precede the reaction that awaits the result. + // It causes that reaction to resume at the logical time of the result. + reaction(result) {= + lf_await_resume(result); + =} + + reaction(t) result -> request {= + lf_print("Request work at logical time " PRINTF_TIME, lf_time_logical_elapsed()); + lf_set(request, self->count++); + lf_await(result); + lf_print("Result %d received at logical time " PRINTF_TIME, result->value, lf_time_logical_elapsed()); + =} +} + +reactor DoWork { + input request: int + output result: int + + reaction(request) -> result {= + lf_print("Do work starting at logical time " PRINTF_TIME, lf_time_logical_elapsed()); + lf_set(result, request->value * 2); + =} +} + +main reactor { + request_work = new RequestWork() + process_work = new DoWork() + request_work.request -> process_work.request + process_work.result -> request_work.result after 100 ms +} diff --git a/docs/assets/images/diagrams/AsyncUserInput.svg b/docs/assets/images/diagrams/AsyncUserInput.svg new file mode 100644 index 000000000..719131434 --- /dev/null +++ b/docs/assets/images/diagrams/AsyncUserInput.svg @@ -0,0 +1 @@ +AsyncUserInputUserInput(0, 5 s)123Pout \ No newline at end of file diff --git a/docs/assets/images/diagrams/AwaitLET.svg b/docs/assets/images/diagrams/AwaitLET.svg new file mode 100644 index 000000000..04386e6b0 --- /dev/null +++ b/docs/assets/images/diagrams/AwaitLET.svg @@ -0,0 +1 @@ +AwaitLETRequestWork(0, 200 ms)12resultrequestDoWorkrequestresult100 ms \ No newline at end of file diff --git a/docs/sidebars.ts b/docs/sidebars.ts index df32ec6ed..4f34ba6c6 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -104,6 +104,10 @@ const sidebars: SidebarsConfig = { "type": "doc", "id": "writing-reactors/preambles" }, + { + "type": "doc", + "id": "writing-reactors/async-await" + }, { "type": "doc", "id": "writing-reactors/distributed-execution" diff --git a/docs/writing-reactors/async-await.mdx b/docs/writing-reactors/async-await.mdx new file mode 100644 index 000000000..66076d6a2 --- /dev/null +++ b/docs/writing-reactors/async-await.mdx @@ -0,0 +1,142 @@ +--- +title: Async and Await +description: Suspending a reaction to wait for a blocking call or a future input without stalling logical time. +--- + +import { + LanguageSelector, + NoSelectorTargetCodeBlock, + ShowIf, ShowIfs, ShowOnly, +} from '@site/src/components/LinguaFrancaMultiTargetUtils'; + + + +:::note + +`lf_async` and `lf_await` are currently supported only in the `C` target, and only with the threaded runtime using the default (`NP`) or `GEDF_NP` scheduler. + +::: + +A reaction normally runs to completion at a single tag. Its body cannot wait for anything: if it makes a blocking call, such as reading from a socket or waiting for keyboard input, the whole program stalls, because logical time cannot advance while a reaction is executing. And if a reaction needs a result that another reactor will produce at a later tag, it has to end, and a *second* reaction has to pick up the result when it arrives. Anything the first reaction wants to use later must be copied into state variables, a pattern sometimes called **stack ripping**. + +Two mechanisms let a reaction suspend in the middle of its body and continue later, at a later tag, while logical time advances and other reactions execute: + +- **`lf_async`** invokes a function that may block or run for a long time and suspends the reaction until the function returns. While the function is running, logical time advances and other reactions execute. The reaction continues at a tag determined by an action that you choose. +- **`lf_await`** suspends the reaction until another reaction of the same reactor calls `lf_await_resume`, typically because an input has arrived. The reaction continues at the tag of that input. + +In both cases, local variables that were on the stack before the call are still there afterwards, so nothing needs to be copied into state variables. + +## Waiting for an Input with `lf_await` + +The following program sends a request to another reactor and waits, in the same reaction, for the result to come back: + +import C_AwaitLET from '../assets/code/c/src/AwaitLET.lf'; + + + +import AwaitLETSVG from "./../assets/images/diagrams/AwaitLET.svg" + + + +`RequestWork` has two reactions. The second, triggered by the timer, sets the `request` output, calls `lf_await(result)`, and then prints the result. The first, triggered by `result`, does nothing except call `lf_await_resume(result)`. Because the feedback connection has an `after` delay of 100 ms, the result arrives exactly 100 ms after the request, so the output looks like this: + +``` +Request work at logical time 0 +Do work starting at logical time 0 +Result 2 received at logical time 100000000 +Request work at logical time 200000000 +Do work starting at logical time 200000000 +Result 4 received at logical time 300000000 +... +``` + +Each request takes 100 ms of logical time to be answered. In effect, the `DoWork` reactor has a **logical execution time** (LET) of 100 ms, and the requesting reaction is written as if it simply called `DoWork` and waited for the answer. + +Notice three things about how `lf_await` is used: + +1. The input port is passed to both `lf_await` and `lf_await_resume`. The reaction that calls `lf_await(result)` lists `result` as a *source* (the `result` between the triggers and the `->`) so that the port is in scope in its body, without being triggered by it. +2. The reaction that calls `lf_await_resume(result)` is declared *before* the reaction that calls `lf_await`. Reactions of a reactor are ordered by declaration, so this ensures that, at the tag at which the result arrives, the resuming reaction runs first, and the suspended reaction continues after it completes. +3. When `lf_await` returns, the tag is the tag at which `lf_await_resume` was called, and `result->value` is the value that arrived at that tag. Any outputs set after `lf_await` returns are produced at that new tag. + +Outputs set *before* `lf_await` are produced at the tag at which they were set. In the example, `request` is produced at the tag of the timer, before logical time advances, which is what allows `DoWork` to respond at that tag. + +If several reactions of a reactor are suspended on the same input port, a single call to `lf_await_resume` on that port resumes all of them. If nothing is waiting on the port, `lf_await_resume` does nothing. + +## Making a Blocking Call with `lf_async` + +`lf_async` is for calling a C function that may block or execute for an unpredictable time. The following program reads lines from the keyboard with `getline`, which blocks until the user presses return, and yet a timer-driven reaction continues to run every five seconds while the program waits: + +import C_AsyncUserInput from '../assets/code/c/src/AsyncUserInput.lf'; + + + +import AsyncUserInputSVG from "./../assets/images/diagrams/AsyncUserInput.svg" + + + +The call + +```c +int nread = lf_async(awake, read_string, &line); +``` + +invokes `read_string(&line)` and yields whatever `read_string` returns. The first argument, `awake`, is an action of the reactor that determines the tag at which the reaction continues. The calling reaction lists `awake` as an *effect* so that it is in scope, and, as with `lf_await`, a reaction triggered by `awake` is declared before it and calls `lf_async_resume(awake)`. + +Here `awake` is a **physical action**. When `read_string` returns, `lf_async` schedules `awake` at the current physical time, and the reaction continues at that tag. The logical time after the call is therefore the physical time at which the user typed the line, which is exactly what a program reading real-world input wants. Running the program and typing a few lines produces output like this: + +``` +At logical time 0, Line: 0: Waiting for input... +hello +At logical time 2314876000, Line: hello +At logical time 5000000000, Line: 1: Waiting for input... +world +At logical time 7102351000, Line: world +``` + +The function passed to `lf_async` must return a value; functions returning `void` are not supported. The additional arguments are passed through to the function. + +### Logical Actions + +The action passed to `lf_async` may instead be a **logical action**. A logical action is scheduled when `lf_async` is *called*, at the current tag plus the action's minimum delay (or one microstep later if it has no minimum delay), rather than when the function returns. This gives the blocking call a fixed logical duration: + +```lf-c +reactor Blocker { + logical action done(200 ms) + + reaction(done) {= + lf_async_resume(done); + =} + + reaction(startup) -> done {= + int result = lf_async(done, slow, 50); + // Logical time is now exactly 200 ms, regardless of how long slow() took. + =} +} +``` + +If the function returns before the 200 ms have elapsed, the reaction continues at 200 ms. If the function is still running when logical time reaches 200 ms, the reaction triggered by `done` blocks in `lf_async_resume` until the function returns, holding logical time at 200 ms. Either way, the reaction continues at exactly 200 ms. This is useful when the rest of the program must not see results earlier than some fixed delay, or when the timing of the program should not depend on how long the call takes. + +A physical action, by contrast, must be dedicated to one reaction. The same reaction may call `lf_async` on it repeatedly, as `UserInput` does in its loop, but a second reaction using the same physical action is a runtime error. Give each such reaction its own physical action. + +## How It Works + +When a reaction calls `lf_async` or `lf_await`, the following happens: + +1. Any outputs set so far are propagated at the current tag, so downstream reactions see them at the tag at which they were set. +2. The worker thread executing the reaction leaves the pool of worker threads. If no previously added thread is free, a new worker thread is added to the pool to replace it. The remaining workers can therefore advance logical time and execute other reactions as if the calling reaction had completed. +3. For `lf_async`, the function is invoked. For `lf_await`, nothing is invoked. +4. When the resuming reaction calls `lf_async_resume` or `lf_await_resume`, the suspended thread rejoins the pool and continues the reaction at the current tag, after the resuming reaction has completed. + +Worker threads added this way persist until the program terminates, and the number of them is the maximum number of calls that were suspended at the same time. Repeated calls from one reaction, like the loop in `UserInput`, reuse a single added thread. The function `lf_added_worker_thread_count()` returns the number of threads that have been added. + +## Caveats + +**State variables.** Other reactions of the same reactor may execute while a reaction is suspended, and they may modify state variables. Do not assume that a state variable has the same value after `lf_async` or `lf_await` returns that it had before. Local variables, on the other hand, are preserved. + +**Re-triggering.** A suspended reaction still counts as executing. If one of its triggers fires at an intervening tag, that triggering is dropped. In `AwaitLET`, the timer period of 200 ms is longer than the 100 ms round trip, so no timer events are lost. Had the period been shorter than the round trip, the intervening timer events would have been ignored. + +**Mutual exclusion.** Reactions belonging to the same reactor are normally mutually exclusive. With a call to `lf_async`, however, the invoked function may execute concurrently with other reactions of the same reactor. Hence, that function must not access shared state variables or call other functions that do. + +**Termination.** If the program stops before a suspended reaction is resumed, `lf_async` or `lf_await` returns at the stop tag, after the shutdown reactions have executed, so that the reaction can complete. If a reaction calls `lf_async` or `lf_await` while the program is already stopping, the call does not suspend: `lf_async` invokes the function and returns, and `lf_await` returns immediately. + +**Portability.** `lf_async` uses GCC and Clang extensions (statement expressions and `__typeof__`) to return the function's value with its own type. Both mechanisms require the threaded C runtime, which is the default, with the `NP` or `GEDF_NP` scheduler; they are not available with the single-threaded runtime or the adaptive scheduler. From 3ed830bd72bbe60703dee7f7f0b3aa3159113151 Mon Sep 17 00:00:00 2001 From: "Edward A. Lee" Date: Sun, 11 Oct 2026 17:44:46 -0700 Subject: [PATCH 2/5] Added blog on async-await --- blog/2026-10-11-async-await.md | 194 +++++++++++++++++++++++++++++ blog/src/AsyncUserInput.lf | 64 ++++++++++ blog/src/AwaitLET.lf | 40 ++++++ static/img/blog/AsyncUserInput.svg | 1 + static/img/blog/AwaitLET.svg | 1 + 5 files changed, 300 insertions(+) create mode 100644 blog/2026-10-11-async-await.md create mode 100644 blog/src/AsyncUserInput.lf create mode 100644 blog/src/AwaitLET.lf create mode 100644 static/img/blog/AsyncUserInput.svg create mode 100644 static/img/blog/AwaitLET.svg diff --git a/blog/2026-10-11-async-await.md b/blog/2026-10-11-async-await.md new file mode 100644 index 000000000..6802058d1 --- /dev/null +++ b/blog/2026-10-11-async-await.md @@ -0,0 +1,194 @@ +--- +slug: async-await +title: "Async and Await in Lingua Franca" +authors: [eal] +tags: [lingua franca, async, await, blocking, logical execution time, C target] +--- +A reaction in Lingua Franca runs to completion at a single tag; it is "logically instantaneous," meaning that logical time does not elapse during the execution of the reaction. This help make LF programs deterministic, but it also means that a reaction body cannot *wait* for anything. If it blocks on a socket, a keyboard, or a slow library call, the whole program stalls. And if it needs a result that some other reactor will produce at a later tag, the reaction has to end and a second reaction has to pick up where it left off. Two new C-target functions, `lf_async` and `lf_await`, remove this restriction. A reaction can now suspend in the middle of its body, let logical time advance and other reactions run, and continue later at a well-defined tag, with its local variables intact. + +{/* truncate */} + +## The Problem + +Suppose a reactor needs to read a line of text typed by a user. The obvious C code is a call to `getline`, which blocks until the user presses return. Putting that call in a reaction is a disaster: logical time cannot advance while the reaction is executing, so every timer, every input, and every other reactor in the program freezes until the user types something. + +The standard LF workaround has been to spawn a thread. The thread does the blocking call and then schedules a physical action, and a reaction triggered by that action picks up the result: + +```lf-c +reactor UserInput { + physical action line: char* + output out: char* + + reaction(startup) -> line {= + lf_thread_create(&self->thread, &read_lines, line); // Thread calls lf_schedule(line, ...) + =} + + reaction(line) -> out {= + lf_set(out, line->value); + =} +} +``` + +This works, but the logic of the program is now spread across a C thread, a physical action, and two reactions, and anything the second reaction needs from the first has to be stashed in state variables. Programmers who have written callback-based code know this pattern well, and know the name for what it does to a program: **stack ripping**. + +The problem is not limited to blocking I/O. A reaction that sends a request to another reactor and needs the answer faces the same thing. The answer may arrive at a later tag, so a different reaction must receive it, and again everything of interest has to be copied into state. Languages such as JavaScript, Python, C#, and Rust solved this with `async`/`await`, which lets a function pause at an `await` and resume when the thing it is waiting for is ready, without the programmer having to split the function. Lingua Franca now has an analogous mechanism, but with a twist that fits the LF model of time: a suspended reaction does not just resume *eventually*, it resumes at a specific *tag*. This makes the mechanism much more deterministic than those in other languages. + +## `lf_async`: Suspend Around a Blocking Call + +Here is the user-input example, written with `lf_async`: + +```lf-c +reactor UserInput { + preamble {= + // Read a line from stdin into malloc'd memory. Returns 0 on EOF. + size_t read_string(char** result) { ... } + =} + timer t(0, 5 s) + output out: char* + state count: int + physical action awake + + reaction(awake) {= + lf_async_resume(awake); + =} + + reaction(startup) -> out, awake {= + while (true) { + char* line = NULL; + int nread = lf_async(awake, read_string, &line); + if (nread == 0) { + lf_request_stop(); + break; + } + // The logical time is now the physical time at which the line was read. + lf_set(out, line); + } + =} + + reaction(t) -> out {= + char* prompt = NULL; + asprintf(&prompt, "%d: Waiting for input...", self->count++); + lf_set(out, prompt); + =} +} +``` + +![AsyncUserInput diagram](../static/img/blog/AsyncUserInput.svg) + +The key line is + +```c +int nread = lf_async(awake, read_string, &line); +``` + +This calls `read_string(&line)` and returns its result, just as a direct call would. But while `read_string` is blocked waiting for the user, the rest of the program keeps running: the timer reaction prints a prompt every five seconds, and any other reactors in the program proceed normally. When `read_string` returns, the physical action `awake` is scheduled at the current physical time, the reaction triggered by `awake` runs and calls `lf_async_resume(awake)`, and the `startup` reaction continues from the line after `lf_async`, now at the tag of the physical action. The `while` loop is an ordinary loop; the local variable `line` survives the suspension; and no state variables are involved. + +A session looks like this: + +``` +At logical time 0, Line: 0: Waiting for input... +hello +At logical time 2314876000, Line: hello +At logical time 5000000000, Line: 1: Waiting for input... +world +At logical time 7102351000, Line: world +``` + +Each line of input is produced at the logical time equal to the physical time at which the user typed it, which is exactly what you want from a program that reads real-world input. + +Two conventions make this work. The action passed to `lf_async` is listed as an *effect* of the calling reaction (so that it is in scope), and the reaction that calls `lf_async_resume` on that action is declared *before* the reaction that calls `lf_async`. The second rule is what lets the suspended reaction resume *after* the resuming reaction completes at the same tag, so that the reaction continues at a tag that has a precise meaning. + +### Giving a blocking call a logical duration + +The action passed to `lf_async` need not be physical. If it is a **logical action** with a minimum delay, it is scheduled when `lf_async` is *called*, not when the function returns: + +```lf-c +logical action done(200 ms) + +reaction(done) {= + lf_async_resume(done); +=} + +reaction(startup) -> done {= + int result = lf_async(done, slow_computation, 42); + // Logical time is now exactly 200 ms, however long slow_computation() took. +=} +``` + +If `slow_computation` finishes early, the reaction continues at 200 ms anyway. If it is still running when logical time reaches 200 ms, the resuming reaction blocks until it returns, holding logical time at 200 ms. Either way, the rest of the program sees the result at exactly 200 ms. This gives the blocking call a deterministic logical duration, independent of how long it actually takes, which is often what a time-sensitive design needs. + +## `lf_await`: Suspend Until an Input Arrives + +The second function, `lf_await`, does not call anything. It simply suspends the reaction until another reaction of the same reactor calls `lf_await_resume` on the same input port. The natural use is request-response: + +```lf-c +reactor RequestWork { + input result: int + output request: int + timer t(0, 200 ms) + state count: int = 1 + + reaction(result) {= + lf_await_resume(result); + =} + + reaction(t) result -> request {= + lf_print("Request work at logical time " PRINTF_TIME, lf_time_logical_elapsed()); + lf_set(request, self->count++); + lf_await(result); + lf_print("Result %d received at logical time " PRINTF_TIME, result->value, lf_time_logical_elapsed()); + =} +} + +reactor DoWork { + input request: int + output result: int + + reaction(request) -> result {= + lf_set(result, request->value * 2); + =} +} + +main reactor { + request_work = new RequestWork() + process_work = new DoWork() + request_work.request -> process_work.request + process_work.result -> request_work.result after 100 ms +} +``` + +![AwaitLET diagram](../static/img/blog/AwaitLET.svg) + +The timer reaction sets the `request` output, then calls `lf_await(result)`. The output is delivered at the tag of the timer, `DoWork` responds at that same tag, and the `after 100 ms` on the feedback connection delivers the result 100 ms later. At that tag, the reaction triggered by `result` runs first and calls `lf_await_resume(result)`, and the suspended reaction continues: + +``` +Request work at logical time 0 +Result 2 received at logical time 100000000 +Request work at logical time 200000000 +Result 4 received at logical time 300000000 +... +``` + +Notice the port `result` listed between the triggers and the `->` in the second reaction. It is a *source*, not a trigger: the reaction is not triggered by `result`, but `result` is in scope in its body, both to pass to `lf_await` and to read `result->value` afterwards. + +What this example implements is a **logical execution time** (LET) pattern. From the perspective of `RequestWork`, `DoWork` behaves like a function call that takes exactly 100 ms of logical time, no matter how long the computation in `DoWork` actually takes. If several reactions of a reactor are waiting on the same port, a single `lf_await_resume` on that port resumes all of them. + +## What Happens Under the Hood + +In most languages, `async`/`await` is implemented by turning the function into a state machine or coroutine. LF's version takes a different route, because reactions in the C target are ordinary C functions executed by a pool of worker threads. + +When a reaction calls `lf_async` or `lf_await`, the runtime first propagates any outputs the reaction has already set, so that downstream reactions see them at the current tag. Then the worker thread executing the reaction *leaves the pool*. If no spare thread is available, a new worker thread is created to take its place. From the scheduler's point of view, the reaction has finished: the remaining workers advance logical time and execute other reactions exactly as they would have. The departed thread sits on the C stack of the suspended reaction, either blocked in the user's function or waiting to be resumed. + +When the resuming reaction calls `lf_async_resume` or `lf_await_resume`, the suspended thread rejoins the pool and continues the reaction, at the current tag, once the resuming reaction has completed. Threads created this way persist for the rest of the run, so the cost is one extra thread per concurrently suspended reaction, not per call. A reaction that loops over `lf_async`, like `UserInput` above, reuses a single added thread. + +A consequence of this design is that it works with any C function, unmodified. There is no need for a special asynchronous API, no coloring of functions into "async" and "sync" variants, and no requirement that the blocking call cooperate. + +## Determinism + +What distinguishes this from simply running a thread is that the point at which the reaction continues is a *tag*, chosen by the program, not by the operating system scheduler. With a logical action, that tag is fully determined by the program. With a physical action, the tag reflects the physical time at which the call returned, which is the right answer when the program is reacting to the outside world. With `lf_await`, it is the tag at which the awaited input arrives. + +Either way, once the reaction continues, the rest of the program sees its outputs at that tag and nowhere else. Other reactions of the same reactor are mutually exclusive with the suspended one, so there are no data races on state variables, although their values may well have changed by the time the reaction continues. And if the program stops before a resume happens, the suspended call returns at the stop tag, after shutdown reactions have run, so the reaction completes rather than being abandoned. + +## Trying It Out + +`lf_async` and `lf_await` are available in the C target of the current development version of Lingua Franca, with the threaded runtime and the default scheduler. The two examples above are in the [async-await collection](https://github.com/lf-lang/playground-lingua-franca/tree/main/examples/C/src/async-await) of the [LF playground repo](https://github.com/lf-lang/playground-lingua-franca/tree/main), and the full set of rules and caveats is in the handbook page on [Async and Await](/docs/next/writing-reactors/async-await). diff --git a/blog/src/AsyncUserInput.lf b/blog/src/AsyncUserInput.lf new file mode 100644 index 000000000..6652d0137 --- /dev/null +++ b/blog/src/AsyncUserInput.lf @@ -0,0 +1,64 @@ +target C { + keepalive: true +} + +reactor UserInput { + preamble {= + #include + #include + + // Read a line from stdin into dynamically allocated memory, dropping the + // trailing newline. The caller must free the result. Returns 0 on EOF or error. + size_t read_string(char** result) { + char* buffer = NULL; + size_t capacity = 0; + ssize_t nread = getline(&buffer, &capacity, stdin); + if (nread < 0) { + free(buffer); + return 0; + } + if (nread > 0 && buffer[nread - 1] == '\n') { + buffer[nread - 1] = '\0'; + } + *result = buffer; + return nread; + } + =} + timer t(0, 5 s) + output out: char* + state count: int + physical action awake + + // This reaction must precede the reaction that calls lf_async on the physical action. + // It causes that reaction to resume at the logical time of the physical action. + reaction(awake) {= + lf_async_resume(awake); + =} + + reaction(startup) -> out, awake {= + while (true) { + char* line = NULL; + int nread = lf_async(awake, read_string, &line); + if (nread == 0) { + lf_request_stop(); + break; + } + // The logical time is now the physical time at which the line was read. + lf_set(out, line); + } + =} + + reaction(t) -> out {= + char* prompt = NULL; + asprintf(&prompt, "%d: Waiting for input...", self->count++); + lf_set(out, prompt); + =} +} + +main reactor { + user_input = new UserInput() + + reaction(user_input.out) {= + lf_print("At logical time " PRINTF_TIME ", Line: %s", lf_time_logical_elapsed(), user_input.out->value); + =} +} diff --git a/blog/src/AwaitLET.lf b/blog/src/AwaitLET.lf new file mode 100644 index 000000000..1b9f6f0be --- /dev/null +++ b/blog/src/AwaitLET.lf @@ -0,0 +1,40 @@ +target C { + timeout: 1 s +} + +reactor RequestWork { + input result: int + output request: int + timer t(0, 200 ms) + state count: int = 1 + + // This reaction must precede the reaction that awaits the result. + // It causes that reaction to resume at the logical time of the result. + reaction(result) {= + lf_await_resume(result); + =} + + reaction(t) result -> request {= + lf_print("Request work at logical time " PRINTF_TIME, lf_time_logical_elapsed()); + lf_set(request, self->count++); + lf_await(result); + lf_print("Result %d received at logical time " PRINTF_TIME, result->value, lf_time_logical_elapsed()); + =} +} + +reactor DoWork { + input request: int + output result: int + + reaction(request) -> result {= + lf_print("Do work starting at logical time " PRINTF_TIME, lf_time_logical_elapsed()); + lf_set(result, request->value * 2); + =} +} + +main reactor { + request_work = new RequestWork() + process_work = new DoWork() + request_work.request -> process_work.request + process_work.result -> request_work.result after 100 ms +} diff --git a/static/img/blog/AsyncUserInput.svg b/static/img/blog/AsyncUserInput.svg new file mode 100644 index 000000000..719131434 --- /dev/null +++ b/static/img/blog/AsyncUserInput.svg @@ -0,0 +1 @@ +AsyncUserInputUserInput(0, 5 s)123Pout \ No newline at end of file diff --git a/static/img/blog/AwaitLET.svg b/static/img/blog/AwaitLET.svg new file mode 100644 index 000000000..04386e6b0 --- /dev/null +++ b/static/img/blog/AwaitLET.svg @@ -0,0 +1 @@ +AwaitLETRequestWork(0, 200 ms)12resultrequestDoWorkrequestresult100 ms \ No newline at end of file From 764dbbdf4ccc74f5f9cb4a48c89d5085881bc0e1 Mon Sep 17 00:00:00 2001 From: "Edward A. Lee" Date: Mon, 12 Oct 2026 11:46:19 -0700 Subject: [PATCH 3/5] Added caveat about reaction order --- docs/writing-reactors/async-await.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/writing-reactors/async-await.mdx b/docs/writing-reactors/async-await.mdx index 66076d6a2..be8d79a0e 100644 --- a/docs/writing-reactors/async-await.mdx +++ b/docs/writing-reactors/async-await.mdx @@ -131,6 +131,8 @@ Worker threads added this way persist until the program terminates, and the numb ## Caveats +**Reaction order.** When a reaction resumes at a new tag, it will be mutually exclusive with other reactions of the same reactor executing at the same tag, but the order in which they execute is not guaranteed. The resuming reaction will be assured of executing after the reaction that awakened it with `lf_async_resume` or `lf_await_resume`, but not necessarily before or after other reactions executing at the same tag. It is recommended, therefore, that a reaction using `lf_async` or `lf_await` be the last reaction in its reactor, in which case, normal reaction order is ensured. If more than one reaction uses `lf_async` or `lf_await`, the order in which they resume is nondeterministic, so care is needed when updating state variables or overwriting outputs. + **State variables.** Other reactions of the same reactor may execute while a reaction is suspended, and they may modify state variables. Do not assume that a state variable has the same value after `lf_async` or `lf_await` returns that it had before. Local variables, on the other hand, are preserved. **Re-triggering.** A suspended reaction still counts as executing. If one of its triggers fires at an intervening tag, that triggering is dropped. In `AwaitLET`, the timer period of 200 ms is longer than the 100 ms round trip, so no timer events are lost. Had the period been shorter than the round trip, the intervening timer events would have been ignored. From de1ac945820bf66227e192188cd6eb1a918e8de2 Mon Sep 17 00:00:00 2001 From: "Edward A. Lee" Date: Thu, 15 Oct 2026 18:28:27 -0700 Subject: [PATCH 4/5] Document Python async-await mechanism and add to blog --- blog/2026-10-11-async-await.md | 114 ++++++++++++++++++++-- blog/src/py/AsyncUserInput.lf | 48 +++++++++ blog/src/py/AwaitLET.lf | 41 ++++++++ docs/assets/code/py/src/AsyncUserInput.lf | 48 +++++++++ docs/assets/code/py/src/AwaitLET.lf | 41 ++++++++ docs/writing-reactors/async-await.mdx | 83 +++++++++++++--- 6 files changed, 353 insertions(+), 22 deletions(-) create mode 100644 blog/src/py/AsyncUserInput.lf create mode 100644 blog/src/py/AwaitLET.lf create mode 100644 docs/assets/code/py/src/AsyncUserInput.lf create mode 100644 docs/assets/code/py/src/AwaitLET.lf diff --git a/blog/2026-10-11-async-await.md b/blog/2026-10-11-async-await.md index 6802058d1..9513ba367 100644 --- a/blog/2026-10-11-async-await.md +++ b/blog/2026-10-11-async-await.md @@ -2,15 +2,15 @@ slug: async-await title: "Async and Await in Lingua Franca" authors: [eal] -tags: [lingua franca, async, await, blocking, logical execution time, C target] +tags: [lingua franca, async, await, blocking, logical execution time, C target, Python target] --- -A reaction in Lingua Franca runs to completion at a single tag; it is "logically instantaneous," meaning that logical time does not elapse during the execution of the reaction. This help make LF programs deterministic, but it also means that a reaction body cannot *wait* for anything. If it blocks on a socket, a keyboard, or a slow library call, the whole program stalls. And if it needs a result that some other reactor will produce at a later tag, the reaction has to end and a second reaction has to pick up where it left off. Two new C-target functions, `lf_async` and `lf_await`, remove this restriction. A reaction can now suspend in the middle of its body, let logical time advance and other reactions run, and continue later at a well-defined tag, with its local variables intact. +A reaction in Lingua Franca runs to completion at a single tag; it is "logically instantaneous," meaning that logical time does not elapse during the execution of the reaction. This helps make LF programs deterministic, but it also means that a reaction body cannot *wait* for anything. If it blocks on a socket, a keyboard, or a slow library call, the whole program stalls. And if it needs a result that some other reactor will produce at a later tag, the reaction has to end and a second reaction has to pick up where it left off. Two new functions, `lf_async` and `lf_await`, remove this restriction in the C and Python targets. A reaction can now suspend in the middle of its body, let logical time advance and other reactions run, and continue later at a well-defined tag, with its local variables intact. {/* truncate */} ## The Problem -Suppose a reactor needs to read a line of text typed by a user. The obvious C code is a call to `getline`, which blocks until the user presses return. Putting that call in a reaction is a disaster: logical time cannot advance while the reaction is executing, so every timer, every input, and every other reactor in the program freezes until the user types something. +Suppose a reactor needs to read a line of text typed by a user. The obvious C code is a call to `getline`, which blocks until the user presses return. Putting that call in a reaction is a disaster: logical time cannot advance while the reaction is executing, so every timer, every input, and every other reactor in the program freezes until the user types something. A Python reaction that calls `input()` stalls the program in the same way. The standard LF workaround has been to spawn a thread. The thread does the blocking call and then schedules a physical action, and a reaction triggered by that action picks up the result: @@ -29,7 +29,7 @@ reactor UserInput { } ``` -This works, but the logic of the program is now spread across a C thread, a physical action, and two reactions, and anything the second reaction needs from the first has to be stashed in state variables. Programmers who have written callback-based code know this pattern well, and know the name for what it does to a program: **stack ripping**. +This works, but the logic of the program is now spread across a thread, a physical action, and two reactions, and anything the second reaction needs from the first has to be stashed in state variables. Programmers who have written callback-based code know this pattern well, and know the name for what it does to a program: **stack ripping**. The problem is not limited to blocking I/O. A reaction that sends a request to another reactor and needs the answer faces the same thing. The answer may arrive at a later tag, so a different reaction must receive it, and again everything of interest has to be copied into state. Languages such as JavaScript, Python, C#, and Rust solved this with `async`/`await`, which lets a function pause at an `await` and resume when the thing it is waiting for is ready, without the programmer having to split the function. Lingua Franca now has an analogous mechanism, but with a twist that fits the LF model of time: a suspended reaction does not just resume *eventually*, it resumes at a specific *tag*. This makes the mechanism much more deterministic than those in other languages. @@ -98,6 +98,53 @@ Each line of input is produced at the logical time equal to the physical time at Two conventions make this work. The action passed to `lf_async` is listed as an *effect* of the calling reaction (so that it is in scope), and the reaction that calls `lf_async_resume` on that action is declared *before* the reaction that calls `lf_async`. The second rule is what lets the suspended reaction resume *after* the resuming reaction completes at the same tag, so that the reaction continues at a tag that has a precise meaning. +### The Python target + +The Python target provides the same operations as methods of the reactor. The function passed to `lf_async` is an ordinary Python function, not a coroutine, and the reaction that calls it is an ordinary function too: + +```lf-py +preamble {= + def read_string(): + """Read a line from the keyboard. Return None on EOF.""" + try: + return input() + except EOFError: + return None +=} + +reactor UserInput { + timer t(0, 5 s) + output out + state count = 0 + physical action awake + + reaction(awake) {= + self.lf_async_resume(awake) + =} + + reaction(startup) -> out, awake {= + while True: + line = self.lf_async(awake, read_string) + if line is None: + request_stop() + break + # The logical time is now the physical time at which the line was read. + out.set(line) + =} + + reaction(t) -> out {= + out.set(f"{self.count}: Waiting for input...") + self.count += 1 + =} +} +``` + +`self.lf_async(awake, read_string)` calls `read_string()` and returns whatever it returns. While `input()` is blocked, the prompt reaction still runs, and the logical time after the call is again the physical time at which the line was typed. Further arguments are passed through to the callable, so `self.lf_async(awake, slow, 50)` calls `slow(50)`. + +The methods are on `self` because the runtime needs the reactor that is executing the reaction. The C functions hide that argument inside a macro. Define the callable in a file-level preamble, as above, so that it is a plain function. A function defined in a reactor preamble becomes a method of the generated class and is passed as `self.read_string`. + +This is not Python's `async`/`await`. The call does not yield to `asyncio`, and the reaction does not have to be declared `async`. It blocks the worker thread, just as the C version does. + ### Giving a blocking call a logical duration The action passed to `lf_async` need not be physical. If it is a **logical action** with a minimum delay, it is scheduled when `lf_async` is *called*, not when the function returns: @@ -115,6 +162,21 @@ reaction(startup) -> done {= =} ``` +In Python, the same program is: + +```lf-py +logical action done(200 ms) + +reaction(done) {= + self.lf_async_resume(done) +=} + +reaction(startup) -> done {= + result = self.lf_async(done, slow_computation, 42) + # Logical time is now exactly 200 ms, however long slow_computation() took. +=} +``` + If `slow_computation` finishes early, the reaction continues at 200 ms anyway. If it is still running when logical time reaches 200 ms, the resuming reaction blocks until it returns, holding logical time at 200 ms. Either way, the rest of the program sees the result at exactly 200 ms. This gives the blocking call a deterministic logical duration, independent of how long it actually takes, which is often what a time-sensitive design needs. ## `lf_await`: Suspend Until an Input Arrives @@ -171,17 +233,53 @@ Result 4 received at logical time 300000000 Notice the port `result` listed between the triggers and the `->` in the second reaction. It is a *source*, not a trigger: the reaction is not triggered by `result`, but `result` is in scope in its body, both to pass to `lf_await` and to read `result->value` afterwards. +The Python version has the same structure. Ports are read with `.value`, outputs are written with `.set`, and the calls are methods: + +```lf-py +reactor RequestWork { + input result + output request + timer t(0, 200 ms) + state count = 1 + + reaction(result) {= + self.lf_await_resume(result) + =} + + reaction(t) result -> request {= + print(f"Request work at logical time {lf.time.logical_elapsed()}") + request.set(self.count) + self.count += 1 + self.lf_await(result) + print(f"Result {result.value} received at logical time {lf.time.logical_elapsed()}") + =} +} + +reactor DoWork { + input request + output result + + reaction(request) -> result {= + result.set(request.value * 2) + =} +} +``` + +When `self.lf_await(result)` returns, `result.value` is the value that arrived at the new tag. + What this example implements is a **logical execution time** (LET) pattern. From the perspective of `RequestWork`, `DoWork` behaves like a function call that takes exactly 100 ms of logical time, no matter how long the computation in `DoWork` actually takes. If several reactions of a reactor are waiting on the same port, a single `lf_await_resume` on that port resumes all of them. ## What Happens Under the Hood -In most languages, `async`/`await` is implemented by turning the function into a state machine or coroutine. LF's version takes a different route, because reactions in the C target are ordinary C functions executed by a pool of worker threads. +In most languages, `async`/`await` is implemented by turning the function into a state machine or coroutine. LF's version takes a different route, because reactions in the C target are ordinary C functions, and reactions in the Python target are ordinary Python functions, executed by a pool of worker threads. -When a reaction calls `lf_async` or `lf_await`, the runtime first propagates any outputs the reaction has already set, so that downstream reactions see them at the current tag. Then the worker thread executing the reaction *leaves the pool*. If no spare thread is available, a new worker thread is created to take its place. From the scheduler's point of view, the reaction has finished: the remaining workers advance logical time and execute other reactions exactly as they would have. The departed thread sits on the C stack of the suspended reaction, either blocked in the user's function or waiting to be resumed. +When a reaction calls `lf_async` or `lf_await`, the runtime first propagates any outputs the reaction has already set, so that downstream reactions see them at the current tag. Then the worker thread executing the reaction *leaves the pool*. If no spare thread is available, a new worker thread is created to take its place. From the scheduler's point of view, the reaction has finished: the remaining workers advance logical time and execute other reactions exactly as they would have. The departed thread sits on the stack of the suspended reaction, either blocked in the user's function or waiting to be resumed. When the resuming reaction calls `lf_async_resume` or `lf_await_resume`, the suspended thread rejoins the pool and continues the reaction, at the current tag, once the resuming reaction has completed. Threads created this way persist for the rest of the run, so the cost is one extra thread per concurrently suspended reaction, not per call. A reaction that loops over `lf_async`, like `UserInput` above, reuses a single added thread. -A consequence of this design is that it works with any C function, unmodified. There is no need for a special asynchronous API, no coloring of functions into "async" and "sync" variants, and no requirement that the blocking call cooperate. +A consequence of this design is that it works with any C function, unmodified, and with any Python function. There is no need for a special asynchronous API, no coloring of functions into "async" and "sync" variants, and no requirement that the blocking call cooperate. + +The Python methods call that same C runtime. Around the parts of the call that can block inside the runtime, the binding releases Python's global interpreter lock, so the worker that runs the resuming reaction can execute Python code. The function passed to `self.lf_async` runs with the lock held. Blocking calls in the Python standard library, such as `input()` and `time.sleep`, release the lock themselves. A long stretch of pure Python does not, and other Python reactions wait until it returns. ## Determinism @@ -191,4 +289,4 @@ Either way, once the reaction continues, the rest of the program sees its output ## Trying It Out -`lf_async` and `lf_await` are available in the C target of the current development version of Lingua Franca, with the threaded runtime and the default scheduler. The two examples above are in the [async-await collection](https://github.com/lf-lang/playground-lingua-franca/tree/main/examples/C/src/async-await) of the [LF playground repo](https://github.com/lf-lang/playground-lingua-franca/tree/main), and the full set of rules and caveats is in the handbook page on [Async and Await](/docs/next/writing-reactors/async-await). +`lf_async` and `lf_await` are available in the C and Python targets of the current development version of Lingua Franca, with the threaded runtime and the default scheduler. The examples above are in the [C](https://github.com/lf-lang/playground-lingua-franca/tree/main/examples/C/src/async-await) and [Python](https://github.com/lf-lang/playground-lingua-franca/tree/main/examples/Python/src/async-await) collections of the [LF playground repo](https://github.com/lf-lang/playground-lingua-franca/tree/main), and the full set of rules and caveats is in the handbook page on [Async and Await](/docs/next/writing-reactors/async-await). diff --git a/blog/src/py/AsyncUserInput.lf b/blog/src/py/AsyncUserInput.lf new file mode 100644 index 000000000..eab2d864e --- /dev/null +++ b/blog/src/py/AsyncUserInput.lf @@ -0,0 +1,48 @@ +target Python { + keepalive: true +} + +preamble {= + # Read a line from the keyboard. Return None on EOF. + def read_string(): + try: + return input() + except EOFError: + return None +=} + +reactor UserInput { + timer t(0, 5 s) + output out + state count = 0 + physical action awake + + # This reaction must precede the reaction that calls lf_async on the physical action. + # It causes that reaction to resume at the logical time of the physical action. + reaction(awake) {= + self.lf_async_resume(awake) + =} + + reaction(startup) -> out, awake {= + while True: + line = self.lf_async(awake, read_string) + if line is None: + request_stop() + break + # The logical time is now the physical time at which the line was read. + out.set(line) + =} + + reaction(t) -> out {= + out.set(f"{self.count}: Waiting for input...") + self.count += 1 + =} +} + +main reactor { + user_input = new UserInput() + + reaction(user_input.out) {= + print(f"At logical time {lf.time.logical_elapsed()}, Line: {user_input.out.value}") + =} +} diff --git a/blog/src/py/AwaitLET.lf b/blog/src/py/AwaitLET.lf new file mode 100644 index 000000000..cbfc6a4cf --- /dev/null +++ b/blog/src/py/AwaitLET.lf @@ -0,0 +1,41 @@ +target Python { + timeout: 1 s +} + +reactor RequestWork { + input result + output request + timer t(0, 200 ms) + state count = 1 + + # This reaction must precede the reaction that awaits the result. + # It causes that reaction to resume at the logical time of the result. + reaction(result) {= + self.lf_await_resume(result) + =} + + reaction(t) result -> request {= + print(f"Request work at logical time {lf.time.logical_elapsed()}") + request.set(self.count) + self.count += 1 + self.lf_await(result) + print(f"Result {result.value} received at logical time {lf.time.logical_elapsed()}") + =} +} + +reactor DoWork { + input request + output result + + reaction(request) -> result {= + print(f"Do work starting at logical time {lf.time.logical_elapsed()}") + result.set(request.value * 2) + =} +} + +main reactor { + request_work = new RequestWork() + process_work = new DoWork() + request_work.request -> process_work.request + process_work.result -> request_work.result after 100 ms +} diff --git a/docs/assets/code/py/src/AsyncUserInput.lf b/docs/assets/code/py/src/AsyncUserInput.lf new file mode 100644 index 000000000..eab2d864e --- /dev/null +++ b/docs/assets/code/py/src/AsyncUserInput.lf @@ -0,0 +1,48 @@ +target Python { + keepalive: true +} + +preamble {= + # Read a line from the keyboard. Return None on EOF. + def read_string(): + try: + return input() + except EOFError: + return None +=} + +reactor UserInput { + timer t(0, 5 s) + output out + state count = 0 + physical action awake + + # This reaction must precede the reaction that calls lf_async on the physical action. + # It causes that reaction to resume at the logical time of the physical action. + reaction(awake) {= + self.lf_async_resume(awake) + =} + + reaction(startup) -> out, awake {= + while True: + line = self.lf_async(awake, read_string) + if line is None: + request_stop() + break + # The logical time is now the physical time at which the line was read. + out.set(line) + =} + + reaction(t) -> out {= + out.set(f"{self.count}: Waiting for input...") + self.count += 1 + =} +} + +main reactor { + user_input = new UserInput() + + reaction(user_input.out) {= + print(f"At logical time {lf.time.logical_elapsed()}, Line: {user_input.out.value}") + =} +} diff --git a/docs/assets/code/py/src/AwaitLET.lf b/docs/assets/code/py/src/AwaitLET.lf new file mode 100644 index 000000000..cbfc6a4cf --- /dev/null +++ b/docs/assets/code/py/src/AwaitLET.lf @@ -0,0 +1,41 @@ +target Python { + timeout: 1 s +} + +reactor RequestWork { + input result + output request + timer t(0, 200 ms) + state count = 1 + + # This reaction must precede the reaction that awaits the result. + # It causes that reaction to resume at the logical time of the result. + reaction(result) {= + self.lf_await_resume(result) + =} + + reaction(t) result -> request {= + print(f"Request work at logical time {lf.time.logical_elapsed()}") + request.set(self.count) + self.count += 1 + self.lf_await(result) + print(f"Result {result.value} received at logical time {lf.time.logical_elapsed()}") + =} +} + +reactor DoWork { + input request + output result + + reaction(request) -> result {= + print(f"Do work starting at logical time {lf.time.logical_elapsed()}") + result.set(request.value * 2) + =} +} + +main reactor { + request_work = new RequestWork() + process_work = new DoWork() + request_work.request -> process_work.request + process_work.result -> request_work.result after 100 ms +} diff --git a/docs/writing-reactors/async-await.mdx b/docs/writing-reactors/async-await.mdx index be8d79a0e..9ba7f4560 100644 --- a/docs/writing-reactors/async-await.mdx +++ b/docs/writing-reactors/async-await.mdx @@ -9,11 +9,11 @@ import { ShowIf, ShowIfs, ShowOnly, } from '@site/src/components/LinguaFrancaMultiTargetUtils'; - + :::note -`lf_async` and `lf_await` are currently supported only in the `C` target, and only with the threaded runtime using the default (`NP`) or `GEDF_NP` scheduler. +`lf_async` and `lf_await` are supported in the `C` and `Python` targets, and only with the threaded runtime using the default (`NP`) or `GEDF_NP` scheduler. ::: @@ -26,19 +26,22 @@ Two mechanisms let a reaction suspend in the middle of its body and continue lat In both cases, local variables that were on the stack before the call are still there afterwards, so nothing needs to be copied into state variables. +In C, these are functions, and macros fill in the reactor where the runtime needs it. In Python, they are methods of the reactor: `self.lf_async`, `self.lf_async_resume`, `self.lf_await`, and `self.lf_await_resume`. The Python methods do not use Python's `async` and `await` syntax. A reaction remains an ordinary function, and the call blocks the worker thread that is running it. + ## Waiting for an Input with `lf_await` The following program sends a request to another reactor and waits, in the same reaction, for the result to come back: import C_AwaitLET from '../assets/code/c/src/AwaitLET.lf'; +import Py_AwaitLET from '../assets/code/py/src/AwaitLET.lf'; - + import AwaitLETSVG from "./../assets/images/diagrams/AwaitLET.svg" -`RequestWork` has two reactions. The second, triggered by the timer, sets the `request` output, calls `lf_await(result)`, and then prints the result. The first, triggered by `result`, does nothing except call `lf_await_resume(result)`. Because the feedback connection has an `after` delay of 100 ms, the result arrives exactly 100 ms after the request, so the output looks like this: +`RequestWork` has two reactions. The second, triggered by the timer, sets the `request` output, calls `lf_await` on `result`, and then prints the result. The first, triggered by `result`, does nothing except call `lf_await_resume` on `result`. Because the feedback connection has an `after` delay of 100 ms, the result arrives exactly 100 ms after the request, so the output looks like this: ``` Request work at logical time 0 @@ -54,9 +57,9 @@ Each request takes 100 ms of logical time to be answered. In effect, the `DoWork Notice three things about how `lf_await` is used: -1. The input port is passed to both `lf_await` and `lf_await_resume`. The reaction that calls `lf_await(result)` lists `result` as a *source* (the `result` between the triggers and the `->`) so that the port is in scope in its body, without being triggered by it. -2. The reaction that calls `lf_await_resume(result)` is declared *before* the reaction that calls `lf_await`. Reactions of a reactor are ordered by declaration, so this ensures that, at the tag at which the result arrives, the resuming reaction runs first, and the suspended reaction continues after it completes. -3. When `lf_await` returns, the tag is the tag at which `lf_await_resume` was called, and `result->value` is the value that arrived at that tag. Any outputs set after `lf_await` returns are produced at that new tag. +1. The input port is passed to both `lf_await` and `lf_await_resume`. The reaction that calls `lf_await` on `result` lists `result` as a *source* (the `result` between the triggers and the `->`) so that the port is in scope in its body, without being triggered by it. +2. The reaction that calls `lf_await_resume` on `result` is declared *before* the reaction that calls `lf_await`. Reactions of a reactor are ordered by declaration, so this ensures that, at the tag at which the result arrives, the resuming reaction runs first, and the suspended reaction continues after it completes. +3. When `lf_await` returns, the tag is the tag at which `lf_await_resume` was called, and the value of the input is the value that arrived at that tag (`result->value`)(`result.value`). Any outputs set after `lf_await` returns are produced at that new tag. Outputs set *before* `lf_await` are produced at the tag at which they were set. In the example, `request` is produced at the tag of the timer, before logical time advances, which is what allows `DoWork` to respond at that tag. @@ -64,11 +67,12 @@ If several reactions of a reactor are suspended on the same input port, a single ## Making a Blocking Call with `lf_async` -`lf_async` is for calling a C function that may block or execute for an unpredictable time. The following program reads lines from the keyboard with `getline`, which blocks until the user presses return, and yet a timer-driven reaction continues to run every five seconds while the program waits: +`lf_async` is for calling a function that may block or execute for an unpredictable time. In C, that function is an ordinary C function.In Python, that function is an ordinary Python callable, not a coroutine. The following program reads lines from the keyboard, which blocks until the user presses return, and yet a timer-driven reaction continues to run every five seconds while the program waits: import C_AsyncUserInput from '../assets/code/c/src/AsyncUserInput.lf'; +import Py_AsyncUserInput from '../assets/code/py/src/AsyncUserInput.lf'; - + import AsyncUserInputSVG from "./../assets/images/diagrams/AsyncUserInput.svg" @@ -76,11 +80,28 @@ import AsyncUserInputSVG from "./../assets/images/diagrams/AsyncUserInput.svg" The call + + + ```c int nread = lf_async(awake, read_string, &line); ``` -invokes `read_string(&line)` and yields whatever `read_string` returns. The first argument, `awake`, is an action of the reactor that determines the tag at which the reaction continues. The calling reaction lists `awake` as an *effect* so that it is in scope, and, as with `lf_await`, a reaction triggered by `awake` is declared before it and calls `lf_async_resume(awake)`. +invokes `read_string(&line)` and yields whatever `read_string` returns. The function must return a value; functions returning `void` are not supported. The additional arguments are passed through to the function. + + + + +```python +line = self.lf_async(awake, read_string) +``` + +invokes `read_string()` and returns whatever `read_string` returns. Further arguments are passed to the callable, so `self.lf_async(awake, slow, 50)` calls `slow(50)`. Define the callable in a file-level preamble, as this example does, so that it is a plain function. A function defined in a reactor preamble becomes a method of the generated class and is passed as `self.read_string`. + + + + +The first argument, `awake`, is an action of the reactor that determines the tag at which the reaction continues. The calling reaction lists `awake` as an *effect* so that it is in scope, and, as with `lf_await`, a reaction triggered by `awake` is declared before it and calls `lf_async_resume` on `awake`. Here `awake` is a **physical action**. When `read_string` returns, `lf_async` schedules `awake` at the current physical time, and the reaction continues at that tag. The logical time after the call is therefore the physical time at which the user typed the line, which is exactly what a program reading real-world input wants. Running the program and typing a few lines produces output like this: @@ -93,12 +114,13 @@ world At logical time 7102351000, Line: world ``` -The function passed to `lf_async` must return a value; functions returning `void` are not supported. The additional arguments are passed through to the function. - ### Logical Actions The action passed to `lf_async` may instead be a **logical action**. A logical action is scheduled when `lf_async` is *called*, at the current tag plus the action's minimum delay (or one microstep later if it has no minimum delay), rather than when the function returns. This gives the blocking call a fixed logical duration: + + + ```lf-c reactor Blocker { logical action done(200 ms) @@ -114,6 +136,27 @@ reactor Blocker { } ``` + + + +```lf-py +reactor Blocker { + logical action done(200 ms) + + reaction(done) {= + self.lf_async_resume(done) + =} + + reaction(startup) -> done {= + result = self.lf_async(done, slow, 50) + # Logical time is now exactly 200 ms, regardless of how long slow() took. + =} +} +``` + + + + If the function returns before the 200 ms have elapsed, the reaction continues at 200 ms. If the function is still running when logical time reaches 200 ms, the reaction triggered by `done` blocks in `lf_async_resume` until the function returns, holding logical time at 200 ms. Either way, the reaction continues at exactly 200 ms. This is useful when the rest of the program must not see results earlier than some fixed delay, or when the timing of the program should not depend on how long the call takes. A physical action, by contrast, must be dedicated to one reaction. The same reaction may call `lf_async` on it repeatedly, as `UserInput` does in its loop, but a second reaction using the same physical action is a runtime error. Give each such reaction its own physical action. @@ -127,7 +170,13 @@ When a reaction calls `lf_async` or `lf_await`, the following happens: 3. For `lf_async`, the function is invoked. For `lf_await`, nothing is invoked. 4. When the resuming reaction calls `lf_async_resume` or `lf_await_resume`, the suspended thread rejoins the pool and continues the reaction at the current tag, after the resuming reaction has completed. -Worker threads added this way persist until the program terminates, and the number of them is the maximum number of calls that were suspended at the same time. Repeated calls from one reaction, like the loop in `UserInput`, reuse a single added thread. The function `lf_added_worker_thread_count()` returns the number of threads that have been added. +Worker threads added this way persist until the program terminates, and the number of them is the maximum number of calls that were suspended at the same time. Repeated calls from one reaction, like the loop in `UserInput`, reuse a single added thread. The function `lf_added_worker_thread_count()` returns the number of threads that have been added.The method `self.lf_added_worker_thread_count()` returns the number of threads that have been added. + + + +In the Python target, the binding releases the interpreter lock around the parts of the call that can block inside the runtime. The reaction that resumes the call can therefore run on another worker. The callable passed to `self.lf_async` itself runs with the lock held. + + ## Caveats @@ -141,4 +190,10 @@ Worker threads added this way persist until the program terminates, and the numb **Termination.** If the program stops before a suspended reaction is resumed, `lf_async` or `lf_await` returns at the stop tag, after the shutdown reactions have executed, so that the reaction can complete. If a reaction calls `lf_async` or `lf_await` while the program is already stopping, the call does not suspend: `lf_async` invokes the function and returns, and `lf_await` returns immediately. -**Portability.** `lf_async` uses GCC and Clang extensions (statement expressions and `__typeof__`) to return the function's value with its own type. Both mechanisms require the threaded C runtime, which is the default, with the `NP` or `GEDF_NP` scheduler; they are not available with the single-threaded runtime or the adaptive scheduler. + + +**The interpreter lock.** The callable passed to `self.lf_async` holds Python's global interpreter lock while it runs. Other Python reactions make progress during that call only when the callable releases the lock. `input()` and `time.sleep` do. A stretch of pure Python does not, so other Python reactions wait until it returns. + + + +**Portability.** Both mechanisms require the threaded runtime, which is the default, with the `NP` or `GEDF_NP` scheduler. They are not available with the single-threaded runtime or the adaptive scheduler. In C, `lf_async` uses GCC and Clang extensions (statement expressions and `__typeof__`) to return the function's value with its own type. From 9177af70026ecfda7cd5ee210b24a9216c3cc5b1 Mon Sep 17 00:00:00 2001 From: "Edward A. Lee" Date: Mon, 19 Oct 2026 18:06:54 -0700 Subject: [PATCH 5/5] Updated caveats and added Shulu as co-author of the blog --- blog/2026-10-11-async-await.md | 4 ++-- docs/writing-reactors/async-await.mdx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/blog/2026-10-11-async-await.md b/blog/2026-10-11-async-await.md index 9513ba367..ef2477e68 100644 --- a/blog/2026-10-11-async-await.md +++ b/blog/2026-10-11-async-await.md @@ -1,7 +1,7 @@ --- slug: async-await title: "Async and Await in Lingua Franca" -authors: [eal] +authors: [eal, depetrol] tags: [lingua franca, async, await, blocking, logical execution time, C target, Python target] --- A reaction in Lingua Franca runs to completion at a single tag; it is "logically instantaneous," meaning that logical time does not elapse during the execution of the reaction. This helps make LF programs deterministic, but it also means that a reaction body cannot *wait* for anything. If it blocks on a socket, a keyboard, or a slow library call, the whole program stalls. And if it needs a result that some other reactor will produce at a later tag, the reaction has to end and a second reaction has to pick up where it left off. Two new functions, `lf_async` and `lf_await`, remove this restriction in the C and Python targets. A reaction can now suspend in the middle of its body, let logical time advance and other reactions run, and continue later at a well-defined tag, with its local variables intact. @@ -279,7 +279,7 @@ When the resuming reaction calls `lf_async_resume` or `lf_await_resume`, the sus A consequence of this design is that it works with any C function, unmodified, and with any Python function. There is no need for a special asynchronous API, no coloring of functions into "async" and "sync" variants, and no requirement that the blocking call cooperate. -The Python methods call that same C runtime. Around the parts of the call that can block inside the runtime, the binding releases Python's global interpreter lock, so the worker that runs the resuming reaction can execute Python code. The function passed to `self.lf_async` runs with the lock held. Blocking calls in the Python standard library, such as `input()` and `time.sleep`, release the lock themselves. A long stretch of pure Python does not, and other Python reactions wait until it returns. +The Python methods call that same C runtime. Around the parts of the call that can block inside the runtime, the binding releases Python's global interpreter lock, so the worker that runs the resuming reaction can execute Python code. The function passed to `self.lf_async` runs with the lock held. Blocking calls in the Python standard library, such as `input()` and `time.sleep`, release the lock themselves. A long stretch of pure Python does not, so other Python reactions will not run simultaneously those stretches but rather will be interleaved with them using the usual Python multithreading mechanisms. ## Determinism diff --git a/docs/writing-reactors/async-await.mdx b/docs/writing-reactors/async-await.mdx index 9ba7f4560..866aa2628 100644 --- a/docs/writing-reactors/async-await.mdx +++ b/docs/writing-reactors/async-await.mdx @@ -180,7 +180,7 @@ In the Python target, the binding releases the interpreter lock around the parts ## Caveats -**Reaction order.** When a reaction resumes at a new tag, it will be mutually exclusive with other reactions of the same reactor executing at the same tag, but the order in which they execute is not guaranteed. The resuming reaction will be assured of executing after the reaction that awakened it with `lf_async_resume` or `lf_await_resume`, but not necessarily before or after other reactions executing at the same tag. It is recommended, therefore, that a reaction using `lf_async` or `lf_await` be the last reaction in its reactor, in which case, normal reaction order is ensured. If more than one reaction uses `lf_async` or `lf_await`, the order in which they resume is nondeterministic, so care is needed when updating state variables or overwriting outputs. +**Reaction order.** If the reaction with `lf_async_resume` or `lf_await_resume` is put _after_ rather than before the reaction that calls `lf_async` or `lf_await`, then the normal reaction order of LF will not be preserved at the tag where the suspended reaction resumes. This may not create a problem, but best practice is to ensure that the reactions are defined in the correct order. The reaction with `lf_async_resume` or `lf_await_resume` should always be before the corresponding reaction that calls `lf_async` or `lf_await`. **State variables.** Other reactions of the same reactor may execute while a reaction is suspended, and they may modify state variables. Do not assume that a state variable has the same value after `lf_async` or `lf_await` returns that it had before. Local variables, on the other hand, are preserved.