rogchap / v8go

Execute JavaScript from Go
https://rogchap.com/v8go
BSD 3-Clause "New" or "Revised" License
3.17k stars 210 forks source link

Question: How to use v8go within an http.Handler? #144

Open matthewmueller opened 3 years ago

matthewmueller commented 3 years ago

Hey there – I'd like to use v8go for server-side Javascript rendering, but I'm not sure how to use v8go safely and effectively inside an HTTP server.

I've created a couple examples with different approaches I'm considering, but I'd love to hear your recommended approach.

Approach 1. New isolate per request

func main() {
    http.ListenAndServe(":5000", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        iso, _ := v8go.NewIsolate()    // creates a new JavaScript VM
        ctx, _ := v8go.NewContext(iso) // new context within the VM
        value, _ := ctx.RunScript("Math.random()", "render.js")
        w.Write([]byte(value.String()))
    }))
}

As a baseline, I'm assuming this is safe. Since no isolates are shared across goroutines. But maybe we can do better?

Approach 2. Use an resource pool

Use a resource pool like puddle to re-use isolates per request and spawn more as more requests come in.

func newPool() *puddle.Pool {
    maxPoolSize := int32(runtime.NumCPU())
    constructor := func(context.Context) (interface{}, error) {
        return v8go.NewIsolate() // creates a new JavaScript VM
    }
    destructor := func(value interface{}) {
        value.(*v8go.Isolate).Close()
    }
    return puddle.NewPool(constructor, destructor, maxPoolSize)
}

func main() {
    pool := newPool()
    http.ListenAndServe(":5000", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Acquire an isolate from the pool and release after
        res, _ := pool.Acquire(context.Background())
        defer res.Release()
        iso := res.Value().(*v8go.Isolate)
        ctx, _ := v8go.NewContext(iso) // new context within the VM
        value, _ := ctx.RunScript("Math.random()", "render.js")
        w.Write([]byte(value.String()))
    }))
}

This is quite a bit faster, but after reading #129, I'm not so confident in this approach. While isolates aren't accessed by multiple goroutines, there's no pinning of an isolate to a specific thread.

That said, I haven't been able to break this yet, do you know a case where this would break?

Approach 3. Use an event loop

Instead of creating isolates on-demand inside the request's goroutine, we start an event loop in a goroutine and tie that to a specific thread for it's lifetime.

type request struct {
    script   string
    response chan string
}

func eventLoop(requestCh chan request) {
    runtime.LockOSThread()
    defer runtime.UnlockOSThread()
    iso, _ := v8go.NewIsolate()    // creates a new JavaScript VM
    ctx, _ := v8go.NewContext(iso) // new context within the VM
    for {
        request := <-requestCh
        value, _ := ctx.RunScript(request.script, "render.js")
        request.response <- value.String()
    }
}

func main() {
    requestCh := make(chan request, 1)
    go eventLoop(requestCh)
    http.ListenAndServe(":5000", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer timer("/")()
        script := "Math.random()"
        responseCh := make(chan string, 1)
        requestCh <- request{script, responseCh}
        response := <-responseCh
        w.Write([]byte(response))
    }))
}

This also seems like a safe way to do it, but there's quite a bit of channel synchronization.

Also with this approach, I'd like to create an event loop per OS thread, but since it doesn't seem like you can ensure your goroutines are evenly locked across OS threads, I could see a situation where 8 event loops are locked to one OS thread. Do you know any way to deal with this?


Thanks for reading this long message and thank you for this wonderful library! I'm hopeful that I'm missing an easy solution :)

rogchap commented 3 years ago

I think your approach number 2 is good. As long as you don't have multiple goroutines accessing a single Isolate at the same time you should be fine with this approach. Isolates are "more expensive" than Contexts so a pool makes sense (not tried puddle, but I'm guessing that it does something similar to sync.Pool in the stdlib?) The problem with approach 3 is that you are sharing the Context between requests, which would be a security concern as other requests could cause side effects (like changing the global object etc)

matthewmueller commented 3 years ago

Thanks @rogchap!

Good point regarding sharing contexts. I'll avoid that. And yah, I think puddle and sync.Pool are pretty similar, the difference being that you can specify a maximum number of resources that the pool can allocate before blocking and waiting for an idle resource.

I did a bit more research and learned runtime.LockOSThread() is less drastic and more common than I thought.

I mistakenly assumed that the maximum number of threads equals GOMAXPROCS (e.g. by default # of CPU cores) in Go. I assumed that if runtime.LockOSThread() has taken ownership over a thread, then that's one less core for processing requests or perhaps more queueing waiting for that specific thread to be available. What seems to happen is that Go will spawn a new thread once the thread jumps into C code (or makes a syscall) and then manages the switches in an efficient way. There's a good discussion on it here.

For those interested, this video goes into more detail. There's also this document.

I think your approach number 2 is good. As long as you don't have multiple goroutines accessing a single Isolate at the same time you should be fine with this approach.

I'm now questioning this a bit. With 2.) it seems like the way the scheduler works, the same V8 isolate may be moved to different threads (depending what's free). I'm still not really sure the consequences of some cgo code moving threads or if those consequences matter if the threads running the isolates are synchronized and run serially. I'll keep looking in this.