Abrandando o Windows Parte 3: Encerramento do processo
O autor está empenhado em otimizar o desempenho do Chrome no Google - aprox. trans.No verão de 2017, lutei com um problema de desempenho do Windows. O encerramento do processo foi lento, serializado e bloqueou a fila de entrada do sistema, o que levou a vários congelamentos do cursor do mouse durante a montagem do Chrome. O principal motivo foi que, no final dos processos, o Windows passou muito tempo pesquisando objetos GDI, mantendo a seção crítica do usuário global do sistema32. Eu falei sobre isso no artigo
"Processador de 24 núcleos, mas não consigo mover o cursor" .
A Microsoft corrigiu o erro e eu voltei para os meus negócios, mas acabou que o bug estava de volta. Houve queixas sobre a operação lenta dos testes LLVM, com trocas frequentes de entrada.
Mas, de fato, o bug não retornou. O motivo foi uma alteração no nosso código.
2017 issue

Cada processo do Windows contém vários descritores de objetos GDI padrão. Para processos que não fazem nada com gráficos, esses descritores geralmente são NULL. No final do processo, o Windows chama algumas funções para esses descritores, mesmo que sejam NULL. Não importava - os recursos funcionavam rapidamente - até o lançamento do Windows 10 Anniversary Edition, no qual
algumas alterações de segurança tornavam esses recursos lentos . Durante a operação, eles mantiveram o mesmo bloqueio usado para eventos de entrada. Quando um grande número de processos é finalizado ao mesmo tempo, cada um faz várias chamadas para a função lenta que contém esse bloqueio crítico, o que leva a que a entrada do usuário seja bloqueada e o cursor congela.
O patch da Microsoft não era para chamar essas funções para processos sem objetos GDI. Não conheço os detalhes, mas acho que o patch da Microsoft era algo assim:
+ if (IsGUIProcess())
+ NtGdiCloseProcess();
– NtGdiCloseProcess();
Ou seja, pule a limpeza da GDI se o processo não for um GUI / GDI.
Como os compiladores e outros processos que criamos e finalizamos rapidamente não usavam objetos GDI, esse patch acabou sendo suficiente para corrigir o congelamento da interface do usuário.
Edição de 2018
Aconteceu que os processos são muito facilmente alocados a alguns objetos GDI padrão. Se o seu processo carregar o gdi32.dll, você receberá automaticamente objetos GDI (DC, superfícies, regiões, pincéis, fontes etc.), independentemente de precisar ou não deles (observe que esses objetos GDI padrão não são exibidos no Gerenciador de tarefas entre os objetos GDI para o processo).
Mas isso não deve ser um problema. Quero dizer, por que o compilador carregaria o gdi32.dll? Bem, descobriu-se que se você carregar user32.dll, shell32.dll, ole32.dll ou muitas outras DLLs, receberá automaticamente o gdi32.dll (com os objetos GDI padrão acima mencionados). E é muito fácil baixar acidentalmente uma dessas bibliotecas.
O LLVM testa ao carregar cada processo chamado
CommandLineToArgvW (shell32.dll) e, às vezes, chamado
SHGetKnownFolderPath (também shell32.dll) Essas chamadas foram suficientes para retirar o gdi32.dll e gerar esses objetos GDI padrão assustadores. Como o conjunto de testes do LLVM gera tantos processos, ele acaba sendo serializado na conclusão dos processos, causando enormes atrasos e congelamentos de entrada, muito piores do que os de 2017.
Mas desta vez soubemos do principal problema do bloqueio e, imediatamente, soubemos o que fazer.
Primeiro, nos livramos de chamar
CommandLineToArgvW ,
analisando manualmente a linha de comando . Depois disso, o conjunto de testes LLVM raramente chamava qualquer função de qualquer DLL problemática. Mas sabíamos antecipadamente que isso não afetaria o desempenho de nenhuma maneira. O motivo foi que mesmo a chamada
condicional restante era suficiente para sempre puxar shell32.dll, que por sua vez puxava gdi32.dll, que cria objetos GDI padrão.
A segunda correção foi o
atraso no carregamento do shell32.dll . Carregamento atrasado significa que a biblioteca carrega sob demanda - quando a função é chamada - em vez de carregar quando o processo é iniciado. Isso significava que o shell32.dll e o gdi32.dll seriam carregados raramente, e nem sempre.
Depois disso, o conjunto de testes LLVM começou a rodar
cinco vezes mais rápido - em um minuto, em vez de cinco. E o mouse não congela mais nas máquinas de desenvolvimento, para que os funcionários possam trabalhar normalmente durante a execução dos testes. É uma aceleração louca por uma mudança tão modesta, e o autor dos patches ficou tão agradecido pela minha investigação que me indicou para um
bônus corporativo .
Às vezes, as menores mudanças têm as maiores consequências. Você só precisa
saber onde discar "zero" .
Caminho de execução não aceito

Vale repetir que prestamos atenção ao código que
não foi executado - e essa foi uma mudança fundamental. Se você possui uma ferramenta de linha de comando que não acessa o gdi32.dll, a adição de código com uma chamada de função
condicional desacelerará o processo várias vezes se o gdi32.dll estiver carregado. No exemplo abaixo,
CommandLineToArgvW nunca
é chamado, mas mesmo uma simples presença no código (sem atraso de chamada) afeta adversamente o desempenho:
int main(int argc, char* argv[]) { if (argc < 0) { CommandLineToArgvW(nullptr, nullptr); // shell32.dll, pulls in gdi32.dll } }
Portanto, sim, remover uma chamada de função, mesmo que o código nunca seja executado, pode ser suficiente para melhorar significativamente o desempenho em alguns casos.
Reprodução patológica

Quando investiguei o erro inicial, escrevi um programa (
ProcessCreateTests ) que criou 1000 processos e depois os matou todos em paralelo. Isso reproduziu o congelamento e, quando a Microsoft corrigiu o erro, usei um programa de teste para verificar o patch: veja o
vídeo . Após a reencarnação do bug, alterei meu programa
adicionando a opção -user32 , que carrega o user32.dll para cada um dos milhares de processos de teste. Como esperado, o tempo de conclusão de todos os processos de teste aumenta drasticamente com essa opção e é fácil detectar o congelamento do cursor do mouse. O tempo de criação do processo também aumenta com a opção -user32, mas não há suspensões do cursor durante a criação do processo. Você pode usar este programa e ver o quão terrível o problema pode ser. Aqui estão alguns resultados típicos do meu notebook de quatro núcleos / oito threads após uma semana de atividade. A opção -user32 aumenta o tempo para tudo, mas o bloqueio do
UserCrit nos processos é encerrado de maneira especialmente dramática:
> ProcessCreatetests.exe
Process creation took 2.448 s (2.448 ms per process).
Lock blocked for 0.008 s total, maximum was 0.001 s.
Process destruction took 0.801 s (0.801 ms per process).
Lock blocked for 0.004 s total, maximum was 0.001 s.
> ProcessCreatetests.exe -user32
Testing with 1000 descendant processes with user32.dll loaded.
Process creation took 3.154 s (3.154 ms per process).
Lock blocked for 0.032 s total, maximum was 0.007 s.
Process destruction took 2.240 s (2.240 ms per process).
Lock blocked for 1.991 s total, maximum was 0.864 s.
Indo mais fundo só por diversão
Pensei em alguns métodos ETW que podem ser usados para estudar o problema com mais detalhes e já comecei a escrevê-los. Mas me deparei com um comportamento inexplicável, que decidi dedicar um artigo separado. Basta dizer que, nesse caso, o Windows se comporta ainda mais estranhamente.
Outros artigos da série:
Literatura
- Primeiro relatório de suspensão da interface do usuário: "Processador de 24 núcleos, mas não consigo mover o cursor"
- O artigo a seguir, que leva ao entendimento do problema: "O que * o * Windows faz enquanto mantém esse bloqueio"
- Um artigo sobre outro bloco de interface do usuário devido à interação entre o Gmail, os funcionários do ASLR v8, a política de alocação de memória CFG e a verificação lenta da WMI: "CPU de 24 núcleos, mas não consigo digitar um email"
- O compilador que carrega o gdi32.dll parece estranho, mas é ainda mais estranho que o compilador carregue o mshtml.dll, que o VC ++ costumava fazer em alguns casos
- Às vezes, semanas de pesquisa levam a mudanças pequenas, porém críticas, conforme discutido no artigo "Saber onde discar zero"
- Vídeo demonstrando o uso de ProcessCreateTests e ETW para verificar uma correção de bug
- Primeira alteração para o LLVM pela análise manual da linha de comandos
- Segunda correção para o LLVM usando o atraso de carregamento do shell32.dll