Warnung: Die Frage, die Sie gestellt haben, ist wirklich ziemlich komplex - wahrscheinlich viel komplexer als Sie denken. Infolgedessen ist dies eine sehr lange Antwort.
Aus rein theoretischer Sicht gibt es wahrscheinlich eine einfache Antwort darauf: Es gibt (wahrscheinlich) nichts an C #, was wirklich verhindert, dass es so schnell wie C ++ ist. Trotz der Theorie gibt es jedoch einige praktische Gründe, warum es unter bestimmten Umständen unter bestimmten Umständen langsamer ist .
Ich werde drei grundlegende Unterschiede betrachten: Sprachfunktionen, Ausführung virtueller Maschinen und Speicherbereinigung. Die beiden letzteren gehören oft zusammen, können aber unabhängig voneinander sein, daher werde ich sie separat betrachten.
Sprachmerkmale
C ++ legt großen Wert auf Vorlagen und Funktionen im Vorlagensystem, die größtenteils dazu gedacht sind, beim Kompilieren so viel wie möglich zu tun. Aus Sicht des Programms sind sie daher "statisch". Die Vorlagen-Metaprogrammierung ermöglicht die Ausführung völlig beliebiger Berechnungen zur Kompilierungszeit (dh das Vorlagensystem ist Turing abgeschlossen). Daher kann im Wesentlichen alles, was nicht von Eingaben des Benutzers abhängt, zur Kompilierungszeit berechnet werden. Zur Laufzeit ist es also einfach eine Konstante. Die Eingabe hierfür kann jedoch Dinge wie Typinformationen enthalten. Daher wird ein Großteil dessen, was Sie zur Laufzeit in C # über Reflection tun würden, normalerweise zur Kompilierungszeit über die Metaprogrammierung von Vorlagen in C ++ ausgeführt. Es gibt jedoch definitiv einen Kompromiss zwischen Laufzeitgeschwindigkeit und Vielseitigkeit - was Vorlagen tun können,
Die Unterschiede in den Sprachmerkmalen führen dazu, dass fast jeder Versuch, die beiden Sprachen einfach durch Transliterieren von C # in C ++ (oder umgekehrt) zu vergleichen, wahrscheinlich zu Ergebnissen zwischen bedeutungslos und irreführend führt (und dasselbe gilt für die meisten anderen Sprachpaare auch). Die einfache Tatsache ist, dass für alles, was größer als ein paar Codezeilen oder so ist, fast niemand die Sprachen auf dieselbe Weise (oder nahe genug auf dieselbe Weise) verwenden wird, dass ein solcher Vergleich etwas darüber aussagt, wie diese Sprachen aussehen Arbeit im wirklichen Leben.
Virtuelle Maschine
Wie fast jede einigermaßen moderne VM kann und wird Microsoft for .NET eine JIT-Kompilierung (auch als "dynamisch" bezeichnet) durchführen. Dies stellt jedoch eine Reihe von Kompromissen dar.
In erster Linie ist die Optimierung von Code (wie die meisten anderen Optimierungsprobleme) größtenteils ein NP-vollständiges Problem. Für alles andere als ein wirklich triviales / Spielzeugprogramm ist fast garantiert, dass Sie das Ergebnis nicht wirklich "optimieren" (dh Sie finden nicht das wahre Optimum) - der Optimierer macht den Code einfach besser als er war vorher. Einige bekannte Optimierungen benötigen jedoch viel Zeit (und häufig Speicher) für die Ausführung. Bei einem JIT-Compiler wartet der Benutzer, während der Compiler ausgeführt wird. Die meisten teureren Optimierungstechniken sind ausgeschlossen. Die statische Kompilierung hat zwei Vorteile: Erstens, wenn sie langsam ist (z. B. beim Aufbau eines großen Systems), wird sie normalerweise auf einem Server ausgeführt, und niemandverbringt viel Zeit damit, darauf zu warten. Zweitens kann eine ausführbare Datei generiert werden , einmal , und oft von vielen Menschen genutzt. Der erste minimiert die Kosten für die Optimierung; Die zweite amortisiert die viel geringeren Kosten über eine viel größere Anzahl von Ausführungen.
Wie in der ursprünglichen Frage (und auf vielen anderen Websites) erwähnt, bietet die JIT-Kompilierung die Möglichkeit, die Zielumgebung besser zu kennen, was diesen Vorteil (zumindest theoretisch) ausgleichen sollte. Es steht außer Frage, dass dieser Faktor zumindest einen Teil des Nachteils der statischen Kompilierung ausgleichen kann. Für einige ziemlich spezifische Arten von Code und Zielumgebungen gilt dies möglichüberwiegen sogar die Vorteile der statischen Kompilierung, manchmal ziemlich dramatisch. Zumindest nach meinen Tests und Erfahrungen ist dies jedoch ziemlich ungewöhnlich. Zielabhängige Optimierungen scheinen meist entweder relativ kleine Unterschiede zu machen oder können (ohnehin automatisch) nur auf ziemlich spezifische Arten von Problemen angewendet werden. Offensichtlich würde dies passieren, wenn Sie ein relativ altes Programm auf einem modernen Computer ausführen würden. Ein altes Programm, das in C ++ geschrieben wurde, wäre wahrscheinlich zu 32-Bit-Code kompiliert worden und würde auch auf einem modernen 64-Bit-Prozessor weiterhin 32-Bit-Code verwenden. Ein in C # geschriebenes Programm wäre zu Bytecode kompiliert worden, den die VM dann zu 64-Bit-Maschinencode kompiliert hätte. Wenn dieses Programm einen wesentlichen Vorteil aus der Ausführung als 64-Bit-Code ziehen würde, könnte dies einen erheblichen Vorteil bringen. Für eine kurze Zeit, als 64-Bit-Prozessoren noch relativ neu waren, geschah dies ziemlich oft. Aktueller Code, der wahrscheinlich von einem 64-Bit-Prozessor profitiert, ist normalerweise statisch in 64-Bit-Code kompiliert verfügbar.
Die Verwendung einer VM bietet auch die Möglichkeit, die Cache-Nutzung zu verbessern. Anweisungen für eine VM sind häufig kompakter als native Maschinenanweisungen. Mehr davon können in eine bestimmte Menge an Cache-Speicher passen, sodass Sie bei Bedarf eine bessere Chance haben, dass sich ein bestimmter Code im Cache befindet. Dies kann dazu beitragen, dass die interpretierte Ausführung von VM-Code (in Bezug auf die Geschwindigkeit) wettbewerbsfähiger bleibt, als die meisten Leute anfänglich erwarten würden. Sie können viele Anweisungen auf einer modernen CPU in der von einem benötigten Zeit ausführen Cache - Miss.
Erwähnenswert ist auch, dass sich dieser Faktor zwischen den beiden nicht unbedingt unterscheidet. Nichts hindert (zum Beispiel) einen C ++ - Compiler daran, Ausgaben zu erstellen, die auf einer virtuellen Maschine (mit oder ohne JIT) ausgeführt werden sollen. In der Tat ist Microsoft C ++ / CLI fast das - ein (fast) konformer C ++ - Compiler (wenn auch mit vielen Erweiterungen), der eine Ausgabe erzeugt, die auf einer virtuellen Maschine ausgeführt werden soll.
Das Gegenteil ist auch der Fall: Microsoft verfügt jetzt über .NET Native, das C # (oder VB.NET) -Code in eine native ausführbare Datei kompiliert. Dies bietet eine Leistung, die im Allgemeinen viel mehr wie C ++ ist, aber die Funktionen von C # / VB beibehält (z. B. unterstützt C #, das in nativen Code kompiliert wurde, weiterhin die Reflexion). Wenn Sie leistungsintensiven C # -Code haben, kann dies hilfreich sein.
Müllabfuhr
Nach allem, was ich gesehen habe, würde ich sagen, dass die Speicherbereinigung der am schlechtesten verstandene dieser drei Faktoren ist. Nur als offensichtliches Beispiel wird hier die Frage erwähnt: "GC fügt auch nicht viel Overhead hinzu, es sei denn, Sie erstellen und zerstören Tausende von Objekten [...]". Wenn Sie Tausende von Objekten erstellen und zerstören, ist der Aufwand für die Speicherbereinigung in der Realität im Allgemeinen relativ gering. .NET verwendet einen Generations-Scavenger, bei dem es sich um eine Vielzahl von Kopierkollektoren handelt. Der Garbage Collector arbeitet an "Stellen" (z. B. Registern und Ausführungsstapel), an denen Zeiger / Referenzen bekannt sindzugänglich sein. Anschließend werden diese Zeiger auf Objekte "gejagt", die auf dem Heap zugewiesen wurden. Es untersucht diese Objekte auf weitere Zeiger / Referenzen, bis es allen bis zum Ende einer Kette gefolgt ist und alle Objekte gefunden hat, auf die (zumindest potenziell) zugegriffen werden kann. Im nächsten Schritt werden alle Objekte verwendet, die verwendet werden (oder zumindest verwendet werden könnten ), und der Heap wird komprimiert, indem alle in einen zusammenhängenden Block an einem Ende des im Heap verwalteten Speichers kopiert werden. Der Rest des Speichers ist dann frei (Modulo-Finalizer müssen ausgeführt werden, aber zumindest in gut geschriebenem Code sind sie selten genug, dass ich sie für den Moment ignorieren werde).
Dies bedeutet, dass die Garbage Collection beim Erstellen und Zerstören vieler Objekte nur einen geringen Overhead verursacht. Die Zeit, die ein Speicherbereinigungszyklus benötigt, hängt fast ausschließlich von der Anzahl der Objekte ab, die erstellt, aber nicht zerstört wurden. Die Hauptfolge der Eile beim Erstellen und Zerstören von Objekten ist einfach, dass der GC häufiger ausgeführt werden muss, aber jeder Zyklus immer noch schnell ist. Wenn Sie Objekte erstellen und diese nicht zerstören, wird der GC häufiger ausgeführt und jeder Zyklus ist wesentlich langsamer, da er mehr Zeit damit verbringt, Zeiger auf potenziell lebende Objekte zu verfolgen, und mehr Zeit damit verbringt, noch verwendete Objekte zu kopieren.
Um dem entgegenzuwirken, wird bei der Generationenreinigung davon ausgegangen, dass Objekte, die eine ganze Weile "am Leben" geblieben sind, wahrscheinlich noch eine ganze Weile am Leben bleiben. Auf dieser Grundlage gibt es ein System, in dem Objekte, die eine bestimmte Anzahl von Speicherbereinigungszyklen überstehen, "dauerhaft" werden, und der Speicherbereiniger geht einfach davon aus, dass sie noch verwendet werden. Statt sie bei jedem Zyklus zu kopieren, wird er einfach verlassen sie allein. Dies ist oft genug eine gültige Annahme, dass das Aufräumen von Generationen in der Regel einen erheblich geringeren Overhead hat als die meisten anderen Formen der GC.
"Manuelle" Speicherverwaltung wird oft genauso schlecht verstanden. Nur für ein Beispiel gehen viele Vergleichsversuche davon aus, dass die gesamte manuelle Speicherverwaltung ebenfalls einem bestimmten Modell folgt (z. B. Best-Fit-Zuordnung). Dies ist oft wenig (wenn überhaupt) näher an der Realität als die Überzeugungen vieler Menschen über die Müllabfuhr (z. B. die weit verbreitete Annahme, dass dies normalerweise mithilfe der Referenzzählung erfolgt).
Angesichts der Vielzahl von Strategien sowohl für die Speicherbereinigung als auch für die manuelle Speicherverwaltung ist es ziemlich schwierig, die beiden hinsichtlich der Gesamtgeschwindigkeit zu vergleichen. Der Versuch, die Geschwindigkeit der Zuweisung und / oder Freigabe von Speicher (für sich allein) zu vergleichen, führt fast garantiert zu Ergebnissen, die bestenfalls bedeutungslos und im schlimmsten Fall geradezu irreführend sind.
Bonus-Thema: Benchmarks
Da einige Blogs, Websites, Zeitschriftenartikel usw. behaupten, "objektive" Beweise in die eine oder andere Richtung zu liefern, werde ich auch in diesem Bereich meinen Wert von zwei Cent einsetzen.
Die meisten dieser Benchmarks ähneln den Teenagern, die sich für ein Auto entscheiden, und wer gewinnt, darf beide Autos behalten. Die Websites unterscheiden sich jedoch in einem entscheidenden Punkt: Der Typ, der den Benchmark veröffentlicht, darf beide Autos fahren. Durch einen seltsamen Zufall gewinnt sein Auto immer und alle anderen müssen sich damit zufrieden geben, "vertrau mir, ich habe dein Auto wirklich so schnell gefahren, wie es gehen würde."
Es ist einfach, einen schlechten Benchmark zu schreiben, der Ergebnisse liefert, die so gut wie nichts bedeuten. Fast jeder, der in der Nähe der Fähigkeiten ist, die erforderlich sind, um einen Benchmark zu entwerfen, der irgendetwas Sinnvolles hervorbringt, hat auch die Fähigkeit, einen zu erstellen, der die Ergebnisse liefert, die er sich gewünscht hat. Tatsächlich ist es wahrscheinlich einfacher , Code zu schreiben, um ein bestimmtes Ergebnis zu erzielen, als Code, der wirklich aussagekräftige Ergebnisse liefert.
Wie mein Freund James Kanze es ausdrückte: "Vertraue niemals einem Maßstab, den du nicht selbst gefälscht hast."
Fazit
Es gibt keine einfache Antwort. Ich bin mir ziemlich sicher, dass ich eine Münze werfen könnte, um den Gewinner zu bestimmen, dann eine Zahl zwischen (sagen wir) 1 und 20 für den Prozentsatz auswählen könnte, um den sie gewinnen würde, und einen Code schreiben könnte, der wie ein vernünftiger und fairer Benchmark aussehen würde, und hat diese ausgemachte Sache hervorgebracht (zumindest bei einigen Zielprozessoren - ein anderer Prozessor könnte den Prozentsatz ein wenig ändern).
Wie andere bereits betont haben, ist die Geschwindigkeit für den meisten Code nahezu irrelevant. Die logische Folge , dass (was viel häufiger ignoriert) ist , dass in dem kleinen Code , wo Geschwindigkeit nicht egal, es zählt in der Regel eine Menge . Zumindest meiner Erfahrung nach ist C ++ für den Code, bei dem es wirklich darauf ankommt, fast immer der Gewinner. Es gibt definitiv Faktoren, die C # bevorzugen, aber in der Praxis scheinen sie durch Faktoren aufgewogen zu werden, die C ++ bevorzugen. Sie können sicherlich Benchmarks finden, die das Ergebnis Ihrer Wahl anzeigen, aber wenn Sie echten Code schreiben, können Sie ihn in C ++ fast immer schneller machen als in C #. Es kann (oder auch nicht) mehr Geschick und / oder Mühe erfordern, um zu schreiben, aber es ist praktisch immer möglich.