Skip to content

mainthread: avoid deadlock when Wait is called from the main thread#333

Merged
mappu merged 1 commit into
mappu:masterfrom
MarSoft:fix/mainthread-deadlock
May 25, 2026
Merged

mainthread: avoid deadlock when Wait is called from the main thread#333
mappu merged 1 commit into
mappu:masterfrom
MarSoft:fix/mainthread-deadlock

Conversation

@MarSoft

@MarSoft MarSoft commented May 24, 2026

Copy link
Copy Markdown
Contributor

mainthread: avoid deadlock when Wait is called from the main thread

Problem

mainthread.Wait queues the callback onto the Qt main thread's event loop and blocks on a sync.WaitGroup. If the caller is already the main thread, it blocks itself: the queued event never runs (the main thread is stuck in wg.Wait() and never returns to its event loop), and Wait hangs forever.

This is easy to hit in practice. Consider a helper function that touches GUI state:

func updateLabel(text string) {
    mainthread.Wait(func() {
        myLabel.SetText(text)
    })
}

If updateLabel is called from a background goroutine, this works correctly. If the same helper is later reused from a Qt slot (e.g. a button-click handler) — perhaps via several layers of indirection — the app silently deadlocks. Callers often can't easily know which thread they're on, especially in deep call chains.

Reproduction

package main

import (
    "os"
    "github.com/mappu/miqt/qt"
    "github.com/mappu/miqt/qt/mainthread"
)

func main() {
    qt.NewQApplication(os.Args)
    btn := qt.NewQPushButton3("Click to hang")
    btn.OnPressed(func() {
        mainthread.Wait(func() {
            btn.SetText("never reached")
        })
    })
    btn.Show()
    qt.QApplication_Exec()
}

Click the button → app hangs.

Fix

Check whether the current thread is the Qt main thread, and if so, invoke the callback directly without going through the event loop.

The check is implemented in C++ using QThread::currentThread() == QCoreApplication::instance()->thread() — the standard Qt idiom — and exposed to Go as mainthread.IsCurrent().

After the fix, the example above works: clicking the button updates its label, because Wait notices it's already on the main thread and calls the function directly.

Public API

A new mainthread.IsCurrent() bool is added for users who want to make the same decision in their own code (e.g. picking between sync and async paths, or asserting thread invariants).

Performance

IsCurrent() adds one CGo call + a QThread::currentThread() lookup + a pointer comparison to every Wait. In context this is negligible:

  • From a goroutine (the common case): Wait already does a CGo call, a QMetaObject::invokeMethod queued dispatch (event allocation, event-loop mutex, append, futex wait/wake, context switch through the event loop, second CGo bridge for the callback). The extra check is well under 1% of the existing cost.
  • From the main thread: the check avoids the queued-dispatch path entirely. The cost goes from infinite (deadlock) to a single CGo call.

The only scenario where the overhead would matter is calling Wait from a tight loop on a goroutine — but that's misuse (you'd batch instead).

Scope

Both qt/mainthread and qt6/mainthread packages get the same fix.

mainthread.Wait queues the callback onto the Qt main thread's event loop
and blocks on a sync.WaitGroup. If the caller is already the main thread,
it blocks itself: the queued event never runs, and Wait hangs forever.

This can happen when a function that uses Wait is reachable from both
goroutines and Qt slots (e.g. a helper called both from a worker and from
a button-click handler). The caller often can't easily know which thread
they're on.

Fix: check whether the current thread is the Qt main thread, and if so,
invoke the callback directly. The check is exposed as a new IsCurrent()
helper for users who want to make the same decision in their own code.

The check is implemented in C++ using QThread::currentThread() against
QCoreApplication::instance()->thread() — the standard Qt idiom.

Both qt5 and qt6 mainthread packages get the same fix.
@mappu

mappu commented May 25, 2026

Copy link
Copy Markdown
Owner

LGTM 🚢 Thanks again for the nice contribution!

I remember another user has definitely encountered this before, but in that case they switched to .Start() without Wait.

I think it would be possible to implement mainthread_is_current entirely on the Go side without a C function? But IsCurrent() is a nice API no matter if the implementation changes in future.

@mappu mappu merged commit b127b36 into mappu:master May 25, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants