Retraso de Windows Parte 3: Terminación del proceso
El autor se dedica a optimizar el rendimiento de Chrome en Google: aprox. trans.En el verano de 2017, luché con un problema de rendimiento de Windows. La finalización del proceso fue lenta, serializada y bloqueó la cola de entrada del sistema, lo que provocó múltiples bloqueos del cursor del mouse durante el ensamblaje de Chrome. La razón principal fue que al final de los procesos, Windows pasó mucho tiempo buscando objetos GDI, mientras mantenía presionada la sección crítica del usuario global del sistema32. Hablé sobre esto en el artículo
"Procesador de 24 núcleos, pero no puedo mover el cursor" .
Microsoft solucionó el error y volví a mi negocio, pero luego resultó que el error había regresado. Hubo quejas sobre el funcionamiento lento de las pruebas LLVM, con frecuentes bloqueos de entrada.
Pero, de hecho, el error no regresó. La razón fue un cambio en nuestro código.
Número 2017

Cada proceso de Windows contiene varios descriptores de objetos GDI estándar. Para los procesos que no hacen nada con gráficos, estos descriptores suelen ser NULL. Al final del proceso, Windows llama a algunas funciones para estos descriptores, incluso si son NULL. No importó, las características funcionaron rápidamente, hasta el lanzamiento de la Edición de aniversario de Windows 10, en la que
algunos cambios de seguridad hicieron que estas características fueran lentas . Durante la operación, mantuvieron el mismo bloqueo que se utilizó para los eventos de entrada. Cuando una gran cantidad de procesos se terminan simultáneamente, cada uno realiza varias llamadas a la función lenta que mantiene este bloqueo crítico, lo que finalmente lleva a que la entrada del usuario se bloquee y el cursor se congele.
El parche de Microsoft no debía llamar a estas funciones para procesos sin objetos GDI. No conozco los detalles, pero creo que el parche de Microsoft fue algo como esto:
+ if (IsGUIProcess())
+ NtGdiCloseProcess();
– NtGdiCloseProcess();
Es decir, simplemente omita la limpieza de GDI si el proceso no es un proceso GUI / GDI.
Dado que los compiladores y otros procesos que creamos y terminamos rápidamente no usaban objetos GDI, este parche resultó ser suficiente para reparar la congelación de la interfaz de usuario.
Edición 2018
Resultó que a los procesos se les asignan muy fácilmente algunos objetos GDI estándar. Si su proceso carga gdi32.dll, recibirá automáticamente objetos GDI (DC, superficies, regiones, pinceles, fuentes, etc.), ya sea que los necesite o no (tenga en cuenta que estos objetos GDI estándar no se muestran en el Administrador de tareas entre los objetos GDI para el proceso).
Pero eso no debería ser un problema. Quiero decir, ¿por qué el compilador cargaría gdi32.dll? Bueno, resultó que si carga user32.dll, shell32.dll, ole32.dll o muchas otras DLL, automáticamente obtendrá además gdi32.dll (con los objetos GDI estándar mencionados anteriormente). Y es muy fácil descargar accidentalmente una de estas bibliotecas.
LLVM prueba al cargar cada proceso llamado
CommandLineToArgvW (shell32.dll), y a veces llamado
SHGetKnownFolderPath (también shell32.dll) Estas llamadas fueron suficientes para extraer gdi32.dll y generar estos objetos GDI estándar de miedo. Dado que el conjunto de pruebas LLVM genera tantos procesos, finalmente se serializa al finalizar los procesos, causando enormes demoras y congelaciones de entrada, mucho peores que las de 2017.
Pero esta vez sabíamos sobre el principal problema con el bloqueo, por lo que inmediatamente supimos qué hacer.
En primer lugar, nos deshicimos de llamar a
CommandLineToArgvW ,
analizando manualmente la línea de comando . Después de eso, el conjunto de pruebas LLVM rara vez llamaba a alguna función desde una DLL problemática. Pero sabíamos de antemano que esto no afectaría el rendimiento de ninguna manera. La razón era que incluso la llamada
condicional restante era suficiente para extraer siempre shell32.dll, que a su vez extraía gdi32.dll, que crea objetos GDI estándar.
La segunda solución fue la
carga retrasada de shell32.dll . La carga retrasada significa que la biblioteca se carga bajo demanda, cuando se llama a la función, en lugar de cargarse cuando comienza el proceso. Esto significaba que shell32.dll y gdi32.dll se cargarían raramente, y no siempre.
Después de eso, el conjunto de pruebas LLVM comenzó a ejecutarse
cinco veces más rápido, en un minuto en lugar de cinco. Y ya no se congela el mouse en las máquinas de desarrollo, para que los empleados puedan trabajar normalmente durante la ejecución de las pruebas. Esta es una aceleración loca para un cambio tan modesto, y el autor de los parches estaba tan agradecido por mi investigación que me nominó para un
bono corporativo .
A veces, los cambios más pequeños tienen las mayores consecuencias. Solo necesita
saber dónde marcar "cero" .
Ruta de ejecución no aceptada

Vale la pena repetir que prestamos atención al código que
no se ejecutó , y este fue un cambio clave. Si tiene una herramienta de línea de comando que no accede a gdi32.dll, entonces agregar código con una llamada de función
condicional ralentizará el proceso muchas veces si se carga gdi32.dll. En el siguiente ejemplo, nunca se llama a
CommandLineToArgvW , pero incluso una simple presencia en el código (sin demora de llamada) afecta negativamente el rendimiento:
int main(int argc, char* argv[]) { if (argc < 0) { CommandLineToArgvW(nullptr, nullptr); // shell32.dll, pulls in gdi32.dll } }
Entonces, sí, eliminar una llamada de función, incluso si el código nunca se ejecuta, puede ser suficiente para mejorar significativamente el rendimiento en algunos casos.
Reproducción de patología

Cuando investigué el error inicial, escribí un programa (
ProcessCreateTests ) que creó 1000 procesos y luego los eliminó a todos en paralelo. Esto reprodujo el congelamiento, y cuando Microsoft corrigió el error, utilicé un programa de prueba para verificar el parche: mira el
video . Después de la reencarnación del error, cambié mi programa
agregando la opción -user32 , que carga user32.dll para cada uno de los miles de procesos de prueba. Como se esperaba, el tiempo de finalización de todos los procesos de prueba aumenta dramáticamente con esta opción, y es fácil detectar el congelamiento del cursor del mouse. El tiempo de creación del proceso también aumenta con la opción -user32, pero no hay suspensiones de cursor durante la creación del proceso. Puede usar este programa y ver qué tan terrible puede ser el problema. Estos son algunos resultados típicos de mi portátil de cuatro núcleos / ocho subprocesos después de una semana de tiempo de actividad. La opción -user32 aumenta el tiempo para todo, pero el bloqueo de
UserCrit en los procesos termina especialmente dramáticamente:
> 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.
Cavando más profundo solo por diversión
Pensé en algunos métodos ETW que se pueden usar para estudiar el problema con más detalle, y ya comencé a escribirlos. Pero me encontré con un comportamiento tan inexplicable, que decidí dedicar un artículo separado. Baste decir que en este caso, Windows se comporta aún más extrañamente.
Otros artículos de la serie:
Literatura