lxn / walk

A Windows GUI toolkit for the Go Programming Language
Other
6.86k stars 887 forks source link

Proposal: Allow execution of message loops on different threads #619

Open JoshuaSjoding opened 5 years ago

JoshuaSjoding commented 5 years ago

I propose making some design changes that would allow calls to FormBase.Run() to take place on distinct threads. The calls may or may not be concurrent.

The Windows GUI subsystem requires that a related set of windows be created on a common thread, and that the message loop that processes messages on their behalf run on that same thread. However, the subsystem does not preclude operation on different threads, so long as they deal with distinct sets of windows and each has its own message loop.

We took a step toward this capability with #601. This proposal is to continue that work in the hope that the following code can run correctly and without error:

func main() {
    const threads = 8
    var wg sync.WaitGroup
    wg.Add(threads)
    for n := 0; n < threads; n++ {
        n := n
        go func() {
            defer wg.Done()
            runtime.LockOSThread()
            defer runtime.UnlockOSThread()
            mw, err := walk.NewMainWindow()
            if err != nil {
                panic(err)
            }
            go func() {
                time.Sleep(time.Duration(n+1) * 500 * time.Millisecond)
                mw.Synchronize(func() {
                    mw.Close()
                })
            }()
            mw.SetVisible(true)
            mw.Run()
            mw.Dispose()
        }()
    }
    wg.Wait()
}

I think we can minimize or entirely avoid breaking API changes, but that remains to be seen.

Current Design

Walk stores a lot of state in global variables that are directly accessed by functions scattered throughout the library. Application settings, active form management, window class registration, function synchronization and event processing all assume single threaded operation.

Proposed Design

All data that requires thread-affinity should move to WindowGroup. All access from other threads must be synchronized.

All data stored in Application must be guarded by a mutex. All direct usage of appSingleton should call App() instead.

All calls to runSynchronized() must be replaced by calls to WindowGroup.runSynchronized().

Concerns

The Application.ActiveForm() function is problematic because the active form should (probably) be associated with a window group, not the application as a whole. We can (mostly) preserve API compatibility if we grab the thread ID of the caller, then look up its window group via the window group manager. This means that the function will give different results to callers on different threads, which could be surprising.

Notes

Callers must create and run a related set of windows on a single thread. The existing InitWindow() function automatically perma-locks the calling goroutine to an OS thread on behalf of the caller. I personally would prefer that it didn't, but changing this behavior now would probably break a lot of code.

ezdiy commented 5 years ago

Since there seems some interest in this, you might take a look at https://github.com/ezdiy/walk/commit/500c5863b236e2878caf13569155ca86fd2e4f8b and a complex example https://github.com/ezdiy/walk/commit/847b0cf5250307d7973a64257129ac1a3161a4d1 - tableview & action amalgamed into threaded duo.

The approach taken is more or less that of GIL we all know and love in python. The upside is that it is non-invasive. The downside is that if you want to do something actually threaded, you have to call walk.RunUnlocked(f) which temporarily lifts the global "in-walk" lock broadly protecting everything walk does from your thread, and f can run unsynchronized. Exact same way we handle UI loop code (main reason for threading in the first place).

lxn commented 5 years ago

The Application.ActiveForm() function is problematic because the active form should (probably) be associated with a window group, not the application as a whole.

This means that the function will give different results to callers on different threads, which could be surprising.

Maybe not ideal, but this shouldn't be too much of an issue, since behavior of existing code isn't affected. Only when using multiple threads for the GUI, you need to be aware of this.

The existing InitWindow() function automatically perma-locks the calling goroutine to an OS thread on behalf of the caller. I personally would prefer that it didn't, but changing this behavior now would probably break a lot of code.

Where would you prefer to put the locking and what would it enable?

JoshuaSjoding commented 5 years ago

The design concept that's most appealing to me would take the main message loop from FormBase and move it to WindowGroup, putting WindowGroup in charge of a locked thread that's mostly hidden from outside callers. Then each window that gets created would receive a window group that it should coordinate with.

Window class registration, window creation, etc. would be synchronized via the group so that it all runs on the common thread the group manages.

Multiple forms could use the same window group, and thus could share resources. The main loop would dispatch messages to the appropriate form. There are some details to work through here, but I think they could all be worked out.

The nice thing about this approach is that callers wouldn't have to care about threads at all, aside from calling Form.Synchronize() when needed as before.