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

runtime/mainthread: new package to manage main thread #64777

Open
eliasnaur opened this issue Dec 17, 2023 · 110 comments · May be fixed by #69185
Open

runtime/mainthread: new package to manage main thread #64777

eliasnaur opened this issue Dec 17, 2023 · 110 comments · May be fixed by #69185
Labels
compiler/runtime Issues related to the Go compiler and/or runtime. Proposal Proposal-Accepted
Milestone

Comments

@eliasnaur
Copy link
Contributor

eliasnaur commented Dec 17, 2023

Update The proposal is #64777 (comment).

Proposal Details

This proposal is a less flexible but perhaps easier to implement and maintain alternative to #64755. See that issue for background and motivation for non-main packages to take control of the main thread.

I propose adding a new function, runtime.RunOnMainThread, for running a function on the startup, or main, thread. In particular,

package runtime

// RunOnMainThread runs a function immediately after program initialization on a
// goroutine wired to the startup thread. It panics if called outside an init
// function, if called more than once, or if [runtime.LockOSThread] has already
// been called from an init function.
// Once RunOnMainThread is called, later LockOSThread calls from an init function
// will panic.
func RunOnMainThread(f func())

This is the complete proposal.

Variants

Just like #64755, an alternative spelling is syscall.RunOnMainThread optionally limited to GOOS=darwin and GOOS=ios.

@aclements
Copy link
Member

The combination of "It panics if called outside an init function" and "[it panics] if called more than once" is rather unfortunate. If a project happen to (perhaps transitively) pull in two packages that both might need main thread functionality, but the project doesn't actually need that functionality from both packages, they're now in a pickle because the mere act of importing the package must run init functions, and those must call RunOnMainThread if there's any chance the package may need to use the main thread.

@eliasnaur
Copy link
Contributor Author

eliasnaur commented Dec 18, 2023

Here's a neat variant that you may like better:

// RunOnOSThread runs the function in a new goroutine
// wired to the thread of the caller. If the caller has locked
// the thread with `LockOSThread`, it is unlocked before
// returning. The caller continues on a different thread.
func RunOnOSThread(f func())

The advantages are:

  • No panics nor blocking.
  • Works any time for any thread, not just the main.
  • Complements LockOSThread.
  • Composable: multiple callers don't interfere with each other.
  • Easier to implement than LockMainOSThread (I believe). In particular, RunOnOSThread performs a context switch similar to what the runtime will do to make iterators efficient.

The disadvantages are:

  • Explicitly starting a goroutine is a weird API, but contrary to the original proposal, the function is executed immediately.
  • Calling RunOnOSThread during init switches the remaining initialization to a different thread. If this is unacceptable, we could say that
// RunOnOSThread panics if run from an init function.

at the cost of a panic condition and forcing main thread APIs to require some call from the main goroutine. E.g.

package app // gioui.org/app

// Window represent a platform GUI window.
// Because of platform limitations, at least once one of Window's
// methods must be called from the main goroutine.
// Otherwise, call [Main].
type Window struct {...}

// Main must be called from the main goroutine at least once,
// if no [Window] methods will be called from the main goroutine.
func Main()

@eliasnaur eliasnaur added the compiler/runtime Issues related to the Go compiler and/or runtime. label Jan 20, 2024
@eliasnaur
Copy link
Contributor Author

Gentle ping. I believe #64777 (comment) addresses the objections @aclements brought up here and on #64755.

@ianlancetaylor
Copy link
Contributor

As I understand it the goal here is for an imported package to be able to run non-Go code on the initial program thread. The RunOnOSThread function variant is kind of useless if run on anything other than the initial program thread; if you don't care that thread you are on, it could be replaced by a go statement that starts by calling runtime.LockOSThread. So RunOnOSThread seems like a confusing way to accomplish the goal.

Honestly it does not seem so terrible to me if an operation that has to take place on the initial program thread requires some cooperation from the main function. I understand that that is a bit annoying. But it's a bit annoying for frameworks to require running on the initial program thread.

@eliasnaur
Copy link
Contributor Author

As I understand it the goal here is for an imported package to be able to run non-Go code on the initial program thread. The RunOnOSThread function variant is kind of useless if run on anything other than the initial program thread; if you don't care that thread you are on, it could be replaced by a go statement that starts by calling runtime.LockOSThread. So RunOnOSThread seems like a confusing way to accomplish the goal.

RunOnOSThread tries to juggle several goals: orthogonality, few special cases, easy to describe, implement and maintain. I think it achieves those goals fairly well, but I understand if you think it's too general for its special purpose. Would placing it in package cgo, syscall, or x/sys make a difference? Would spelling it RunOnMainThread and panicing if called without the main thread wired?

Honestly it does not seem so terrible to me if an operation that has to take place on the initial program thread requires some cooperation from the main function. I understand that that is a bit annoying.

The number of direct uses of RunOnOSThread is probably low, say a handful of distinct cases. But please consider the larger indirect impact: every GUI program and Windows service written in Go (at least).

The alternative is not entirely trivial either. Consider a straightforward CLI tool that you want to add an optional GUI to, or a CLI service you optionally want to run as a Windows service. You then have to change your program flow, e.g. rewriting your logic as an interface with callbacks, and you must call the API from your main function. Both requirements are alien to Go programmers not familiar with the underlying frameworks.

Case in point, it took me a while to figure out why svc.Run didn't work from a goroutine and I'm used to macOS main thread APIs. Further, the changes to turn my otherwise straightforward http.ListenAndServe program to conform to svc.Handler were frankly obnoxious and felt disproportional to the effect.

But it's a bit annoying for frameworks to require running on the initial program thread.

So let's not pass on the annoyance to Go programmers :-)

Go often makes quality-of-life changes that are not strictly necessary, sometimes at non-trivial cost: cgo.Handle, replacing // +build with the more intuitive //go:build, runtime.Pinner come to mind. I hope RunOnOSThread (or something with similar effect) can be another such change, delighting programmers by making their programs simpler.

@hajimehoshi
Copy link
Member

RunOnMainThread is very useful to improve user experience with some UI libraries. Now we don't have it, we have to force users to call some functions (e.g. Gomobile's app.Main) on the main thread, which is the initial OS thread on init/main functions.

@eliasnaur
Copy link
Contributor Author

I believe a fix to #67499 will enable iter.Pull to be used to share the UI main thread between user code and the system event loop. Perhaps that's enough for your use cases?

@hajimehoshi
Copy link
Member

Thanks, but wouldn't I still need to call iter.Pull at the head of the main function, which is the same restriction as app.Main?

@eliasnaur
Copy link
Contributor Author

Yes, something would have to drive the iter.Pull iterator that controls the main thread. The difference is that you can pass control back to the user and thus (almost) hide the main thread limitation from them.

For Gio, I plan to keep app.Main as is, but also make the blocking app.Window.Events API start (and drive) the main event loop if called from the main goroutine. The only user-visible restriction is that if they don't call app.Main, they need to run event handling from the main goroutine for at least one window. Basically, the main goroutine must be blocked in the app package somehow.

@hajimehoshi
Copy link
Member

Isn't this proposal RunOnMainThread enabling the function call on the main thread without any explicit initialization like app.Main at the head of func main?

@eliasnaur
Copy link
Contributor Author

eliasnaur commented May 29, 2024

Yes, but it seems unlikely to be accepted now that RunOnMainThread can (almost) be achieved with iterators, see the example at #67499 (comment) where there's no explicit initialization call. With #67499 fixed, the only limitation of the iterator approach is that users must call a function in your main-thread API to advance the platform specific main event loop. Unlike app.Main that function may return control to the user at each event. In case of Gio, Window.Events naturally fits that role.

It's even possible to eliminate the restriction and "steal" the main thread during, say, a package init. See #67694.

In light of the above, and assuming rangefuncs and iter.Pull are released in Go 1.23 or later, I'm closing this in favor of #67694.

@hajimehoshi
Copy link
Member

One possible thing RunOnMainThread could do and iter.Pull could not is that RunOnMainThread can be called even from the main thread without deadlock by detecting whether the current running thread is the main thread or not, but iter.Pull could cause a deadlock IIUC.

@eliasnaur
Copy link
Contributor Author

Can you elaborate? I don't think iter.Pull itself can ever deadlock, regardless of #67694.

@aclements
Copy link
Member

Yes, but it seems unlikely to be accepted now that RunOnMainThread can (almost) be achieved with iterators

Honestly, I would rather have the right dedicated API for this than have to do anything weird in iter.Pull to make this work (if it was a happy coincidence that iter.Pull made this possible, that would be a different matter, but we always seem to be a few steps away from that).

Your RunOnOSThread proposal is certainly interesting, but I think it has a few blocking issues. With your proposed semantics, if some package were to call it from init, the rest of the init functions wouldn't run on the locked main thread, which I would consider to break the current guarantee that init functions run locked to the main thread. For example, we currently guarantee that user code cannot "undo" that lock. This would give them a way to undo that lock. So, I think it would have to panic if called from init, as you suggested. But that's both a half solution, forcing users to call something from main, and an odd limitation on composability.

@mknyszek and I were trying to answer the question, "if you could have a dedicated API for this, what would be ideal?" and we came up with the following API. I don't know if this is practical to implement; it might be a heavy lift.

// RunOnMainThread starts a new goroutine running f that is
// locked to the main thread started by the OS.
//
// This is intended only for interacting with OS services that
// must be called from the main thread.
//
// There can be multiple main thread goroutines and the Go
// runtime will schedule between them when possible.
// However, if a goroutine blocks in the OS, it may not be
// possible to schedule other main thread goroutines.
func RunOnMainThread(f func())

@aclements
Copy link
Member

@mknyszek and I were discussing how to implement this and I wanted to capture our thoughts:

  • These main thread goroutines are locked to the main M. We try to reuse the LockOSThread scheduler mechanism as much as possible.
  • There's a separate queue of runnable main thread goroutines.
  • The scheduler invariant is: if there's no running main thread goroutine, but there are runnable main thread goroutines, then the head of the main thread queue is in some regular (P or global) run queue.
  • We enforce this invariant by:
    • Unparking a main thread goroutine adds it to the main thread queue. If it's the head of the queue and there's no running main thread goroutine, add it to the P run queue like a normal unpark.
    • If the scheduler picks a main thread goroutine from the run queue, switch to the main thread and run there. This is probably exactly the LockOSThread mechanism.
    • When a main thread goroutine gets descheduled (including if its P gets retaken), and the main thread runnable queue is not empty, add the head of the queue to the regular run queue.

@hajimehoshi hajimehoshi reopened this May 30, 2024
@hajimehoshi
Copy link
Member

@eliasnaur

Can you elaborate? I don't think iter.Pull itself can ever deadlock, regardless of #67694.

I don't know how the API would be to post a task to the main thread in the case of iter.Pull, but wouldn't this be deadlocked?

PostTaskToMainThreadSynchronously(func() {
     PostTaskToMainThreadSynchronously(func() {})
})

On the other thand, this should never be blocked:

RunOnMainThread(func() {
    RunOnMainThread(func() {})
})

I thought the internal func could be run immediately on the same thread. Or, as @aclements suggested, RunOnMainThread might invoke a different goroutine running on the main thread.

@eliasnaur
Copy link
Contributor Author

Thank you for thinking about this issue and for proposing something you may be able to stomach! If I understand your proposal correctly, main thread packages will replace LockOSThread during initialization with calls to RunOnMainThread.

However, given

// However, if a goroutine blocks in the OS, it may not be
// possible to schedule other main thread goroutines.

What happens when invoking the various obnoxious main-thread APIs that assume complete control over the main thread? They include iOS' UIApplicationMain, macOS' NSApplicationMain[0], Windows' StartServiceCtrlDispatcher. They all require the main thread and never return. API can invoke a callback on the captured main thread, but RunOnMainThread and the Go scheduler doesn't know about them.

In other words, it seems to me that to be generally useful, RunOnMainThread and the Go scheduler should somehow detect the use of the above APIs and seamlessly relinquish scheduling control to the platform.

In contrast, #67499 and #67694 are more limited, but allow sharing of the main thread among different packages albeit with careful cooperation from the main function/goroutine.

[0] It's possible to avoid NSApplicationMain and drive the main thread event loop yourself. However, some gestures such as window resizing will still block until completed.

@mknyszek
Copy link
Contributor

mknyszek commented Jun 5, 2024

In other words, it seems to me that to be generally useful, RunOnMainThread and the Go scheduler should somehow detect the use of the above APIs and seamlessly relinquish scheduling control to the platform.

I'm not sure I understand what "relinquish scheduling control" actually looks like. With a function like UIApplicationMain you're basically wedging the main thread from the perspective of the Go runtime. There's only one "main thread" per process, so asking to call multiple things on the main thread is just fundamentally non-composable. But perhaps you were just speaking rhetorically, and I took it too literally. :)

In contrast, #67499 and #67694 are more limited, but allow sharing of the main thread among different packages albeit with careful cooperation from the main function/goroutine.

In my reply to #67694 I basically said the same thing, but I don't see how #67494 is much better than RunOnMainThread in the specific case you describe. Something must fall over because there's only one main thread.

This just makes me think that there should maybe also be a way for a package to signal the intent to take full control of the main thread, so that other packages report a nice error message instead of hanging when they try to do the same thing. Or something.

[0] It's possible to avoid NSApplicationMain and drive the main thread event loop yourself. However, some gestures such as window resizing will still block until completed.

I'm curious about the second sentence here: what is the issue with blocking? Does it require blocking on the main thread specifically?

@eliasnaur
Copy link
Contributor Author

eliasnaur commented Jun 6, 2024

In other words, it seems to me that to be generally useful, RunOnMainThread and the Go scheduler should somehow detect the use of the above APIs and seamlessly relinquish scheduling control to the platform.

I'm not sure I understand what "relinquish scheduling control" actually looks like.

One way would be for the Go runtime to schedule RunOnMainThread goroutines using the native APIs. See below.

With a function like UIApplicationMain you're basically wedging the main thread from the perspective of the Go runtime.

Maybe my imagination fails me, but I don't know of a main-thread API that doesn't assume the OS has control of the main thread. As far as I know, the programming model is (1) pass main thread control to the OS (2) call APIs from callbacks, scheduled manually with run-on-main-thread APIs or as a result of events such as "mouse clicked". In other words, no main thread API block, except for the one call to UIApplicationMain/NSApplicationMain/... .

@hajimehoshi maybe you know of some?

There's only one "main thread" per process, so asking to call multiple things on the main thread is just fundamentally non-composable. But perhaps you were just speaking rhetorically, and I took it too literally. :)

It's true that more than 1 blocking call on the same thread is fundamentally non-composable. But as I stated above, there's only ever one blocking call (which may be interrupted), and every other API call won't block (as a matter of design, blocking the main/UI thread is considered very bad form because it results in GUI stutter).

So for the sake of Go packages A and B to both use the main thread, your proposed RunOnMainThread offers too little and too much:

  • too little, because it gives up once some goroutine calls UIApplicationMain
  • too much, because if you're using RunOnMainThread, you're almost certainly going to call some native main-thread API. Why not just call the native RunOnMainThread function?

I can't think of salvaging RunOnMainThread without it becoming aware of UIApplicationMain somehow. That is:

  • The first call to RunOnMainThread will call, say, UIApplicationMain, ask for a callback on the main thread and resume execution from that. UIApplicationMain is essentially suspended while any main thread goroutine is running. UIApplicationMain is resumed when all main thread goroutines are blocking or returns.
  • Every subsequent call to RunOnMainThread will ask for a callback from UIApplicationMain.

Very messy.

In contrast, #67499 and #67694 are more limited, but allow sharing of the main thread among different packages albeit with careful cooperation from the main function/goroutine.

In my reply to #67694 I basically said the same thing, but I don't see how #67494 is much better than RunOnMainThread in the specific case you describe. Something must fall over because there's only one main thread.

This just makes me think that there should maybe also be a way for a package to signal the intent to take full control of the main thread, so that other packages report a nice error message instead of hanging when they try to do the same thing. Or something.

Putting the powerful #67694 aside, and only assuming iter.Pull works with Cgo callbacks (#67499), two otherwise unrelated packages A and B may cooperate:

package pkga

import "C"

func init() {
    runtime.LockOSThread() // Lock main thread to the main goroutine
}

var mainYield func(struct{}) bool

//export mainLoopInitialized
func mainLoopInitialized() {
    mainYield(struct{}{})
}

func runMainThreadEventLoopIfNecessary() {
    if !C.isOnMainThread() { // https://developer.apple.com/documentation/foundation/nsthread/1412704-ismainthread
        panic("must be called from the main goroutine")
    }

    if C.isMainLoopRunning() { // https://developer.apple.com/documentation/foundation/nsrunloop/1412652-currentmode
        return
    }

    next, _ := iter.Pull(func(yield func(struct{}) bool) {
        mainYield = yield
        C.runMainLoop() // will call back mainLoopInitialized
    })
    mainNext = next
    next()
    // Here, the main loop is started, but suspended.
}

// MainThreadAPIA1 calls the native A1 API.
func MainThreadAPIA1(...) {
    if C.isOnMainThread() {
        runMainThreadLoopIfNecessary()
        C.mainThreadAPI1(...) // doesn't block
    } else {
        done := make(chan struct{})
        h := cgo.NewHandle(func() {
            mainThreadAPI1(...)
            close(done)
        })
        defer h.Delete()
        C.runOnMainThread(h) // https://developer.apple.com/documentation/objectivec/nsobject/1414900-performselectoronmainthread
    }
}

func WaitForEvents() {
    if !C.isOnMainThread() {
        panic("must be called from the main goroutine")
    }
   mainNext()
}

Package B would contain a duplicate of most of the code (or share a common package):

package pkgb
import "C"

func init() {
    runtime.LockOSThread() // Lock main thread to the main goroutine
}

var mainYield func(struct{}) bool

//export mainLoopInitialized
func mainLoopInitialized() {
    mainYield(struct{}{})
}

func runMainThreadEventLoopIfNecessary() {
    if !C.isOnMainThread() { // https://developer.apple.com/documentation/foundation/nsthread/1412704-ismainthread
        panic("must be called from the main goroutine")
    }

    if C.isMainLoopRunning() { // https://developer.apple.com/documentation/foundation/nsrunloop/1412652-currentmode
        return
    }

    next, _ := iter.Pull(func(yield func(struct{}) bool) {
        mainYield = yield
        C.runMainLoop() // will call back mainLoopInitialized
    })
    mainNext = next
    next()
    // Here, the main loop is started, but suspended.
}

// MainThreadAPIB1 calls the native B1 API.
func MainThreadAPIB1(...) {
    if C.isOnMainThread() {
        runMainThreadLoopIfNecessary()
        C.mainThreadAPI1(...) // doesn't block
    } else {
        done := make(chan struct{})
        h := cgo.NewHandle(func() {
            mainThreadAPI1(...)
            close(done)
        })
        defer h.Delete()
        C.runOnMainThread(h) // https://developer.apple.com/documentation/objectivec/nsobject/1414900-performselectoronmainthread
    }
}

func WaitForEvents() {
    if !C.isOnMainThread() {
        panic("must be called from the main goroutine")
    }
   mainNext()
}

Finally, the caller:

package main

import "pkga"
import "pkgb"

func main() {
   pkga.MainThreadAPIA1(...)
   pkgb.MainThreadAPIB1(...)

   // or even
   go func() {
      pkga.MainThreadAPIA2(...)
   }()
   for {
      pkga.WaitForEvents()
      // or
      pkgb.WaitForEvents()
   }
}

Quite a bit of machinery needed, but notably:

  • No shared state between pkga and pkgb
  • No callbacks in the exported APIs of pkga and pkgb
  • No special cooperation from the Go runtime

Now, with something like #67694, you can get rid of 'WaitForEvents' and the isMainThread special cases, at the cost of the first of pkga and pkgb "stealing" the main thread through an iter.Pull during initialization:

package pkga

func init() {
    if !C.isOnMainThread() {
        // Someone else already stole the main thread.
        return
    }
    next, _ := iter.Pull(func(yield func(struct{}) bool) {
        go yield(struct{}{}) // Pass back a different thread.
        C.runMainLoop()
    })
    next()
    // Main thread "stolen" and passed to UIApplicationMain/...
}

// MainThreadAPIB1 calls the native B1 API.
func MainThreadAPIB1(...) {
    done := make(chan struct{})
    h := cgo.NewHandle(func() {
        mainThreadAPI1(...)
        close(done)
    })
    defer h.Delete()
    C.runOnMainThread(h)
}

Note that for Gio and probably most/all GUI packages, fixing #67499 is enough: WaitForEvents fits naturally inside the blocking gui.Window.Events iterator. It may even be enough for Windows services, because a service also waits for events from the OS.

[0] It's possible to avoid NSApplicationMain and drive the main thread event loop yourself. However, some gestures such as window resizing will still block until completed.

I'm curious about the second sentence here: what is the issue with blocking? Does it require blocking on the main thread specifically?

Correct. You may drive the event loop yourself on macOS, but it has to happen on the main thread.

@qiulaidongfeng
Copy link
Member

qiulaidongfeng commented Sep 3, 2024

That said, I don't yet see why that is better. Our goal here is not the simplest implementation. It's the API that is easiest to use correctly. Calling mainthread.Do within a call to mainthread.Do sounds like a logic error. It's like acquiring a lock when you are already holding the lock. I don't see why we want to support that.

I implemented the mainthread package according to this idea.
Based on these two points, mainthread.Do runs f in the mainthread, and mainthread.Yield gives the mainthread to mainthread.Do.
I implemented the new mainthread package in CL 609977 using coroutine semantics.
Coroutine definitions come from https://research.swtch.com/coro

Coroutines provide concurrency without parallelism: when one coroutine is running, the one that resumed it or yielded to it is not.

Specifically in the mainthread package, the main thread and other thread is used as a coroutine through three functions within the package.

If wants to run f in the mainthread, run mainthread.Do(f)

  • if the caller is on the main thread, then it can run f directly without coroutine switch.
  • if the caller is not on the mainthread, it requires the mainthread to call mainthread.Yield for a coroutine switch that causes the function running on the mainthread to become f.

In the mainthread, calling mainthread.Yield will perform a coroutine switch, changing the function that the mainthread runs to f, thus showing the semantics that mainthread.Yield cedes the mainthread to mainthread.Do.

Waiting returns a channel, and a struct{} is sent to the channel when mainthread.Do is called on a non-main thread, notifying the caller that mainthread.Do on a non-main thread is waiting for the mainthread to call mainthread.Yield happen coroutine switch.

Based on the above, if the mainthread package is implemented using coroutine semantics as shown in CL 609977, the question of whether nested calls to mainthread.Do are supported looks like this.

mainthread.Do nested call, it is fine by default, no need to do anything to support it, it's the other way around that needs dedicated support

, because if call mainthread.Do, the outside call to mainthread.Do is called f1, and the inside call to mainthread.Do is called f2,this would be like on main thread calling

func(){
f1()
f2()
}()

is completely different from mutex reentrant. I don't understand how this is like a mutex reentrant?

What I can think of that makes mainthread.Do look like a lock is this document
// Nothing else runs on the main thread until f returns or calls [Yield].

But in my opinion, even if it means locking the mainthread to ensure that f always runs on the mainthread, it does not mean that nested calls to mainthread.Do are like mutex reentrant, because if it is mutex, you can protect invariants by locking and unlocking, but mainthread.Do cannot do the mutex effect.

Only need to call mainthread.Yield in mainthread.Do to give up the main thread to other mainthread.Do (if there are other mainthread.Dos), wait for other mainthread.Do to return, and it can take back the main thread. This is the behavior of mutex is completely different, it is exactly the behavior of coroutine (when one coroutine is running, the one that resumed it or yielded to it is not).

I don't entirely understand what you are suggesting for Yield. Can you show us the complete user documentation for the package that you are proposing? Thanks.

The documentation for my proposed new package looks like this

// 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. If have other [Do] call, it can
// yield the main thread to other [Do] calls by calling [Yield].
//
// 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).
// In this situation, main must explicitly yield the main thread
// to allow other thread calls to Do are to proceed.
// See the documentation for [Waiting] for examples.
package mainthread // imported as "runtime/mainthread"

// Do calls f on the main thread.
// Nothing else runs on the main thread until f returns or calls [Yield].
//
// 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].
func Do(f func())

// Yield give the main thread to [Do] 
// call that is blocked waiting for the main thread when Yield is called.
// It then waits to reacquire the main thread and returns.
// It only give main thread to one [Do] at a call it.
//
// Yield must only be called from the main thread.
// If called from a different thread, Yield panics.
func Yield()

// Waiting returns a channel that receives a message when a call to [Do]
// is blocked waiting for the main thread. A message is sent
// when a call to [Do] is blocked while waiting for the main thread.
// There is only one waiting channel; all calls to Waiting return the same channel.
//
// Programs that run a C-based API such as a GUI event loop on the main thread
// should arrange to share it by watching for events on Waiting and calling [Yield].
// A typical approach is to define a new event type that can be sent to the event loop,
// respond to that event in the loop by calling Yield,
// and then start a separate goroutine (running on a non-main thread)
// that watches the waiting channel and signals the event loop:
//
//	go func() {
//	    for range mainthread.Waiting() {
//	        sendYieldEvent()
//	    }
//	}()
//
//	C.EventLoop()
//
func Waiting() <-chan struct{}

I think using mainthread.Yield is code like this

package main

import (
    "runtime"
    "runtime/mainthread
)

func init(){
    runtime.LockOSThread()
}

func main(){
// other code
C.EventLoop()
    for {
        select {
        // other case handle c event
        case <-mainthread.Waiting():
            mainthread.Yield()
        }
    }
}

So, I think it is the best way to a Yield give main thread to a Do, because in this way, it can achieve what the document says (mediates access to the program's main thread) with a simple implementation.

And a more complex implementation is not necessary, because if a Yield gives the main thread to multiple Do, it is more complicated to implement in order to properly synchronize concurrent programs, which has a cost (both when writing CL and when the program is running), but the same thing can be Waiting and Yield multiple times. Unless are sure that there is only one Do, the usual practice is to have select in the for loop so that you can wait and Yield multiple times, so I don't think the more complex implementation is worth it and not necessary.

@hajimehoshi
Copy link
Member

hajimehoshi commented Sep 3, 2024

+1 to enable nested mainthread.Do. As I said repeatedly, mainthread.Do in main might panic when runtime.LockOSThread was called in init. Or, perhaps, we would have a complicated model to explain why mainthread.Do in main would not panic even with LockOSThread in init. Allowing nested Do resolves this issue.

@ianlancetaylor
Copy link
Contributor

What about a case like

    mainthread.Do(func( ){
        myMutex.Lock()
        defer myMutex.Unlock()
        // call C function on main thread
        g()
        // call  C function on main thread
    })

    ...

    func g() {
        mainthread.Do(func() {
            // call C function on main thread
            mainthread.Yield()
        })
    }

In this case let's assume that the first function doesn't realize that g is going to call mainthread.Do and mainthread.Yield. Since g is yielding the main thread, other functions can run. But since myMutex is locked, those other functions may see an inconsistent state.

That is, permitting nested mainthread.Do means that a function that thinks it is in sole control of the main thread may not in fact be in control. This is the sense in which a nested mainthread.Do is similar to a nested mutex lock.

@qiulaidongfeng
Copy link
Member

qiulaidongfeng commented Sep 3, 2024 via email

@ianlancetaylor
Copy link
Contributor

To me it seems perfectly reasonable that if my code calls Do and does not call Yield, then my code has exclusive access to the main thread.

If we permit recursive calls to mainthread.Do, then I can't ensure exclusive access.

If we do not permit recursive calls, then I can ensure exclusive access. The only way I could lose exclusive access would be if some other function that I don't know about calls mainthread.Yield, but it would be clearly incorrect for a package to call mainthread.Yield without calling mainthread.Do (or clearly documenting the call to mainthread.Yield).

So what I am suggesting is that preventing recursive calls to mainthread.Do will catch errors, just as preventing recursive calls to sync.Mutex.Lock catches errors.

I think that if we are going to permit recursive calls to mainthread.Do, we need a better reason than simplifying the implementation. We should not simplify the implementation at the cost of making the API harder to use correctly.

I hope that makes sense.

@qiulaidongfeng
Copy link
Member

qiulaidongfeng commented Sep 3, 2024 via email

qiulaidongfeng added a commit to qiulaidongfeng/go that referenced this issue Sep 7, 2024
DO NOT SUBMIT -- f call Do now not panic

New Function Do,Yield,Waiting coordinate the main thread using two channal.

Fixes golang#64777

Change-Id: Ib15a03c0efc6db83a7819acc865a896752797341
@eliasnaur
Copy link
Contributor Author

As asked in review of https://go-review.googlesource.com/c/go/+/609977/comments/667a4ab7_78cfd118, how should LockOSThread and UnlockOSThread behave during mainthread.Do? They may panic or be effectively ignored, but either choice should be documented.

@qiulaidongfeng
Copy link
Member

qiulaidongfeng commented Sep 24, 2024

how should LockOSThread and UnlockOSThread behave during mainthread.Do? They may panic or be effectively ignored, but either choice should be documented.

Another option, as CL does now, is to call LockOSThread and UnlockOSThread at mainthread.Do, which are both valid calls, it will act on the goroutine of f running on the main thread.
Specifically speaking:
If Do is called on the main thread, LockOSThread and UnlockOSThread affect the goroutine of Do.
If Do is called on a non-main thread, the LockOSThread and UnlockOSThread affect the goroutine of Yield.

@qiulaidongfeng
Copy link
Member

There's another problem, as @eliasnaur said What happens if the Go runtime is initialized by a non-main thread (e.g. c-shared build mode)?
I have studied it and I don't know how to judge whether it is on the mainthread. If others have no way to judge whether it is on the mainthread, the mainthread package can only panic all the time in this case.

@ianlancetaylor
Copy link
Contributor

I don't see a strong reason for LockOSThread to behave any differently if called during mainthread.Do. I agree that calling LockOSThread without calling UnlockOSThread can lead to deadlock for other calls to mainthread.Do. But I think such a bug is not particularly likely and easy to understand if it happens. I don't think that we want mainthread.Do to interact badly if it calls code in some other package that for some reasons wants to use LockOSThread.

It's a good point that mainthread.Do can't work in code complied in c-archive or c-shared mode. I guess that in that case a panic is the only option. At least it seems like the option we should start with; maybe we can think of something else later.

@qiulaidongfeng
Copy link
Member

qiulaidongfeng commented Sep 24, 2024

I don't see a strong reason for LockOSThread to behave any differently if called during mainthread.Do.

Can it be understood that calling LockOSThread in mainthread.Do always affects the goroutine of the Do?
By the way, this is happening because if Do is not on the main thread, Do and Yield are running on two different goroutine, we can forward LockOSThread to Do's goroutine, and panic and Goexit is the same?

I agree that calling LockOSThread without calling UnlockOSThread can lead to deadlock for other calls to mainthread.Do. But I think such a bug is not particularly likely and easy to understand if it happens. I don't think that we want mainthread.Do to interact badly if it calls code in some other package that for some reasons wants to use LockOSThread.

We can implement with stronger restrictions first and relax them later, just like comparable, which is more restrictive in go1.18 and looser in go1.20.

@hajimehoshi
Copy link
Member

Can't we use OS APIs to get main threads? (e.g. Looper.getMainLooper() on Android, dispatch_get_main_queue() on iOS)? If mainthread is not available with c-archive, this is not available on mobiles (with gomobile-bind)...

@qiulaidongfeng
Copy link
Member

qiulaidongfeng commented Sep 24, 2024

Perhaps there is a more general approach, which is to have the user call an API function during c-archive and c-shared to tell go runtime the id of the main thread.
However, this requires a new API, such as

func SetMainThreadId(id uint)

But,this is a new proposal.
And this is likely to silently succeed when the user gives the wrong thread ID, rather than panic.

@ianlancetaylor
Copy link
Contributor

Can it be understood that calling LockOSThread in mainthread.Do always affects the goroutine of the Do?

Yes. What else could it affect?

By the way, this is happening because if Do is not on the main thread, Do and Yield are running on two different goroutine, we can forward LockOSThread to Do's goroutine, and panic and Goexit is the same?

Are you talking about the case where code calls LockOSThread and then calls mainthread.Do? I don't think we should forward the LockOSThread in that case.

I don't think we should worry too much about c-archive and c-shared right now. If we panic today, then if somebody comes up with a real use case we can reconsider.

@qiulaidongfeng
Copy link
Member

What I mean is that if Do is called from a non main thread, it runs on a different goroutine than the Yield called from the main thread. Because if Do is not on the main thread, in order to respect the fact that the main thread may have already called LockOSThread like this code template, we cannot schedule other goroutines to the main thread. Instead, we can use a channal to send f from Do to Yield, allowing Yield to call f on the main thread. After f's call ends, we can send a signal to Do from another channal.
code template:

package main
import (
    "runtime"
    "runtime/mainthread"
)

func init() {
    runtime.LockOSThread()
}

func main(){
    // other code
    for {
        select {
            case <-mainthread.Waiting():
                mainthread.Yield()
            // other case
        }
    }
}

@ianlancetaylor
Copy link
Contributor

I'm not sure I fully understand this. Can you show a complete example? What do you think should happen?

@qiulaidongfeng
Copy link
Member

qiulaidongfeng commented Sep 25, 2024

For example:

package main
import (
    "runtime"
    "runtime/mainthread"
)

func init() {
    runtime.LockOSThread()
}

func main(){
    go func(){
        mainthread.Do(func(){
            runtime.LockOSThread()
        })
    }()
    for {
        select {
            case <-mainthread.Waiting():
                mainthread.Yield()
        }
    }
}

Running this example with the current CL will definitely cause a panic.
Because in the main thread, in order to respect runtime.LockOSThread,we cannot schedule Do goroutine to main thread ,so mainthread.Do on non main thread use channal send f to mainthread.Yield , and f is called in the mainthread.Yield on the main thread. As a result, the Yield goroutine calls runtime.LockOSThread twice but does not call runtime.UnlockOSThread.

More information:
There is an example in the test added to proc_test.go in CL that illustrates the mainthread.Do, What if I don't forward something like runtime.Goexit.
If mainthread.Yield does not forward runtime.Goexit to mainthread.Do, in main thread t.Fatal called in mainthread.Do will call runtime.Goexit. As a result, the goroutine of TestMain is exit, and the test will never exit normally. So, from this example, forwarding runtime.Goexit and panic can make mainthread.Do easier to use without worrying about the main thread goroutine (usually the main function goroutine) exit due to executing f.
But, for runtime.LockOSThread, I cannot come up with an example to prove that it should be forward.

@qiulaidongfeng
Copy link
Member

So, I think we should forward runtime.Goexit and panic to mainthread.Do, and let it act on Do goroutine. For runtime.LockOSThread, I'm not sure what to do.

@ianlancetaylor
Copy link
Contributor

I don't see a problem if the example in #64777 (comment) panics.

@hajimehoshi
Copy link
Member

Does this mean, if one of dependencies invokes LockOSThread in its init, all the other libraries would not be able to use mainthread?

@qiulaidongfeng
Copy link
Member

qiulaidongfeng commented Sep 26, 2024 via email

@hajimehoshi
Copy link
Member

Ok thanks

@eliasnaur
Copy link
Contributor Author

I don't think we should worry too much about c-archive and c-shared right now. If we panic today, then if somebody comes up with a real use case we can reconsider.

I don't know if it qualifies as a real use case, but note that Android forces Go code into c-shared mode and calls into the Java UI libraries must happen on the main thread.

@ianlancetaylor
Copy link
Contributor

How does that work today on Android? That is, how does Go code know what is the main thread? Is there some guarantee that the library is loaded on the main thread, so that LockOSThread will work?

@eliasnaur
Copy link
Contributor Author

There's no guarantee, AFAIK Java's System.loadLibrary loads and initializes the native library on the thread that called it. The way to use the main thread on Android is to use the Java equivalent of mainthread.Do: Looper.getMainLooper and Handler.post.

#64777 (comment) suggests using the platform APIs to detect whether the calling thread is the main thread, but that's annoying because there's no Looper.getMainLooper in the C NDK. Another option is to say that in c-shared or c-archive mode, the main thread is defined to be the thread bound to the first goroutine calling mainthread.Yield. If the thread is created by Go, Yield panics. Not great.

Thinking about the above, I now have doubts whether mainthread is too much for its use-cases. I can come up with:

(1) Packages that wants to abstract the starting of the platform main event loop (calling UIApplicationMain on iOS, NSApp.run on macOS, etc.) away from the user. They need a single call to mainthread.Do that never returns.

(2) Code that wants to call native main-thread API on platforms with a platform main loop. They can use the platform API today (Looper.getMainLooper on Android, performSelectorOnMainThread on iOS, etc.). This is not an additional burden compared to mainthread.Do, because they were going to call native API anyway.

(3) Code that wants to call native main-thread API where there is no platform main loop. They can use mainthread.Do, because no main thread API will block.

I'm assuming that:

  • For every platform, there is at most one blocking main-thread API (the "main event loop").
  • If there is a main event loop, there is native API to wake it up and run callbacks on the main thread.

Use-cases (2) and (3) can co-exist with each other and (1). Multiple (1) packages will see the earliest caller of mainthread.Do "win" the main thread, and the rest block forever. With Yield, every use-case (1) loser would end up being scheduled, calling some native isMainEventLoopRunning (e.g. NSApplication.isRunning on macOS) and exit early.

What did I miss? Why are we adding the Waiting/Yield machinery that's already available as native API?

@eliasnaur
Copy link
Contributor Author

eliasnaur commented Oct 20, 2024

Gentle ping. With the lesser usefulness of mainthread.Do in c-archive mode, and in light of my 3 use-case classes above, I propose limiting the proposal to just the essential Do:

// 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())

@qiulaidongfeng
Copy link
Member

qiulaidongfeng commented Oct 20, 2024

Does this API mean that if need to use the main thread, either runtime.LockOSThred in init or mainthread.Do, one or the other , don`t incoexistence.

@eliasnaur
Copy link
Contributor Author

You can arrange for UnlockOSThread to be called from main, but that's may be surprising to the user that expects the main thread locked at all times.

A better way is to keep LockOSThread-during-init for Go < 1.24, and use mainthread.Do on Go >= 1.24.

@qiulaidongfeng
Copy link
Member

You can arrange for UnlockOSThread to be called from main, but that's may be surprising to the user that expects the main thread locked at all times.

A better way is to keep LockOSThread-during-init for Go < 1.24, and use mainthread.Do on Go >= 1.24.

Sounds like the original proposal was better,there is no need to use different methods depending on the go version.

See https://go.dev/doc/go1compat

  • The APIs may grow, acquiring new packages and features, but not in a way that breaks existing Go 1 code.
  • Go 1 defines two things: first, the specification of the language; and second, the specification of a set of core APIs, the "standard packages" of the Go library.
  • Compatibility is at the source level.

If in order to use the new package,user need to not only add new code, but also modify the old valid code, even if just compile different codes in different go versions through build tags, I don't think this is consistent with go1compat. According to my understand, add new functions may require new code. For example, the standard library has a new API after added range-over-func, but it cannot affect the old code. For example, sync.Map.Range was directly called before. This kind of code does not need to adapt to the new go version , it will run as is.

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 Proposal-Accepted
Projects
Status: In Progress
Status: Accepted
Development

Successfully merging a pull request may close this issue.

12 participants