Multi UI-threading and databinding issues

267 views Asked by At

I'm having issues updating the UI threads. Application is running 1 UI thread for each form, meaning just using SyncronizationContext with the UI thread doesn't work. I'm doing this for looping update performance as well as modal popup possibilities like select value before you can use the form.

How I'm creating it in ApplicationContext:

public AppContext()
    {
        
    foreach(var form in activeForms)
            {
                
                form.Load += Form_Load;
                form.FormClosed += Form_FormClosed;
                StartFormInSeparateThread(form);
                //form.Show();
            }
}
private void StartFormInSeparateThread(Form form)
    {
        Thread thread = new Thread(() =>
        {
            
            Application.Run(form);
        });
        thread.ApartmentState = ApartmentState.STA;
        thread.Start();
        
    }

There are controls on each for that are databound and updating with values from the same databound object. Controls being Labels and DataGridview (bound to a bindinglist). What would be ideal is having the Bindinglist threadsafe and execute on these multiple UI threads. Found some examples that I attempted like this:

List<SynchronizationContext> listctx = new();

public ThreadSafeBindingList2()
{
    //SynchronizationContext ctx = SynchronizationContext.Current;
    //listctx.Add(ctx);
}
public void SyncContxt()
{
    SynchronizationContext ctx = SynchronizationContext.Current;
    listctx.Add(ctx);
}
protected override void OnAddingNew(AddingNewEventArgs e)
{
    for (int i = 0; i < listctx.Count; i++)
    {
        if (listctx[i] == null)
        {
            BaseAddingNew(e);
        }
        else
        {
            listctx[i].Send(delegate
            {
                BaseAddingNew(e);
            }, null);
        }
    }
}
void BaseAddingNew(AddingNewEventArgs e)
{ 
    base.OnAddingNew(e); 
}

protected override void OnListChanged(ListChangedEventArgs e)
{
    for (int i = 0; i < listctx.Count; i++)
    {
        if (listctx[i] == null)
        {
            BaseListChanged(e);
        }
        else
        {
            listctx[i].Send(delegate
            {
                
                BaseListChanged(e);
            }, null);
        }
    }
}

void BaseListChanged(ListChangedEventArgs e)
{
    base.OnListChanged(e); 
} 

I'm also using a static class as a data property change hub for all controls so I don't change the databinding source more than once (again due to performance), where I have a background worker "ticking" every 1-3 seconds depending on system load:

 private static void BackgroundWorker_DoWork(object? sender, DoWorkEventArgs e)
    {
        if (timerStart is false)
        {
            Thread.Sleep(6000);
            timerStart = true;
        }
        while (DisplayTimerUpdateBGW.CancellationPending is false)
        {
            
            //UIThread.Post((object stat) => //Send
            //{
            threadSleepTimer = OrderList.Where(x => x.Status != OrderOrderlineStatus.Claimed).ToList().Count > 20 ? 2000 : 1000;
            if (OrderList.Count > 40)
                threadSleepTimer = 3000;

            UpdateDisplayTimer();

            //}, null);

            Thread.Sleep(threadSleepTimer);
        }
    } 
 private static void UpdateDisplayTimer()
    {
        var displayLoopStartTimer = DateTime.Now;
        TimeSpan displayLoopEndTimer = new();

        Span<int> orderID = CollectionsMarshal.AsSpan(OrderList.Select(x => x.ID).ToList());
        for (int i = 0; i < orderID.Length; i++)
        {
            OrderModel order = OrderList[i];
            order.OrderInfo = "Ble";
            Span<int> OrderLineID = CollectionsMarshal.AsSpan(order.Orderlines.Select(x => x.Id).ToList());
            for (int j = 0; j < OrderLineID.Length; j++)
            {
                OrderlineModel ol = order.Orderlines[j];
                TimeSpan TotalElapsedTime = ol.OrderlineCompletedTimeStamp != null ? (TimeSpan)(ol.OrderlineCompletedTimeStamp - ol.OrderlineReceivedTimeStamp) : DateTime.Now - ol.OrderlineReceivedTimeStamp;
                string displaytimerValue = "";

                if (ol.OrderlineCompletedTimeStamp == null)
                    displaytimerValue = TotalElapsedTime.ToString(@"mm\:ss");
                else
                    displaytimerValue = $" {(DateTime.Now - ol.OrderlineCompletedTimeStamp)?.ToString(@"mm\:ss")} \n({TotalElapsedTime.ToString(@"mm\:ss")})";

                ol.DisplayTimer = displaytimerValue;

            }
        }
    }

Ideally I want to have the labels and datagridview properties databindings so that I can have INotifyPropertyChanged just updating these relevant properties in all UI threads.

Any help would be appreciated!

1

There are 1 answers

7
IV. On

One of many ways to look at this is that there's only one display area (albeit which might consist of many screens) and only one element of it can change at any given moment. To my way of thinking, this means that having more than one UI thread can often be self defeating (unless your UI is testing another UI). And since the machine has some finite number of cores, having a very large number of threads (whether of the UI or worker variety) means you can start to have a lot of overhead marshalling the context as threads switch off.

If we wanted to make a Minimal Reproducible Example that has 10 Form objects executing continuous "mock update" tasks in parallel, what we could do instead of the "data property change hub" you mentioned is to implement INotifyPropertyChanged in those form classes with static PropertyChanged event that gets fired when the update occurs. To mock data binding where FormWithLongRunningTask is the binding source, the main form subscribes to the PropertyChanged event and adds a new Record to the BindingList<Record> by identifying the sender and inspecting e to determine which property has changed. In this case, if the property is TimeStamp, the received data is marshalled onto the one-and-only UI thread to display the result in the DataGridView.

public partial class MainForm : Form
{
    public MainForm() => InitializeComponent();
    protected override void OnLoad(EventArgs e)
    {
        base.OnLoad(e);
        // Subscribe to the static event here.
        FormWithLongRunningTask.PropertyChanged += onAnyFWLRTPropertyChanged;
        // Start up the 10 forms which will do "popcorn" updates.
        for (int i = 0; i < 10; i++)
        {
            new FormWithLongRunningTask { Name = $"Form{i}" }.Show(this);
        }
    }
    private void onAnyFWLRTPropertyChanged(object? sender, PropertyChangedEventArgs e)
    {
        if (sender is FormWithLongRunningTask form)
        {
            BeginInvoke(() =>
            {
                switch (e.PropertyName)
                {
                    case nameof(FormWithLongRunningTask.TimeStamp):
                        dataGridViewEx.DataSource.Add(new Record
                        {
                            Sender = form.Name,
                            TimeStamp = form.TimeStamp,
                        });
                        break;
                    default:
                        break;
                }
            });
        }
    }
}

main form with DataGridView


The DataGridView on the main form uses this custom class:

class DataGridViewEx : DataGridView
{
    public new BindingList<Record> DataSource { get; } = new BindingList<Record>();
    protected override void OnHandleCreated(EventArgs e)
    {
        base.OnHandleCreated(e);
        if (!DesignMode)
        {
            base.DataSource = this.DataSource;
            AllowUserToAddRows = false;

            #region F O R M A T    C O L U M N S
            DataSource.Add(new Record());
            Columns[nameof(Record.Sender)].AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill;
            var col = Columns[nameof(Record.TimeStamp)];
            col.AutoSizeMode = DataGridViewAutoSizeColumnMode.AllCells;
            col.DefaultCellStyle.Format = "hh:mm:ss tt";
            DataSource.Clear();
            #endregion F O R M A T    C O L U M N S
        }
    }
    protected override void OnCellPainting(DataGridViewCellPaintingEventArgs e)
    {
        base.OnCellPainting(e);
        if ((e.RowIndex > -1) && (e.RowIndex < DataSource.Count))
        {
            var record = DataSource[e.RowIndex];
            var color = _colors[int.Parse(record.Sender.Replace("Form", string.Empty))];
            e.CellStyle.ForeColor = color;
            if (e.ColumnIndex > 0)
            {
                CurrentCell = this[0, e.RowIndex];
            }
        }
    }
    Color[] _colors = new Color[]
    {
        Color.Black, Color.Blue, Color.Green, Color.LightSalmon, Color.SeaGreen,
        Color.BlueViolet, Color.DarkCyan, Color.Maroon, Color.Chocolate, Color.DarkKhaki
    };
}    
class Record
{
    public string Sender { get; set; } = string.Empty;
    public DateTime TimeStamp { get; set; }
}

The 'other' 10 forms use this class which mocks a binding source like this:

public partial class FormWithLongRunningTask : Form, INotifyPropertyChanged
{
    static Random _rando = new Random(8);
    public FormWithLongRunningTask() => InitializeComponent();

    protected override void OnLoad(EventArgs e)
    {
        base.OnLoad(e);
        _ = runRandomDelayLoop();
    }
    private async Task runRandomDelayLoop()
    {
        while(true)
        {
            try
            {
                await Task.Delay(TimeSpan.FromSeconds(_rando.NextDouble() * 10));
                TimeStamp = DateTime.Now;
                Text = $"@ {TimeStamp.ToLongTimeString()}";
                BringToFront();
            }
            catch (ObjectDisposedException)
            {
            }
        }
    }
    DateTime _timeStamp = DateTime.Now;
    public DateTime TimeStamp
    {
        get => _timeStamp;
        set
        {
            if (!Equals(_timeStamp, value))
            {
                _timeStamp = value;
                OnPropertyChanged();
            }
        }
    }
    protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
    event PropertyChangedEventHandler? INotifyPropertyChanged.PropertyChanged
    {
        add => PropertyChanged += value;
        remove => PropertyChanged -= value;
    }
    public static event PropertyChangedEventHandler? PropertyChanged;
}

I believe that there's no 'right' answer to your question but I hope there's something here that might move things forward for you.