Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

proposal: runtime: add LockMainOSThread to take control of the main thread #64755

Open
eliasnaur opened this issue Dec 15, 2023 · 10 comments
Open
Labels
compiler/runtime Issues related to the Go compiler and/or runtime. Proposal
Milestone

Comments

@eliasnaur
Copy link
Contributor

eliasnaur commented Dec 15, 2023

Proposal Details

I propose adding a new function, runtime.LockMainOSThread, which is like LockOSThread but for the "main" (or "startup") thread. In particular,

package runtime

// LockMainOSThread behaves like [LockOSThread], except it blocks until the startup
// thread becomes available and wires the goroutine to that.
//
// Note that init functions run on the startup thread; the main function runs on the
// startup thread if LockOSThread or this function was called during initialization.
func LockMainOSThread()

This is the complete proposal.

Variants

If the runtime package is deemed too unpalatable, one variant is to spell the new function syscall.LockMainOSThread and optionally limit it to GOOS=darwin and GOOS=ios.

The #64777 alternative is less flexible, but perhaps easier to maintain.

Background

Some APIs, most notably macOS' AppKit and iOS' UIKit require exclusive control of the startup thread. Go already supports such APIs by specifying that the main function runs on the main thread if runtime.LockOSThread is called in an init function. Unfortunately, Go's design doesn't allow a non-main package to take control of the main thread, and so any use of main thread APIs bleed through to the user.

For example, this is Gio's current API for creating and driving a GUI window:

import "gioui.org/app"

func main() {
    go func() {
        w := new(app.Window)
        for {
             e := w.NextEvent()
             // Respond to e, usually by redrawing window content.
        }
    }()
    app.Main()
}

Note the extra goroutine and app.Main call.

This proposal allows a non-main package to take control of the main thread, hiding that implementation detail from the user.

For example, package gioui.org/app has an init function that calls runtime.LockOSThread to guarantee the main thread requirement. With this proposal, the gioui.org/app package could replace that init function,

func init() {
        // Run main on the startup thread to satisfy the requirement
        // that Main runs on that thread.
	runtime.LockOSThread()
}

with a lazy initializer:

var firstUse sync.once

func UsePackage() {
        firstuse.Do(func() {
            go func() {
                // Take control of the main thread. If another goroutine takes control
                // before us, calling functions or methods in this package will deadlock.
                runtime.LockMainOSThread()

                // Run the main thread API. This would be `UIApplicationMain` on iOS and Mac Catalyst.
                C.callMainThreadFunctionThatNeverReturns()
            }()
         })
      ...
}

and allow the user-facing API to be reduced to the straightforward

import "gioui.org/app"

func main() {
        w := new(app.Window)
        for {
             e := w.NextEvent()
             // Respond to e, usually by redrawing window content.
        }
}

Alternatives

Some alternatives are:

  • On macOS (only) it is possible to force [NSApp run] to return and drive the main thread event loop from Go. However, this option has several drawbacks:
    • The user is still required to call some function from the main goroutine, to drive the event loop.
    • Some gestures take control of the event loop until completion. Resizing is one example, the animated maximize/unmaximize of windows is another. To remain in control of the main thread, each such blocking gesture must be disabled and re-implemented which is significant work and risks (current or future) incompatibilities.
    • The workaround is not available for iOS.
    • The workaround is also not available for Mac Catalyst which is Apple's unifying API that would otherwise allow a single Darwin implementation that covers both macOS and iOS.
  • Build Go programs in c-archive mode and link to a main function implemented in C. This is what Gio does for iOS, but it breaks the convenient go run and go build of self-contained executables. With this proposal, it's feasible to implement go build (and even go -exec ... run) for iOS.

Backwards compatibility

This proposal is backwards compatible.

@gopherbot gopherbot added this to the Proposal milestone Dec 15, 2023
@eliasnaur eliasnaur changed the title proposal: runtime: relax LockOSThread rules to allow giving up control of the main thread proposal: runtime: add function to allow giving up control of the main thread Dec 15, 2023
@eliasnaur eliasnaur changed the title proposal: runtime: add function to allow giving up control of the main thread proposal: runtime: add LockMainOSThread to take control of the main thread Dec 15, 2023
@prattmic
Copy link
Member

If I understand correctly, this approach is working today:

func init() {
        // Run main on the startup thread to satisfy the requirement
        // that Main runs on that thread.
	runtime.LockOSThread()
}

Would an alternative be to add // Note that init functions run on the startup thread; the main function runs on the startup thread if LockOSThread is called during initialization. to LockOSThread to add a compatibility guarantee?

In other words, it isn't quite clear to me what the concrete benefit of LockMainOSThread is. Is it that packages that need to do things on the main thread don't need to tell users to do something specific in main?

What happens if two different goroutines call LockMainOSThread?

cc @golang/runtime

@prattmic prattmic added the compiler/runtime Issues related to the Go compiler and/or runtime. label Dec 15, 2023
@eliasnaur
Copy link
Contributor Author

eliasnaur commented Dec 15, 2023

If I understand correctly, this approach is working today:

func init() {
        // Run main on the startup thread to satisfy the requirement
        // that Main runs on that thread.
	runtime.LockOSThread()
}

It works in the sense that every user of, say, AppKit or UIKit need to give up the main thread through contortions that a library is unable to hide. For a Gio user, it's the difference between

import "gioui.org/app"

func main() {
    go func() {
        w := new(app.Window)
        for {
             e := w.NextEvent()
             // Respond to e, usually by redrawing window content.
        }
    }()
    app.Main()
}

and

import "gioui.org/app"

func main() {
        w := new(app.Window)
        for {
             e := w.NextEvent()
             // Respond to e, usually by redrawing window content.
        }
}

Would an alternative be to add // Note that init functions run on the startup thread; the main function runs on the startup thread if LockOSThread is called during initialization. to LockOSThread to add a compatibility guarantee?

This is already documented as a guarantee by runtime.LockOSThread.

In other words, it isn't quite clear to me what the concrete benefit of LockMainOSThread is. Is it that packages that need to do things on the main thread don't need to tell users to do something specific in main?

Yes.

What happens if two different goroutines call LockMainOSThread?

One (or both) block, waiting for the main thread to become available.

@eliasnaur
Copy link
Contributor Author

I've filed an less flexible but perhaps simpler alternative: #64777. Both proposals now also mention the syscall option to avoid polluting package runtime.

@eliasnaur
Copy link
Contributor Author

eliasnaur commented Dec 17, 2023

Anecdotally, and without bearing on the proposal, I just want to mention that Go is unique to be able to offer this feature by virtue of goroutines. Other GUI libraries either revert to a callback design or live with the hacky workarounds. For example, a prominent Rust library first considered using user mode context switching (similar to how goroutines can switch OS thread), but eventually ended up inverting their control flow to a callback design.

@aclements
Copy link
Member

I actually prefer the principle of this proposal over the callback alternative in #64777. Part of the philosophy of goroutines is that you should be able to write straight-line (blocking) code and the language implementation will hide the details of context management and control flow. #64777 feels more akin to async/promise-style programming.

That said, LockMainOSThread certainly has some issues:

  • Implementation-wise, we don't currently have any mechanism to say "this computation must run on this specific thread", which I think turns out to be quite different from LockOSThread's "this computation must continue to run on its current thread." It's not immediately obvious to me that there's a simple way to add this capability.
  • It doesn't compose well. If two goroutines both use LockMainOSThread and one of them makes a blocking system call, the other one is just stuck until that returns. This could very easily lead to deadlocks. (In a sense, this problem is inherited from these various facilities that require things to be done on the main thread. That requirement itself doesn't compose.)

I see you've spelled out some clever restrictions on #64777's RunOnMainThread that work around these issues, but I also feel like they do so by exposing implementation limitations and leaning into non-composability. :)

@eliasnaur
Copy link
Contributor Author

I actually prefer the principle of this proposal over the callback alternative in #64777. Part of the philosophy of goroutines is that you should be able to write straight-line (blocking) code and the language implementation will hide the details of context management and control flow. #64777 feels more akin to async/promise-style programming.

That said, LockMainOSThread certainly has some issues:

  • It doesn't compose well. If two goroutines both use LockMainOSThread and one of them makes a blocking system call, the other one is just stuck until that returns. This could very easily lead to deadlocks. (In a sense, this problem is inherited from these various facilities that require things to be done on the main thread. That requirement itself doesn't compose.)

It's hopeless to design for more than one package being able to use the main thread, without explicit coordination. In fact, my motivating use-case is a native call that never returns.

That leaves the behaviour when more than one goroutine calls LockMainOSThread. I'm proposing the loser block, which as you point out leads to a likely deadlock. How about panicing, similar to the "deadlock, all goroutines are asleep" condition?

  • Implementation-wise, we don't currently have any mechanism to say "this computation must run on this specific thread", which I think turns out to be quite different from LockOSThread's "this computation must continue to run on its current thread." It's not immediately obvious to me that there's a simple way to add this capability.

Assuming a losing LockMainOSThread panics, I imagine implementation becomes easier because a goroutine will never be parked with in a special state (e.g. waitingForMainThread). Something like

  • Check if current thread is main. If so, we're done.
  • Otherwise reschedule similar to a very short time.Sleep, but search for a particular m instead of any m.
  • If the main thread is busy, continue scheduling as a normal goroutine, but initiate a panic.

This implementation requires the Go runtime to never schedule anything on the main thread, thus wasting a thread in the general case. I don't know the complexity of forcing a (non-syscalling) goroutine off a thread.

I'm no runtime export, so I apologize if the above is hopelessly naïve.

@smiletrl
Copy link
Contributor

smiletrl commented Dec 22, 2023

Assuming a losing LockMainOSThread panics, I imagine implementation becomes easier because a goroutine will never be parked with in a special state (e.g. waitingForMainThread). Something like

Check if current thread is main. If so, we're done.
Otherwise reschedule similar to a very short time.Sleep, but search for a particular m instead of any m.
If the main thread is busy, continue scheduling as a normal goroutine, but initiate a panic.

Goroutines preempt one specific thread ^ It looks much simpler/clear to keep things in main(), if certain things must happen in main thread.

@eliasnaur
Copy link
Contributor Author

Another use-case came to my attention: because Windows services require the main thread, every Go package for running a Go program as a Windows service expose contorted callback APIs.

Examples: https://pkg.go.dev/golang.org/x/sys/windows/svc#Run, https://pkg.go.dev/github.com/kardianos/service#Service (see its Run documentation), https://pkg.go.dev/github.com/judwhite/go-svc#Run.

In fact, none of the various Runs document the main goroutine requirement! I learned this the hard way, trying to debug why go svc.Run() didn't work.

I believe with this proposal, or a variant, you'll be able to expose a Windows service with a straightforward, non-blocking

func main() {
    svc.SetupService()
    // do rest of setup: open database connections, start HTTP listener etc.
}

Or, if you're interested in service events:

func main() {
    go func() {
        evts, err := svc.RunService()
        for {
             e, ok := evts.Event()
             // handle and respond to e.
        }
    }()
    // do rest of setup: open database connections, start HTTP listener etc.
}

@dottedmag
Copy link
Contributor

dottedmag commented Jan 19, 2024

How would several libraries utilizing this functionality coordinate?

In the current state (LockOSThread from init, callbacks) they can because it's up to a user to structure a single goroutine to call several libraries.

The only combination that comes to mind immediately is a debugger under Darwin, where both ptrace and AppKit want to be called from main thread, but I'm pretty sure other examples eventually will transpire.

RunOnMainThread seems more composable than locking the thread to one library's goroutine.

@eliasnaur
Copy link
Contributor Author

How would several libraries utilizing this functionality coordinate?

Like today: explicitly.

In the current state (LockOSThread from init, callbacks) they can because it's up to a user to structure a single goroutine to call several libraries.

Not so for AppKit, UIKit nor Windows services: they all require the main thread and never relinquish control of it. The best you can do is to ask nicely for a callback.

This proposal is primarily for enabling Go packages to expose natural Go APIs for main-thread greedy platform facilities.

The only combination that comes to mind immediately is a debugger under Darwin, where both ptrace and AppKit want to be called from main thread, but I'm pretty sure other examples eventually will transpire.

RunOnMainThread seems more composable than locking the thread to one library's goroutine.

@dottedmag you may like the #64777 (comment) variant better. It's currently my favorite.

whereswaldon pushed a commit to gioui/gio that referenced this issue Jan 26, 2024
Despite the option to control the main thread event loop, some gestures
still block the event loop until completion. This change disables the
blocking resize gestures and implements a non-blocking replacement.

A complete replacement is left for future work, or the implementation of
golang/go#64755.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
whereswaldon pushed a commit to gioui/gio that referenced this issue Feb 8, 2024
Despite the option to control the main thread event loop, some gestures
still block the event loop until completion. This change disables the
blocking resize gestures and implements a non-blocking replacement.

A complete replacement is left for future work, or the implementation of
golang/go#64755.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
whereswaldon pushed a commit to gioui/gio that referenced this issue Feb 8, 2024
Despite the option to control the main thread event loop, some gestures
still block the event loop until completion. This change disables the
blocking resize gestures and implements a non-blocking replacement.

A complete replacement is left for future work, or the implementation of
golang/go#64755.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
whereswaldon pushed a commit to gioui/gio that referenced this issue Feb 8, 2024
Despite the option to control the main thread event loop, some gestures
still block the event loop until completion. This change disables the
blocking resize gestures and implements a non-blocking replacement.

A complete replacement is left for future work, or the implementation of
golang/go#64755.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
compiler/runtime Issues related to the Go compiler and/or runtime. Proposal
Projects
Status: No status
Status: Incoming
Development

No branches or pull requests

7 participants
@dottedmag @eliasnaur @prattmic @aclements @smiletrl @gopherbot and others