Предотвращение зависаний в приложениях для 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 Обозреватель. Он должен отображать общий размер папки, сведения, которые недоступны из файловой системы. Диалоговое окно отображается сразу, а поле "размер" обновляется из рабочего потока:

Снимок экрана: страница

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

Рекомендации

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

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

Сделайте следующее:

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

Не надо:

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

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

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

Сделайте следующее:

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

Разумное использование блокировок

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

Все становится еще сложнее, так как операции блокировки включают гораздо больше, чем обычные функции для критических разделов, мьютексов и других традиционных блокировок. Любой блокирующий вызов, который пересекает границы потока, имеет свойства синхронизации, которые могут привести к взаимоблокировке. Вызывающий поток выполняет операцию с семантикой "acquire" и не может разблокировать, пока целевой поток не "освобождает" этот вызов. К этой категории относится довольно много функций 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 блокировку верхнего уровня в иерархии блокировок, даже если мы хотели синхронизировать доступ к переменным-членам класса.

Сделайте следующее:

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

Не надо:

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

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

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

Пример кода ниже иллюстрирует эту проблему. Неограниченный доступ к переменной buffer иногда приводит к нарушению доступа (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;  
}  

Сделайте следующее:

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

Не надо:

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