top of page

Create ESAPI Scripts That Don't Freeze the UI

ESAPI is Single-Threaded


In a binary plug-in script, any use of the Eclipse Scripting API (ESAPI) must be made on the same thread as the Execute method in the Script class. But if your user interface (UI) is also on this thread, any time-consuming operations using ESAPI will freeze the UI until the operation ends. (For an excellent tutorial on threading, see Threading in C#.)


One solution is to run the UI on a different thread and execute any ESAPI operations on the original thread. In this blog post, I'll show you one way to do that. I'll use a simple example: when the user presses a button, we'll calculate the mean dose of the current plan while showing the user our progress.


The EsapiWorker Class


In WPF applications, the UI uses a Dispatcher object to handle user events (e.g., a button click). The Dispatcher maintains a queue of such tasks to execute on the thread it is associated with. The Dispatcher can actually execute any code sent to it, not just user events. We're going to use the main thread's Dispatcher to execute ESAPI operations. We'll wrap this logic in a class:


public class EsapiWorker
{
    private readonly ScriptContext _scriptContext;
    private readonly Dispatcher _dispatcher;

    public EsapiWorker(ScriptContext scriptContext)
    {
        _scriptContext = scriptContext;
        _dispatcher = Dispatcher.CurrentDispatcher;
    }

    public void Run(Action<ScriptContext> a)
    {
        _dispatcher.BeginInvoke(a, _scriptContext);
    }
}

The constructor takes the ScriptContext object, which will be used later. It also obtains the CurrentDispatcher, which is the Dispatcher associated with the current thread. Therefore, we must instantiate the EsapiWorker on the main thread.


The Run method takes an Action with the ScriptContext as its parameter (we'll see why this parameter is helpful in a moment). In the Run method, we call BeginInvoke on the Dispatcher to execute the Action on the main thread. The Run method may be called from any thread, but the Action will always be executed on the main thread.


The UI Thread


Next, we need to create the UI thread and use it to show the main Window. Let's start by writing a method that will set up the thread and execute any Action we pass to it:


private void RunOnNewStaThread(Action a)
{
    Thread thread = new Thread(() =&gt; a());
    thread.SetApartmentState(ApartmentState.STA);
    thread.IsBackground = true;
    thread.Start();
}

For reasons I don't want to get into, the UI thread must be set to apartment state STA. We then set the thread as a background thread, so that it's automatically terminated if the main application ends. The Action that we will pass to the method (shown later) includes the creation of the Window.


The RunOnNewStaThread method will be called in the Execute method of the Script class (shown later). Once we start the new UI thread, the main thread will continue to execute until it reaches the end of the Execute method. However, this also ends the script!


Using the DispatcherFrame Class


In order to prevent the script from ending prematurely, we need to tell the main thread to wait until the Window in the UI thread is closed. One way to do this is by using a DispatcherFrame. This class allows us to create a new queue of tasks (called a "frame") within the main Dispatcher, and wait until the frame is no longer needed. Here's the final code in the Execute method of the Script class:


public void Execute(ScriptContext scriptContext)
{
    // The ESAPI worker needs to be created in the main thread
    var esapiWorker = new EsapiWorker(scriptContext);

    // This new queue of tasks will prevent the script
    // for exiting until the new window is closed
    DispatcherFrame frame = new DispatcherFrame();

    RunOnNewStaThread(() =>
    {
        // This method won't return until the window is closed
        InitializeAndStartMainWindow(esapiWorker);

        // End the queue so that the script can exit
        frame.Continue = false;
    });

    // Start the new queue, waiting until the window is closed
    Dispatcher.PushFrame(frame);
}

We create the frame before starting the new UI thread. As soon as the new thread is created, we "push" that frame on the main Dispatcher. Note that we tell the frame to stop (by setting frame.Continue to false) after the main Window is finished.


Showing the Window


The method to initialize and show the main Window is defined as follows:


private void InitializeAndStartMainWindow(EsapiWorker esapiWorker)
{
    var viewModel = new MainViewModel(esapiWorker);
    var mainWindow = new MainWindow(viewModel);
    mainWindow.ShowDialog();
}

First, we create the view model (see the Model-View-ViewModel pattern), which will take the EsapiWorker in order to use it (I'll show you how in a moment). Then, we create the Window and show it as a dialog. It's important to show it as a dialog so that this method doesn't return until the Window is closed.


Using the EsapiWorker and Updating the UI


Now let's actually use the EsapiWorker object in our view model. If you remember the example, we want to calculate the mean dose of a plan while showing its progress. Here's the method in the view model that does this:


public void CalculateMeanDose()
{
    _esapiWorker.Run(scriptContext =>
    {
        var dose = scriptContext.PlanSetup.Dose;
        var meanDose = new DoseValue(0.0, dose.DoseMax3D.Unit);

        for (int z = 0; z < dose.ZSize; z++)
        {
            Progress = (double)(z + 1) / dose.ZSize;

            var buffer = new int[dose.XSize, dose.YSize];
            dose.GetVoxels(z, buffer);

            for (int x = 0; x < dose.XSize; x++)
                for (int y = 0; y < dose.YSize; y++)
                    meanDose += dose.VoxelToDoseValue(buffer[x, y]);
        }

        meanDose /= dose.XSize * dose.YSize * dose.ZSize;
        MeanDose = meanDose;
    });
}

When we call the Run method, we pass it an Action that takes a ScriptContext as a parameter. This allows us to use the script context within our Action. Remember, this Action will be executed on the main thread because the EsapiWorker will send it to the main thread's Dispatcher.


At each dose plane, we update the Progress property of the view model. This property is data-bound to a progress bar in the Window, so it's automatically updated as the Progress value is changed. After the mean dose is calculated, the MeanDose property in the view model is set to the result. The MeanDose property is data-bound to a TextBlock.


The final step is to call the view model's CalculateMeanDose method when the calculate button is clicked. One way to do this is to call it from the button's Click handler.


When we run this script and start the calculation, the progress bar is updated as the calculation executes in the main thread. The UI is completely responsive during the calculation because the UI is on a separate thread. When the calculation finishes, the result is shown in a TextBlock.


Final Thoughts


The solution I've presented here is just one way to tackle the problem of multithreading with ESAPI. And it's not a perfect solution. I haven't had a chance to test all possible edge cases where it may fail. If you find another solution to this problem, please let me know!

Related Posts

See All

Comentários


bottom of page