Eine nicht erkannte Funktion verlangsamt das Programm fünfmal

Windows verlangsamen Teil 3: Prozessbeendigung



Der Autor ist damit beschäftigt, die Leistung von Chrome bei Google zu optimieren - ca. trans.

Im Sommer 2017 hatte ich Probleme mit der Windows-Leistung. Die Prozessbeendigung war langsam, serialisiert und blockierte die Systemeingabewarteschlange, was zu mehreren Einfrierungen des Mauszeigers während der Montage von Chrome führte. Der Hauptgrund war, dass Windows am Ende der Prozesse viel Zeit damit verbrachte, nach GDI-Objekten zu suchen, während der kritische Abschnitt des systemglobalen Benutzers 32 gedrückt gehalten wurde. Ich habe darüber im Artikel "24-Kern-Prozessor, aber ich kann den Cursor nicht bewegen" gesprochen.

Microsoft hat den Fehler behoben und ich bin zu meinem Geschäft zurückgekehrt, aber dann stellte sich heraus, dass der Fehler zurück war. Es gab Beschwerden über den langsamen Betrieb der LLVM-Tests mit häufigen Eingabehängen.

Tatsächlich kehrte der Fehler jedoch nicht zurück. Der Grund war eine Änderung in unserem Code.

Ausgabe 2017


Jeder Windows-Prozess enthält mehrere Standard-GDI-Objektdeskriptoren. Für Prozesse, die nichts mit Grafiken tun, sind diese Deskriptoren normalerweise NULL. Am Ende des Prozesses ruft Windows einige Funktionen für diese Deskriptoren auf, auch wenn sie NULL sind. Es war egal - die Funktionen funktionierten schnell - bis zur Veröffentlichung der Windows 10 Anniversary Edition, in der einige Sicherheitsänderungen diese Funktionen verlangsamten . Während des Betriebs hatten sie dieselbe Sperre, die für Eingabeereignisse verwendet wurde. Wenn eine große Anzahl von Prozessen gleichzeitig beendet wird, ruft jeder mehrere Male die langsame Funktion auf, die diese kritische Sperre enthält, was letztendlich dazu führt, dass Benutzereingaben blockiert werden und der Cursor einfriert.

Der Patch von Microsoft bestand darin, diese Funktionen nicht für Prozesse ohne GDI-Objekte aufzurufen. Ich kenne die Details nicht, aber ich denke, der Microsoft-Patch war ungefähr so:

+ if (IsGUIProcess())
+ NtGdiCloseProcess();
– NtGdiCloseProcess();


Das heißt, überspringen Sie einfach die GDI-Bereinigung, wenn der Prozess kein GUI / GDI-Prozess ist.

Da Compiler und andere Prozesse, die wir schnell erstellen und beenden, keine GDI-Objekte verwendeten, erwies sich dieser Patch als ausreichend, um das Einfrieren der Benutzeroberfläche zu beheben.

Ausgabe 2018


Es stellte sich heraus, dass den Prozessen einige Standard-GDI-Objekte sehr einfach zugeordnet werden können. Wenn Ihr Prozess gdi32.dll lädt, erhalten Sie automatisch GDI-Objekte (DC, Oberflächen, Regionen, Pinsel, Schriftarten usw.), unabhängig davon, ob Sie diese benötigen oder nicht (beachten Sie, dass diese Standard-GDI-Objekte nicht im Task-Manager angezeigt werden unter den GDI-Objekten für den Prozess).

Das sollte aber kein Problem sein. Ich meine, warum sollte der Compiler gdi32.dll laden? Nun, es stellte sich heraus, dass Sie beim Laden von user32.dll, shell32.dll, ole32.dll oder vielen anderen DLLs automatisch zusätzlich gdi32.dll (mit den oben genannten Standard-GDI-Objekten) erhalten. Und es ist sehr einfach, versehentlich eine dieser Bibliotheken herunterzuladen.

LLVM-Tests beim Laden jedes Prozesses mit dem Namen CommandLineToArgvW (shell32.dll) und manchmal mit dem Namen SHGetKnownFolderPath (auch shell32.dll) Diese Aufrufe reichten aus, um gdi32.dll abzurufen und diese beängstigenden Standard-GDI-Objekte zu generieren. Da die LLVM-Testsuite so viele Prozesse generiert, wird sie letztendlich nach Abschluss der Prozesse serialisiert, was zu enormen Verzögerungen und Einfrierungen der Eingabe führt, viel schlimmer als im Jahr 2017.

Aber diesmal wussten wir über das Hauptproblem beim Blockieren Bescheid und wussten sofort, was zu tun ist.

Zunächst mussten wir CommandLineToArgvW nicht mehr aufrufen und die Befehlszeile manuell analysieren . Danach hat die LLVM-Testsuite selten eine Funktion aus einer problematischen DLL aufgerufen. Wir wussten jedoch im Voraus, dass dies die Leistung in keiner Weise beeinträchtigen würde. Der Grund war, dass selbst der verbleibende bedingte Aufruf ausreichte, um immer shell32.dll zu ziehen, was wiederum gdi32.dll zog, wodurch Standard-GDI-Objekte erstellt wurden.

Der zweite Fix war das verzögerte Laden von shell32.dll . Verzögertes Laden bedeutet, dass die Bibliothek bei Bedarf geladen wird - wenn die Funktion aufgerufen wird - anstatt zu laden, wenn der Prozess startet. Dies bedeutete, dass shell32.dll und gdi32.dll selten und nicht immer geladen wurden.

Danach lief die LLVM-Testsuite fünfmal schneller - in einer Minute statt in fünf. Und auf den Entwicklungsmaschinen friert keine Maus mehr ein, sodass die Mitarbeiter während der Ausführung der Tests normal arbeiten können. Dies ist eine verrückte Beschleunigung für eine so bescheidene Änderung, und der Autor der Patches war für meine Untersuchung so dankbar, dass er mich für einen Unternehmensbonus nominierte .

Manchmal haben die kleinsten Änderungen die größten Konsequenzen. Sie müssen nur wissen, wo Sie "Null" wählen können .

Ausführungspfad nicht akzeptiert


Es ist erwähnenswert, dass wir auf Code geachtet haben, der nicht ausgeführt wurde - und dies war eine wichtige Änderung. Wenn Sie über ein Befehlszeilentool verfügen, das nicht auf gdi32.dll zugreift, verlangsamt das Hinzufügen von Code mit einem bedingten Funktionsaufruf den Vorgang um ein Vielfaches, wenn gdi32.dll geladen wird. Im folgenden Beispiel wird CommandLineToArgvW niemals aufgerufen, aber selbst eine einfache Präsenz im Code (ohne Anrufverzögerung) beeinträchtigt die Leistung:

 int main(int argc, char* argv[]) { if (argc < 0) { CommandLineToArgvW(nullptr, nullptr); // shell32.dll, pulls in gdi32.dll } } 

Ja, das Entfernen eines Funktionsaufrufs, auch wenn der Code nie ausgeführt wird, kann in einigen Fällen ausreichen, um die Leistung erheblich zu verbessern.

Fortpflanzung der Pathologie


Als ich den anfänglichen Fehler untersuchte, schrieb ich ein Programm ( ProcessCreateTests ), das 1000 Prozesse erstellte und sie dann alle parallel abbrach . Dies reproduzierte das Einfrieren, und als Microsoft den Fehler behebte, verwendete ich ein Testprogramm, um den Patch zu überprüfen: siehe Video . Nach der Reinkarnation des Fehlers habe ich mein Programm geändert, indem ich die Option -user32 hinzugefügt habe , mit der user32.dll für jeden der Tausenden von Testprozessen geladen wird. Wie erwartet erhöht sich die Abschlusszeit aller Testprozesse mit dieser Option dramatisch, und es ist leicht zu erkennen, dass der Mauszeiger einfriert. Die Prozesserstellungszeit erhöht sich ebenfalls mit der Option -user32, es gibt jedoch keine Cursorsuspensionen während der Prozesserstellung. Sie können dieses Programm verwenden und sehen, wie schrecklich das Problem sein kann. Hier sind einige typische Ergebnisse meines Notebooks mit vier Kernen und acht Threads nach einer Woche Betriebszeit. Die Option -user32 verlängert die Zeit für alles, aber die UserCrit- Sperre für Prozesse wird besonders dramatisch beendet:

> 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.


Nur zum Spaß tiefer graben


Ich dachte über einige ETW-Methoden nach, mit denen das Problem genauer untersucht werden kann, und begann bereits, sie zu schreiben. Aber ich stieß auf solch ein unerklärliches Verhalten, dass ich mich entschied, einen separaten Artikel zu widmen. Es genügt zu sagen, dass sich Windows in diesem Fall noch seltsamer verhält.

Andere Artikel in der Reihe:


Literatur


Source: https://habr.com/ru/post/de432076/


All Articles