Ninja-Segfault
Montag, 7. Februar 2011 | Autor: Nico
Letzte Woche hatte ich einen Bug, der mich 4 Stunden und die entsprechende Menge Nerven gekostet hat. Der Originalcode ist ziemlich undurchsichtig, deswegen habe ich eine kleine, kompilierbare Demo geschrieben:
#include <vector> #include <stack> class Blubb { public: std::vector<int> * liste; Blubb() { liste = new std::vector<int>(10); } ~Blubb() { delete liste; } }; int main() { std::stack<Blubb> stapel; Blubb blip; stapel.push(blip); return 0; }
Wer meint, C++ zu beherrschen, darf jetzt grübeln, warum dieser Code einen Segfault wirft. Viel Spaß dabei! 😉
Ansonsten führen wir diesen Code einfach mal aus:
nico@linux:~/code> ./a.out *** glibc detected *** ./a.out: double free or corruption (fasttop): 0x0000000000606060 *** ======= Backtrace: ========= /lib64/libc.so.6(+0x73226)[0x7fb32a25c226] /lib64/libc.so.6(cfree+0x6c)[0x7fb32a260fcc] ./a.out[0x400a83] ./a.out[0x4026d2] ./a.out[0x40240c] ./a.out[0x402031] ./a.out[0x401cb3] ./a.out[0x4016a6] ./a.out[0x40105f] ./a.out[0x400c1e] ./a.out[0x400aa2] ./a.out[0x400930] /lib64/libc.so.6(__libc_start_main+0xfd)[0x7fb32a207b7d] ./a.out[0x4007f9] ======= Memory map: ======== 00400000-00405000 r-xp 00000000 08:07 2105675 /home/nico/code/a.out 00604000-00605000 r--p 00004000 08:07 2105675 /home/nico/code/a.out ... [diverse Zeilen ausgelassen] ... 7fff829f3000-7fff829f4000 r-xp 00000000 00:00 0 [vdso] ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall] Aborted nico@linux:~/code>
Diese Meldung ist noch relativ aussagekräftig - der ursprüngliche Code allerdings zeigte diese nicht, sondern rief nur: Segfault! Segfault bedeutet: Wir greifen auf Speicher zu, auf den wir nicht zugreifen dürfen. Das passiert fast ausschließlich aus zwei Gründen:
1. Man greift auf eine Stelle in einem Array o.Ä. zu, die gar nicht mehr im Array drin liegt. Typisches Beispiel:
vector liste(3); cout << liste[3];
Hier wird ein vector der Länge 3 definiert. Dann möchte man das dritte Element anzeigen - da aber die Zählung bei Null beginnt, greift man mit [3]
fälschlicherweise auf das vierte Element zu.
2. Man hat mit Pointern1 rumgespielt und dabei nicht aufgepasst und nun zeigt irgend ein Pointer an eine Speicherstelle, die uns nicht gehört.
Ursache
Der Bug im obigen Code ist aus der zweiten Kategorie. Am besten wir gehen den Code nochmal durch:
class Blubb { public: std::vector<int> * liste; Blubb() { liste = new std::vector<int>(10); } ~Blubb() { delete liste; } };
Wir haben eine Klasse Blubb - diese erzeugt in ihrem Konstruktor mit new
2 einen vector und speichert den Pointer, der auf diesen zeigt. Mit new
erzeugte Objekte gehen nicht von selbst wieder weg - wenn wir sie nicht mehr brauchen, müssen wir sie mit delete
wieder löschen, d.h. den Speicherplatz, den sie belegen, wieder freigeben. Das tun wir hier im Destruktor. Täten wir’s nicht, würde der Vector in alle Ewigkeit Speicherplatz belegen - das nennt man Speicherleck3 und ist natürlich zu vermeiden.
Soweit, so gut. Kommen wir zur main:
int main() { std::stack<Blubb> stapel; Blubb blip; stapel.push(blip); return 0; }
Wir machen uns hier einen Stack auf, auf den wir ein Blubb-Objekt legen wollen. Ich habe hier einen Stack genommen, aber das Problem, das gleich auftaucht, existiert mit nahezu allen Containern4.
Wenn man jetzt eine Weile rumspielt und den Fehler einkreist, merkt man irgendwann, dass der Segfault von unserem delete liste;
herrührt. Man merkt dann auch, dass delete liste;
zweimal(!) aufgerufen wird, weil der Desktruktor zweimal aufgerufen wird!
„Hä?!” war auch meine erste Reaktion.
Das Problem ist, dass push(blip);
das Objekt blip
auf den Stack kopiert. Das heißt wir haben nun zwei Kopien von blip
! Beide Kopien enthalten denselben Pointer zur liste
.
Wenn nun das Ende der Funktion main erreicht ist, werden alle in main definierten Objekte zerstört, d.h. deren Destruktor aufgerufen. Diese Objekte sind 1. stapel
, 2. alle Objekte auf dem Stapel 3. blip
.
Das ist ein Problem, denn sowohl bei der Zerstörung von blip
, als auch der Zerstörung der Kopie, wird delete liste;
aufgerufen. Beim ersten Mal ist noch alles in Ordnung: Der Speicherplatz an der in liste
gespeicherten Adresse wird freigegeben. Beim zweiten Mal versucht delete
erneut, den selben Speicher freizugeben, der uns nun aber gar nicht mehr gehört! Das ist nicht in Ordnung und kann zu Fehlermeldungen und Abstürzen führen5.
Nun ist auch die obige Fehlermeldung klar:
*** glibc detected *** ./a.out: double free or corruption (fasttop): 0x0000000000606060 ***
Ersteres ist der Fall: double free - hier in Form eines doppelten delete
6.
Lösungsmöglichkeiten
Man könnte denken, das Problem ließe sich mit folgender Variante umgehen:
int main() { std::stack<Blubb> stapel; stapel.push(Blubb()); return 0; }
Tatsächlich aber werden auch hier zwei Kopien erzeugt. Zunächst wird mit Blubb()
ein, wenn auch namenloses, Objekt erzeugt. Davon wird ganz regulär eine Kopie erstellt und das namenlose Objekt dann wieder gelöscht (und delete liste;
ausgeführt). Es ist genau das selbe Spiel - wir gewinnen nichts dabei.
Natürlich ist es nicht sinnvoll, extra ein namenloses Objekt anzulegen, nur damit es kopiert und dann gleich wieder gelöscht wird. Das kostet schließlich alles Performance. In Zukunft soll C++ daher ein paar Mechanismen erhalten, die das eleganter lösen. Aber bis dieser neue C++-Standard namens C++0x da ist, vergehen sicher noch einige Jahre. Der GCC enthält zwar schon ein paar der angedachten Features, aber noch nichts, was uns hier weiterhelfen würde.
Copy-Constructor
Das Kernproblem an der ganzen Sache ist, dass wir keinen eigenen Copy-Constructor definiert haben. Deshalb definiert sich C++ selbst einen. Dieser kopiert einfach die ganze Klasse, was an sich gut klingt - aber was passiert mit unserem Pointer liste
? Der wird kopiert. Was passiert mit dem Vector, auf den der Pointer zeigt? Nichts! C++ fasst diesen Vector nicht an - es wird per default wirklich nur der Pointer kopiert!
Diese Art der Kopie nennt man Shallow-Copy. Das ist der Grund, weshalb beide Kopien auf den selben Vector zeigen.
Wie löst man das Problem? Es gibt zwei Möglichkeiten:
1. Deep Copy
Das Gegenteil von Shallow-Copy heißt Deep-Copy. Deep bedeutet hier, dass auch Datenstrukturen, für die in der Klasse lediglich ein Pointer existiert, mitkopiert werden. Das muss man manuell machen, das heißt, man muss einen Copy-Constructor schreiben. Zum Beispiel sowas7:
Blubb(const Blubb & original) { liste = new std::vector(*original.liste); }
2. Kopieren verbieten
Man kann das Kopieren auch schlicht verbieten! Dazu definiert man einfach einen private
Copy-Constructor:
private: Blubb(const Blubb & original) { liste = new std::vector(*original.liste); }
Ob das sinnvoll ist, kommt allerdings auf das Design des Programms an.
- Variablen, die selbst keinen Inhalt enthalten, sondern eine Speicheradresse. Auch „Zeiger” genannt. ↩
- für mehr Informationen bezüglich dynamischer Speicherallokation mit
new
, siehe cplusplus.com/doc/tutorial/dynamic ↩ - In diesem kleinen Codestück macht es natürlich nicht viel aus - aber es ist ja nur ein Beispiel! ↩
- Außer intrusive Containern aus z.B. Boost ↩
- Muss aber nicht. Leider, denn so kann der Bug lange unentdeckt bleiben. ↩
-
malloc
/free
sind noch aus der C-Welt. In C++ ist die Speicherallokation mittelsnew
/delete
üblicher. ↩ - Ich übernehme keine Garantie für Korrektheit. Beim Definieren von Copy-Construktoren oder Assignment-Operatoren gibt es eine Reihe Fallstricke, mit denen ich mich nicht im Detail befasst habe. ↩