Zwei komplizierte Probleme, mit denen man sich bei Programmen sehr häufig herumschlagen muss, sind Rechte und Speicherlecks bzw. allgemein der Ressourcenverbrauch. So hat mein KeePassXC seit einigen Versionen 1,6 GB RAM genutzt, was früher nicht der Fall war. Seit wann es so ist, kann ich nicht genau sagen, und deshalb haben ich – in der Hoffnung, es möge irgendwann wieder von allein besser werden 😉 – mit choom die Auswirkungen etwas gelindert.

Aber heute habe ich im Chat #keepassxc:matrix.org die Entwickler gefragt und die sahen dies auch als Fehler an und so habe ich mich auf die Suche nach dem Speicherleck begeben.

Valgrind

Ein altbekanntes Programm zum Untersuchen von Speicherproblemen ist valgrind. Die Nutzung ist auch recht einfach und mir ist dadurch ein Zugriff auf nicht initialisierten Speicher aufgefallen und valgrind hat auch bestätigt, dass KeePassXC über 1,5 GB RAM genutzt hat, aber es hat keinen Speicher gefunden, der verloren gegangen ist.

Ein typisches Problem ist nämlich, dass Programme sich mit malloc Speicher beschaffen, aber diesen nach der Nutzung nicht mit free an das System zurückgeben. Für langlaufende Prozesse belegt das Programm immer mehr Speicher, bis es irgendwann an eine Grenze kommt und abstürzt.

Von diesen Speicherproblemen gibt es noch weitere Arten, die valgrind aufspüren kann:

  • double-free: Speicher wird zweimal zurückgegeben
  • access after free: Auf Speicher, der bereits zurückgegeben wurde, wird zugegriffen

Heaptrack

Da ich mit valgrind keine Erklärung für den Speicherverbrauch gefunden hatte, habe ich nach anderen Möglichkeiten der Analyse des Speichers für die Programmausführung – heap genannt – gesucht und bin auf heaptrack (Blog, Beschreibung der Nutzung und technischen Details von heaptrack) gestoßen. Bei Debian gibt es ein Paket mit heaptrack und ein dazugehöriges Paket heaptrack-gui mit dem Programm heaptrack_gui zur grafischen Auswertung der Daten.

Der Aufruf ist genauso einfach wie bei valgrind: heaptrack keepassxc. In heaptrack_gui kann man danach die erstellte Datei öffnen (ggf. auch mit einer vorherigen vergleichen) und sich diverse Auswertungen ansehen:

Übersicht einer Speicheranalyse mit <em>heaptrack_gui</em> Verlauf über die Zeit des belegten Arbeitsspeichers Verlauf über die Zeit der Speicherreservierungen Darstellung der Aufrufpfade für Speicherreservierungen als <em>Flame-Graph</em> Diagramm der Speicherblockgrößen und ihrer Häufigkeit

Auswertung der Aufrufpfade ausgehend von der <em>malloc</em> aufrufenden Funktion

Dem Ziel näher gekommen bin ich über die Bottom-up-Auswertung, in der man sehr schön sieht, dass die Funktion QPixmap::fromImage() von vier bzw. fünf Stellen in Resources::icon() aufgerufen wird. Ab der Codezeile 176 sieht man auch deutlich die vier Aufrufe von QPixmap::fromImage() und dass der fünfte Aufruf im Else-Zweig liegt, weshalb er auch nur 16 mal ausgeführt wurde und damit 40 MB statt 346 MB genutzt hat.

Allgemeines

Mit all den Auswertungen von heaptrack_gui kann man also sehr schön das Speichernutzungsverhalten von Programmen untersuchen und nach Problemstellen suchen. Leider kommt heaptrack nicht mit externen Debugsymbolen, wie sie Debian in den dbgsym-Paketen liefert, nicht zurecht und zeigt die Codezeilen nicht an. Man kann sich aber als Abhilfe entsprechende Symlinks erstellen; siehe dafür Ausgelagerte Debugsymbole mit heaptrack/libbacktrace.

Mit heaptrack lassen sich auch nicht nur C++-Programme, sondern auch einfache C-Programme analysieren, im Grunde alle Programme, die malloc und free aufrufen, zum Beispiel auch mein Rust-Programm gitlog2rss. Als kleines Beispiel kann man heaptrack uname aufrufen oder auch Python mit Argumenten heaptrack python3 -c 'print(1)'.

Mit heaptrack -p PID kann man sich auch an einen laufenden Prozess anhängen und die kommenden Speicherreservierungen aufzeichnen. Dies könnte sich bei Diensten nützlich machen, die nur bei bestimmten Aktionen beobachtet werden sollen.

Gdb

Bei meinem Speicherproblem war weiterhin unklar, was die Ursache für die übermäßige Nutzung war. Deshalb habe ich wiedereinmal zum Debugger gdb gegriffen und dem Programm bei der Ausführung zugesehen. Praktisch dabei ist, dass man einem Haltepunkt Befehle zuordnen kann, die ausgeführt werden, wenn der Punkt erreicht wird. Dies habe ich schon öfter genutzt, um Werte zu protokollieren, indem ich in der Befehlsliste auch continue hatte, so dass das Programm nicht anhielt.

Die qt-Objekte sind durch Vererbung und Kapselung so verschachtelt, dass man mit Feldzugriffen nicht direkt an die gesuchten Werte herankommt. Deshalb ist es besser die Methodenaufrufe wie zum Programmieren üblich zu verwenden. Für die Ausgabe von QString muss man diesen in Utf8 umwandeln und den Zeiger auf den Puffer abfragen: name.toUtf8().data().

``` gdb (gdb) start Temporary breakpoint 1 at 0x72dea: file /home/joerg/git/keepassxc/src/main.cpp, line 48. Starting program: /home/joerg/git/keepassxc/build/src/keepassxc [Thread debugging using libthread_db enabled] Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Temporary breakpoint 1, main (argc=1, argv=0x7fffffffe3c8) at /home/joerg/git/keepassxc/src/main.cpp:48 48 { (gdb) br Resources::icon Breakpoint 2 at 0x555555683aa8: file /home/joerg/git/keepassxc/src/core/Resources.cpp, line 149. (gdb) commands Type commands for breakpoint(s) 2, one per line. End with a line saying just "end".

printf ">>> name=%s, !isValid=%s\n", name.isNull()?"NULL":name.toUtf8().data(), !overrideColor.isValid()?"true":"false" continue end (gdb) c … (gdb) i b Num Type Disp Enb Address What 2 breakpoint keep y 0x0000555555683aa8 in Resources::icon(QString const&, bool, QColor const&) at /home/joerg/git/keepassxc/src/core/Resources.cpp:149 breakpoint already hit 193 times printf ">>> name=%s, !isValid=%s\n", name.isNull()?"NULL":name.toUtf8().data(), !overrideColor.isValid()?"true":"false" continue ```

Für genau diesen Zweck habe ich heute dprintf entdeckt, das einen speziellen Haltepunkt setzt und automatisch die Ausführung fortsetzt. Der folgende Befehl hätte den obigen ersetzen können, wenn es nicht merkwürdige Abstürze beim Erreichen des Haltepunkts gegeben hätte:

gdb (gdb) dprintf Resources::icon, ">>> name=%s, !isValid=%s\n", name.isNull()?"NULL":name.toUtf8().data(), !overrideColor.isValid()?"true":"false" Dprintf 1 at 0x555555683aa8: file /home/joerg/git/keepassxc/src/core/Resources.cpp, line 149. (gdb) i b Num Type Disp Enb Address What 1 dprintf keep y 0x0000555555683aa8 in Resources::icon(QString const&, bool, QColor const&) at /home/joerg/git/keepassxc/src/core/Resources.cpp:149 printf ">>> name=%s, !isValid=%s\n", name.isNull()?"NULL":name.toUtf8().data(), !overrideColor.isValid()?"true":"false"

Aber man kann den Befehlsblock noch zu mehr verwenden. Dem Codeblock geht der merkwürdige Aufruf QIcon::setThemeName("application"); voran und ich hatte so den Gedanken, dass ein Ändern des Stilnamens zu einem Rücksetzen eines inneren Puffers führt, woraufhin ständig neuer Speicher alloziert wird. Deshalb wollte ich diese Anweisung überspringen jump +1, wenn der Name gesetzt ist, und sollte er Null sein, sollte in roter Schrift die Ausgabe NULL erfolgen. Die Anweisung printf schreibt auch nur auf die Konsole, weshalb man die üblichen Steuercodes für Farben (im Abschnitt »ECMA-48 Set Graphics Rendition«) verwenden kann.

``` gdb (gdb) b Resources.cpp:163 Breakpoint 3 at 0x555555683b1b: file /home/joerg/git/keepassxc/src/core/Resources.cpp, line 163. (gdb) r … (gdb) p QIcon::themeName().toUtf8().data() $3 = 0x555555aef168 "application" (gdb) commands 3 Type commands for breakpoint(s) 3, one per line. End with a line saying just "end".

if QIcon::themeName().isNull() printf "\e[1m\e[31mNULL\e[39m\e[0m\n" c else jump +1 end end (gdb) c … ```

Leider hat sich das auch nicht als Ursache herausgestellt. Also habe ich dann weiter gesucht und festgestellt, dass img.width() nicht 128, sondern 1121 ist, womit img über 1 MB groß ist und nicht nur 16 kB. Am Ende lieferte die Dokumentation von QIcon::pixmap die Erklärung dafür, wobei die sich sehr widersprüchlich liest:

Returns a pixmap with the requested size, mode, and state, generating one if necessary. The pixmap might be smaller than requested, but never larger.

Setting the Qt::AA_UseHighDpiPixmaps application attribute enables this function to return pixmaps that are larger than the requested size. Such images will have a devicePixelRatio larger than 1.

Also habe ich einfach mal beherzt statt 128 nur 16 eingetragen und siehe da, der Speicherverbrauch sank auf ein Zehntel (und KeePassXC startet auch schneller).