Compartir a través de


Prevención de bloqueos en aplicaciones de Windows

Plataformas afectadas

Clientes : Windows 7
Servidores : Windows Server 2008 R2

Descripción

Bloqueos: perspectiva del usuario

A los usuarios les gustan las aplicaciones con capacidad de respuesta. Al hacer clic en un menú, quieren que la aplicación reaccione al instante, incluso si actualmente está imprimiendo su trabajo. Cuando guardan un documento largo en su procesador de texto favorito, quieren seguir escribiendo mientras el disco sigue girando. Los usuarios se impacientes se vuelven impacientes cuando la aplicación no reacciona de forma oportuna a su entrada.

Un programador puede reconocer muchas razones legítimas para que una aplicación no responda al instante a la entrada del usuario. Es posible que la aplicación esté ocupada recalculando algunos datos o simplemente esperando a que se complete su E/S de disco. Sin embargo, desde la investigación del usuario, sabemos que los usuarios se molestan y frustran después de un par de segundos de falta de respuesta. Después de 5 segundos, intentarán finalizar una aplicación bloqueada. Junto a bloqueos, las bloqueos de la aplicación son el origen más común de la interrupción del usuario al trabajar con aplicaciones Win32.

Hay muchas causas principales diferentes para que la aplicación se bloquee y no todas se manifiestan en una interfaz de usuario que no responde. Sin embargo, una interfaz de usuario que no responde es una de las experiencias de bloqueo más comunes, y este escenario recibe actualmente la compatibilidad con el sistema operativo más eficaz para la detección y la recuperación. Windows detecta, recopila automáticamente información de depuración y, opcionalmente, finaliza o reinicia las aplicaciones bloqueadas. De lo contrario, es posible que el usuario tenga que reiniciar la máquina para recuperar una aplicación bloqueada.

Bloqueos: perspectiva del sistema operativo

Cuando una aplicación (o con mayor precisión, un subproceso) crea una ventana en el escritorio, entra en un contrato implícito con el Administrador de ventanas de escritorio (DWM) para procesar mensajes de ventana de forma oportuna. DwM publica mensajes (entrada de teclado/mouse y mensajes de otras ventanas, así como de sí mismos) en la cola de mensajes específica del subproceso. El subproceso recupera y envía esos mensajes a través de su cola de mensajes. Si el subproceso no proporciona servicio a la cola llamando a GetMessage(), los mensajes no se procesan y la ventana se bloquea: no puede volver a dibujar ni aceptar la entrada del usuario. El sistema operativo detecta este estado asociando un temporizador a los mensajes pendientes en la cola de mensajes. Si no se ha recuperado un mensaje en un plazo de 5 segundos, DWM declara que la ventana se va a colgar. Puede consultar este estado de ventana en particular a través de la API IsHungAppWindow().

La detección es solo el primer paso. En este momento, el usuario todavía no puede finalizar la aplicación; al hacer clic en el botón X (Cerrar) se produciría un mensaje de WM_CLOSE, que se bloquearía en la cola de mensajes igual que cualquier otro mensaje. El Administrador de ventanas de escritorio ayuda a ocultarse sin problemas y, a continuación, reemplazar la ventana bloqueada por una copia "fantasma" que muestra un mapa de bits del área de cliente anterior de la ventana original (y agregar "No responder" a la barra de título). Siempre que el subproceso de la ventana original no recupere mensajes, DWM administra ambas ventanas simultáneamente, pero permite al usuario interactuar solo con la copia fantasma. Con esta ventana fantasma, el usuario solo puede mover, minimizar y, lo que es más importante, cerrar la aplicación que no responde, pero no cambiar su estado interno.

Toda la experiencia fantasma tiene este aspecto:

Captura de pantalla que muestra el cuadro de diálogo

El Administrador de ventanas de escritorio hace una última cosa; se integra con Informe de errores de Windows, lo que permite al usuario cerrar y, opcionalmente, reiniciar la aplicación, sino también enviar datos de depuración valiosos a Microsoft. Para obtener estos datos de bloqueo para sus propias aplicaciones, regístrese en el sitio web de Winqual.

Windows 7 ha agregado una nueva característica a esta experiencia. El sistema operativo analiza la aplicación bloqueada y, en determinadas circunstancias, proporciona al usuario la opción de cancelar una operación de bloqueo y hacer que la aplicación responda de nuevo. La implementación actual admite la cancelación de las llamadas socket de bloqueo; más operaciones serán cancelables por el usuario en futuras versiones.

Para integrar la aplicación con la experiencia de recuperación de bloqueo y sacar el máximo partido de los datos disponibles, siga estos pasos:

  • Asegúrese de que la aplicación se registra para el reinicio y la recuperación, lo que hace que un bloqueo sea lo más libre posible para el usuario. Una aplicación registrada correctamente puede reiniciarse automáticamente con la mayoría de sus datos no guardados intactos. Esto funciona tanto para bloqueos de aplicación como para bloqueos.
  • Obtenga información de frecuencia, así como datos de depuración para las aplicaciones bloqueadas y bloqueadas desde el sitio web de Winqual. Puede usar esta información incluso durante la versión beta para mejorar el código. Consulte "Presentación de Informe de errores de Windows" para obtener una breve introducción.
  • Puede deshabilitar la característica fantasma en la aplicación a través de una llamada a DisableProcessWindowsGhosting (). Sin embargo, esto impide que el usuario medio cierre y reinicie una aplicación bloqueada y a menudo termina en un reinicio.

Bloqueos: perspectiva del desarrollador

El sistema operativo define un bloqueo de aplicación como un subproceso de interfaz de usuario que no ha procesado mensajes durante al menos 5 segundos. Los errores obvios provocan algunos bloqueos, por ejemplo, un subproceso esperando un evento que nunca se señala, y dos subprocesos que mantienen un bloqueo e intentan adquirir los demás. Puede corregir esos errores sin demasiado esfuerzo. Sin embargo, muchos bloqueos no son tan claros. Sí, el subproceso de interfaz de usuario no recupera mensajes, pero está igualmente ocupado haciendo otro trabajo "importante" y finalmente volverá al procesamiento de mensajes.

Sin embargo, el usuario percibe esto como un error. El diseño debe coincidir con las expectativas del usuario. Si el diseño de la aplicación conduce a una aplicación que no responde, el diseño tendrá que cambiar. Por último, y esto es importante, la falta de respuesta no se puede corregir como un error de código; requiere trabajo inicial durante la fase de diseño. Intentar volver a ajustar la base de código existente de una aplicación para que la interfaz de usuario tenga mayor capacidad de respuesta suele ser demasiado costosa. Las siguientes directrices de diseño pueden ayudar.

  • Hacer que la capacidad de respuesta de la interfaz de usuario sea un requisito de nivel superior; el usuario siempre debe sentirse en el control de la aplicación.
  • Asegúrese de que los usuarios puedan cancelar las operaciones que tarden más de un segundo en completarse o que las operaciones se puedan completar en segundo plano; proporcionar la interfaz de usuario de progreso adecuada si es necesario

Captura de pantalla que muestra el cuadro de diálogo

  • Cola de operaciones de ejecución prolongada o de bloqueo como tareas en segundo plano (esto requiere un mecanismo de mensajería bien pensado para informar al subproceso de la interfaz de usuario cuando se ha completado el trabajo)
  • Mantenga sencillo el código para subprocesos de interfaz de usuario; quitar tantas llamadas API de bloqueo como sea posible
  • Mostrar ventanas y diálogos solo cuando estén listos y totalmente operativos. Si el cuadro de diálogo necesita mostrar información que consume demasiado recursos para calcular, primero muestre alguna información genérica y actualícela sobre la marcha cuando haya más datos disponibles. Un buen ejemplo es el cuadro de diálogo de propiedades de carpeta del Explorador de Windows. Debe mostrar el tamaño total de la carpeta, información que no está disponible fácilmente en el sistema de archivos. El cuadro de diálogo se muestra inmediatamente y el campo "tamaño" se actualiza desde un subproceso de trabajo:

Captura de pantalla que muestra la página

Desafortunadamente, no hay ninguna manera sencilla de diseñar y escribir una aplicación con capacidad de respuesta. Windows no proporciona un marco asincrónico sencillo que permitiría programar fácilmente las operaciones de bloqueo o larga duración. En las secciones siguientes se presentan algunos de los procedimientos recomendados para evitar bloqueos y resaltar algunos de los problemas comunes.

Prácticas recomendadas

Mantener simple el subproceso de interfaz de usuario

La responsabilidad principal del subproceso de la interfaz de usuario es recuperar y enviar mensajes. Cualquier otro tipo de trabajo presenta el riesgo de colgar las ventanas que pertenecen a este subproceso.

Sí:

  • Traslado de algoritmos sin límite o intensivos de recursos que dan lugar a operaciones de ejecución prolongada a subprocesos de trabajo
  • Identifique tantas llamadas de función de bloqueo como sea posible e intente moverlas a subprocesos de trabajo; cualquier función que llame a otro archivo DLL debe ser sospechosa
  • Realice un esfuerzo adicional para quitar todas las llamadas API de E/S de archivos y redes del subproceso de trabajo. Estas funciones pueden bloquearse durante muchos segundos si no son minutos. Si necesita realizar cualquier tipo de E/S en el subproceso de interfaz de usuario, considere la posibilidad de usar E/S asincrónica.
  • Tenga en cuenta que el subproceso de interfaz de usuario también está dando servicio a todos los servidores COM de un solo subproceso (STA) hospedados por el proceso; si realiza una llamada de bloqueo, estos servidores COM no responderán hasta que vuelva a atender la cola de mensajes.

No:

  • Espere en cualquier objeto kernel (como Event o Mutex) durante más de un período de tiempo muy corto; Si tiene que esperar en absoluto, considere la posibilidad de usar MsgWaitForMultipleObjects(), que se desbloqueará cuando llegue un nuevo mensaje.
  • Comparta la cola de mensajes de ventana de un subproceso con otro subproceso mediante la función AttachThreadInput(). No solo es extremadamente difícil sincronizar correctamente el acceso a la cola, también puede impedir que el sistema operativo Windows detecte correctamente una ventana bloqueada.
  • Use TerminateThread() en cualquiera de los subprocesos de trabajo. La terminación de un subproceso de esta manera no le permitirá liberar bloqueos o eventos de señal y puede dar lugar fácilmente a objetos de sincronización huérfanos.
  • Llame a cualquier código "desconocido" desde el subproceso de la interfaz de usuario. Esto es especialmente cierto si la aplicación tiene un modelo de extensibilidad; no hay ninguna garantía de que el código de terceros siga las directrices de capacidad de respuesta.
  • Realizar cualquier tipo de llamada de difusión de bloqueo; SendMessage(HWND_BROADCAST) le pone a la misericordia de cada aplicación mal escrita que se está ejecutando actualmente.

Implementación de patrones asincrónicos

La eliminación de operaciones de ejecución prolongada o de bloqueo del subproceso de interfaz de usuario requiere implementar un marco asincrónico que permita descargar esas operaciones a subprocesos de trabajo.

Sí:

  • Use api de mensajes de ventana asincrónicas en el subproceso de la interfaz de usuario, especialmente reemplazando SendMessage por uno de sus elementos del mismo nivel que no son de bloqueo: PostMessage, SendNotifyMessage o SendMessageCallback.
  • Use subprocesos en segundo plano para ejecutar tareas de ejecución prolongada o de bloqueo. Uso de la nueva API de grupo de subprocesos para implementar los subprocesos de trabajo
  • Proporcionar compatibilidad con la cancelación para tareas en segundo plano de larga duración. Para bloquear las operaciones de E/S, use la cancelación de E/S, pero solo como último recurso; no es fácil cancelar la operación "correcta".
  • Implementar un diseño asincrónico para código administrado mediante el patrón IAsyncResult o mediante Eventos

Usar bloqueos sabiamente

La aplicación o dll necesita bloqueos para sincronizar el acceso a sus estructuras de datos internas. El uso de varios bloqueos aumenta el paralelismo y hace que la aplicación tenga mayor capacidad de respuesta. Sin embargo, el uso de varios bloqueos también aumenta la posibilidad de adquirir esos bloqueos en diferentes órdenes y hacer que los subprocesos se interbloqueen. Si dos subprocesos contienen un bloqueo y, a continuación, intentan adquirir el bloqueo del otro subproceso, sus operaciones formarán una espera circular que bloqueará cualquier progreso hacia delante para estos subprocesos. Puede evitar este interbloqueo solo asegurándose de que todos los subprocesos de la aplicación siempre adquieran todos los bloqueos en el mismo orden. Sin embargo, no siempre es fácil adquirir bloqueos en el orden "correcto". Los componentes de software se pueden componer, pero no se pueden bloquear las adquisiciones. Si el código llama a algún otro componente, los bloqueos de ese componente ahora se convierten en parte del orden de bloqueo implícito, incluso si no tiene visibilidad sobre esos bloqueos.

Las cosas son aún más difíciles porque las operaciones de bloqueo incluyen mucho más que las funciones habituales para secciones críticas, exclusión mutua y otros bloqueos tradicionales. Cualquier llamada de bloqueo que cruce los límites del subproceso tiene propiedades de sincronización que pueden dar lugar a un interbloqueo. El subproceso de llamada realiza una operación con semántica "acquire" y no se puede desbloquear hasta que el subproceso de destino "libera" esa llamada. Muchas funciones User32 (por ejemplo, SendMessage), así como muchas llamadas COM de bloqueo se encuentran en esta categoría.

Peor aún, el sistema operativo tiene su propio bloqueo interno específico del proceso que a veces se mantiene mientras se ejecuta el código. Este bloqueo se adquiere cuando se cargan archivos DLL en el proceso y, por tanto, se denomina "bloqueo del cargador". La función DllMain siempre se ejecuta bajo el bloqueo del cargador; Si adquiere algún bloqueo en DllMain (y no debe), debe hacer que la parte de bloqueo del cargador forme parte del orden de bloqueo. Llamar a determinadas API de Win32 también puede adquirir el bloqueo del cargador en su nombre: funciones como LoadLibraryEx, GetModuleHandle y, especialmente, CoCreateInstance.

Para unir todo esto, examine el código de ejemplo siguiente. Esta función adquiere varios objetos de sincronización y define implícitamente un orden de bloqueo, algo que no es necesariamente obvio en la inspección de cursores. En la entrada de la función, el código adquiere una sección crítica y no la libera hasta que la función salga, lo que lo convierte en el nodo superior de la jerarquía de bloqueos. A continuación, el código llama a la función LoadIcon() de Win32, que en segundo plano podría llamar al cargador de sistema operativo para cargar este binario. Esta operación adquiriría el bloqueo del cargador, que ahora también forma parte de esta jerarquía de bloqueos (asegúrese de que la función DllMain no adquiere el bloqueo g_cs). A continuación, el código llama a SendMessage(), una operación de bloqueo entre subprocesos, que no se devolverá a menos que responda el subproceso de la interfaz de usuario. De nuevo, asegúrese de que el subproceso de interfaz de usuario nunca adquiere 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;  
}  

Al examinar este código, parece claro que hemos hecho implícitamente g_cs el bloqueo de nivel superior en nuestra jerarquía de bloqueos, incluso si solo queríamos sincronizar el acceso a las variables de miembro de clase.

Sí:

  • Diseñe una jerarquía de bloqueos y obedezca. Agregue todos los bloqueos necesarios. Hay muchos más primitivos de sincronización que solo Mutex y CriticalSections; todos deben incluirse. Incluya el bloqueo del cargador en la jerarquía si toma algún bloqueo en DllMain()
  • Acepte el protocolo de bloqueo con las dependencias. Cualquier código que llame a la aplicación o que pueda llamar a la aplicación debe compartir la misma jerarquía de bloqueos.
  • Bloquear estructuras de datos no funciona. Mueva las adquisiciones de bloqueos lejos de los puntos de entrada de función y guarde solo el acceso a los datos con bloqueos. Si menos código funciona bajo un bloqueo, hay menos posibilidades de interbloqueos.
  • Analice las adquisiciones y versiones de bloqueo en el código de control de errores. A menudo, la jerarquía de bloqueos si se olvida al intentar recuperarse de una condición de error
  • Reemplace bloqueos anidados por contadores de referencia: no pueden interbloquear. Los elementos bloqueados individualmente en listas y tablas son buenos candidatos
  • Tenga cuidado al esperar un identificador de subproceso desde un archivo DLL. Suponga siempre que se podría llamar al código bajo el bloqueo del cargador. Es mejor hacer referencia a los recursos y permitir que el subproceso de trabajo realice su propia limpieza (y, a continuación, use FreeLibraryAndExitThread para finalizar limpiamente).
  • Use Wait Chain Traversal API si desea diagnosticar sus propios interbloqueos

No:

  • Haga algo distinto del trabajo de inicialización muy simple en la función DllMain(). Consulte Función de devolución de llamada DllMain para obtener más detalles. Especialmente, no llame a LoadLibraryEx o CoCreateInstance
  • Escriba sus propios primitivos de bloqueo. El código de sincronización personalizado puede introducir fácilmente errores sutiles en la base de código. Use la selección enriquecida de objetos de sincronización del sistema operativo en su lugar.
  • Realice cualquier trabajo en los constructores y destructores para variables globales; se ejecutan bajo el bloqueo del cargador.

Tenga cuidado con las excepciones

Las excepciones permiten la separación del flujo de programa normal y el control de errores. Debido a esta separación, puede ser difícil conocer el estado preciso del programa antes de la excepción y el controlador de excepciones podría perder pasos cruciales para restaurar un estado válido. Esto es especialmente cierto para las adquisiciones de bloqueo que deben liberarse en el controlador para evitar futuros interbloqueos.

El código de ejemplo siguiente ilustra este problema. El acceso sin enlazar a la variable "buffer" producirá ocasionalmente una infracción de acceso (AV). El controlador de excepciones nativo detecta este antivirus, pero no tiene ninguna manera fácil de determinar si la sección crítica ya se adquirió en el momento de la excepción (el AV podría haber tenido lugar incluso en algún lugar del código 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;  
}  

Sí:

  • Quite __try/__except siempre que sea posible; no use SetUnhandledExceptionFilter
  • Encapsula los bloqueos en plantillas personalizadas de tipo auto_ptr si usa excepciones de C++. El bloqueo debe liberarse en el destructor. En el caso de las excepciones nativas, libere los bloqueos en la instrucción __finally
  • Tenga cuidado con el código que se ejecuta en un controlador de excepciones nativo; la excepción podría haber filtrado muchos bloqueos, por lo que el controlador no debe adquirir ninguno.

No:

  • Controla las excepciones nativas si no es necesario o requiere las API de Win32. Si usa controladores de excepciones nativos para la generación de informes o la recuperación de datos después de errores catastróficos, considere la posibilidad de usar el mecanismo de sistema operativo predeterminado de Informe de errores de Windows en su lugar.
  • Use excepciones de C++ con cualquier tipo de código de interfaz de usuario (usuario32); una excepción producida en una devolución de llamada recorrerá capas de código C proporcionadas por el sistema operativo. Ese código no conoce la semántica de anulación de la inscripción de C++