Yes, every program has a main/initial thread, lots of UI frameworks require accessing UI elements only from the threads they're created on (often the main thread), and a run/event loop keeps the process from exiting.
In a Swift executable the main run loop is going to be handled by Swift Concurrency / Dispatch, which is what most are used to and where @MainActor is going to work. In @edwardd20 and my cases, we have Swift being loaded as a dynamic library by another runtime with its own event loop.
For all intents and purposes, an event loop implementation is like this:
class Application {
var events: [Event] = []
func exec() {
while true {
while !events.isEmpty {
let event = events.removeFirst()
switch event {
case .mouseMove:
...
case .keyPress:
...
...
case .exit:
return
}
}
}
}
}
The main thread is always busy in that loop so that the application doesn't shut down. There's no opportunity for a call to @MainActor to actually run because the main thread is always in that loop. So the solution is to use the event system to run your code instead of relying on the @MainActor.
For Qt, this is what QMetaObject::invokeMethod does. It puts a new event in the queue to run an arbitrary function. I would hope that .NET has a similar mechanism.
Yes, but with the way the current implementation works, the first synchronous part of a program runs inline, and starting the main thread’s run loop at that point effectively prevents dispatchMain() from ever being called. I wouldn't necessarily rely on this behavior if it can be avoided, but it is somewhat neat and may be the simplest workaround in certain cases.
This isn’t true on Windows, yet it is one of the assumptions I keep seeing in discussions about Swift Concurrency.
Windows is a highly multithreaded environment. In Win32, every window can be associated with a different thread. (Really it’s the message queue that is associated with the thread.) This flexibility extends to .NET WinForms, but not UWP or WinUI.
COM expands upon the OS’s underlying model by adding the notion of “apartments” that are themselves either single- or multi-threaded. And over time the kernel has grown new features like threadpools and fibers. I haven’t fleshed it out completely yet, but effective use of Swift Concurrency on Windows probably involves using a lot more non-global executors than on Darwin.
Of course, multiple windows can be associated with the same thread, and in the simplest case they are all associated with the process’s initial thread. But there’s no guarantee that thread survives beyond the call to WinMain(). Analogous to dispatch_main() on Darwin, the initial thread can exit while the process continues running. The thread isn’t blocked or looping; it has truly ceased to exist.
As I said, I've tried so many things but I actually did get it working. I think the key was simply to remove @MainActor from everything that my C# code referenced. I can still do async's and await's, just avoid @MainActor.
Thanks to everyone on this thread for helping me better understand how Dispatch actually works.
Thanks to what was talked about here I’m now using _dispatch_get_main_queue_handle_4CF and _dispatch_main_queue_callback_4CF with a QSocketNotifier to drain the dispatch queue on the Qt main thread when jobs are ready. Now I can actually just use @MainActor!
@edwardd20 if it helps at all, I asked Gemini to take the Qt C++ code and make an equivalent for .NET and C#. Hopefully it works or you can get it working so you can have the convenience of @MainActor
using System;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Win32.SafeHandles;
public static class DispatchBridge
{
[DllImport("libdispatch", EntryPoint = "_dispatch_get_main_queue_handle_4CF")]
private static extern int GetMainQueueHandle();
[DllImport("libdispatch", EntryPoint = "_dispatch_main_queue_callback_4CF")]
private static extern void MainQueueCallback(IntPtr msg);
public static void Install()
{
int fd = GetMainQueueHandle();
if (fd < 0) return;
// Capture the main thread's event loop context (e.g., WPF, WinForms, Avalonia)
SynchronizationContext? mainContext = SynchronizationContext.Current;
// Run the epoll-backed monitoring loop on the ThreadPool
Task.Run(() => MonitorEventFdAsync(fd, mainContext));
}
private static async Task MonitorEventFdAsync(int fd, SynchronizationContext? mainContext)
{
// SafeFileHandle wraps the raw Linux eventfd handle without closing it on dispose
using var handle = new SafeFileHandle((IntPtr)fd, ownsHandle: false);
Memory<byte> buffer = new byte[8]; // uint64_t counter for eventfd
while (true)
{
// Awaits until epoll signals data is ready to read
int bytesRead = await RandomAccess.ReadAsync(handle, buffer, fileOffset: 0);
if (bytesRead <= 0) break;
// Drain the libdispatch queue on the main thread loop
if (mainContext != null)
{
mainContext.Post(_ => MainQueueCallback(IntPtr.Zero), null);
}
else
{
MainQueueCallback(IntPtr.Zero);
}
}
}
}
The downside is that there is no “callback-oriented” way to run a runloop. You would need to register a runloop source that bridges to Qt, and unconditionally invoke the runloop once every time through Qt’s main loop.
Thanks for the correction - I hadn’t come across that post.
I was looking for a proper API to have it be callback-based instead of continually calling a run method, but couldn’t find any, which is why I went to the _4CF functions.
I’ll switch my approach to call RunLoop.main.run and then schedule an event with Qt to call it again even if there aren’t jobs ready.
I’m hoping that these workarounds won’t be needed once custom main executors lands.
For qtbridge-swift there is a patch up that might be of interest to you. Note, it does make use of the custom executor apis so requires SPI (wouldn't work with an xcode toolchain right now, but that shouldn't need a custom executor anyways) and a new enough toolchain.
It is essentially just a qt-flavoured version of swift-platform-executors. I'm not too sure how you're interacting with Qt but it should be as simple as
QtEventLoop.installGlobalExecutor()
at the entrypoint of your code.
It currently isn't merged into dev yet and isn't exposed as an SPM .product (it's only defined as an internal .target(name: "QtEventLoop") within the package, and there are fatalErrors in some unhandled code paths), so you'd have to copy and paste the code for now. But if it's useful on its own, maybe it could be exposed as an SPM product in the future (: