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

Думаю что каждому из программистов попадалось приложение которое по тем или иным причинам блокировало 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

Всем спасибо!

Комментарии (29)


  1. impwx
    10.06.2015 19:16

    Интересный вариант. Теперь хорошо бы придумать автоматический способ обнаруживать такой блокирующий код, чтобы можно было его зарефакторить.


    1. Diaver Автор
      10.06.2015 19:19

      Можно попробовать Debugger.Launch, но вопрос что будет в стеке что делать дальше.


    1. JIghtuse
      10.06.2015 19:23
      +1

      Наверное, задачу останова будет попроще решить.


      1. impwx
        10.06.2015 19:43
        +2

        Ну что вы. Я же не прошу точно указать причину ошибки. Достаточно было бы обнаружить факт блокировки и получить callstack этого потока, чтобы отправить его в качестве багрепорта дальше уже в ручном режиме разобраться. Без этого приведенный в статье способ выглядит как заглушка — «мы знаем что проблема может быть, но локализовать и исправить не можем, так что пока хоть так».


        1. ad1Dima
          11.06.2015 11:01

          Буквально только что решал проблему блокировки UI, которая была из-за проброса Exception в UI thread. И там нет пользовательского стектрейса.


  1. HackerDelphi
    10.06.2015 21:29
    +3

    Отличный пост, приму на заметку.
    Небольшой комментарий не въедливости ради, а для того, чтобы у тех, кто будет использовать этот код, не произошёл «нежданчик»:

    Скрытый текст
    Эта строка:
    Dispatcher.CurrentDispatcher.Invoke(() => UIBlocked());
    

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


    1. steck
      10.06.2015 21:48
      +6

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

      и
      var totalMilliseconds = (DateTime.Now - _lastForegroundTimerTickTime).TotalMilliseconds;
      на
      var totalMilliseconds = _stopwatch.ElapsedMilliseconds;
      Всё-таки мерить время через DateTime.Now как-то не очень прилично


      1. withkittens
        10.06.2015 21:54
        -3

        Так это бенчмарки меряются Stopwatch'ем.
        Не вижу проблемы измерять сотни миллисекунд через DateTime.


        1. GrigoryPerepechko
          10.06.2015 22:13
          +1

          Слепые тоже не видят. Но обычно на форумах этим не гордятся.
          Погрешность может быть очень большой. На моей памяти правда погрешность была в пределах 16мс, но я не писал под 95 винду. Там это было 55мс.

          И самое главное «как долго заняла операция» и «сколько сейчас времени» — это разные задачи. Средства очевидно тоже разные.


          1. withkittens
            10.06.2015 23:08
            +1

            Ну, очевидно, погрешность в 16мс поломает всю задачу определения, повис ли UI-поток.
            Осталось выставить CPU Affinity, чтобы поток не скакал между ядрами, и прогреть кэш.


            1. GrigoryPerepechko
              10.06.2015 23:15

              То есть человек меняющий константу FreezeTimeLimit должен помнить что если он ее уменьшит до некого N при котором погрешность будет составлять критические для тов. withkittens скажем 10% — ему нужно будет заменить семантически менее корректное решение на семантически более корректное, потому что когда писали изначально — погрешность всех устраивала. Так?


              1. withkittens
                10.06.2015 23:42

                Мне удивительно, как вы ратуете за Stopwatch с наносекундной точностью, когда как DispatcherTimer, использующийся в статье, имеет погрешность в 10мс минимум.


                1. Diaver Автор
                  11.06.2015 14:26

                  Отвечу сразу всем: спасибо за CodeReview, с большей частью комментариев полностью согласен. Узнал пару новых моментов.


        1. mayorovp
          11.06.2015 08:08
          +1

          А если пользователь изменит системное время?


        1. kekekeks
          11.06.2015 13:45
          +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


          1. withkittens
            11.06.2015 17:26

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


  1. lam0x86
    11.06.2015 00:06
    +1

    Dispatcher.CurrentDispatcher.Invoke(() => UIBlocked());
    

    Я правильно понимаю, что BackgroundTimerTick вызывается на потоке ThreadPool-а, и вызов в нём Dispatcher.CurrentDispatcher порождает новый диспетчер, связанный с этим потоком? На мой взгляд, это довольно странно. Задачи, выполняемые на потоках пула, не должны влиять на состояние потоков, в которых они запускаются.


    1. lam0x86
      11.06.2015 00:09
      +2

      Я бы заранее создал отдельный UI-поток для таких случаев и передавал его в конструктор BlockDetector-а. Но вообще, прочитав Вашу статью, мне сначала показалось, что автор из моей команды — уж больно похоже на то, что мы сделали у себя)


  1. brn
    11.06.2015 09:47

    Постоянно дергать DateTime.Now плохая идея. Это крайне медленная функция. Для таких задач лучше подходит DateTime.UtcNow


    1. lam0x86
      11.06.2015 10:54

      Кроме того, использование DateTime.Now может привести к неожиданным результатам при переходе между зимним и летним временами или при NTP-синхронизации системного времени.


  1. lam0x86
    11.06.2015 10:55

    (del)


  1. Temp1ar
    11.06.2015 18:20
    +2

    Могу рассказать о настоящей серебряной пуле для детекта UI Freeze: есть в Windows Vista+ механизм ETW(Event Tracing for Windows), и готовый провайдер, который умеет кидать сообщения и коллстек, когда какое-нибудь приложение(не обязательно WPF и .NET) в системе не опрашивает очередь сообщений более 200ms. Не нужно лезть в код и инструментировать, создавать два диспетчера. Всё работает в режиме Attach.

    С помощью этого механизма dotTrace в режиме Timeline показывает вам те самые UI Freeze на графике и можно поизучать хотспоты на этих участках.


    1. Diaver Автор
      11.06.2015 18:22

      Напишите пжл статью, очень интересно.


  1. HomoLuden
    25.06.2015 15:31

    Обнаружить, что UI «отвис» можно подпиской на событе Dispatcher.Hooks.DispatcherInactive

    Думаю обнаружить начало выполнения операций диспетчером можно с использованием других событий Dispatcher.Hooks.


  1. HomoLuden
    25.06.2015 15:37

    WPF разрешает создавать несколько UI тредов. Делается это так:

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

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

    Я использовал такое решение для создания на WPF индикатора длительных операций, способный крутить анимацию даже при замораживании основного потока UI. ОСТОРОЖНО!!! Корректная реализация IDisposable требуется.


    1. mayorovp
      25.06.2015 16:04

      Нет, указанный автором способ создает реальные потоки, ограничение тут в другом. Если делать как автор — то можно создать в отдельном потоке только отдельное окно. По вашей же ссылке предлагается создать контрол, лежащий где-то в дереве — но при этом работающий в своем потоке.


      1. HomoLuden
        25.06.2015 16:24

        Тогда непонятно — зачем?
        Инстансы окон и так, вроде бы, не должны блокировать друг друга.


        1. Diaver Автор
          25.06.2015 16:32

          Экземпляры окон UI по умолчанию работают в одном треде.
          Скачайте демо-проект и поиграйтесь с исходниками.


          1. HomoLuden
            25.06.2015 17:04

            Как дойдут руки до описанной проблемы, попробую предложенное решение скрестить с Dispatcher.Hooks. Есть ощущение, что «веселее вместе».