协助UI分派程序处理大量的方法调用。

3
以下帖子比预期的稍微长了一些,我为此道歉,但也许您会发现它有趣,也许您有帮助我的想法。
我正在开发一个小应用程序,其GUI由多个列表控件组成。每个列表控件都有一个与之关联的线程,该线程永久地生成字符串并将其添加到列表中。
为了允许不同线程更新列表控件,我构建了一个扩展的ObservableCollection,它异步调用所有操作到UI调度程序,这很好地解决了问题。以下是该类的代码片段,以插入操作为例:
public class ThreadSaveObservableCollection<T> : ObservableCollection<T> {

    private int _index;

    private Dispatcher _uiDispatcher;
    private ReaderWriterLock _rwLock;

    // ...

    private bool _insertRegFlag;

    new public void Insert (int index, T item) {

        if (Thread.CurrentThread == _uiDispatcher.Thread) {

            insert_(index, item);
        } else {

            if (_insertRegFlag) { }
            else {

                BufferedInvoker.RegisterMethod(_index + "." + (int)Methods.Insert);
                _insertRegFlag = true;
            }

            BufferedInvoker.AddInvocation(new Invocation<int, T> { Ident = _index + "." + (int)Methods.Insert, Dispatcher = _uiDispatcher, Priority = DispatcherPriority.Normal, Param1 = index, Param2 = item, Method = new Action<int, T>(insert_) });
        }
    }

    private void insert_ (int index, T item) {

        _rwLock.AcquireWriterLock(Timeout.Infinite);

        DateTime timeStampA = DateTime.Now;

        base.Insert(index, item);

        DateTime timeStampB = DateTime.Now;

        BufferedInvoker.Returned(_index + "." + (int)Methods.Insert, timeStampB.Subtract(timeStampA).TotalMilliseconds);

        _rwLock.ReleaseWriterLock();
    }

    // ...
}

为了模拟待处理调用任务的调用形式,我创建了以下内容:
public interface IInvocation {

    string Ident { get; set; }
    void Invoke ();
}

public struct Invocation : IInvocation {

    public string Ident { get; set; }
    public Dispatcher Dispatcher { get; set; }
    public DispatcherPriority Priority { get; set; }
    public Delegate Method { get; set; }

    public void Invoke () {

        Dispatcher.BeginInvoke(Method, Priority, new object[] { });
    }
}

我的问题是因为我在UI Dispatcher上调用了大量的方法(大约有8到10个线程不断地生产字符串,然后将它们添加到它们的列表中),所以我的UI失去了响应用户I/O(例如使用鼠标)的能力,大约30秒后就无法接受任何用户交互,一分钟后完全无法响应。
为了解决这个问题,我编写了一种缓冲调用程序,负责缓存所有我想要调用到UI调度程序的方法调用,然后以受控方式进行调用,例如在调用之间有些延迟,以避免淹没UI调度程序。
下面是一些代码来说明我的做法(请参见代码段后的描述)。
public static class BufferedInvoker {

    private static long _invoked;
    private static long _returned;
    private static long _pending;
    private static bool _isInbalanced;

    private static List<IInvocation> _workLoad;
    private static Queue<IInvocation> _queue;

    private static Thread _enqueuingThread;
    private static Thread _dequeuingThread;
    private static ManualResetEvent _terminateSignal;
    private static ManualResetEvent _enqueuSignal;
    private static ManualResetEvent _dequeueSignal;

    public static void AddInvocation (IInvocation invocation) {

        lock (_workLoad) {

            _workLoad.Add(invocation);
            _enqueuSignal.Set();
        }
    }

    private static void _enqueuing () {

        while (!_terminateSignal.WaitOne(0, false)) {

            if (_enqueuSignal.WaitOne()) {

                lock (_workLoad) {

                    lock (_queue) {

                        if (_workLoad.Count == 0 || _queue.Count == 20) {

                            _enqueuSignal.Reset();
                            continue;
                        }

                        IInvocation item = _workLoad[0];
                        _workLoad.RemoveAt(0);
                        _queue.Enqueue(item);

                        if (_queue.Count == 1) _dequeueSignal.Set();
                    }
                }
            }
        }
    }

    private static void _dequeuing () {

        while (!_terminateSignal.WaitOne(0, false)) {

            if (_dequeueSignal.WaitOne()) {

                lock (_queue) {

                    if (_queue.Count == 0) {

                        _dequeueSignal.Reset();
                        continue;
                    }

                    Thread.Sleep(delay);

                    IInvocation i = _queue.Dequeue();
                    i.Invoke();

                    _invoked++;
                    _waiting = _triggered - _invoked;
                }
            }
        }
    }

    public static void Returned (string ident, double duration) {

        _returned++;

        // ...
    }
}

这个 BufferedInvoker 的想法是,ObservableCollections 不会自己调用操作,而是调用 BufferedInvokerAddInvocation 方法,将 invocation-task 放入其 _workload 列表中。然后,BufferedInvoker 维护两个“内部”线程,这些线程在一个 _queue 上运作 - 一个线程从 _workload 列表中获取调用并将它们放入 _queue 中,另一个线程将调用从 _queue 中取出,并依次进行调用。
因此,这不过是两个缓冲区,用于存储 待处理的调用任务,以 延迟 它们的实际调用。我还统计了由 _dequeuing 线程实际调用的 调用任务 数量(即长整型 _invoked)和已经从其执行中返回的方法数量(每个 ObservableCollection 中的方法在完成执行时调用 BufferedInvokerReturned() 方法 - 这个数字存储在 _returned 变量中)。
我的想法是通过 (_invoked - _returned) 获取待处理调用的数量,以感受 UI 调度程序的 工作量 - 但令人惊讶的是,_pending 总是低于1或2。
所以我的问题现在是,尽管我正在延迟将方法调用到 UI 调度程序(使用 Thread.Sleep(delay)),但应用程序在一段时间后开始滞后,这反映了 UI 处理用户 I/O 的负担过重。
但是 - 这就是我真正想知道的 - 即使 UI 已经被冻结,_pending 计数器也从未达到高值,大多数时候它都是0。
所以现在我必须找到: (1) 测量 UI 调度程序的工作量以确定 UI 调度程序超负荷的点; (2) 对此采取措施。
非常感谢您阅读到这里,希望您有任何想法如何在 UI 调度程序上调用任意数量的方法而不会使其过载。

2
与其缓冲调用,您应该尝试缓冲数据,然后为每个批次调用单个操作。这样可以减轻 UI 线程的负担。 - dlev
我同意@dlev的看法。你正在添加大量的线程(和Sleep!)开销,但这并不能减少工作量。批处理字符串,也许可以为Dispatcher.Timer制作包以进行处理。 - H H
我正在编写的应用程序旨在观察特定算法的运行时行为,这是通过维护算法执行步骤的日志来完成的。因此,我无法摆脱添加到列表中的所有单个字符串,因为每个字符串都代表了算法执行的日志条目的反映。 - marc wellman
1
我正在编写的应用程序的目的是观察特定算法的运行时行为,这是通过维护算法执行步骤的日志来完成的。您是否考虑过编写日志文件(或者更好的方法是每个线程一个日志文件),并且让UI线程定期(比如说,使用计时器)去读取该日志文件,并将其内容填充到其结构中? - Chris Shain
2
如果您没有时间将数据写入磁盘,那么肯定不应该尝试将所有消息发送到UI线程<g>。 我自己也遇到过这个问题,由于UI无法跟上更新treeViews的速度,所以所有对象都排队在Windows消息中,因此我对您的痛苦感同身受。 我能想到的唯一解决方案是,基本上是同步更新,以防止UI消息泛滥。 - Martin James
显示剩余2条评论
1个回答

3

我注意到你睡觉时锁定了队列,这意味着在睡眠期间没有人可以排队,使队列变得无用。

应用程序之所以不会滞后,不是因为队列繁忙,而是因为锁定时间几乎总是很长。

我认为最好删除所有手动实现的队列、锁和监视器,只使用内置的ConcurrentQueue。每个UI控件和线程一个队列,每个队列一个定时器。

无论如何,这是我的建议:

ConcurrentQueue<Item> queue = new ...;

//timer pulls every 100ms or so
var timer = new Timer(_ => {
 var localItems = new List<Item>();
 while(queue.TryDequeue(...)) { localItems.Add(...); }
 if(localItems.Count != 0) { pushToUI(localItems); }
});

//producer pushes unlimited amounts
new Thread(() => { while(true) queue.Enqueue(...); });

简单。

感谢回复!我认为使用Thread.Sleep()语句时并不会完全停止整个应用程序,而只会暂停负责调用的_dequeuing线程。这是我的想法,通过稍微延迟调用来避免对UI界面造成过多压力。 - marc wellman
3
你使用了锁 _queue,该锁被 enqueuedequeue 两个方法共同使用。 - usr
你说得太对了!我完全忽略了那个问题!!这显然是我实现中一个有趣(错误)的问题。我需要重新考虑排队的想法,并且会仔细研究你建议的ConcurrentQueue。谢谢你,伙计! - marc wellman
我已经添加了队列的伪代码。我认为它是内置功能的优雅组合。以你的方式解决这个问题对人类来说是不可能的。我做不到,所以我也不会尝试。人类无法在锁和同步的丛林中生存下来。我们只是无法完全做到。 - usr
给我一些时间重新考虑这个问题,如果解决了我的问题,你可以给我你的邮寄地址,这样我就可以送你花了 ;) - marc wellman
如果生产者线程推送的数据过于庞大(例如100k项),您可以对其进行汇总或从中选择一小部分随机项添加到UI中。在我的伪代码中,您可以在while循环之后立即执行此操作。 - usr

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接