golang / go

The Go programming language
https://go.dev
BSD 3-Clause "New" or "Revised" License
123.92k stars 17.65k forks source link

cmd/compile: add go:wasmexport directive #65199

Closed johanbrandhorst closed 2 months ago

johanbrandhorst commented 9 months ago

Background

38248 defined a new compiler directive, go:wasmimport, for interfacing with host defined functions. This allowed calling from Go code into host functions, but it’s still not possible to call from the WebAssembly (Wasm) host into Go code.

Some applications have adopted the practice of allowing them to be extended by calling into Wasm compiled code according to some well defined ABI. Examples include Envoy, Istio, VS Code and others. Go cannot support compiling code to these applications, as the only exported function in the module compiled by Go is _start, mapping to the main function in a main package.

Despite this, some users are designing custom plugin systems using this interface, utilizing standard in and standard out for communicating with the Wasm binary. This shows a desire for exporting Go functions in the community.

There have been historical discussions on implementing this before (including #42372, #25612 and #41715), but none of them have reached a consensus on a design and implementation. In particular, #42372 had a long discussion (and design doc) that never provided a satisfying answer for how to run executed functions in the Go runtime. Instead of reviving that discussion, this proposal will attempt to build on it and answer the questions posed. This proposal supersedes #42372.

Exporting functions to the wasm host is also a necessity for a hypothetical GOOS=wasip2 targeting preview 2 of the WASI specification. This could be implemented as a special case in the compiler but since this is a feature requested by users it could reuse that functionality (similar to go:wasmimport today).

Proposal

Repurpose the -buildmode build flag value c-shared for the wasip1 port. It now signals to the compiler to replace the _start function with an _initialize function, which performs runtime and package initialization.

Add a new compiler directive, go:wasmexport, which is used to signal to the compiler that a function should be exported using a Wasm export in the resulting Wasm binary. Using the compiler directive will result in a compilation failure unless the target GOOS is wasip1.

There is a single ~optional~ required parameter to the directive, defining the name of the exported function: (UPDATE: make the parameter required, consistent with the //export pragma and easier to implement).

//go:wasmexport name

The directive is only allowed on functions, not methods.

Discussion

Parallel with -buildmode=c-shared and CGO

The proposed implementation is inspired by the implementation of C references to Go functions. When an exported function is called, a new goroutine (G) is created, which executes on a single thread (M), since Wasm is a single threaded architecture. The runtime will wake up and resume scheduling goroutines as necessary, with the exported function being one of the goroutines available for scheduling. Any other goroutines started during package initialization or left over from previous exported function executions will also be available for scheduling.

Why a "-buildmode" option?

The wasi_snapshot_preview1 documentation states that a _start function and an _initialize function are mutually exclusive. Additionally, at the end of the current _start functions as compiled by Go, proc_exit is called. At this point, the module is considered done, and cannot be interacted with. Given these conditions, we need some way for a user to declare that they want to build a binary especially for exporting one or more functions and to include the _initialize function for package and runtime initialization.

We also considered using a GOWASM option instead, but this feels wrong since that environment variable is used to specify options relating to the architecture (existing options are satconv and signext), while this export option is dependent on the behavior of the "OS" (what functions to export, what initialization pattern to expect).

What happens to func main when exports are involved?

Go code compiled to a wasip1 Wasm binary can be either a "Command", which includes the _start function, or a "Reactor/Library", which includes the _initialize function.

When using -buildmode=c-shared, the resulting Wasm binary will not contain a _start function, and will only contain the _initialize function and any exported functions. The Go main function will not be exported to the host. The user can choose to export it like any other function using the //go:wasmexport directive. The _initialize function will not automatically call main. The main function will not initialize the runtime.

When the -buildmode flag is unset, the _start function and any exported functions will be exported to the host. Using //go:wasmexport on the main function in this mode will result in a compilation error. In this mode, only _start will initialize the runtime, and so must be the first export called from the host. Any other exported functions may only be called through calling into host functions that call other exports during the execution of the _start function. Once the _start function has returned, no other exports may be called on the same instance.

Why not reuse //export?

//export is used to export Go functions to C when using buildmode=c-shared. Use of //export puts restrictions on the use of the file, namely that it cannot contain definitions, only declarations. It’s also something of an ugly duckling among compiler directives in that it doesn’t use the now established go: prefix. A new directive removes the need for users to define functions separately from the declaration, has a nice symmetry with go:wasmimport, and uses the well established go: prefix.

Handling Reentrant Calls and Panics

Reentrant calls happen when the Go application calls a host import, and that invocation calls back into an exported function. Reentrant calls are handled by creating a new goroutine. If a panic reaches the top-level of the go:wasmexport call, the program crashes because there are no mechanisms allowing the guest application to propagate the panic to the Wasm host.

Naming exports

When the name of the Go function matches that of the desired Wasm export, the name parameter can be omitted.

For example:

//go:wasmexport add
func add(x, y int) int {
    return x + y
}

Is equivalent to

//go:wasmexport
func add(x, y int) int {
    return x + y
}

The names _start and _initialize are reserved and not available for user exported functions.

Third-party libraries

Third-party libraries will need to be able to define exports, as WASI functionality such as wasi-http requires calling into exported functions, which would be provided by the third party library in a user-friendly wrapper. Any exports defined in third party libraries are compiled to exported Wasm functions.

Module names

The current Wasm architecture doesn’t define a module name of the compiled module, and this proposal does not suggest adding one. Module names are useful to namespace different compiled Wasm binaries, but it can usually be configured by the runtime or using post-processing tools on the binaries. Future proposals may suggest some way to build this into the Go build system, but this proposal suggests not naming it for simplicity.

Conflicting exports

If the compiler detects multiple exports using the same name, a compile error will occur and warn the user that multiple definitions are in conflict. This may have to happen at link time. If this happens in third-party libraries the user has no recourse but to avoid using one of the libraries.

Supported Types

The go:wasmimport directive allows the declaration of host imports by naming the module and function that the application depends on. The directive applies restrictions on the types that can be used in the function signatures, limiting to fixed-size integers and floats, and unsafe.Pointer, which allows simple mapping rules between the Go and Wasm types. The go:wasmexport directive will use the same type restrictions. Any future relaxing of this restriction will be subject to a separate proposal.

Spawning Goroutines from go:wasmexport functions

The proposal considers scenarios where the go:wasmexport call spawns new goroutines. In the absence of threading or stack switching capability in Wasm, the simplest option is to document that all goroutines still running when the invocation of the go:wasmexport function returns will be paused until the control flow re-enters the Go application.

In the future, we anticipate that Wasm will gain the ability to either spawn threads or integrate with the event loop of the host runtime (e.g., via stack-switching) to drive background goroutines to completion after the invocation of a go:wasmexport function has returned.

Blocking in go:wasmexport functions

When the goroutine running the exported function blocks for any reason, the function will yield to the Go runtime. The Go runtime will schedule other goroutines as necessary. If there are no other goroutines, the application will crash with a deadlock, as there is no way to proceed, and Wasm code cannot block.

Authors

@johanbrandhorst, @achille-roussel, @Pryz, @dgryski, @evanphx, @neelance, @mdlayher

Acknowledgements

Thanks to all participants in the go:wasmexport discussion at the Go contributor summit at GopherCon 2023, without which this proposal would not have been possible.

CC @golang/wasm @cherrymui

johanbrandhorst commented 2 months ago

Is that possible to also support lib mode on js platform?

What is your use case? As this discussion mentions, it's already possible to export Go functions to JS using syscall/js.FuncOf. In any case, I agree that it would require a separate proposal.

Zxilly commented 2 months ago

I'm using syscall/js.FuncOf for the callbacks right now, but this requires me to create an infinite block in the main function, which seems tricky. And, calling a function in wasm and waiting for the result seems more in line with ffi.

cherrymui commented 2 months ago

I'll note that with CL 606855, wasmexport does work on js, just that it is an "executable", not a library. This means you can call the start function, and in main.main call a wasmimport function to transfer the control back to the host, which can then call wasmexport functions.

vlkv commented 2 months ago

To make your test case work, it seems you want to use library mode...

Thank you very much for explanation and help. My test example works perfectly now!

We could consider making it fail loud explicitly if an exported function is called before the runtime is initialized (_start or _initialize not called).

Good idea.

paralin commented 2 months ago

With syscall/js you can pass a callback function to the JavaScript code and then have that callback (which cannot block) queue the incoming messages in a linked list and trigger any waiters by closing a channel. This works well in my use case.

aykevl commented 2 months ago

A few questions regarding this proposal:

johanbrandhorst commented 2 months ago

Great questions 😁

* What about goroutines started as part of package initializers that don't finish before package initialization is done? I assume they will be suspended in a similar way that goroutines are suspended after a `//go:wasmexport` function returns?

Goroutines stared in init should be paused when init returns and be available for scheduling again when an export is called.

* What if a package initializer blocks? Will it also result in a crash?

I think we should be able to do what we do for exports - schedule other available goroutines, and crash if nothing can be scheduled. That way you could start some goroutines and wait for them to finish in your init.

* What if threads are implemented? I suppose that these background goroutines will continue to run in that case? (It would be strange to artificially suspend them when there's no reason to). Kinda hypothetical for now, but am curious what the idea is in that regard.

The semantics mapping goroutines to threads would be up to any future proposal to implement Wasm threads, as you suggest. I think it would be nice to be able to continue running in the background, but I don't have enough of an understanding of all the consequences yet to say if that will be possible. I believe it's what we do for CGO though, so that's a good indication that we could do it for Wasm too.

aykevl commented 2 months ago

@johanbrandhorst thanks for the clarification! That confirms my assumptions (and simplifies the implementation in TinyGo).

aykevl commented 2 months ago

Another question: what's the reason for disallowing blocking operations in wasip1? I understand why they can't happen in JavaScript, but as far as I can see wasip1 can support it (for example, an exported function could call time.Sleep which then calls out to a wasip1 function that blocks). Is it just for consistency, or something internal to the Go runtime?

(Calling time.Sleep inside a //go:wasmexport function would be fine in TinyGo on wasip1 for example).

achille-roussel commented 2 months ago

If we were calling a blocking host function there would be no opportunity for the runtime to schedule other goroutines during that time (because we only have one thread).

For example, time.Sleep is supposed to block the current goroutine only, not the entire application, so it has to be implemented by the Go runtime and the only time we ever want to block on the host is when we are waiting for I/O events in the call to poll_oneoff.

aykevl commented 2 months ago

Right, that's different from how we do it in TinyGo. Once all available goroutines are suspended using time.Sleep, it sleeps using poll_oneoff until the first one is ready and runs it at that time (at least under wasip1, JS is different). I can modify the behavior so that it will panic instead of sleeping for compatibility.

mattjohnsonpint commented 2 months ago

Wait a sec, are you saying any call to time.Sleep in a wasi app will panic? That seems problematic, especially if any imported libraries are using it.

inliquid commented 2 months ago

@aykevl I think it's not necessary for TinyGo to 100% repeat what big Go implements. TinyGo is used widely by community because it's in many ways superior to big Go in Wasm world. And if you were to implement Wasm/WASI plugins TinyGo is the only option. WebAssembly support is still experimental and there in no guarantee that it won't be abandoned at some point, like it happened to go mobile.

cherrymui commented 2 months ago

Calling time.Sleep in a wasmexport function is just fine. It doesn't panic. E.g.

//go:wasmexport E
func E() {
    fmt.Println(time.Now())
    time.Sleep(1 * time.Second)
    fmt.Println(time.Now())
}

prints

2024-09-02 23:28:27.178171 +0000 UTC m=+0.000094126
2024-09-02 23:28:28.179793 +0000 UTC m=+1.001718085

It just sleeps a second.

And the Go runtime will naturally schedule other goroutines during time.Sleep. E.g.

//go:wasmexport E
func E() {
    fmt.Println(time.Now())
    go println("do something while sleeping")
    time.Sleep(1 * time.Second)
    fmt.Println(time.Now())
}

prints

2024-09-02 23:50:20.213754 +0000 UTC m=+0.000094501
do something while sleeping
2024-09-02 23:50:21.215162 +0000 UTC m=+1.001505626

In general, blocking syscalls are okay. It just blocks until the operation is done. It cannot return to the host, as the Wasm module itself is single threaded. However, the "syscalls" are provided by the host, so it calls to the host for the syscall implementation, which doesn't necessarily have to block. E.g. if I run the first wasmexport function above with wazero with configuration

    wazero.NewModuleConfig().
        WithStdout(os.Stdout).WithStderr(os.Stderr).
        WithNanosleep(func(ns int64){ go println("do something in host while sleeping"); time.Sleep(time.Duration(ns)) }).
        WithSysNanotime().
        WithSysWalltime()

(note the WithNanosleep line), it prints

2024-09-02 23:41:05.43752 +0000 UTC m=+0.000137335
do something in host while sleeping
2024-09-02 23:41:06.439137 +0000 UTC m=+1.001757335

It is only and indeed problematic if the wasmexport function blocks indefinitely, e.g. a deadlock, which will cause a runtime fatal error.

cherrymui commented 2 months ago

Right, that's different from how we do it in TinyGo. Once all available goroutines are suspended using time.Sleep, it sleeps using poll_oneoff until the first one is ready

@akavel I think this is similar to the implementation in this repo. At time.Sleep, the runtime will schedule other runnable goroutines to run. It calls the system sleep when there is no runnable goroutines. And wasmexport should not change that.

aykevl commented 2 months ago

@cherrymui Thank you for explaining! Yes that makes much more sense. I'll update the TinyGo PR to match.

gopherbot commented 2 months ago

Change https://go.dev/cl/611315 mentions this issue: cmd/compile: correct wasmexport result type checking