Детектор блокировок UI в WPF c нотификацией

  • Tutorial


Приветствую!

Думаю что каждому из программистов попадалось приложение которое по тем или иным причинам блокировало UI. Причин у таких блокировок может быть множество, такие как: синхронные запросы к сервисам, выполнение долгих операций в UI треде и прочее.
В самом лучшем случае участки кода приводящие к блокировкам UI должны быть переписаны / исправлены, но это не всегда возможно по разным причинам и соответственно хочется получить некую серебряную пулю, которая сможет решить проблему с минимальной стоимостью.
О одной такой пуле и пойдет речь.

Подробности под катом.

Определяем что UI заблокирован

Собственно определение того что заблокирован UI сводится к простому решению запустить два счетчика. Первый счетчик работает в главном треде приложения и ставит временные метки при каждом срабатывании. Второй счетчик работает в фоновом треде и вычисляет разницу между текущим временем и временем установленным первым счетчиком. Если разница между временами превышает определенный лимит, выбрасывается событие о том что UI заблокирован и наоборот, если UI уже не заблокирован выбрасываем событие о том что приложение ожило.
Делается это так:
internal class BlockDetector
{
    bool _isBusy;

    private const int FreezeTimeLimit = 400;

    private readonly DispatcherTimer _foregroundTimer;

    private readonly Timer _backgroundTimer;

    private DateTime _lastForegroundTimerTickTime;

    public event Action UIBlocked;

    public event Action UIReleased;

    public BlockDetector()
    {
        _foregroundTimer = new DispatcherTimer{ Interval = TimeSpan.FromMilliseconds(FreezeTimeLimit / 2) };
        _foregroundTimer.Tick += ForegroundTimerTick;

        _backgroundTimer = new Timer(BackgroundTimerTick, null, FreezeTimeLimit, Timeout.Infinite);
    }

    private void BackgroundTimerTick(object someObject)
    {
        var totalMilliseconds = (DateTime.Now - _lastForegroundTimerTickTime).TotalMilliseconds;
        if (totalMilliseconds > FreezeTimeLimit && _isBusy == false)
        {
            _isBusy = true;
            Dispatcher.CurrentDispatcher.Invoke(() => UIBlocked()); ;
        }
        else
        {
            if (totalMilliseconds < FreezeTimeLimit && _isBusy)
            {
                _isBusy = false;
                Dispatcher.CurrentDispatcher.Invoke(() => UIReleased()); ;
            }

        }
        _backgroundTimer.Change(FreezeTimeLimit, Timeout.Infinite);
    }

    private void ForegroundTimerTick(object sender, EventArgs e)
    {
        _lastForegroundTimerTickTime = DateTime.Now;
    }

    public void Start()
    {
        _foregroundTimer.Start();
    }

    public void Stop()
    {
        _foregroundTimer.Stop();
        _backgroundTimer.Dispose();
    }
}


Сообщение о блокировке UI

Для того чтобы показать пользователю сообщение о том что приложение работает, подписываемся на события от класса BlockDetector и показываем новое окно с сообщением о заблокированном UI.

WPF разрешает создавать несколько UI тредов. Делается это так:
private void ShowNotify()
{
    var thread = new Thread((ThreadStart)delegate
    {
        // получаем ссылку на текущий диспетчер
        _threadDispacher = Dispatcher.CurrentDispatcher;
        
        SynchronizationContext.SetSynchronizationContext(new DispatcherSynchronizationContext(_threadDispacher));
        
        // создаем новое окно  
        _notifyWindow = _createWindowDelegate.Invoke();
        
        // подписываем на событие закрытия окна и завершаем текущий тред
        _notifyWindow.Closed += (sender,e) => _threadDispacher.BeginInvokeShutdown(DispatcherPriority.Background);
        _notifyWindow.Show();
        
        // запускаем обработку сообщений Windows для треда
        Dispatcher.Run();
    });

    thread.SetApartmentState(ApartmentState.STA);
    thread.IsBackground = true;
    thread.Start();
}


Делегат на создание окна нужен для того чтобы иметь возможность более гибкого подхода к окну нотификации.
Более подробно прочитать о создании окна в отдельном треде можно почитать в этой статье Launching a WPF Window in a Separate Thread

Результат
Необходимо оговорится что предложенное решение не является той самой серебряной пулей, которая подойдет абсолютно всем. Уверен, что в целом ряде случаев применить такое решение окажется невозможным по тем или иным причинам.
Посмотреть как это все работает можно на подготовленном мной демо-проекте: yadi.sk/d/WeIG1JvEhC2Hw

Всем спасибо!
Поделиться публикацией
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама
Комментарии 29
  • 0
    Интересный вариант. Теперь хорошо бы придумать автоматический способ обнаруживать такой блокирующий код, чтобы можно было его зарефакторить.
    • 0
      Можно попробовать Debugger.Launch, но вопрос что будет в стеке что делать дальше.
      • +1
        Наверное, задачу останова будет попроще решить.
        • +2
          Ну что вы. Я же не прошу точно указать причину ошибки. Достаточно было бы обнаружить факт блокировки и получить callstack этого потока, чтобы отправить его в качестве багрепорта дальше уже в ручном режиме разобраться. Без этого приведенный в статье способ выглядит как заглушка — «мы знаем что проблема может быть, но локализовать и исправить не можем, так что пока хоть так».
          • 0
            Буквально только что решал проблему блокировки UI, которая была из-за проброса Exception в UI thread. И там нет пользовательского стектрейса.
      • +3
        Отличный пост, приму на заметку.
        Небольшой комментарий не въедливости ради, а для того, чтобы у тех, кто будет использовать этот код, не произошёл «нежданчик»:
        Скрытый текст
        Эта строка:
        Dispatcher.CurrentDispatcher.Invoke(() => UIBlocked());
        

        и вторая для UIReleased — нет ни проверки на null, ни первичного переноса значения делегата в отдельную переменную (что настоятельно рекомендуется, тем более, что в этом месте гарантирована многопоточность).

        • +6
          Ну ещё можно посоветовать заменить
          _lastForegroundTimerTickTime = DateTime.Now;
          на
          _stopwatch = Stopwatch.StartNew();

          и
          var totalMilliseconds = (DateTime.Now - _lastForegroundTimerTickTime).TotalMilliseconds;
          на
          var totalMilliseconds = _stopwatch.ElapsedMilliseconds;
          Всё-таки мерить время через DateTime.Now как-то не очень прилично
          • –3
            Так это бенчмарки меряются Stopwatch'ем.
            Не вижу проблемы измерять сотни миллисекунд через DateTime.
            • +1
              Слепые тоже не видят. Но обычно на форумах этим не гордятся.
              Погрешность может быть очень большой. На моей памяти правда погрешность была в пределах 16мс, но я не писал под 95 винду. Там это было 55мс.

              И самое главное «как долго заняла операция» и «сколько сейчас времени» — это разные задачи. Средства очевидно тоже разные.
              • +1
                Ну, очевидно, погрешность в 16мс поломает всю задачу определения, повис ли UI-поток.
                Осталось выставить CPU Affinity, чтобы поток не скакал между ядрами, и прогреть кэш.
                • 0
                  То есть человек меняющий константу FreezeTimeLimit должен помнить что если он ее уменьшит до некого N при котором погрешность будет составлять критические для тов. withkittens скажем 10% — ему нужно будет заменить семантически менее корректное решение на семантически более корректное, потому что когда писали изначально — погрешность всех устраивала. Так?
                  • 0
                    Мне удивительно, как вы ратуете за Stopwatch с наносекундной точностью, когда как DispatcherTimer, использующийся в статье, имеет погрешность в 10мс минимум.
                    • 0
                      Отвечу сразу всем: спасибо за CodeReview, с большей частью комментариев полностью согласен. Узнал пару новых моментов.
              • +1
                А если пользователь изменит системное время?
                • +4
                  У вас может перевод часов на зимнее время и обратно случиться. Или сработать синхронизация времени по NTP. Или пользователь что-то поменяет. DateTime.Now подходит для ответа на вопрос, какое сейчас системное время, но не подходит для ответа на вопрос, сколько прошло времени с заданного момента в прошлом. Для этого используется монотонный счётчик времени, до которого на винде можно достучаться через GetTickCount64 и QueryPerformanceCounter. Второй используется в Stopwatch и использует HPET, но затратнее. Первый даёт меньшую точность, менее затратен, но нужно делать P/Invoke.

                  См.
                  github.com/akkadotnet/akka.net/issues/846
                  github.com/akkadotnet/akka.net/tree/dev/src/core/Akka/Util/MonotonicClock.cs
                  • 0
                    Вот.
                    Спасибо за адекватный и исчерпывающий ответ.
                    Теперь я могу признать, что был не прав.
            • +1
              Dispatcher.CurrentDispatcher.Invoke(() => UIBlocked());
              

              Я правильно понимаю, что BackgroundTimerTick вызывается на потоке ThreadPool-а, и вызов в нём Dispatcher.CurrentDispatcher порождает новый диспетчер, связанный с этим потоком? На мой взгляд, это довольно странно. Задачи, выполняемые на потоках пула, не должны влиять на состояние потоков, в которых они запускаются.
              • +2
                Я бы заранее создал отдельный UI-поток для таких случаев и передавал его в конструктор BlockDetector-а. Но вообще, прочитав Вашу статью, мне сначала показалось, что автор из моей команды — уж больно похоже на то, что мы сделали у себя)
              • 0
                Постоянно дергать DateTime.Now плохая идея. Это крайне медленная функция. Для таких задач лучше подходит DateTime.UtcNow
                • 0
                  Кроме того, использование DateTime.Now может привести к неожиданным результатам при переходе между зимним и летним временами или при NTP-синхронизации системного времени.
                • 0
                  (del)
                  • +2
                    Могу рассказать о настоящей серебряной пуле для детекта UI Freeze: есть в Windows Vista+ механизм ETW(Event Tracing for Windows), и готовый провайдер, который умеет кидать сообщения и коллстек, когда какое-нибудь приложение(не обязательно WPF и .NET) в системе не опрашивает очередь сообщений более 200ms. Не нужно лезть в код и инструментировать, создавать два диспетчера. Всё работает в режиме Attach.

                    С помощью этого механизма dotTrace в режиме Timeline показывает вам те самые UI Freeze на графике и можно поизучать хотспоты на этих участках.
                    • 0
                      Напишите пжл статью, очень интересно.
                    • 0
                      Обнаружить, что UI «отвис» можно подпиской на событе Dispatcher.Hooks.DispatcherInactive

                      Думаю обнаружить начало выполнения операций диспетчером можно с использованием других событий Dispatcher.Hooks.
                      • 0
                        WPF разрешает создавать несколько UI тредов. Делается это так:

                        Указанным способом создается несколько блокированных инстансов потока, которые работают синхронно.

                        Если кому-то понадобится создать несколько реальных UI потоков WPF, то можно подглядеть решение на MSDN.

                        Я использовал такое решение для создания на WPF индикатора длительных операций, способный крутить анимацию даже при замораживании основного потока UI. ОСТОРОЖНО!!! Корректная реализация IDisposable требуется.
                        • 0
                          Нет, указанный автором способ создает реальные потоки, ограничение тут в другом. Если делать как автор — то можно создать в отдельном потоке только отдельное окно. По вашей же ссылке предлагается создать контрол, лежащий где-то в дереве — но при этом работающий в своем потоке.
                          • 0
                            Тогда непонятно — зачем?
                            Инстансы окон и так, вроде бы, не должны блокировать друг друга.
                            • 0
                              Экземпляры окон UI по умолчанию работают в одном треде.
                              Скачайте демо-проект и поиграйтесь с исходниками.
                              • 0
                                Как дойдут руки до описанной проблемы, попробую предложенное решение скрестить с Dispatcher.Hooks. Есть ощущение, что «веселее вместе».

                        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.