How are signals actually implemented? I'm asking because I'm currently working with WASM/WASI, in which a process is strictly single-threaded. The WASM equivalent of a syscall (a "host call") exists, but as you'd expect, it suspends the execution of the WASM code until it returns. As a result, if you want to set up a bidirectional bytestream between the WASM code and the host-language's runtime, you can't block on a call to `read()`, since this would effectively block until data arrives. Polling is obviously not a great solution.
So if I were to implement a signaling mechanism in a WASM runtime, what would that look like?
I think the closest existing concept to signals is the gas/fuel mechanism implemented in some WebAssembly runtimes, used by WASM blockchain VMs to preempt untrusted code. Runtimes either instrument the JITed code to check a fuel counter in every function/loop, or they register a timer signal/interrupt that periodically checks the counter.
From a similar handler, you could check if a signal flag has been set, then call a user-defined signal handler exported from the module.
If you don't control the runtime, you could do this instrumentation to the bytecode rather than the generated machine code, effectively simulating preemption via injected yield points.
There don't seem to be many write-ups on this concept. The best reference seems to be existing implementations:
Wasmer's implementation of metering[0] just traps when it runs out of fuel. WasmEdge's implementation of interruptibility[1] checks a flag and stops execution if it's set.
While neither of these support resuming execution after the deadline, replacing the halt with a call to a signal dispatcher should work.
Wasmtime has two different implementations of interrupting execution that both support resuming[2]. The fuel mechanism[3] is deterministic but the epoch mechanism[4] is more performant. If you're free to pick your runtime, I'm sure you could configure Wasmtime into doing what you want.
The idea of running code up to some resource limit and then aborting it is documented in the Lisp 1.5 manual from 1962, P. 34:
6.4 The Cons Counter and Errorset
The cons counter is a useful device for breaking out of program loops. it automatically causes a trap when a certain number of conses have been performed. The counter is turned on by executing count [n], where n is an integer. If n conses are performed before the counter is turned off, a trap will occur and an error diagnostic will be given.
> if I were to implement a signaling mechanism in a WASM runtime, what would that look like?
Sythesizing the other comments:
The "natural" way to do it exactly resembles a microcontroller interrupt handler. At an instruction boundary, push the current execution state onto the stack; jump to an alternate execution point; provide a special instruction to return to the previous execution state. This also copies all the _problems_ of POSIX signals (may happen in the middle of any execution state, what to do if you're in the middle of a syscall, etc).
If you just want a bidirectional bytestream: build a bidirectional bytestream. The classic select() or poll() style interface.
If you need higher performance but don't want the hassle of interruption at any stack state: build co-operative multitasking into the runtime. Provide a means of very quickly checking a single bit (in practice, a word of atomic granularity) to see if something needs to be done, then write the software so it can check this at various points and co-operate. Similar to uses of CancellationToken in C#.
Let me make sure I'm tracking the essence of your comment:
>At an instruction boundary, push the current execution state onto the stack; jump to an alternate execution point; provide a special instruction to return to the previous execution state.
In the VM, for each instruction:
1. Check if some bit has been set, which would indicate "we gotta interrupt"
2. Push current state onto the stack
3. Jump to pre-defined location & execute whatever's there
At some point, the code from step 3 (be it code I wrote or some guest-supplied interrupt handler) will push an instruction onto the stack that says "resume".
Is that roughly correct?
If so, this sounds like something that (unsurprisingly) requires support at the VM level. WASM has no such feature, though... are you aware of any clever hacks or workarounds?
While this doesn’t answer the question, and apologies if you’re aware of this already… if you just want a bidirectional bytestream, you are probably better off implementing an interface like select()/poll(). Actually, while I have zero WASI experience, it seems like it has one already. This is not the same as “polling” as in checking over and over; rather, the WASM program makes a list of conditions like “data available to read” or “buffer space available to write”, and performs a syscalls which blocks until any of those conditions is met. Then the actual read/write call can be done without blocking. (There is also a design variant where the whole read/write operation is scheduled in advance and the blocking syscall just notifies you which previously scheduled operation has completed, similar to Windows IOCP or Linux io_uring.)
No no, you're right in the thick of the issue, so thank you for your comment :)
At issue is the fact that select/poll requires integration into the language runtime of whatever has been compiled to WASM. For example, imagine that you've compiled an application written in Go to WASM and you want to create a syscall that is analogous to select while still being a distinct thing. The problem you run into is this: what if there's a timer that's going to fire in 500µs? You have no way of determining that and setting your `select` timeout accordingly. To do so would require hacking the Go runtime itself, along with the compiler.
>There is also a design variant where the whole read/write operation is scheduled in advance and the blocking syscall just notifies you which previously scheduled operation has completed, similar to Windows IOCP or Linux io_uring.
I'm not sure I'm fully grasping the distinction, but it seems appealing... can you elaborate? Or is there something I could read/watch?
Nobody else answered you so I'm going to make up an answer which, even if it's not a specifically correct answer, it's a good answer :)
look into a.out and ELF formats for executable files; your executable files, said files hereby called "your code", will be in this format on linux.
There will be an entry point for your code, so if your program gets run, the kernel (or whatever userspace launcher it's given this task to... now that I think of it, from the command line, the shell would fork itself, and one fork would exec your program, so we're talking about exec here) will load your code into memory, and transfer control to--transfer control to means set the program counter to--your code at a particular address (the address of C main(), so to speak). Before this happens, the launcher will have allocated memory for you, set up the stack pointer, opened stdin/out/err, put the command line arguments into place (argc, argv), etc. Then your code can run till it's done, making system calls if it needs to, and potentially being preemptively multitasked, but that's all invisible to your code, black box in terms of how it's implemented.
now for signals...
Signals from the operating system need their own entry point(s) into your code. So, while your code is running, the OS can interrupt it (stuffing everything of yours onto your stack so it can be popped off later when control flow is returned to the same point later). In this meantime, the OS will now transfer control (set the program counter just like it did when it first launched you) and you're off and running with code to handle the signal, the interrupt.
If you have not prepared your code for this event, the default (set up by the launcher for programs) program flow at this point will be sent to a generic handler that says "unhandled signal", and your program will be killed. They're actually lying, the signal is being handled, but it's being handled by a handler that cleans up and exits.
But, you can write code to handle the signal yourself, and when your program launches you (generally in main() or nearby) register the address of the handler you want to use so that when the interrupt comes the OS will know where to send control. At that point it's as if you just called a function/subroutine at wherever your code running at that moment, and when that function returns, things continue as they were before, modulo whatever you did in your handler.
There's a bunch of specifics I don't know, is there one entry point for all signals, and a signal argument token decides where to transfer control, or are there 10 separate handlers so you don't waste time? etc.
if you're doing no-wait I/O with a single thread, one way is to make a blocking call, call into something that doesn't return till there's new data, and that new data may then arrive, handled by your interrupt handler; in that case returning from your input-getting handler could return from the blocking call and your code is off and running as if it just returned in the first place. There are a myriad variations on this, but that's the general idea.
happy to clarify anything, others can probably answer better, etc.
Firstly, thank you. Your comment is relating a couple of distinct ideas together in a way that is very helpful.
>Then your code can run till it's done, making system calls if it needs to, and potentially being preemptively multitasked, but that's all invisible to your code, black box in terms of how it's implemented.
I'm realizing that I don't actually understand how pre-emptive multi-tasking is implemented at the VM level, and I think this is perhaps what I actually want. Can you elaborate and/or suggest any articles/video-lectures on the subject?
Maybe what I need is an operating-systems course...
I think glibc or equiv provides the default signal handlers as part of the stuff that gets included with even "hello world" to make it 10kb (or 10mb, whatever your tools emit).
The default actions are handled by the kernel so that they still exist even before glibc has a chance to come up (for instance so core dumps happen even in early dynamic linking).
So if I were to implement a signaling mechanism in a WASM runtime, what would that look like?