Предотвращение зависания в приложениях Windows

Затронутые платформы

Клиенты — Windows 7
Серверы — Windows Server 2008 R2

Описание

Зависание — точка зрения пользователя

Пользователи любят адаптивные приложения. Когда они щелкают на меню, они хотят, чтобы приложение мгновенно реагировало, даже если оно в настоящее время печатает их работу. Когда они сохраняют длинный документ в своем любимом текстовом процессоре, они хотят продолжать печатать, пока диск по-прежнему вращается. Пользователи становятся нетерпеливыми довольно быстро, когда приложение не реагирует своевременно на их действия.

Программист может распознать множество законных причин, по которым приложение не будет сразу реагировать на входные данные пользователя. Приложение может быть занято перерасчетом некоторых данных или просто ожидает завершения ввода-вывода диска. Однако из исследования пользователей мы знаем, что пользователи раздражаются и разочарованы всего через пару секунд безответственности. Через 5 секунд они попытаются завершить зависшее приложение. Помимо сбоев, зависания приложений являются вторым по частоте источником нарушений работы пользователей при работе с приложениями Win32.

Существует множество различных основных причин зависания приложений, и не все из них манифестируются в неответственном пользовательском интерфейсе. Однако неотзывчивый пользовательский интерфейс является одним из наиболее распространенных случаев зависания, и этот сценарий в настоящее время получает наибольшую поддержку со стороны операционной системы как для обнаружения, так и для восстановления. Windows автоматически обнаруживает, собирает сведения об отладке и при необходимости завершает или перезапускает приложения. В противном случае пользователю может потребоваться перезапустить компьютер, чтобы восстановить зависающее приложение.

Зависания — точка зрения операционной системы

Когда приложение (или, более точно, поток) создает окно на рабочем столе, оно вступает в неявный контракт с диспетчером окон рабочего стола (DWM) для своевременной обработки сообщений окна. DWM отправляет сообщения (ввод с клавиатуры и мыши, сообщения из других окон и свои собственные) в очередь сообщений, специфичную для потока. Поток извлекает и отправляет эти сообщения через свою очередь сообщений. Если поток не обслуживает очередь, вызывая GetMessage(), сообщения не обрабатываются, и окно зависает: оно не может ни перерисовываться, ни принимать входные данные от пользователя. Операционная система обнаруживает это состояние путем присоединения таймера к ожидающим сообщениям в очереди сообщений. Если сообщение не было получено в течение 5 секунд, DWM считает окно зависшим. Вы можете запросить это конкретное состояние окна через API IsHungAppWindow().

Обнаружение — это только первый шаг. На этом этапе пользователь по-прежнему не может даже завершить приложение. Нажатие кнопки X (Закрыть) приведет к WM_CLOSE сообщению, которое зависло бы в очереди сообщений так же, как и любое другое сообщение. Диспетчер окон рабочего стола помогает, плавно скрывая, а затем заменяя не отвечающее окно "призрачной" копией, отображающей изображение предыдущей области клиента оригинального окна (с добавлением в заголовок надписи "Не отвечает"). Если поток исходного окна не получает сообщения, DWM одновременно управляет обеими окнами, но позволяет пользователю взаимодействовать только с фантомной копией. Используя это окно призрака, пользователь может перемещаться, свести к минимуму и - самое главное - закрыть неответственное приложение, но не изменить его внутреннее состояние.

Весь опыт работы с призраком выглядит следующим образом:

Снимок экрана: диалоговое окно

Диспетчер окон рабочего стола выполняет одну последнюю вещь; он интегрируется с отчетами об ошибках Windows, позволяя пользователю не только закрыть и при необходимости перезапустить приложение, но и отправить ценные данные отладки обратно в корпорацию Майкрософт. Вы можете получить эти данные зависания для собственных приложений, зарегистрироваться на веб-сайте Winqual.

Windows 7 добавила в этот интерфейс одну новую функцию. Операционная система анализирует зависающее приложение и при определенных обстоятельствах дает пользователю возможность отменить операцию блокировки и снова сделать приложение адаптивным. Текущая реализация поддерживает отмену блокирующих вызовов Сокета, больше операций будут доступны для отмены пользователем в будущих выпусках.

Чтобы интегрировать ваше приложение с процедурой восстановления после зависания и использовать доступные данные максимально эффективно, выполните следующие действия.

  • Убедитесь, что приложение регистрируется для перезапуска и восстановления, что делает зависание как можно более безболезненным для пользователя. Правильно зарегистрированное приложение может автоматически перезапуститься с большей частью не сохраненных данных. Это работает как для зависаний приложений, так и для их сбоев.
  • Получите информацию о частоте, а также данные для отладки зависших и аварийно завершенных приложений на веб-сайте Winqual. Эти сведения можно использовать даже во время бета-версии для улучшения кода. Краткий обзор см. в статье "Введение отчетов об ошибках Windows".
  • Функцию затенения окон в приложении можно отключить с помощью вызова функции DisableProcessWindowsGhosting(). Однако это предотвращает закрытие и перезапуск виселого приложения среднего пользователя и часто заканчивается перезагрузкой.

Зависания — точка зрения разработчика

Операционная система определяет зависание приложения как поток пользовательского интерфейса, который не обрабатывает сообщения в течение как минимум 5 секунд. Очевидные ошибки вызывают некоторые зависания, например, поток, ожидающий события, которое никогда не было сигнализировано, и два потока, каждый из которых держит блокировку и пытается получить замок другого. Эти ошибки можно исправить без слишком много усилий. Тем не менее, многие зависания не так ясны. Да, поток пользовательского интерфейса не получает сообщения, но он также занят выполнением других "важных" работ и в конечном итоге возвращается к обработке сообщений.

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

  • Создание требований к реагированию пользовательского интерфейса на верхнем уровне; Пользователь всегда должен контролировать свое приложение.
  • Убедитесь, что пользователи могут отменить операции, которые занимают больше одной секунды, и (или) операции могут выполняться в фоновом режиме; При необходимости укажите соответствующий пользовательский интерфейс хода выполнения

Снимок экрана: диалоговое окно

  • Поставьте в очередь длительные или блокирующие операции в качестве фоновых задач (для этого требуется хорошо продуманный механизм обмена сообщениями для информирования потока пользовательского интерфейса по завершении работы).
  • Не усложняйте код для потоков пользовательского интерфейса; удалите как можно больше блокирующих вызовов API
  • Показывать окна и диалоговые окна только в том случае, если они готовы и полностью работают. Если диалоговое окно должно отображать слишком ресурсоемкие сведения для вычисления, сначала отобразите общую информацию и обновите ее при получении дополнительных данных. Хорошим примером является диалоговое окно свойств папки из проводника Windows. Он должен отобразить общий размер папки, сведения, которые не легко доступны из файловой системы. Диалоговое окно отображается сразу и поле "size" обновляется из рабочего потока:

Снимок экрана, показывающий страницу «Общие» в свойствах Windows, где выделен текст

К сожалению, нет простого способа разработки и записи адаптивного приложения. Windows не предоставляет простой асинхронной структуры, которая позволяет легко планировать блокирующие или длительные операции. В следующих разделах рассматриваются лучшие практики по предотвращению зависаний и освещаются распространенные ошибки.

Лучшие практики

Упрощайте поток пользовательского интерфейса

Основная ответственность потока пользовательского интерфейса заключается в получении и отправке сообщений. Любая другая работа представляет риск зависания окон, принадлежащих этому потоку.

Do:

  • Перемещайте ресурсоемкие или неограниченные алгоритмы, которые приводят к длительным операциям, в рабочие потоки.
  • Определите как можно больше блокирующих вызовов функций и попытайтесь переместить их в рабочие потоки; любая функция, которая обращается к другой DLL, должна вызывать подозрение.
  • Сделайте дополнительные усилия, чтобы удалить все вызовы ввода-вывода файлов и сетевых API из рабочего потока. Эти функции могут блокироваться в течение нескольких секунд, если не минут. Если в потоке пользовательского интерфейса необходимо выполнить любой тип ввода-вывода, рассмотрите возможность использования асинхронного ввода-вывода
  • Помните, что поток пользовательского интерфейса также обслуживает все однопоточные COM-серверы, размещенные в вашем процессе; если вы выполняете блокирующий вызов, эти COM-серверы не будут отвечать, пока снова не обслужите очередь сообщений.

Не делайте:

  • Подождите на любой объект ядра (например, Event или Mutex) в течение более чем короткого времени; Если вы должны ждать вообще, рассмотрите возможность использования MsgWaitForMultipleObjects(), который разблокирует при поступлении нового сообщения.
  • Поделитесь очередью сообщений окна потока с другим потоком с помощью функции AttachThreadInput(). Не только очень трудно правильно синхронизировать доступ к очереди, но и не позволит операционной системе Windows правильно обнаруживать зависающее окно.
  • Используйте TerminateThread() в любом из рабочих потоков. Завершение потока таким образом не позволит ему освободить блокировки или сигнальные события и легко привести к потерянным объектам синхронизации
  • Вызовите любой "неизвестный" код из потока пользовательского интерфейса. Это особенно верно, если у приложения есть модель расширяемости; Нет никаких гарантий, что сторонний код следует вашим рекомендациям по реагированию
  • Выполните любой тип блокирующего широковещательного вызова; SendMessage(HWND_BROADCAST) оставляет вас в зависимости от каждого плохо написанного приложения, в данный момент работающего.

Реализация асинхронных шаблонов

Для удаления длительных или блокирующих операций из потока пользовательского интерфейса требуется реализация асинхронной платформы, которая позволяет выгрузить эти операции в рабочие потоки.

Do:

  • Используйте API асинхронных оконных сообщений в потоке пользовательского интерфейса, особенно замените SendMessage одним из его неблокирующих аналогов: PostMessage, SendNotifyMessage или SendMessageCallback.
  • Используйте фоновые потоки для выполнения длительных или блокирующих задач. Используйте новый API пула потоков для реализации рабочих потоков
  • Предоставьте возможность отмены для длительных фоновых задач. Для блокирующих операций ввода-вывода используйте отмену ввода-вывода, но только в качестве последнего средства; не всегда легко отменить «правильную» операцию.
  • Реализация асинхронного проектирования управляемого кода с помощью шаблона IAsyncResult или с помощью событий

Используйте блокировки мудро

Приложению или библиотеке DLL требуется блокировка для синхронизации доступа к внутренним структурам данных. Использование нескольких блокировок повышает параллелизм и повышает скорость реагирования приложения. Однако использование нескольких блокировок также повышает вероятность получения этих блокировок в разном порядке, что может привести к взаимоблокировке потоков. Если два потока удерживают блокировку и затем пытаются получить блокировку другого потока, их операции будут формировать циклическое ожидание, которое блокирует любое продвижение вперед для этих потоков. Эту взаимоблокировку можно избежать, только гарантируя, что все потоки в приложении всегда захватывают все блокировки в одном порядке. Однако, не всегда легко получить блокировки в правильном порядке. Компоненты программного обеспечения можно создавать, но приобретение блокировки не может. Если код вызывает какой-то другой компонент, блокировки этого компонента теперь становятся частью неявного порядка блокировки, даже если у вас нет видимости этих блокировок.

Ситуация усложняется тем, что операции блокировки включают намного больше, чем обычные функции, используемые в критических секциях, мьютексах и других традиционных механизмах блокировки. Любой блокирующий вызов, пересекающий границы потока, имеет свойства синхронизации, которые могут привести к взаимоблокировке. Вызывающий поток выполняет операцию с семантикой захвата и не может быть разблокирован до тех пор, пока целевой поток не освобождает этот вызов. Довольно несколько функций User32 (например SendMessage), а также многие блокирующие вызовы COM попадают в эту категорию.

Кроме того, операционная система имеет собственную внутреннюю блокировку, которая иногда сохраняется во время выполнения кода. Эта блокировка приобретается при загрузке библиотек DLL в процесс и поэтому называется "блокировкой загрузчика". Функция DllMain всегда выполняется под блокировкой загрузчика; если вы устанавливаете какие-либо блокировки в DllMain (чего следует избегать), необходимо включить блокировку загрузчика в порядок блокировок. Вызов некоторых API Win32 также может получить блокировку загрузчика от вашего имени - функции, такие как LoadLibraryEx, GetModuleHandle и особенно CoCreateInstance.

Чтобы связать все это вместе, ознакомьтесь с примером кода ниже. Эта функция получает несколько объектов синхронизации и неявно определяет порядок блокировки, то, что не обязательно очевидно при курсорной проверке. При входе функции код получает критически важный раздел и не освобождает его до выхода функции, тем самым делая его верхним узлом в нашей иерархии блокировки. Затем код вызывает функцию Win32 LoadIcon(), которая под обложкой может вызвать загрузчик операционной системы для загрузки этого двоичного файла. Эта операция получит блокировку загрузчика, которая становится теперь частью этой иерархии блокировок (необходимо убедиться, что функция DllMain не получает блокировку g_cs). Далее код вызывает SendMessage(), блокирующую операцию межпотокового взаимодействия, которая не завершится, пока поток пользовательского интерфейса не ответит. Опять же, убедитесь, что поток пользовательского интерфейса никогда не получает g_cs.

bool foo::bar (char* buffer)  
{  
      EnterCriticalSection(&g_cs);  
      // Get 'new data' icon  
      this.m_Icon = LoadIcon(hInst, MAKEINTRESOURCE(5));  
      // Let UI thread know to update icon SendMessage(hWnd,WM_COMMAND,IDM_ICON,NULL);  
      this.m_Params = GetParams(buffer);  
      LeaveCriticalSection(&g_cs);
      return true;  
}  

Глядя на этот код, очевидно, что мы неявно сделали g_cs блокировку верхнего уровня в нашей иерархии блокировки, даже если мы хотели синхронизировать доступ только к переменным члена класса.

Do:

  • Создайте иерархию блокировки и подчиняйтесь ей. Добавьте все необходимые блокировки. Существует гораздо больше примитивов синхронизации, чем просто Mutex и CriticalSections; все они должны быть включены. Включите блокировку загрузчика в иерархию, если вы принимаете какие-либо блокировки в DllMain()
  • Согласуйте протокол блокировки с вашими зависимостями. Любой код, который ваш приложение вызывает или который может вызывать ваше приложение, должен использовать одну и ту же иерархию блокировок.
  • Блокируйте структуры данных, а не функции. Перемещайте получение блокировок от точек входа функций и используйте блокировки только для защиты доступа к данным. Если меньше кода работает под блокировкой, существует меньшая вероятность возникновения взаимоблокировок.
  • Анализируйте приобретение и освобождение блокировки в вашем коде обработки ошибок. Часто иерархия блокировок забывается при попытке восстановиться из состояния ошибки.
  • Замените вложенные блокировки счетчиками ссылок — они не могут блокироваться. Отдельные заблокированные элементы в списках и таблицах являются хорошими кандидатами
  • Будьте осторожны при ожидании дескриптора потока из библиотеки DLL. Всегда предполагайте, что ваш код может вызываться под блокировкой загрузчика. Лучше вести подсчет ссылок на ваши ресурсы и позволить рабочему потоку выполнять собственную очистку (а затем использовать FreeLibraryAndExitThread, чтобы завершить работу корректно)
  • Используйте API обхода цепи ожидания, если вы хотите диагностировать свои собственные взаимоблокировки.

Не делайте:

  • Выполните все действия, отличные от простой инициализации в функции DllMain(). Для получения дополнительных сведений см. функцию обратного вызова DllMain. Особенно не вызывайте LoadLibraryEx или CoCreateInstance
  • Напишите собственные примитивы блокировки. Пользовательский код синхронизации может легко вносить тонкие ошибки в кодовую базу. Вместо этого используйте широкий выбор объектов синхронизации операционной системы
  • Любая работа в конструкторах и деструкторах для глобальных переменных выполняется в условиях блокировки загрузчика.

Будьте осторожны с исключениями

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

Приведенный ниже пример кода иллюстрирует эту проблему. Несвязанный доступ к переменной buffer иногда приводит к нарушению доступа (AV). Этот AV перехватывается встроенным обработчиком исключений, но нет простого способа определить, была ли критическая секция уже захвачена на момент возникновения исключения (AV может даже произойти где-нибудь в коде EnterCriticalSection).

 BOOL bar (char* buffer)  
{  
   BOOL rc = FALSE;  
   __try {  
      EnterCriticalSection(&cs);  
      while (*buffer++ != '&') ;  
      rc = GetParams(buffer);  
      LeaveCriticalSection(&cs);  
   } __except (EXCEPTION_EXECUTE_HANDLER)  
   {  
      return FALSE;  
   } 
   return rc;  
}  

Do:

  • По возможности удалите __try/__except; Не используйте SetUnhandledExceptionFilter
  • Заключите блокировки в пользовательские шаблоны, похожие на auto_ptr, если вы используете исключения C++. Блокировка должна быть выпущена в деструкторе. Для собственных исключений освободите блокировки в инструкции __finally
  • Будьте осторожны с кодом, выполняемым в нативном обработчике исключений; исключение могло привести к утечке множества замков, поэтому ваш обработчик не должен захватывать ни одного.

Не делать:

  • Обрабатывайте собственные исключения, если это не обязательно или если этого не требуют API Win32. Если вы используете собственные обработчики исключений для создания отчетов или восстановления данных после катастрофических сбоев, рассмотрите возможность использования механизма создания отчетов об ошибках Windows по умолчанию.
  • Используйте исключения C++ с любым кодом пользовательского интерфейса (user32); Исключение, созданное в обратном вызове, будет проходить через уровни кода C, предоставленного операционной системой. Этот код не знает о семантике разворачивания C++