249 lines
8.6 KiB
Markdown
249 lines
8.6 KiB
Markdown
|
|
# Threading Model and Native Handle Ownership
|
||
|
|
|
||
|
|
This document describes the threading rules, event loop architecture, and native
|
||
|
|
resource management patterns used in the maui-linux project.
|
||
|
|
|
||
|
|
## Overview
|
||
|
|
|
||
|
|
maui-linux uses a **single-threaded UI model** inherited from GTK. All UI
|
||
|
|
operations -- rendering, layout, input handling, and widget mutation -- must
|
||
|
|
execute on a single designated thread called the *GTK thread*. Background work
|
||
|
|
is permitted, but its results must be marshaled back to the GTK thread before
|
||
|
|
touching any UI state.
|
||
|
|
|
||
|
|
## The GTK Thread
|
||
|
|
|
||
|
|
At startup, `LinuxApplication.Run` initializes the dispatcher and records the
|
||
|
|
current managed thread ID:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
// LinuxApplication.cs
|
||
|
|
private static int _gtkThreadId;
|
||
|
|
|
||
|
|
private static void StartHeartbeat()
|
||
|
|
{
|
||
|
|
_gtkThreadId = Environment.CurrentManagedThreadId;
|
||
|
|
// ...
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
// LinuxDispatcher.cs
|
||
|
|
public static void Initialize()
|
||
|
|
{
|
||
|
|
_mainThreadId = Environment.CurrentManagedThreadId;
|
||
|
|
_mainDispatcher = new LinuxDispatcher();
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Both `_gtkThreadId` (in `LinuxApplication`) and `_mainThreadId` (in
|
||
|
|
`LinuxDispatcher`) capture the same thread -- the one that will run the event
|
||
|
|
loop. Diagnostic logging in `LogInvalidate` and `LogRequestRedraw` warns when
|
||
|
|
UI work is attempted from any other thread.
|
||
|
|
|
||
|
|
## Thread Marshaling
|
||
|
|
|
||
|
|
There are two mechanisms to marshal work onto the GTK thread.
|
||
|
|
|
||
|
|
### 1. `GLibNative.IdleAdd` / `GLibNative.TimeoutAdd` (low-level)
|
||
|
|
|
||
|
|
These wrap `g_idle_add` and `g_timeout_add` from GLib. The callback runs on the
|
||
|
|
next iteration of the GTK main loop. Returning `true` reschedules the callback;
|
||
|
|
returning `false` removes it.
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
// Marshal a one-shot action to the GTK thread
|
||
|
|
GLibNative.IdleAdd(() =>
|
||
|
|
{
|
||
|
|
// Runs on the GTK thread
|
||
|
|
RequestRedrawInternal();
|
||
|
|
return false; // do not repeat
|
||
|
|
});
|
||
|
|
|
||
|
|
// Schedule a recurring action every 250 ms
|
||
|
|
GLibNative.TimeoutAdd(250, () =>
|
||
|
|
{
|
||
|
|
// heartbeat logic
|
||
|
|
return true; // keep repeating
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
`GLibNative` prevents the delegate from being garbage-collected by storing it
|
||
|
|
in a static `_callbacks` list (protected by `_callbackLock`). The wrapper is
|
||
|
|
removed from the list when the callback returns `false`.
|
||
|
|
|
||
|
|
`GtkNative` has its own `IdleAdd` overload with the same prevent-GC pattern
|
||
|
|
(via `_idleCallbacks`). Prefer the `GLibNative` versions for new code -- they
|
||
|
|
have proper locking and error logging.
|
||
|
|
|
||
|
|
### 2. `LinuxDispatcher` (high-level, MAUI-compatible)
|
||
|
|
|
||
|
|
`LinuxDispatcher` implements `IDispatcher` so that MAUI's `Dispatcher.Dispatch`
|
||
|
|
works correctly on Linux.
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
// From any thread:
|
||
|
|
dispatcher.Dispatch(() =>
|
||
|
|
{
|
||
|
|
// Runs on the GTK thread via GLibNative.IdleAdd
|
||
|
|
});
|
||
|
|
|
||
|
|
dispatcher.DispatchDelayed(TimeSpan.FromMilliseconds(500), () =>
|
||
|
|
{
|
||
|
|
// Runs on the GTK thread after a 500 ms delay via GLibNative.TimeoutAdd
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
If `Dispatch` is called from the GTK thread, the action runs **synchronously**
|
||
|
|
(no round-trip through the event loop). `DispatchDelayed` always goes through
|
||
|
|
`TimeoutAdd`, regardless of calling thread.
|
||
|
|
|
||
|
|
`LinuxDispatcherTimer` wraps `GLibNative.TimeoutAdd` to implement
|
||
|
|
`IDispatcherTimer`. Stopping a timer calls `GLibNative.SourceRemove` to cancel
|
||
|
|
the GLib source.
|
||
|
|
|
||
|
|
## Event Loop
|
||
|
|
|
||
|
|
The application supports two event-loop modes, selected by
|
||
|
|
`LinuxApplicationOptions.UseGtk`.
|
||
|
|
|
||
|
|
### X11 Mode (`RunX11`)
|
||
|
|
|
||
|
|
A manual polling loop on the main thread:
|
||
|
|
|
||
|
|
```
|
||
|
|
while (_mainWindow.IsRunning)
|
||
|
|
{
|
||
|
|
_mainWindow.ProcessEvents(); // drain X11 event queue
|
||
|
|
SkiaWebView.ProcessGtkEvents(); // pump GTK for WebView support
|
||
|
|
UpdateAnimations();
|
||
|
|
Render();
|
||
|
|
Thread.Sleep(1); // yield CPU
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
X11 events are read with `XNextEvent` / `XPending`. GTK is pre-initialized
|
||
|
|
(`gtk_init_check`) so that WebView (WebKitGTK) works, but GTK does not own the
|
||
|
|
main loop. GTK events are drained cooperatively via `ProcessGtkEvents`.
|
||
|
|
|
||
|
|
### GTK Mode (`RunGtk`)
|
||
|
|
|
||
|
|
GTK owns the main loop:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
StartHeartbeat(); // records _gtkThreadId, starts 250 ms heartbeat
|
||
|
|
PerformGtkLayout(w, h);
|
||
|
|
_gtkWindow.RequestRedraw();
|
||
|
|
_gtkWindow.Run(); // calls gtk_main() -- blocks until quit
|
||
|
|
GtkHostService.Instance.Shutdown();
|
||
|
|
```
|
||
|
|
|
||
|
|
All rendering is driven by GTK draw callbacks. `GLibNative.IdleAdd` and
|
||
|
|
`TimeoutAdd` integrate naturally because GTK's main loop processes GLib
|
||
|
|
sources.
|
||
|
|
|
||
|
|
## Native Handle Ownership
|
||
|
|
|
||
|
|
### Raw `IntPtr` Handles (current codebase)
|
||
|
|
|
||
|
|
Most native resources are currently held as raw `IntPtr` fields. The owning
|
||
|
|
class implements `IDisposable` and frees resources in `Dispose(bool)`.
|
||
|
|
|
||
|
|
**X11 resources** (`X11Window`):
|
||
|
|
|
||
|
|
| Resource | Acquire | Release |
|
||
|
|
|---|---|---|
|
||
|
|
| `Display*` | `XOpenDisplay` | `XCloseDisplay` |
|
||
|
|
| `Window` | `XCreateSimpleWindow` | `XDestroyWindow` |
|
||
|
|
| `Cursor` | `XCreateFontCursor` | `XFreeCursor` |
|
||
|
|
| `XImage*` | `XCreateImage` | `XDestroyImage` (also frees pixel buffer) |
|
||
|
|
|
||
|
|
Release order matters: cursors must be freed **before** the display is closed,
|
||
|
|
and the window must be destroyed before the display is closed.
|
||
|
|
|
||
|
|
**GObject resources** (`GtkHostWindow` and others):
|
||
|
|
|
||
|
|
| Resource | Acquire | Release |
|
||
|
|
|---|---|---|
|
||
|
|
| GTK widget | `gtk_window_new`, `gtk_drawing_area_new`, etc. | `gtk_widget_destroy` |
|
||
|
|
| GdkPixbuf | `gdk_pixbuf_new_from_file` | `g_object_unref` |
|
||
|
|
| GtkCssProvider | `gtk_css_provider_new` | `g_object_unref` |
|
||
|
|
|
||
|
|
GObject uses **reference counting**. `g_object_unref` decrements the ref
|
||
|
|
count; when it reaches zero the object is freed. If you receive an object with
|
||
|
|
a "floating" reference (common for newly created GTK widgets), adding it to a
|
||
|
|
container sinks the reference -- the container then owns it. Only call
|
||
|
|
`g_object_unref` on objects you have explicitly ref'd or that you own.
|
||
|
|
|
||
|
|
### Signal Connection / Disconnection
|
||
|
|
|
||
|
|
GTK signals are connected with `g_signal_connect_data` and return a `ulong`
|
||
|
|
handler ID. Signals **must** be disconnected before the widget is destroyed,
|
||
|
|
or the pointers backing the managed delegates become dangling.
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
// Connect (store the handler ID)
|
||
|
|
_deleteSignalId = GtkNative.g_signal_connect_data(
|
||
|
|
_window, "delete-event",
|
||
|
|
Marshal.GetFunctionPointerForDelegate(_deleteEventHandler),
|
||
|
|
IntPtr.Zero, IntPtr.Zero, 0);
|
||
|
|
|
||
|
|
// Disconnect (in Dispose, before gtk_widget_destroy)
|
||
|
|
if (_deleteSignalId != 0)
|
||
|
|
GtkNative.g_signal_handler_disconnect(_window, _deleteSignalId);
|
||
|
|
```
|
||
|
|
|
||
|
|
Keep the delegate instance alive for as long as the signal is connected (store
|
||
|
|
it as a field). If the GC collects the delegate while the signal is still
|
||
|
|
connected, GTK will call into freed memory.
|
||
|
|
|
||
|
|
### SafeHandle Wrappers (for new code)
|
||
|
|
|
||
|
|
`Native/SafeHandles.cs` provides `SafeHandle`-derived wrappers that automate
|
||
|
|
release:
|
||
|
|
|
||
|
|
| Type | Releases via |
|
||
|
|
|---|---|
|
||
|
|
| `SafeGtkWidgetHandle` | `gtk_widget_destroy` |
|
||
|
|
| `SafeGObjectHandle` | `g_object_unref` |
|
||
|
|
| `SafeX11DisplayHandle` | `XCloseDisplay` |
|
||
|
|
| `SafeX11CursorHandle` | `XFreeCursor` (requires display ptr at construction) |
|
||
|
|
| `SafeCssProviderHandle` | `g_object_unref` |
|
||
|
|
|
||
|
|
New code should prefer these over raw `IntPtr` where practical. They guarantee
|
||
|
|
release even if `Dispose` is not called (via the CLR release mechanism),
|
||
|
|
and they prevent double-free by tracking validity.
|
||
|
|
|
||
|
|
## Common Pitfalls
|
||
|
|
|
||
|
|
1. **UI work off the GTK thread.** Any call to GTK or X11 APIs, or mutation of
|
||
|
|
`SkiaView` state, from a background thread is undefined behavior. Always
|
||
|
|
marshal with `GLibNative.IdleAdd` or `LinuxDispatcher.Dispatch`. The
|
||
|
|
diagnostic warnings from `LogInvalidate` / `LogRequestRedraw` exist to catch
|
||
|
|
this -- do not ignore them.
|
||
|
|
|
||
|
|
2. **Delegate collection during signal connection.** A `GSourceFunc` or signal
|
||
|
|
callback delegate passed to native code must be stored in a field or a static
|
||
|
|
list for its entire connected lifetime. `GLibNative._callbacks` and
|
||
|
|
`GtkNative._idleCallbacks` exist for this purpose.
|
||
|
|
|
||
|
|
3. **Reentrancy in `RequestRedraw`.** The `_isRedrawing` flag guards against
|
||
|
|
recursive invalidation. Calling `RequestRedraw` from within a render
|
||
|
|
callback is safe (it will be a no-op), but scheduling further idle callbacks
|
||
|
|
from within an idle callback can lead to unbounded queue growth if not gated.
|
||
|
|
|
||
|
|
4. **X11 resource release ordering.** Cursors and windows depend on the display
|
||
|
|
connection. Always free them before calling `XCloseDisplay`. The
|
||
|
|
`X11Window.Dispose` method shows the correct order:
|
||
|
|
cursors first, then the window, then the display.
|
||
|
|
|
||
|
|
5. **`XDestroyImage` frees the pixel buffer.** When you call `XCreateImage`
|
||
|
|
with a pointer to pixel data, `XDestroyImage` will free that memory. If you
|
||
|
|
allocated the buffer with `Marshal.AllocHGlobal`, do **not** free it yourself
|
||
|
|
after `XDestroyImage`. If `XCreateImage` fails, you **must** free it
|
||
|
|
yourself.
|
||
|
|
|
||
|
|
6. **`Thread.Sleep(1)` in X11 mode.** The X11 event loop uses a 1 ms sleep to
|
||
|
|
yield CPU. This limits frame rate and increases input latency compared to
|
||
|
|
GTK mode. Be aware of this when profiling rendering performance.
|