Threading: keeping the UI responsive
Any slow operation running on the main thread — a large file read, a network request, image processing, a long calculation — blocks wxRuby3’s event loop for its entire duration. The window stops responding. Buttons don’t click. The window can’t be moved or resized. On macOS the spinning beachball appears. Users assume the app has crashed.
The solution is to move slow work off the main thread. This lesson explains why that’s harder than it sounds in MRI Ruby, shows the correct approach, and gives you several working patterns to choose from.
The GIL problem
MRI Ruby (the standard Ruby interpreter) uses a Global Interpreter Lock — only one thread can execute Ruby code at any given time. The scheduler switches between threads periodically, but only at safe switching points between Ruby bytecode instructions or during blocking system calls.
wxRuby3’s event loop is a C++ loop running native Cocoa/Win32/GTK code. From Ruby’s perspective, it looks like one long uninterrupted C extension call. The scheduler has no opportunity to switch to your background thread because no safe switching point occurs — the GIL stays held by the main thread for the entire duration of the event loop.
The result: sleep in a background thread does release the GIL, but there is no guarantee the scheduler will give that time to your thread rather than back to the event loop.
JRuby and TruffleRuby have true parallel threads without a GIL. If you use either of these runtimes, background threads are scheduled by the JVM’s thread scheduler and the event loop does not starve them. The patterns in this lesson still work, but the timer fix is not strictly necessary.
The fix: give threads time
The solution is a global timer that fires every 25ms and calls Thread.pass — explicitly telling the Ruby scheduler to switch to another thread:
|
|
This creates regular windows during which background threads can run. The 25ms interval is taken from the official wxRuby3 threading sample — frequent enough to keep threads responsive, infrequent enough not to degrade UI performance.
Always add this line when your app uses background threads. Without it, threads will appear to hang or update the UI with long unpredictable delays.
The cardinal rule
Never touch UI widgets from a background thread.
wxRuby3’s widget methods are not thread-safe. Calling them from any thread other than the main thread causes crashes, corruption, or silent failures. Every UI update — progress bar, label text, button state — must happen on the main thread.
The patterns below all follow this rule.
Pattern 1 — call_after (simplest)
call_after schedules a block to run on the main thread at the next safe opportunity. It is the simplest way to update the UI from a background thread.
|
|
Key points:
call_afteris called onself(the frame) — it is a method onWx::EvtHandler- Batch your
call_aftercalls — posting one per iteration floods the callback queue. Update every N steps instead. - The
@cancelledflag is a simple boolean. Setting it from the main thread and reading it from the background thread is safe because reading/writing a boolean is atomic in MRI Ruby. - Always set
@cancelled = trueinon_closeso the thread exits cleanly when the window closes.
Pattern 2 — Custom events with queue_event
The canonical wxWidgets approach is to define a custom event class and post instances of it from the background thread using queue_event. This is thread-safe and completely decoupled — the thread knows nothing about which widgets to update.
|
|
The thread captures frame = self before starting — a local variable in the block. It then posts typed ProgressEvent objects via frame.event_handler.queue_event. The frame handles them with evt_progress, reading the event’s data to update the UI. The thread never references @progress, @log, or any widget directly.
This pattern scales well for complex applications where multiple threads post different event types to the same frame.
Pattern 3 — Thread::Queue with on_idle
A third approach uses Ruby’s built-in Thread::Queue and wxRuby3’s idle event. The background thread pushes data into the queue; the idle handler drains it on the main thread. No wxRuby3 threading mechanism is involved at all.
|
|
Thread::Queue is thread-safe by design — no extra synchronisation needed. The idle handler uses shift(true) (non-blocking) with a rescue nil to drain all available items without waiting. event.request_more keeps idle events firing while work is in progress; event.skip passes the idle event up so wxRuby3’s own idle processing continues.
Pattern 4 — Fibers (cooperative multitasking)
Fibers are not threads — they run on the main thread and yield control explicitly. This means no GIL issues and no Thread.pass timer needed. The trade-off is that you must break your work into small chunks and yield between them.
|
|
Notice that Wx::Timer.every(25) { Thread.pass } is not needed here — fibers run on the main thread and the idle event drives their execution. Each call to on_idle resumes the fiber for one step, then returns control to the event loop. event.request_more ensures idle events keep firing.
Fibers work well for CPU-bound work that can be broken into small chunks. They are less suitable for IO-bound work where you need to wait for a network response or file read — use threads for those.
Choosing the right pattern
| Pattern | Best for | GIL timer needed |
|---|---|---|
call_after |
Simple progress updates, most common case | Yes |
queue_event |
Complex apps, multiple thread types, decoupled design | Yes |
Thread::Queue + on_idle |
Pure Ruby preference, no wxRuby3 event classes | Yes |
| Fibers | CPU-bound work that can be chunked | No |
Thread safety checklist
Before shipping any threaded code, verify:
-
Wx::Timer.every(25) { Thread.pass }is inWx::App.run(MRI Ruby) - No UI widget is touched directly from a background thread
- All UI updates go through
call_after,queue_event, orThread::Queue+ idle -
@cancelled = trueis set inon_closeso threads exit cleanly - The background thread checks
@cancelledregularly -
call_afterupdates are batched — not posted on every iteration - Long-running work is in the thread, not in
call_afterblocks
When not to use threads
Not every slow operation needs a thread. If the operation completes in under 200ms on typical hardware, the freeze is imperceptible. Use Wx::BusyCursor to signal that work is happening:
|
|
The cursor reverts automatically when the block exits.
Previous: File I/O and preferences | Next: Capstone: file processor