Приветствую!
Думаю что каждому из программистов попадалось приложение которое по тем или иным причинам блокировало 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)
HackerDelphi
10.06.2015 21:29+3Отличный пост, приму на заметку.
Небольшой комментарий не въедливости ради, а для того, чтобы у тех, кто будет использовать этот код, не произошёл «нежданчик»:
Скрытый текстЭта строка:
Dispatcher.CurrentDispatcher.Invoke(() => UIBlocked());
и вторая для UIReleased — нет ни проверки на null, ни первичного переноса значения делегата в отдельную переменную (что настоятельно рекомендуется, тем более, что в этом месте гарантирована многопоточность).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 как-то не очень приличноwithkittens
10.06.2015 21:54-3Так это бенчмарки меряются
Stopwatch
'ем.
Не вижу проблемы измерять сотни миллисекунд черезDateTime
.GrigoryPerepechko
10.06.2015 22:13+1Слепые тоже не видят. Но обычно на форумах этим не гордятся.
Погрешность может быть очень большой. На моей памяти правда погрешность была в пределах 16мс, но я не писал под 95 винду. Там это было 55мс.
И самое главное «как долго заняла операция» и «сколько сейчас времени» — это разные задачи. Средства очевидно тоже разные.withkittens
10.06.2015 23:08+1Ну, очевидно, погрешность в 16мс поломает всю задачу определения, повис ли UI-поток.
Осталось выставить CPU Affinity, чтобы поток не скакал между ядрами, и прогреть кэш.GrigoryPerepechko
10.06.2015 23:15То есть человек меняющий константу FreezeTimeLimit должен помнить что если он ее уменьшит до некого N при котором погрешность будет составлять критические для тов. withkittens скажем 10% — ему нужно будет заменить семантически менее корректное решение на семантически более корректное, потому что когда писали изначально — погрешность всех устраивала. Так?
withkittens
10.06.2015 23:42Мне удивительно, как вы ратуете за
Stopwatch
с наносекундной точностью, когда какDispatcherTimer
, использующийся в статье, имеет погрешность в 10мс минимум.Diaver Автор
11.06.2015 14:26Отвечу сразу всем: спасибо за CodeReview, с большей частью комментариев полностью согласен. Узнал пару новых моментов.
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.cswithkittens
11.06.2015 17:26Вот.
Спасибо за адекватный и исчерпывающий ответ.
Теперь я могу признать, что был не прав.
lam0x86
11.06.2015 00:06+1Dispatcher.CurrentDispatcher.Invoke(() => UIBlocked());
Я правильно понимаю, что BackgroundTimerTick вызывается на потоке ThreadPool-а, и вызов в нём Dispatcher.CurrentDispatcher порождает новый диспетчер, связанный с этим потоком? На мой взгляд, это довольно странно. Задачи, выполняемые на потоках пула, не должны влиять на состояние потоков, в которых они запускаются.lam0x86
11.06.2015 00:09+2Я бы заранее создал отдельный UI-поток для таких случаев и передавал его в конструктор BlockDetector-а. Но вообще, прочитав Вашу статью, мне сначала показалось, что автор из моей команды — уж больно похоже на то, что мы сделали у себя)
brn
11.06.2015 09:47Постоянно дергать DateTime.Now плохая идея. Это крайне медленная функция. Для таких задач лучше подходит DateTime.UtcNow
lam0x86
11.06.2015 10:54Кроме того, использование DateTime.Now может привести к неожиданным результатам при переходе между зимним и летним временами или при NTP-синхронизации системного времени.
Temp1ar
11.06.2015 18:20+2Могу рассказать о настоящей серебряной пуле для детекта UI Freeze: есть в Windows Vista+ механизм ETW(Event Tracing for Windows), и готовый провайдер, который умеет кидать сообщения и коллстек, когда какое-нибудь приложение(не обязательно WPF и .NET) в системе не опрашивает очередь сообщений более 200ms. Не нужно лезть в код и инструментировать, создавать два диспетчера. Всё работает в режиме Attach.
С помощью этого механизма dotTrace в режиме Timeline показывает вам те самые UI Freeze на графике и можно поизучать хотспоты на этих участках.
HomoLuden
25.06.2015 15:31Обнаружить, что UI «отвис» можно подпиской на событе Dispatcher.Hooks.DispatcherInactive
Думаю обнаружить начало выполнения операций диспетчером можно с использованием других событий Dispatcher.Hooks.
HomoLuden
25.06.2015 15:37WPF разрешает создавать несколько UI тредов. Делается это так:
Указанным способом создается несколько блокированных инстансов потока, которые работают синхронно.
Если кому-то понадобится создать несколько реальных UI потоков WPF, то можно подглядеть решение на MSDN.
Я использовал такое решение для создания на WPF индикатора длительных операций, способный крутить анимацию даже при замораживании основного потока UI. ОСТОРОЖНО!!! Корректная реализация IDisposable требуется.mayorovp
25.06.2015 16:04Нет, указанный автором способ создает реальные потоки, ограничение тут в другом. Если делать как автор — то можно создать в отдельном потоке только отдельное окно. По вашей же ссылке предлагается создать контрол, лежащий где-то в дереве — но при этом работающий в своем потоке.
HomoLuden
25.06.2015 16:24Тогда непонятно — зачем?
Инстансы окон и так, вроде бы, не должны блокировать друг друга.
impwx
Интересный вариант. Теперь хорошо бы придумать автоматический способ обнаруживать такой блокирующий код, чтобы можно было его зарефакторить.
Diaver Автор
Можно попробовать Debugger.Launch, но вопрос что будет в стеке что делать дальше.
JIghtuse
Наверное, задачу останова будет попроще решить.
impwx
Ну что вы. Я же не прошу точно указать причину ошибки. Достаточно было бы обнаружить факт блокировки и получить callstack этого потока, чтобы отправить его в качестве багрепорта дальше уже в ручном режиме разобраться. Без этого приведенный в статье способ выглядит как заглушка — «мы знаем что проблема может быть, но локализовать и исправить не можем, так что пока хоть так».
ad1Dima
Буквально только что решал проблему блокировки UI, которая была из-за проброса Exception в UI thread. И там нет пользовательского стектрейса.