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/mainthread: add mainthread.Do for mediating access to the main thread #70089

Open
eliasnaur opened this issue Oct 29, 2024 · 32 comments
Labels
Milestone

Comments

@eliasnaur
Copy link
Contributor

Proposal Details

This is #64777 (comment) in proposal form. It is a reduced and compatible variant of #64777 (comment).

I propose to add a new package, mainthread, with a single function, Do, that allows Go programs to execute a function on the main thread.

// Package mainthread mediates access to the program's main thread.
//
// Most Go programs do not need to run on specific threads 
// and can ignore this package, but some C libraries, often GUI-related libraries,
// only work when invoked from the program's main thread.
//
// [Do] runs a function on the main thread. No other code can run on the main thread
// until that function returns.
//
// Each package's initialization functions always run on the main thread,
// as if by successive calls to Do(init).
//
// For compatibility with earlier versions of Go, if an init function calls [runtime.LockOSThread], 
// then package main's func main also runs on the main thread, as if by Do(main).
package mainthread // imported as "runtime/mainthread"

// Do calls f on the main thread.
// Nothing else runs on the main thread until f returns.
// If f calls Do, the nested call panics.
//
// Package initialization functions run as if by Do(init).
// If an init function calls [runtime.LockOSThread], then package main's func main
// runs as if by Do(main), until the thread is unlocked using [runtime.UnlockOSThread].
//
// Do panics if the Go runtime is not in control of the main thread, such as in build modes
// c-shared and c-archive.
func Do(f func())

The larger proposal (#64777 (comment)) adds Yield and Waiting to support sharing the main thread in a Go program. However, the Go runtime doesn't always have control over the main thread, most notably in c-shared or c-archive mode on platforms such as Android. In those cases, the platform facility for mediating main thread access are strictly superior to mainthread.Do. See #64777 (comment) for a detailed analysis and assumptions.

In short, I believe it's better to accept this simpler proposal to only allow Go programs access to the main thread when the Go runtime has control over it, and let other cases be handled by platform API.

I hope this can be implemented in Go 1.24.

@gopherbot gopherbot added this to the Proposal milestone Oct 29, 2024
@gabyhelp
Copy link

Related Issues and Documentation

(Emoji vote if this was helpful or unhelpful; more detailed feedback welcome in this discussion.)

@apparentlymart
Copy link

(I see that earlier versions of this proposal were already discussed at length elsewhere and I did try to catch up on it first, but I apologize if I'm asking a question that's redundant from earlier discussions.)

If this function will panic when called from an environment where the Go runtime does not "own" the main thread, is it justified to also offer a function to test whether a call to this function is possible? That could, for example, allow a caller to choose to treat "I'm running in the wrong mode" as an error to be handled gracefully, rather than as an exception to be handled by panicking.

package mainthread

// CanDo returns true if and only if a subsequent call to [Do] would not panic.
func CanDo() bool

(Another variation of this would be for Do itself to return an error, but the usability of not having to worry about error handling when you know you're running in a context where this should work seems nice... this concern of detecting whether it will succeed seems specific to library developers that want their library to degrade gracefully in c-shared/c-archive/etc build modes.)

@Jorropo
Copy link
Member

Jorropo commented Oct 29, 2024

I hope this can be implemented in Go 1.24.

Just so you know, the current merge window closes 21 11, this would be a quick turn around time. There is the option of getting exceptions but theses are rare and usually limited to very low dangerous community impact.

@qiulaidongfeng
Copy link
Member

Does this API mean that if the main package imports a package that calls runtime.LockOSThread in init (for event loop in main thread) ,
like fyne did, calls to mainthread.Do by other packages will block permanently?

If so, that means we may need to modify existing valid code when using the mainthread package, which I don't think is backward-compatible,see #64777 (comment).
Fyne Info:
On Windows:
call LockOSThread in https://github.com/fyne-io/fyne/blob/7d813563712924b381ced18c04869c059e2cb4c6/internal/driver/glfw/loop.go#L35
event loop in https://github.com/fyne-io/fyne/blob/7d813563712924b381ced18c04869c059e2cb4c6/internal/driver/glfw/loop.go#L107

@eliasnaur
Copy link
Contributor Author

@qiulaidongfeng I believe your comment is addressed by #64777 (comment). In short, LockOSThread during init does not compose automatically with mainthread.Do, because they both act on a single resource, the main thread. There is no backwards compatibility issue, however, because this proposal doesn't affect the behaviour of LockOSThread during init.

@eliasnaur
Copy link
Contributor Author

@apparentlymart the original proposal says to panic in c-shared/c-archive mode, but I'm not against CanDo or the like.

@gopherbot
Copy link
Contributor

Change https://go.dev/cl/628815 mentions this issue: runtime/mainthread: new package

@ianlancetaylor ianlancetaylor moved this to Incoming in Proposals Nov 20, 2024
@rsc
Copy link
Contributor

rsc commented Dec 4, 2024

This proposal has been added to the active column of the proposals project
and will now be reviewed at the weekly proposal review meetings.
— rsc for the proposal review group

@rsc rsc moved this from Incoming to Active in Proposals Dec 4, 2024
@aclements
Copy link
Member

I think we need to re-ground this discussion in concrete use cases. I'm sure at least some of this will be me asking you to repeat what's already been said in #64777, but I think getting re-consolidating this information will be helpful.

Let's define main thread to mean the OS-created thread that started the process, and define startup thread to mean the thread we run Go init functions on. In typical Go binaries, these are one and the same. In c-shared and c-archive mode, the Go runtime always creates a new thread to run init functions, and exits that thread after init functions are done, so the startup thread is not the main thread. There's also a library load thread, which is the thread that first calls into the Go runtime in c-shared and c-archive mode. This may be the main thread or may be another thread, but the Go runtime relinquishes control of this thread very quickly.

What are the situations where a library needs to be called on the main thread (and not just consistently on some thread, and not just on the startup thread), and the platform doesn't provide a mechanism for calling code on the main thread? Can you give concrete examples so we have something to ground the requirements in?

How are libraries even sensitive to this? Do they behave differently from C if you link against them at build time (statically or dynamically) versus if you dlopen them at run time? (The abstract case we were able to come up with in proposal review is that a native library has global constructors/ELF initializers and is sensitive to other functions running on the same thread. But even that isn't necessarily the main thread if that library gets dlopened.)

@eliasnaur
Copy link
Contributor Author

eliasnaur commented Dec 20, 2024

In c-shared and c-archive mode, the Go runtime always creates a new thread to run init functions, and exits that thread after init functions are done, so the startup thread is not the main thread.

Thank you for this nugget. I'm very surprised that the library load thread doesn't run Go init functions, which seems to imply that a c-shared/c-archive Go function may be called by C concurrently with Go init functions. This behaviour also seems inconsistent with constructors/ELF initializers which I believe complete before dlopen returns.

What are the situations where a library needs to be called on the main thread (and not just consistently on some thread, and not just on the startup thread), and the platform doesn't provide a mechanism for calling code on the main thread? Can you give concrete examples so we have something to ground the requirements in?

I know of only one example. Windows StartServiceCtrlDispatcher called by (among others) golang.org/x/sys/windows/svc.Run[0]. I don't know of a Windows facility for calling functions on the main thread.

Other issues mention Linux namespace ("container") APIs. However, from a very cursory glance (and no experience), they don't seem to require the main thread, merely some thread. @thediveo may have more information (from comment)

[0]: Incidentally, scv.Run doesn't document that it needs to run on the main thread, even though StartServiceCtrlDispatcher says

Connects the main thread of a service process to the service control manager, which causes the thread to be the service control dispatcher thread for the calling process.

@thediveo
Copy link
Contributor

thediveo commented Dec 20, 2024

Other issues mention Linux namespace ("container") APIs. However, from a very cursory glance (and no experience), they don't seem to require the main thread, merely some thread. @thediveo may have more information (from comment)

Correctly, in fact it is even better (while not strictly necessary) to do Linux namespace switching on OS-level threads (tasks) other than the main/initial thread, as in some cases you might end up with throw-away threads to not leak namespace state: if you do this on the main/initial thread this will become (in Go runtime parlance) "wedged", an idle thread. You cannot simply kill this thread because then some relevant process information becomes inaccessible to the other threads of the same process.

And yes, to the Linux kernel, all threads are to some extend created equal, as the main thread representing the process is still only a thread, albeit a group leader for organizational/orchestration purposes. These tasks can share certain resources to make them look like threads of the same task, but the Linux kernel allows some highly useful things, like a thread opting out of this sharing to do some useful shenanigans.

@ianlancetaylor
Copy link
Member

I'm very surprised that the library load thread doesn't run Go init functions, which seems to imply that a c-shared/c-archive Go function may be called by C concurrently with Go init functions.

When this happens, the call to the Go function blocks until the Go init functions have completed. See https://go.googlesource.com/go/+/refs/heads/master/src/runtime/cgocall.go#409 and also https://go.googlesource.com/go/+/refs/heads/master/src/runtime/cgo/gcc_libinit.c#52.

@eliasnaur
Copy link
Contributor Author

Thanks Ian for the clarification.

How are libraries even sensitive to this? Do they behave differently from C if you link against them at build time (statically or dynamically) versus if you dlopen them at run time?

As far as I know, all main thread requirements originate in the OS kernel (or OS libraries), and leak through to libraries. For this reason, I don't think build time versus runtime library loading makes a difference.

@aclements
Copy link
Member

In proposal review, we're realizing that we've lost track of the motivation for this whole change.

Thanks for the example of StartServiceCtrlDispatcher.

In #64755, you mentioned:

Some APIs, most notably macOS' AppKit and iOS' UIKit require exclusive control of the startup thread.

Given that this proposal has been through a lot, are these still good driving examples to be focusing on? Are these the only examples we can find?

@eliasnaur
Copy link
Contributor Author

eliasnaur commented Jan 9, 2025

Android GUI API also requires exclusive control of the main thread, but because you're forced to run in c-shared mode, mainthread.Do is not necessary (and hard to implement).

Only other API I can think of are Linux(/etc.?) container API, but I'm no expert. Perhaps @thediveo can provide examples.

@thediveo
Copy link
Contributor

thediveo commented Jan 9, 2025

The APIs of Docker, containerd and the k8s CRI are all with rock solid client-server architecture, so there are no restrictions to specific OS threads.

Podman only so when deploying it socket-activated as a server and using its remote API socket. Using the podman grpc client is a mess though, but that's concerning unwanted namespace moves of the calling process, not a main thread restriction.

Linux per se has mainly the idiosyncracy that certain elements in the procfs become inaccessible when the main thread dies, but Go covers this by "wedging" G0 in this case. This also happens in a similar way with other operating systems.

My understanding for this issue is that the can safely ignore all this, because we're dealing with the prominent use cases of especially UI libraries.

@aclements
Copy link
Member

Thanks. Given this, its our understanding that this API is thus only necessary for UI toolkits and then only on macOS Catalyst and iOS.

Given this context, do we need to support Yield or Waiting? It sounds like the answer is "no", in which case this more limited API is sufficient.

The other issue was implementing this for c-shared and c-archive mode. Are those needed for macOS Catalyst or iOS? It sounds like there's currently not even a workaround in these build modes, suggesting it's somehow not a problem. @eliasnaur (or anyone else), could you provide more clarity on that?

@eliasnaur
Copy link
Contributor Author

eliasnaur commented Jan 23, 2025

Thanks. Given this, its our understanding that this API is thus only necessary for UI toolkits and then only on macOS Catalyst and iOS.

What about macOS' AppKit and Windows' StartServiceCtrlDispatcher? AppKit provides affordances to fetch and dispatch events, but that's a red herring: gestures such as drag-window-edge-to-resize are modal and will block the main thread until completed.

Given this context, do we need to support Yield or Waiting? It sounds like the answer is "no", in which case this more limited API is sufficient.

I agree.

The other issue was implementing this for c-shared and c-archive mode. Are those needed for macOS Catalyst or iOS? It sounds like there's currently not even a workaround in these build modes, suggesting it's somehow not a problem. @eliasnaur (or anyone else), could you provide more clarity on that?

By "workaround", do you mean the LockOSThread-during-init trick? There's no equivalent in c-archive or c-shared programs, but it's easy to arrange for the host environment to call into Go on the main thread[0].

It would be nice to allow a GUI Go package to work in all build modes, but the proposed panic behaviour does increase the amount of preparation to call Do. To illustrate, here is a sketch program for macOS AppKit:

//go:build darwin

package gui

/*
static void NSApp_run(void) {
    [NSapp run];
}

// Run a function on the main thread using native API.
static void runOnMainThread(f uintptr) {
    dispatch_async(dispatch_get_main_queue(), ^{
        callGoFunc(f);
    });
}
*/
import "C"

//export callGoFunc
func callGoFunc(h uintptr) {
    f := cgo.Handle(h).Value().(func())
    f()
}

var mainOnce sync.Once

func NewWindow() *Window {
    mainOnce.Do(func() {
        go func() {
            defer func() {
                if err := recover(); err != nil {
                    // Probably c-archive or c-shared mode.
                }
            })
            // Note that C.runOnMainThread is not going to work
            // as long as the Go runtime controls it.
            mainthread.Do(C.NSApp_run) // never returns.
        }
    })
    // Create a new window, knowing that the main thread event loop
    // is running.
    // Note that this is not using mainthread.Do, because the Go runtime
    // no longer have control over the main thread; it is blocked inside
    // [NSApp run].
    C.runOnMainThread(cgo.NewHandle(func() {
        ...
    }))
}

The ceremony for calling mainthread.Do is quite long:

  • A sync.Once to not leak a goroutine every call to NewWindow.
  • A goroutine because either C.NSApp_run or mainthread.Do blocks forever.
  • A recover to tolerate c-* modes (the host must call [NSApp run]).

Ceremony suggests the API is not quite right. For the sake of comparison, here's hypothetical mainthread.Loop that is precisely tailored to the use-case of forever-running event loops:

package mainthread

// Loop schedules f to be called on the main thread.
// Loop returns immediately and does not wait for f to return.
// Once a function is scheduled, every subsequent call is ignored.
//
// Calls when the runtime doesn't control the main thread are
// ignored. This applies to c-shared and c-archive programs and
// programs that call [LockOSThread] during init.
func Loop(f func())

Loop eliminates all ceremony:

func NewWindow() *Window {
    mainthread.Loop(C.NSApp_run)
    ...
}

[0]: In fact, Gio calls main even in Android's c-shared mode to paper over the missing support for buildmode=exe programs on Android.

@andydotxyz
Copy link
Contributor

On this topic Fyne is just completing a thread model migration in which we implemented a Do function to call on the main goroutine.

What we discovered in the process is that you likely need two versions - one which will wait until completed and the other is just scheduling the call and returning without waiting (likely an immediate return).

Honestly we have been blown away by how fast the goroutine context switching is, having more builtin functionality to handle these would be a boost for sure.

I agree that the questions above about specificity are critical - as the "main" routine may or may not truly be what people need. If this API can tie it to a clearly defined thread in the Go ecosystem we should be best, rather than setting expectations based on OS or other system "thread". Part of me wonders if this may need to (or in the future consider) allowing insertion into a specified goroutine instead? (i.e. main vs startup vs graphics ...)

@aarzilli
Copy link
Contributor

Honestly we have been blown away by how fast the goroutine context switching is, having more builtin functionality to handle these would be a boost for sure.

Not if LockOSThread is involved #21827

@andydotxyz
Copy link
Contributor

Honestly we have been blown away by how fast the goroutine context switching is, having more builtin functionality to handle these would be a boost for sure.

Not if LockOSThread is involved #21827

Even with that on I was getting around 2'500'000 goroutine context changes per second which is surprising to me and more than enough for most apps.

@rsc
Copy link
Contributor

rsc commented Feb 5, 2025

It sounds like the claim is that we need mainthread.Do only to run an event loop that never returns, and then at that point there is no portable way to run another Go function on the main thread. If apps know which event loop is running they are encouraged to use cgo to communicate directly with it. That seems a little unsatisfying, but perhaps it is sufficient. Is it?

It sounds more like mainthread.Take than mainthread.Do. Perhaps it should panic when f returns?

@andydotxyz
Copy link
Contributor

It sounds like the claim is that we need mainthread.Do only to run an event loop that never return

That's not my take on it at all - from Fyne's point of view the Do is to allow a given function to run on the main thread. Yes it probably needs an event loop to make this possible, but the outcome is having routines join main to complete briefly rather than a standard way to block the main thread...

@ianlancetaylor
Copy link
Member

@andydotxyz Can you expand on exactly when Fyne needs to run something on the main thread, and why? Thanks.

@andydotxyz
Copy link
Contributor

@andydotxyz Can you expand on exactly when Fyne needs to run something on the main thread, and why? Thanks.

There are various places but in the most general form interactions with the operating system's graphical capabilities must happen on the correct thread. For example once I have a window open if I want to update it (set content, draw to screen) it must be executed on the correct thread. Varying bad outcomes if we don't - macOS will panic the app...

@eliasnaur
Copy link
Contributor Author

eliasnaur commented Feb 6, 2025

@andydotxyz Can you expand on exactly when Fyne needs to run something on the main thread, and why? Thanks.

There are various places but in the most general form interactions with the operating system's graphical capabilities must happen on the correct thread. For example once I have a window open if I want to update it (set content, draw to screen) it must be executed on the correct thread. Varying bad outcomes if we don't - macOS will panic the app...

This is true, but I argue that offering a general "call this on the main thread" facility is out of scope of the Go standard library.

There's a longer analysis; the gist is that while the Go runtime sometimes has control of the main thread (buildmode=exe, before [NSApp run] and similar), in many important cases it doesn't (buildmode=c-archive/c-shared, Android, after [NSApp run] takes control).

Therefore, this proposal is only about taking control of the main thread when the Go runtime has it, in order to call [NSApp run]. You can do this today with an awkward combination of LockOSThread during init and forcing the user to give up the main goroutine, but having an explicit mainthread.Take is more composable and library friendly. In fact, I considermainthread.Take a complete replacement for LockOSThread-during-init.

@andydotxyz
Copy link
Contributor

Therefore, this proposal is only about taking control of the main thread when the Go runtime has it, in order to call [NSApp run]. You can do this today with an awkward combination of LockOSThread during init and forcing the user to give up the main goroutine, but having an explicit mainthread.Take is more composable and library friendly. In fact, I considermainthread.Take a complete replacement for LockOSThread-during-init.

Ah yes, to solve only the boot problem that makes sense. But if this is being added to the standard library it would be great to pair Do and Take to make the complete solution.

@ianlancetaylor
Copy link
Member

In my mind running code on "the correct thread" is very different from running code on "the main thread." Code that has to run on the main thread is for a small, though important, set of cases. Code that has to run on a specific thread is a much larger set of cases, but fortunately is also much easier to implement. We shouldn't try to mix the complicated case of running on the main thread with the simpler case of running on a specific thread.

@andydotxyz
Copy link
Contributor

Apologies if I was being too vague. "the correct thread" in my previous message meant the main thread on all platforms except macOS where it can be main or the thread which the graphics was initialised on (which is often main but does not have to be). I hope that helps.

@ianlancetaylor
Copy link
Member

@andydotxyz Thanks, let's try to pin this down. In #70089 (comment) @aclements asked exactly when we need to support this. See their summary at #70089 (comment). I'm not sure that your requirements got recorded anywhere. Can you describe the exact environments in which code needs to run on the main thread? Thanks.

@andydotxyz
Copy link
Contributor

My understanding is that to access the graphical context correctly you will need to call the functions from the main thread on Windows, Linux, Android and iOS. On macOS this is typically done as well by convention (When using Apple's AppKit I think that is the default) though technically it could be a different thread as long as it is consistent for the life of the app.

Perhaps I read the title and assumed a larger scope than it is being whittled down to. Given that init/LockOSThread exists I find the Do more useful than the Take - but if the idea is to remove the usage of init then I could see the value of the latter.

@ianlancetaylor
Copy link
Member

I could certainly be wrong, but that is not my current understanding. My understanding is that on Linux systems, with some GUI packages, you need to consistently call the GUI on the same thread. But there is no requirement that this be the main thread of the application. It can be any thread, as long as it's consistent. Linux in general does not care about the main thread of the application at all (except that it sometimes makes a different if the main thread exits).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
Status: Active
Development

No branches or pull requests