From d1b01889b0480bc717d0190198c56b4250496aca Mon Sep 17 00:00:00 2001 From: Richard Musiol Date: Sun, 20 May 2018 00:56:36 +0200 Subject: [PATCH] runtime, sycall/js: add support for callbacks from JavaScript This commit adds support for JavaScript callbacks back into WebAssembly. This is experimental API, just like the rest of the syscall/js package. The time package now also uses this mechanism to properly support timers without resorting to a busy loop. JavaScript code can call into the same entry point multiple times. The new RUN register is used to keep track of the program's run state. Possible values are: starting, running, paused and exited. If no goroutine is ready any more, the scheduler can put the program into the "paused" state and the WebAssembly code will stop running. When a callback occurs, the JavaScript code puts the callback data into a queue and then calls into WebAssembly to allow the Go code to continue running. Updates #18892 Updates #25506 Change-Id: Ib8701cfa0536d10d69bd541c85b0e2a754eb54fb --- misc/wasm/wasm_exec.js | 37 +++++++++- src/cmd/internal/obj/wasm/a.out.go | 3 + src/cmd/internal/obj/wasm/anames.go | 1 + src/cmd/internal/obj/wasm/wasmobj.go | 16 +++- src/cmd/link/internal/wasm/asm.go | 1 + src/runtime/lock_futex.go | 6 ++ src/runtime/lock_js.go | 94 ++++++++++++++++++++---- src/runtime/lock_sema.go | 6 ++ src/runtime/proc.go | 8 ++ src/runtime/rt0_js_wasm.s | 78 ++++++++++++++------ src/runtime/sys_wasm.s | 11 +-- src/syscall/js/callback.go | 106 +++++++++++++++++++++++++++ src/syscall/js/js.go | 4 + 13 files changed, 324 insertions(+), 47 deletions(-) create mode 100644 src/syscall/js/callback.go diff --git a/misc/wasm/wasm_exec.js b/misc/wasm/wasm_exec.js index e579ecf677d950..1ed734b237febf 100755 --- a/misc/wasm/wasm_exec.js +++ b/misc/wasm/wasm_exec.js @@ -120,6 +120,7 @@ go: { // func wasmExit(code int32) "runtime.wasmExit": (sp) => { + this.exited = true; this.exit(mem().getInt32(sp + 8, true)); }, @@ -143,6 +144,11 @@ mem().setInt32(sp + 16, (msec % 1000) * 1000000, true); }, + // func scheduleCallback(delay int64) + "runtime.scheduleCallback": (sp) => { + setTimeout(() => { this._resolveCallbackPromise(); }, getInt64(sp + 8)); + }, + // func getRandomData(r []byte) "runtime.getRandomData": (sp) => { crypto.getRandomValues(loadSlice(sp + 8)); @@ -270,7 +276,19 @@ async run(instance) { this._inst = instance; - this._values = [undefined, null, global, this._inst.exports.mem]; // TODO: garbage collection + this._values = [ // TODO: garbage collection + undefined, + null, + global, + this._inst.exports.mem, + () => { + if (this.exited) { + throw new Error('bad callback: Go program has already exited (hint: use "select {}")'); + } + setTimeout(this._resolveCallbackPromise, 0); // make sure it is asynchronous + }, + ]; + this.exited = false; const mem = new DataView(this._inst.exports.mem.buffer) @@ -304,7 +322,16 @@ offset += 8; }); - this._inst.exports.run(argc, argv); + while (true) { + const callbackPromise = new Promise((resolve) => { + this._resolveCallbackPromise = resolve; + }); + this._inst.exports.run(argc, argv); + if (this.exited) { + break; + } + await callbackPromise; + } } } @@ -319,6 +346,12 @@ go.env = process.env; go.exit = process.exit; WebAssembly.instantiate(fs.readFileSync(process.argv[2]), go.importObject).then((result) => { + process.on("exit", () => { // Node.js exits if no callback is pending + if (!go.exited) { + console.error("error: all goroutines asleep and no JavaScript callback pending - deadlock!"); + process.exit(1); + } + }); return go.run(result.instance); }).catch((err) => { console.error(err); diff --git a/src/cmd/internal/obj/wasm/a.out.go b/src/cmd/internal/obj/wasm/a.out.go index 9c04be2609a0a1..6f882215ff4e1b 100644 --- a/src/cmd/internal/obj/wasm/a.out.go +++ b/src/cmd/internal/obj/wasm/a.out.go @@ -219,6 +219,8 @@ const ( // However, it is not allowed to switch goroutines while inside of an ACALLNORESUME call. ACALLNORESUME + ARETUNWIND + AMOVB AMOVH AMOVW @@ -244,6 +246,7 @@ const ( REG_RET1 REG_RET2 REG_RET3 + REG_RUN // locals REG_R0 diff --git a/src/cmd/internal/obj/wasm/anames.go b/src/cmd/internal/obj/wasm/anames.go index 20d04446d08d29..745f0d773a94d7 100644 --- a/src/cmd/internal/obj/wasm/anames.go +++ b/src/cmd/internal/obj/wasm/anames.go @@ -180,6 +180,7 @@ var Anames = []string{ "F64ReinterpretI64", "RESUMEPOINT", "CALLNORESUME", + "RETUNWIND", "MOVB", "MOVH", "MOVW", diff --git a/src/cmd/internal/obj/wasm/wasmobj.go b/src/cmd/internal/obj/wasm/wasmobj.go index ca09b3fa0b23e5..8498b407245f0a 100644 --- a/src/cmd/internal/obj/wasm/wasmobj.go +++ b/src/cmd/internal/obj/wasm/wasmobj.go @@ -25,6 +25,7 @@ var Register = map[string]int16{ "RET1": REG_RET1, "RET2": REG_RET2, "RET3": REG_RET3, + "RUN": REG_RUN, "R0": REG_R0, "R1": REG_R1, @@ -487,7 +488,7 @@ func preprocess(ctxt *obj.Link, s *obj.LSym, newprog obj.ProgAlloc) { p = appendp(p, AEnd) // end of Loop } - case obj.ARET: + case obj.ARET, ARETUNWIND: ret := *p p.As = obj.ANOP @@ -528,7 +529,14 @@ func preprocess(ctxt *obj.Link, s *obj.LSym, newprog obj.ProgAlloc) { p = appendp(p, AI32Add) p = appendp(p, ASet, regAddr(REG_SP)) - // not switching goroutine, return 0 + if ret.As == ARETUNWIND { + // function needs to unwind the WebAssembly stack, return 1 + p = appendp(p, AI32Const, constAddr(1)) + p = appendp(p, AReturn) + break + } + + // not unwinding the WebAssembly stack, return 0 p = appendp(p, AI32Const, constAddr(0)) p = appendp(p, AReturn) } @@ -726,7 +734,7 @@ func assemble(ctxt *obj.Link, s *obj.LSym, newprog obj.ProgAlloc) { } reg := p.From.Reg switch { - case reg >= REG_PC_F && reg <= REG_RET3: + case reg >= REG_PC_F && reg <= REG_RUN: w.WriteByte(0x23) // get_global writeUleb128(w, uint64(reg-REG_PC_F)) case reg >= REG_R0 && reg <= REG_F15: @@ -743,7 +751,7 @@ func assemble(ctxt *obj.Link, s *obj.LSym, newprog obj.ProgAlloc) { } reg := p.To.Reg switch { - case reg >= REG_PC_F && reg <= REG_RET3: + case reg >= REG_PC_F && reg <= REG_RUN: w.WriteByte(0x24) // set_global writeUleb128(w, uint64(reg-REG_PC_F)) case reg >= REG_R0 && reg <= REG_F15: diff --git a/src/cmd/link/internal/wasm/asm.go b/src/cmd/link/internal/wasm/asm.go index 235a5a25d1453f..7eff544632094c 100644 --- a/src/cmd/link/internal/wasm/asm.go +++ b/src/cmd/link/internal/wasm/asm.go @@ -304,6 +304,7 @@ func writeGlobalSec(ctxt *ld.Link) { I64, // 6: RET1 I64, // 7: RET2 I64, // 8: RET3 + I32, // 9: RUN } writeUleb128(ctxt.Out, uint64(len(globalRegs))) // number of globals diff --git a/src/runtime/lock_futex.go b/src/runtime/lock_futex.go index 18dd4629a0cd03..b590c4b92bde1c 100644 --- a/src/runtime/lock_futex.go +++ b/src/runtime/lock_futex.go @@ -229,3 +229,9 @@ func notetsleepg(n *note, ns int64) bool { exitsyscall() return ok } + +func pauseSchedulerUntilCallback() bool { + return false +} + +func checkTimeouts() {} diff --git a/src/runtime/lock_js.go b/src/runtime/lock_js.go index 21e53d075e0f8c..f0a75016a593e9 100644 --- a/src/runtime/lock_js.go +++ b/src/runtime/lock_js.go @@ -6,14 +6,22 @@ package runtime +import ( + _ "unsafe" +) + // js/wasm has no support for threads yet. There is no preemption. -// Waiting for a mutex or timeout is implemented as a busy loop -// while allowing other goroutines to run. +// Waiting for a mutex is as simple as allowing other goroutines +// to run until the mutex got unlocked. const ( mutex_unlocked = 0 mutex_locked = 1 + note_cleared = 0 + note_woken = 1 + note_timeout = 2 + active_spin = 4 active_spin_cnt = 30 passive_spin = 1 @@ -34,16 +42,28 @@ func unlock(l *mutex) { } // One-time notifications. + +type noteWithTimeout struct { + gp *g + deadline int64 +} + +var notes = make(map[*note]*g) +var notesWithTimeout = make(map[*note]noteWithTimeout) + func noteclear(n *note) { - n.key = 0 + n.key = note_cleared } func notewakeup(n *note) { - if n.key != 0 { - print("notewakeup - double wakeup (", n.key, ")\n") + if n.key == note_woken { throw("notewakeup - double wakeup") } - n.key = 1 + cleared := n.key == note_cleared + n.key = note_woken + if cleared { + goready(notes[n], 1) + } } func notesleep(n *note) { @@ -62,14 +82,62 @@ func notetsleepg(n *note, ns int64) bool { throw("notetsleepg on g0") } - deadline := nanotime() + ns - for { - if n.key != 0 { - return true + if ns >= 0 { + delay := ns/1000000 + 2 + if delay > 1<<31-1 { + delay = 1<<31 - 1 // cap to max int32 } - Gosched() - if ns >= 0 && nanotime() >= deadline { - return false + + notes[n] = gp + notesWithTimeout[n] = noteWithTimeout{gp: gp, deadline: nanotime() + ns} + scheduleCallback(delay) + gopark(nil, nil, waitReasonSleep, traceEvNone, 1) + delete(notes, n) + delete(notesWithTimeout, n) + + return n.key == note_woken + } + + for n.key != note_woken { + notes[n] = gp + gopark(nil, nil, waitReasonZero, traceEvNone, 1) + delete(notes, n) + } + return true +} + +func checkTimeouts() { + now := nanotime() + for n, nt := range notesWithTimeout { + if n.key == note_cleared && now > nt.deadline { + n.key = note_timeout + goready(nt.gp, 1) } } } + +var waitingForCallback []*g + +//go:linkname sleepUntilCallback syscall/js.sleepUntilCallback +func sleepUntilCallback() { + waitingForCallback = append(waitingForCallback, getg()) + gopark(nil, nil, waitReasonZero, traceEvNone, 1) +} + +func pauseSchedulerUntilCallback() bool { + if len(waitingForCallback) == 0 { + return false + } + + pause() + checkTimeouts() + for _, gp := range waitingForCallback { + goready(gp, 1) + } + waitingForCallback = waitingForCallback[:0] + return true +} + +func pause() + +func scheduleCallback(delay int64) diff --git a/src/runtime/lock_sema.go b/src/runtime/lock_sema.go index 4cb0e84db38889..6e01d70f7578f6 100644 --- a/src/runtime/lock_sema.go +++ b/src/runtime/lock_sema.go @@ -282,3 +282,9 @@ func notetsleepg(n *note, ns int64) bool { exitsyscall() return ok } + +func pauseSchedulerUntilCallback() bool { + return false +} + +func checkTimeouts() {} diff --git a/src/runtime/proc.go b/src/runtime/proc.go index 6d4da3432a4585..676f6122682a04 100644 --- a/src/runtime/proc.go +++ b/src/runtime/proc.go @@ -262,6 +262,7 @@ func forcegchelper() { // Gosched yields the processor, allowing other goroutines to run. It does not // suspend the current goroutine, so execution resumes automatically. func Gosched() { + checkTimeouts() mcall(gosched_m) } @@ -281,6 +282,9 @@ func goschedguarded() { // Reasons should be unique and descriptive. // Do not re-use reasons, add new ones. func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) { + if reason != waitReasonSleep { + checkTimeouts() // timeouts may expire while two goroutines keep the scheduler busy + } mp := acquirem() gp := mp.curg status := readgstatus(gp) @@ -2315,6 +2319,10 @@ stop: return gp, false } + if pauseSchedulerUntilCallback() { + goto top + } + // Before we drop our P, make a snapshot of the allp slice, // which can change underfoot once we no longer block // safe-points. We don't need to snapshot the contents because diff --git a/src/runtime/rt0_js_wasm.s b/src/runtime/rt0_js_wasm.s index 2a878d990c55c0..c6290e5af89342 100644 --- a/src/runtime/rt0_js_wasm.s +++ b/src/runtime/rt0_js_wasm.s @@ -5,45 +5,81 @@ #include "go_asm.h" #include "textflag.h" +// The register RUN indicates the current run state of the program. +// Possible values are: +// 0: starting +// 1: running +// 2: paused +// 3: exited + // _rt0_wasm_js does NOT follow the Go ABI. It has two WebAssembly parameters: // R0: argc (i32) // R1: argv (i32) TEXT _rt0_wasm_js(SB),NOSPLIT,$0 - MOVD $runtime·wasmStack+m0Stack__size(SB), SP + Get RUN + I32Const $0 // starting + I32Eq + If + MOVD $runtime·wasmStack+m0Stack__size(SB), SP + + Get SP + Get R0 // argc + I64ExtendUI32 + I64Store $0 - Get SP - Get R0 // argc - I64ExtendUI32 - I64Store $0 + Get SP + Get R1 // argv + I64ExtendUI32 + I64Store $8 - Get SP - Get R1 // argv - I64ExtendUI32 - I64Store $8 + I32Const $runtime·rt0_go(SB) + I32Const $16 + I32ShrU + Set PC_F - I32Const $runtime·rt0_go(SB) - I32Const $16 - I32ShrU - Set PC_F + I32Const $1 // running + Set RUN + Else + Get RUN + I32Const $2 // paused + I32Eq + If + I32Const $1 // running + Set RUN + Else + Unreachable + End + End -// Call the function for the current PC_F. Repeat until SP=0 indicates program end. +// Call the function for the current PC_F. Repeat until RUN != 0 indicates pause or exit. // The WebAssembly stack may unwind, e.g. when switching goroutines. // The Go stack on the linear memory is then used to jump to the correct functions // with this loop, without having to restore the full WebAssembly stack. loop: Loop - Get SP - I32Eqz - If - Return - End - Get PC_F CallIndirect $0 Drop - Br loop + Get RUN + I32Const $1 // running + I32Eq + BrIf loop End + Return + +TEXT runtime·pause(SB), NOSPLIT, $0 + I32Const $2 // paused + Set RUN + RETUNWIND + +TEXT runtime·exit(SB), NOSPLIT, $0-8 + Call runtime·wasmExit(SB) + Drop + I32Const $3 // exited + Set RUN + RETUNWIND + TEXT _rt0_wasm_js_lib(SB),NOSPLIT,$0 UNDEF diff --git a/src/runtime/sys_wasm.s b/src/runtime/sys_wasm.s index 9a67ceec63e763..5987e329ed57e2 100644 --- a/src/runtime/sys_wasm.s +++ b/src/runtime/sys_wasm.s @@ -149,13 +149,6 @@ TEXT runtime·wasmTruncU(SB), NOSPLIT, $0-0 I64TruncUF64 Return -TEXT runtime·exit(SB), NOSPLIT, $0-8 - Call runtime·wasmExit(SB) - Drop - I32Const $0 - Set SP - I32Const $1 - TEXT runtime·exitThread(SB), NOSPLIT, $0-0 UNDEF @@ -194,6 +187,10 @@ TEXT ·walltime(SB), NOSPLIT, $0 CallImport RET +TEXT ·scheduleCallback(SB), NOSPLIT, $0 + CallImport + RET + TEXT ·getRandomData(SB), NOSPLIT, $0 CallImport RET diff --git a/src/syscall/js/callback.go b/src/syscall/js/callback.go new file mode 100644 index 00000000000000..d0ee1b50998867 --- /dev/null +++ b/src/syscall/js/callback.go @@ -0,0 +1,106 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// +build js,wasm + +package js + +var pendingCallbacks = Global.Get("Array").New() + +var makeCallbackHelper = Global.Call("eval", ` + (function(id, pendingCallbacks, resolveCallbackPromise) { + return function() { + pendingCallbacks.push({ id: id, args: arguments }); + resolveCallbackPromise(); + }; + }) +`) + +var makeEventCallbackHelper = Global.Call("eval", ` + (function(preventDefault, stopPropagation, stopImmediatePropagation, fn) { + return function(event) { + if (preventDefault) { + event.preventDefault(); + } + if (stopPropagation) { + event.stopPropagation(); + } + if (stopImmediatePropagation) { + event.stopImmediatePropagation(); + } + fn(event); + }; + }) +`) + +var callbacks = make(map[uint32]func([]Value)) +var nextCallbackID uint32 = 1 + +// Callback is a Go function that got wrapped for use as a JavaScript callback. +type Callback struct { + id uint32 + value Value +} + +// NewCallback returns a wrapped callback which can be used as an argument with Set, Call, etc. Invoking the +// callback in JavaScript will queue the Go function fn for execution. This execution happens asynchronously. +// Callback.Close must be called to free up resources when the callback will not be used any more. +func NewCallback(fn func(args []Value)) Callback { + id := nextCallbackID + nextCallbackID++ + callbacks[id] = fn + return Callback{ + id: id, + value: makeCallbackHelper.Invoke(id, pendingCallbacks, resolveCallbackPromise), + } +} + +// NewEventCallback returns a wrapped callback, just like NewCallback, but the callback expects to have exactly +// one argument (the event) and it will synchronously call preventDefault, stopPropagation and/or +// stopImmediatePropagation on this argument. +func NewEventCallback(preventDefault, stopPropagation, stopImmediatePropagation bool, fn func(event Value)) Callback { + c := NewCallback(func(args []Value) { + fn(args[0]) + }) + return Callback{ + id: c.id, + value: makeEventCallbackHelper.Invoke(preventDefault, stopPropagation, stopImmediatePropagation, c), + } +} + +func (c Callback) Close() { + delete(callbacks, c.id) +} + +func init() { + go callbackLoop() +} + +func callbackLoop() { + for { + sleepUntilCallback() + for { + cb := pendingCallbacks.Call("shift") + if cb == Undefined { + break + } + + id := uint32(cb.Get("id").Int()) + f, ok := callbacks[id] + if !ok { + Global.Get("console").Call("error", "call to closed callback") + continue + } + + argsObj := cb.Get("args") + args := make([]Value, argsObj.Length()) + for i := range args { + args[i] = argsObj.Index(i) + } + f(args) + } + } +} + +func sleepUntilCallback() diff --git a/src/syscall/js/js.go b/src/syscall/js/js.go index 9332a262546df3..2954cb99aaa23c 100644 --- a/src/syscall/js/js.go +++ b/src/syscall/js/js.go @@ -40,6 +40,8 @@ var ( Global = Value{2} memory = Value{3} + + resolveCallbackPromise = Value{4} ) var uint8Array = Global.Get("Uint8Array") @@ -49,6 +51,8 @@ func ValueOf(x interface{}) Value { switch x := x.(type) { case Value: return x + case Callback: + return x.value case nil: return Null case bool: