Skip to content

Threading Architecture

Phawit Pornwattanakul edited this page Jun 19, 2026 · 6 revisions

Execution mode

Sakura framework is built for versatile usage from simple application to game that required high-precision and low-latency like rhythm games. To achieve that, Sakura implemented the highly dynamically update loop that can dynamically scale from a single core to four independent thread that can switch in runtime without required for restarting the application.

  • Multi-threaded mode : Framework distribute the workload across four independent threads (Input, Audio, Update and Draw) to provide maximum performance and low latency. Ideal for game that required high-precision and low-latency like rhythm games.

  • Single-threaded mode : All four workload (Input, Audio, Update and Draw) are executed in an interleaved manner on a single thread. Useful for normal application, low performance environment or some platform that has a strict threading constraint. Or the game that doesn't required high-precision and low-latency like visual novel or turn-based strategy games. The input will be slightly delayed but should not noticeable for most of the game that doesn't required low-latency input.

Note : The execution mode can be switched in runtime by pressing Ctrl + F7 and the settings will be saved in FrameworkConfigManager so it will persist between application restart.

Frame limiter

Sakura support a dynamic frame limiting system for balancing the performance and the resource usage of the hardware. Can adjust via Host.FrameLimiter reactive at the runtime. The limiter calculate the target framerate by using information from the display's native refresh rate or default to 60 Hz if the information is not available.

  • VSync : Locks the draw thread to the display's refresh rate and enable hardware vertical synchronization on the window to eliminate the screen tearing.
  • Limit2x (Default) : Targets 2x the monitor's refresh rate (e.g. 120 FPS for 60 Hz monitor)
  • Limit4x : Targets 4x the monitor's refresh rate (e.g. 240 FPS for 60 Hz monitor)
  • Limit8x : Targets 8x the monitor's refresh rate (e.g. 480 FPS for 60 Hz monitor)
  • Unlimited : Uncap the draw thread to run as fast as the GPU allow.

Note : Framework's default keybind for switching frame limiter is Ctrl + F10 and the settings will be saved in FrameworkConfigManager so it will persist between application restart.

Note about unlimited mode : In unlimited mode, the draw thread will run as fast as the GPU allow which can cause thermal throttling and OS starvation. So we recommend to use this mode only for benchmarking only. So to prevent this problem about user complaint on unlimited mode, Sakura limit a maximum CPU frequency to 1000 Hz for all thread (since some display, the Limit8x can exceed 1000 Hz), to unblock this limit you can set HostOptions.LimitUnlimitedUpdateRate to false.

Thread workload

  1. Input thread : Run on the main OS thread since windowing toolkit required that the window events must be polled on the thread that created the window. So the main OS thread act as the input thread

    • Target frequency : 1000 Hz
    • Responsibility : Pumps OS-level events (mouse, keyboard, window, resizes) via SDL3
    • Safety : The framework will pack the event as an action and pushes in a thread-safe concurrent queue (inputQueue)
  2. Audio thread : Independent thread for manage the framework's audio subsystem.

    • Target frequency : 1000 Hz
    • Responsibility : Interface directly witn the underlying audio backend (currently BASS)
    • Safety : Audio component like track and samples cannot be mutated directly from other threads. State changes (play, pause, volume adjustment) will be pushed to the audio thread via a thread-safe EnqueueAction queue.
  3. Update thread : Heart of the application's logic

    • Target frequency : Frame limiter * 2
    • Responsibility : Flushes the input queue, processing scheduler, evaluate transformations and executes Update() in the draw heirarchy
    • Safety : At the end of its loop, it will generate a lightweight snapshot of the visual state (DrawNode) and safely hands it of to the FrameBufferManager
  4. Draw thread : Thread responsible for GPU communication

    • Frequency : Frame limiter
    • Responsibility : Consume the DrawNode snapshot generated from update thread, interpolates vertex positions based on timing variances and send draw call to the GPU backend via the renderer (currently OpenGL)
    • Safety: The OpenGL context is strictly bound to this thread during multi-thread execution.

Thread Synchronization and Safety

To prevent race conditions and ensure thread safety, Sakura utilized several strict synchronization mechanisms:

  • State seperation (DrawNode) : the update and draw thread never touch the same memory at the same time. The mutable draw heirarchy is exclusively owned by the update thread. When drawing is required, the update thread will generate a DrawNode that's a lightweight, visual-only snapshot of the drawable's current frame data. The draw thread will only touch the DrawNode tree.

  • Triple buffering : The handoff of the DrawNode managed by FrameBufferManager using a triple-buffering system.

    • Buffer A : Currently being written to by the update thread
    • Buffer B : Currently being read by the draw thread
    • Buffer C : Waiting to be drawn, allowing update thread to immediately begin the next frame without waiting waiting for GPU.
  • Graphics context handoff : The OpenGL context can be only be active on one thread at a time. When swapping from single-thread to multi-thread mode, the main thread explicitly clear the graphics context (ClearCurrent) allowing the draw thread to claim it (MakeCurrent) when it initialized. When switch back, this can safely reversed.

Schedulers and Thread Routing

It's common to need to mutate the state of the framework's component from an asynchronous callback or from different thread. Sakura handles this via strict routing mechanisms.

  • The Update scheduler : Every drawable contains a Scheduler. If a background task (e.g. web request, file load) need to change a UI element, it must enqueue the action via Scheduler.Add(). The update thread will execute this action safely during its next loop.
  • The draw thread queue : Background thread cannot load textures directly into OpenGL, when an image finishes decoding on a background ThreadPool thread, The actual texture upload is queue from the draw thread using Renderer.ScheduleToDrawThread()
  • Asynchronous loading : See below

When and where needed to use

  1. ScheduleToMainThread

    • Target thread : The true main thread that boot the application executable, initialize SDL and run the main window loops
    • When to use : Interacting directly with the native host OS window boundary (e.g. Modifying Window.Title, toggle borderless mode, load native file picker)
    • Why it's matter : Desktop OS strictly demand that window layout adjustment need to hapen exclusively on the primary thread that spin them up. Attempting to change this on other thread will cause the OS panic.
  2. Scheduler.Schedule / Scheduler.Add

    • Target thread : The update thread (framework logic thread)
    • When to use : Anything that alters the state of the UI element and UI tree, components or layout properties that need to happen synchronously with the update tick. (e.g. Remove drawable from container, run delayed action via Scheduler.AddDelayed(), animate component)
    • Why it's matter : If background thread try to mutate the active visual tree while the update tick loop is going in it will cause a collision exception. Use Scheduler to ensure nutation happen safely between update tick.
  3. Renderer.ScheduleToDrawThread

    • Target thread : The draw thread (GPU communication thread)
    • When to use : Anything that need to talk directly to the GPU API (e.g. Upload texture, clear GPU resource like texture or shader)
    • Why it's matter : Graphics pipeline work via an exclusive binding process (MakeCurrent). Your GPU context live purely on the draw thread, if other thread attempts to allocate or delete a texture handle, the graphic driver will return an invalid state error, result in an instant crash (Segmentation fault / Error 139).
  4. AudioManager.EnqueueAction

    • Target thread : The audio thread (Audio processing thread)
    • When to use : Adjust properties of sounds, sample and channel configurations managed by the audio subsystem like BASS (e.g. Change track volume, pause/resume sample, adjust pan)
    • Why it's matter: Audio backends like BASS runs its own native background threads. If you alter sound volumes on the core logic in the exact microsecond BASS trying to read that sample state to mix audio buffers, it can cause a thread race conditions lead th a crash. Queuing these tasks locks the timing down flawlessly.

TL;DR : You can process a calculation anythere you like (like use Task.Run()) but always route the final assignment to the correct thread scheduler.

Scheduler Target Thread Primary Job What breaks if you do it wrong?
ScheduleToMainThread OS Main UI Thread Operating system window controls, native desktop integrations. NSInternalInconsistencyException/ OS window thread panics.
Scheduler.Schedule Game Update Thread Modifying scene graphs, changing gameplay variables, frame timing. InvalidOperationException(Collection modified concurrent errors).
ScheduleToDrawThread Graphics/Draw Thread Generating textures, freeing GPU buffers, talking directly to OpenGL. Error 139 (Segmentation Fault) due to missing active graphics context.
EnqueueAction Audio Processing Thread Tweaking BASS stream properties (volume, pan, playback state). BASS_ERROR_HANDLE or weird pop sound

Note : To prevent a GC spikes in scheduler when run a multithread since GC is the worst enemy when create a game especially Gen 2 GC. So when you pass an inline lambda to the scheduler that uses a local variable or class member, C# allocates a hidden "closure" class on the managed heap. If you do this repeatedly (e.g. frequent called method or an update loop) you will trigger a massive gen 0 GC pressure that can lead to micro stutter.

// ❌ BAD : Allocating a hidden closure object on the heap every time.
public void TriggerFade()
{
    // this creates a new allocation every time it is called
    Scheduler.Add(() => this.Alpha = 0.5f); 
}

// ✅ GOOD : Cache the delegate if you need to schedule it frequently.

private Action fadeAction;

public override void Load()
{
    base.Load();
    // cache the action once during initialization
    fadeAction = () => this.Alpha = 0.5f; 
}

public void TriggerFade()
{
    Scheduler.Add(fadeAction); // zero allocation
}

Asynchronous loading

Sakura put a heavy emphasis on non-blocking loading to ensure that the update and draw thread remain fluid, preventing stuttering and frame drops. Whenever a component requires heavy initialization (e.g. parsing files, allocating large arrays, complex nested layouts) it should be loaded asynchronously.

The framework handles this safely via the LoadComponentAsync<T>() method which is available on all Drawable components.

Basic usage

When you call LoadComponentAsync, the framework offloads the component's Load() method to a background thread. Once the background initialization finishes, the framework automatically uses the target's Scheduler to safely route your callback back to the update thread.

public class MainMenuScreen : Container
{
    private LoadingSpinner spinner;

    public MainMenuScreen()
    {
        // added synchronously because it is lightweight
        Add(spinner = new LoadingSpinner { 
            Anchor = Anchor.Centre 
        });
    }

    public void OnPlayButtonClicked()
    {
        spinner.Show();
        var gameplayScreen = new GameplayScreen();

        // this is fire and forget, so the spinner will spin until loading finishes
        LoadComponentAsync(gameplayScreen, loadedScreen => 
        {
            // this callback safely executes on the main Update thread once ready.
            spinner.Hide();
            Add(loadedScreen);
        });
    }
}

Critical rule : Never block the update thread by calling .Wait() or Thread.Sleep() on the returned Task. Always rely on the callback action to handle the component once it's ready.

Long-Running Loads with [LongRunningLoad]

For components that perform exceptionally heavy I/O operations like network requests or large file parsing or downloads. You should mark the class with the [LongRunningLoad] attribute.

[LongRunningLoad]
public class ReallyLongLeaderboard : Container
{
    public override void Load()
    {
        base.Load();
        
        // really heavy operation!
        FetchDataFromApi(); 
    }
}

Attaching this attribute enforces two strict behaviors to protect the framework's architecture:

  • Dedicated thread pool isolation: [LongRunningLoad] components are routed to a specialized LongRunning task pool. This ensures that a stalled network request doesn't consume standard worker threads and starve normal async UI components from loading.
  • Strict async enforcement: You must load these components via LoadComponentAsync. If you attempt to load them synchronously (e.g., by directly calling Add(new ReallyLongLeaderboard())), the framework will aggressively throw an InvalidOperationException and abort the addition. This guarantees that heavy payloads can never accidentally freeze the main update loop.

Clone this wiki locally