Compartilhar via


Prevenção de interrupções em aplicativos do Windows

Plataformas afetadas

Clientes – Windows 7
Servidores – Windows Server 2008 R2

Descrição

Travamentos – Perspectiva do Usuário

Os usuários gostam de aplicativos responsivos. Quando eles clicam em um menu, eles querem que o aplicativo reaja instantaneamente, mesmo que ele esteja imprimindo seu trabalho no momento. Quando eles salvam um documento longo em seu processador de palavras favorito, eles querem continuar digitando enquanto o disco ainda está girando. Os usuários ficam impacientes rapidamente quando o aplicativo não reage em tempo hábil à sua entrada.

Um programador pode reconhecer muitos motivos legítimos para um aplicativo não responder instantaneamente à entrada do usuário. O aplicativo pode estar ocupado recalculando alguns dados ou simplesmente aguardando a conclusão da E/S do disco. No entanto, a partir da pesquisa de usuários, sabemos que os usuários ficam irritados e frustrados após apenas alguns segundos de falta de resposta. Após 5 segundos, eles tentarão encerrar um aplicativo suspenso. Ao lado de falhas, travamentos de aplicativo são a fonte mais comum de interrupção do usuário ao trabalhar com aplicativos Win32.

Há muitas causas raiz diferentes para travamentos de aplicativo e nem todas elas se manifestam em uma interface do usuário sem resposta. No entanto, uma interface do usuário sem resposta é uma das experiências de travamento mais comuns, e esse cenário atualmente recebe o maior suporte do sistema operacional para detecção e recuperação. O Windows detecta, coleta informações de depuração automaticamente e, opcionalmente, encerra ou reinicia aplicativos suspensos. Caso contrário, talvez o usuário precise reiniciar o computador para recuperar um aplicativo suspenso.

Travamentos – Perspectiva do Sistema Operacional

Quando um aplicativo (ou mais precisamente, um thread) cria uma janela na área de trabalho, ele entra em um contrato implícito com o DWM (Gerenciador de Janelas da Área de Trabalho) para processar mensagens de janela em tempo hábil. O DWM posta mensagens (entrada de teclado/mouse e mensagens de outras janelas, bem como de si mesmo) na fila de mensagens específicas do thread. O thread recupera e despacha essas mensagens por meio de sua fila de mensagens. Se o thread não atender à fila chamando GetMessage(), as mensagens não serão processadas e a janela travará: ela não poderá ser redesenhada nem aceitar a entrada do usuário. O sistema operacional detecta esse estado anexando um temporizador a mensagens pendentes na fila de mensagens. Se uma mensagem não tiver sido recuperada dentro de 5 segundos, o DWM declarará a janela como suspensa. Você pode consultar esse estado de janela específico por meio da API IsHungAppWindow().

A detecção é apenas a primeira etapa. Neste ponto, o usuário ainda não pode sequer encerrar o aplicativo – clicar no botão X (Fechar) resultaria em uma mensagem de WM_CLOSE, que ficaria presa na fila de mensagens como qualquer outra mensagem. O Gerenciador de Janelas da Área de Trabalho ajuda ocultando e substituindo a janela pendurada por uma cópia 'fantasma' exibindo um bitmap da área de cliente anterior da janela original (e adicionando "Não respondendo" à barra de título). Desde que o thread da janela original não recupere mensagens, o DWM gerencia ambas as janelas simultaneamente, mas permite que o usuário interaja apenas com a cópia fantasma. Usando essa janela fantasma, o usuário só pode mover, minimizar e, o mais importante, fechar o aplicativo sem resposta, mas não alterar seu estado interno.

Toda a experiência fantasma tem esta aparência:

Captura de tela que mostra a caixa de diálogo

O Gerenciador de Janelas da Área de Trabalho faz uma última coisa; ele se integra ao Relatório de Erros do Windows, permitindo que o usuário não apenas feche e, opcionalmente, reinicie o aplicativo, mas também envie dados valiosos de depuração de volta para a Microsoft. Você pode obter esses dados de travamento para seus próprios aplicativos inscrevendo-se no site do Winqual.

O Windows 7 adicionou um novo recurso a essa experiência. O sistema operacional analisa o aplicativo suspenso e, em determinadas circunstâncias, dá ao usuário a opção de cancelar uma operação de bloqueio e tornar o aplicativo responsivo novamente. A implementação atual dá suporte ao cancelamento do bloqueio de chamadas de soquete; mais operações serão canceláveis pelo usuário em versões futuras.

Para integrar seu aplicativo à experiência de recuperação de travamento e aproveitar ao máximo os dados disponíveis, siga estas etapas:

  • Certifique-se de que seu aplicativo se registre para reinicialização e recuperação, tornando um travamento o mais livre de dor possível para o usuário. Um aplicativo registrado corretamente pode ser reiniciado automaticamente com a maioria de seus dados não salvos intactos. Isso funciona para travamentos e falhas de aplicativo.
  • Obtenha informações de frequência, bem como dados de depuração para seus aplicativos suspensos e com falhas no site do Winqual. Você pode usar essas informações mesmo durante sua Versão Beta para melhorar seu código. Confira "Apresentando Relatório de Erros do Windows" para obter uma breve visão geral.
  • Você pode desabilitar o recurso ghosting em seu aplicativo por meio de uma chamada para DisableProcessWindowsGhosting (). No entanto, isso impede que o usuário médio feche e reinicie um aplicativo suspenso e geralmente termina em uma reinicialização.

Travamentos – Perspectiva do Desenvolvedor

O sistema operacional define um aplicativo travado como um thread de interface do usuário que não processa mensagens por pelo menos 5 segundos. Bugs óbvios causam alguns travamentos, por exemplo, um thread aguardando um evento que nunca é sinalizado e dois threads cada um mantendo um bloqueio e tentando adquirir os outros. Você pode corrigir esses bugs sem muito esforço. No entanto, muitos travamentos não são tão claros. Sim, o thread da interface do usuário não está recuperando mensagens , mas está igualmente ocupado fazendo outro trabalho "importante" e, eventualmente, voltará ao processamento de mensagens.

No entanto, o usuário percebe isso como um bug. O design deve corresponder às expectativas do usuário. Se o design do aplicativo levar a um aplicativo sem resposta, o design precisará ser alterado. Por fim, e isso é importante, a falta de resposta não pode ser corrigida como um bug de código; requer trabalho antecipado durante a fase de design. Tentar readequar a base de código existente de um aplicativo para tornar a interface do usuário mais responsiva geralmente é muito cara. As diretrizes de design a seguir podem ajudar.

  • Tornar a capacidade de resposta da interface do usuário um requisito de nível superior; o usuário deve sempre se sentir no controle do seu aplicativo
  • Verifique se os usuários podem cancelar operações que levam mais de um segundo para serem concluídas e/ou se as operações podem ser concluídas em segundo plano; fornecer a interface do usuário de progresso apropriada, se necessário

Captura de tela que mostra a caixa de diálogo 'Copiar itens'.

  • Enfileirar operações de execução longa ou de bloqueio como tarefas em segundo plano (isso requer um mecanismo de mensagens bem pensado para informar o thread da interface do usuário quando o trabalho for concluído)
  • Mantenha o código para threads de interface do usuário simples; remover o máximo possível de chamadas à API de bloqueio
  • Mostrar janelas e caixas de diálogo somente quando estiverem prontas e totalmente operacionais. Se a caixa de diálogo precisar exibir informações que sejam muito intensivas em recursos para calcular, mostre algumas informações genéricas primeiro e atualize-as em tempo real quando mais dados estiverem disponíveis. Um bom exemplo é a caixa de diálogo de propriedades de pasta do Windows Explorer. Ele precisa exibir o tamanho total da pasta, informações que não estão prontamente disponíveis no sistema de arquivos. A caixa de diálogo é exibida imediatamente e o campo "tamanho" é atualizado de um thread de trabalho:

Captura de tela que mostra a página 'Geral' das Propriedades do Windows com o texto 'Tamanho', 'Tamanho no disco' e 'Contém' circulado.

Infelizmente, não há uma maneira simples de projetar e escrever um aplicativo responsivo. O Windows não fornece uma estrutura assíncrona simples que permitiria um agendamento fácil de operações de bloqueio ou de execução longa. As seções a seguir apresentam algumas das práticas recomendadas para evitar travamentos e realçar algumas das armadilhas comuns.

Práticas Recomendadas

Manter o thread da interface do usuário simples

A principal responsabilidade do thread de interface do usuário é recuperar e expedir mensagens. Qualquer outro tipo de trabalho apresenta o risco de pendurar as janelas pertencentes a esse thread.

O que fazer:

  • Mover algoritmos com uso intensivo de recursos ou não associados que resultam em operações de execução prolongada para threads de trabalho
  • Identifique o máximo possível de chamadas de função de bloqueio e tente movê-las para threads de trabalho; qualquer função que chame outra DLL deve ser suspeita
  • Faça um esforço extra para remover todas as chamadas de E/S de arquivo e de API de rede do thread de trabalho. Essas funções podem ser bloqueadas por muitos segundos, se não minutos. Se você precisar fazer qualquer tipo de E/S no thread de interface do usuário, considere o uso de E/S assíncrona
  • Lembre-se de que seu thread de interface do usuário também está atendendo a todos os servidores COM STA (single-threaded apartment) hospedados pelo processo; se você fizer uma chamada de bloqueio, esses servidores COM não responderão até que você ateie a fila de mensagens novamente

Não:

  • Aguarde qualquer objeto kernel (como Event ou Mutex) por mais de um período muito curto de tempo; se você precisar esperar, considere usar MsgWaitForMultipleObjects(), que desbloqueará quando uma nova mensagem chegar
  • Compartilhe a fila de mensagens da janela de um thread com outro thread usando a função AttachThreadInput(). Não é apenas extremamente difícil sincronizar corretamente o acesso à fila, mas também pode impedir que o sistema operacional Windows detecte corretamente uma janela pendurada
  • Use TerminateThread() em qualquer um dos threads de trabalho. Encerrar um thread dessa maneira não permitirá que ele libere bloqueios ou eventos de sinal e pode facilmente resultar em objetos de sincronização órfãos
  • Chame qualquer código 'desconhecido' do thread da interface do usuário. Isso é especialmente verdadeiro se o aplicativo tiver um modelo de extensibilidade; não há nenhuma garantia de que o código de terceiros siga suas diretrizes de capacidade de resposta
  • Fazer qualquer tipo de chamada de transmissão de bloqueio; SendMessage(HWND_BROADCAST) coloca você à mercê de todos os aplicativos mal escritos em execução no momento

Implementar padrões assíncronos

A remoção de operações de execução longa ou de bloqueio do thread de interface do usuário requer a implementação de uma estrutura assíncrona que permite descarregar essas operações para threads de trabalho.

O que fazer:

  • Use APIs de mensagem de janela assíncronas em seu thread de interface do usuário, especialmente substituindo SendMessage por um de seus pares sem bloqueio: PostMessage, SendNotifyMessage ou SendMessageCallback
  • Use threads em segundo plano para executar tarefas de execução longa ou de bloqueio. Usar a nova API do pool de threads para implementar seus threads de trabalho
  • Forneça suporte de cancelamento para tarefas em segundo plano de execução prolongada. Para bloquear operações de E/S, use o cancelamento de E/S, mas apenas como último recurso; não é fácil cancelar a operação 'right'
  • Implementar um design assíncrono para código gerenciado usando o padrão IAsyncResult ou usando Eventos

Usar bloqueios sabiamente

Seu aplicativo ou DLL precisa de bloqueios para sincronizar o acesso às estruturas de dados internas. O uso de vários bloqueios aumenta o paralelismo e torna seu aplicativo mais responsivo. No entanto, o uso de vários bloqueios também aumenta a chance de adquirir esses bloqueios em ordens diferentes e fazer com que seus threads sejam deadlock. Se dois threads mantiverem um bloqueio e tentarem adquirir o bloqueio do outro thread, suas operações formarão uma espera circular que bloqueia qualquer progresso para esses threads. Você pode evitar esse deadlock apenas garantindo que todos os threads no aplicativo sempre adquiram todos os bloqueios na mesma ordem. No entanto, nem sempre é fácil adquirir bloqueios na ordem "certa". Componentes de software podem ser compostos, mas aquisições de bloqueio não podem. Se o código chamar algum outro componente, os bloqueios desse componente agora se tornarão parte da ordem de bloqueio implícita , mesmo que você não tenha visibilidade desses bloqueios.

As coisas ficam ainda mais difíceis porque as operações de bloqueio incluem muito mais do que as funções usuais para Seções Críticas, Mutexes e outros bloqueios tradicionais. Qualquer chamada de bloqueio que cruze os limites do thread tem propriedades de sincronização que podem resultar em um deadlock. O thread de chamada executa uma operação com semântica 'acquire' e não pode desbloquear até que o thread de destino 'libere' essa chamada. Algumas funções user32 (por exemplo, SendMessage), bem como muitas chamadas COM de bloqueio se enquadram nessa categoria.

Pior ainda, o sistema operacional tem seu próprio bloqueio interno específico do processo que, às vezes, é mantido enquanto o código é executado. Esse bloqueio é adquirido quando as DLLs são carregadas no processo e, portanto, é chamado de "bloqueio do carregador". A função DllMain sempre é executada sob o bloqueio do carregador; se você adquirir bloqueios no DllMain (e não deve), precisará tornar o bloqueio do carregador parte do pedido de bloqueio. Chamar determinadas APIs win32 também pode adquirir o bloqueio do carregador em seu nome – funções como LoadLibraryEx, GetModuleHandle e, especialmente, CoCreateInstance.

Para unir tudo isso, examine o código de exemplo abaixo. Essa função adquire vários objetos de sincronização e define implicitamente uma ordem de bloqueio, algo que não é necessariamente óbvio na inspeção de cursor. Na entrada da função, o código adquire uma Seção Crítica e não a libera até que a função seja encerrada, tornando-a o nó superior em nossa hierarquia de bloqueio. Em seguida, o código chama a função Win32 LoadIcon(), que, sob as capas, pode chamar o Carregador do Sistema Operacional para carregar esse binário. Essa operação adquiriria o bloqueio do carregador, que agora também se torna parte dessa hierarquia de bloqueio (verifique se a função DllMain não adquire o bloqueio de g_cs). Em seguida, o código chama SendMessage(), uma operação de bloqueio entre threads, que não será retornada a menos que o thread da interface do usuário responda. Novamente, verifique se o thread da interface do usuário nunca adquire 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;  
}  

Olhando para esse código, parece claro que fizemos implicitamente g_cs o bloqueio de nível superior em nossa hierarquia de bloqueio, mesmo que quiséssemos apenas sincronizar o acesso às variáveis de membro da classe.

O que fazer:

  • Crie uma hierarquia de bloqueio e obedeça-a. Adicione todos os bloqueios necessários. Há muito mais primitivos de sincronização do que apenas Mutex e CriticalSections; todos eles precisam ser incluídos. Inclua o bloqueio do carregador em sua hierarquia se você tiver bloqueios em DllMain()
  • Concorde com o protocolo de bloqueio com suas dependências. Qualquer código que seu aplicativo chama ou que possa chamar seu aplicativo precisa compartilhar a mesma hierarquia de bloqueio
  • Bloquear estruturas de dados não funciona. Mova as aquisições de bloqueio para longe dos pontos de entrada da função e proteja apenas o acesso a dados com bloqueios. Se menos código opera sob um bloqueio, há menos chance de deadlocks
  • Analise aquisições e versões de bloqueio no código de tratamento de erros. Geralmente, a hierarquia de bloqueio se for esquecida ao tentar se recuperar de uma condição de erro
  • Substitua bloqueios aninhados por contadores de referência – eles não podem fazer deadlock. Elementos bloqueados individualmente em listas e tabelas são bons candidatos
  • Tenha cuidado ao aguardar um identificador de thread de uma DLL. Sempre suponha que seu código possa ser chamado sob o bloqueio do carregador. É melhor fazer referência à contagem de seus recursos e permitir que o thread de trabalho faça sua própria limpeza (e, em seguida, use FreeLibraryAndExitThread para terminar de forma limpa)
  • Use a API de Passagem da Cadeia de Espera se você quiser diagnosticar seus próprios deadlocks

Não:

  • Faça qualquer coisa que não seja um trabalho de inicialização muito simples em sua função DllMain(). Consulte Função de retorno de chamada DllMain para obter mais detalhes. Especialmente não chamar LoadLibraryEx ou CoCreateInstance
  • Escreva seus próprios primitivos de bloqueio. O código de sincronização personalizado pode facilmente introduzir bugs sutis em sua base de código. Em vez disso, use a seleção avançada de objetos de sincronização do sistema operacional
  • Faça qualquer trabalho nos construtores e destruidores para variáveis globais, eles são executados sob o bloqueio do carregador

Tenha cuidado com exceções

As exceções permitem a separação do fluxo normal do programa e do tratamento de erros. Devido a essa separação, pode ser difícil saber o estado preciso do programa antes da exceção e o manipulador de exceção pode perder etapas cruciais na restauração de um estado válido. Isso é especialmente verdadeiro para aquisições de bloqueio que precisam ser lançadas no manipulador para evitar deadlocks futuros.

O código de exemplo abaixo ilustra esse problema. O acesso não associado à variável "buffer" ocasionalmente resultará em uma violação de acesso (AV). Esse AV é capturado pelo manipulador de exceção nativo, mas não tem uma maneira fácil de determinar se a seção crítica já foi adquirida no momento da exceção (o AV poderia até mesmo ter ocorrido em algum lugar no 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;  
}  

O que fazer:

  • Remova __try/__except sempre que possível; não use SetUnhandledExceptionFilter
  • Encapsule seus bloqueios em modelos personalizados semelhantes a auto_ptr se você usar exceções C++. O bloqueio deve ser liberado no destruidor. Para exceções nativas, libere os bloqueios na instrução __finally
  • Tenha cuidado com o código em execução em um manipulador de exceção nativo; a exceção pode ter vazado muitos bloqueios, portanto, seu manipulador não deve adquirir nenhum

Não:

  • Manipule exceções nativas se não for necessário ou exigido pelas APIs do Win32. Se você usar manipuladores de exceção nativos para relatórios ou recuperação de dados após falhas catastróficas, considere usar o mecanismo padrão do sistema operacional de Relatório de Erros do Windows em vez disso
  • Use exceções C++ com qualquer tipo de código de interface do usuário (user32) ; uma exceção gerada em um retorno de chamada percorrerá camadas de código C fornecidas pelo sistema operacional. Esse código não sabe sobre a semântica de cancelamento de registro do C++