Was ist der Unterschied zwischen den CIL-Anweisungen "Call" und "Callvirt"?
Versiegelte Attribute sind definitiv schneller, wenn man sie über die Reflexion sucht, aber abgesehen davon kenne ich keine anderen Vorteile, die Sie nicht erwähnt haben.
Wenn die Laufzeitumgebung eine call
Anweisung wird ein genaues Codestück (Methode) aufgerufen. Es steht außer Frage, wo sie existiert. Sobald die AWL in JIT umgewandelt wurde, ist der resultierende Maschinencode an der Aufrufstelle ein unbedingter jmp
Anweisung.
Im Gegensatz dazu ist die callvirt
Anweisung wird verwendet, um virtuelle Methoden auf polymorphe Weise aufzurufen. Die genaue Position des Methodencodes muss zur Laufzeit für jeden einzelnen Aufruf bestimmt werden. Der daraus resultierende JIT-Code beinhaltet eine gewisse Umleitung durch vtable-Strukturen. Daher ist der Aufruf langsamer in der Ausführung, aber er ist flexibler, da er polymorphe Aufrufe zulässt.
Beachten Sie, dass der Compiler die folgenden Informationen ausgeben kann call
Anweisungen für virtuelle Methoden. Zum Beispiel:
sealed class SealedObject : object
{
public override bool Equals(object o)
{
// ...
}
}
Erwägen Sie, den Code aufzurufen:
SealedObject a = // ...
object b = // ...
bool equal = a.Equals(b);
Während System.Object.Equals(object)
eine virtuelle Methode ist, gibt es bei dieser Verwendung keine Möglichkeit für eine Überladung der Methode Equals
Methode zu existieren. SealedObject
ist eine versiegelte Klasse und kann keine Unterklassen haben.
Aus diesem Grund ist die .NET sealed
Klassen können eine bessere Leistung beim Methoden-Dispatching aufweisen als ihre nicht versiegelten Gegenstücke.
EDIT: Wie sich herausstellte, lag ich falsch. Der C#-Compiler kann keinen unbedingten Sprung zum Ort der Methode machen, weil die Referenz des Objekts (der Wert von this
innerhalb der Methode) könnte null sein. Stattdessen sendet sie callvirt
der die Nullprüfung durchführt und bei Bedarf einen Fehler auslöst.
Dies erklärt einige bizarre Codes, die ich mit Reflector im .NET Framework gefunden habe:
if (this==null) // ...
Es ist möglich, dass ein Compiler überprüfbaren Code ausgibt, der einen Nullwert für die this
Zeiger (local0), nur csc tut dies nicht.
Ich denke also call
wird nur für statische Methoden und Strukturen von Klassen verwendet.
In Anbetracht dieser Informationen scheint es mir nun, dass sealed
ist nur für die API-Sicherheit nützlich. Ich fand weitere Frage Das scheint darauf hinzudeuten, dass die Versiegelung Ihrer Klassen keine Leistungsvorteile bringt.
EDIT 2: Es steckt mehr dahinter, als es scheint. Zum Beispiel gibt der folgende Code eine call
Anweisung:
new SealedObject().Equals("Rubber ducky");
In einem solchen Fall besteht natürlich keine Möglichkeit, dass die Objektinstanz null sein könnte.
Interessanterweise gibt der folgende Code in einem DEBUG-Build Folgendes aus callvirt
:
var o = new SealedObject();
o.Equals("Rubber ducky");
Das liegt daran, dass Sie in der zweiten Zeile einen Haltepunkt setzen und den Wert von o
. In Release-Builds stelle ich mir vor, der Aufruf wäre ein call
statt callvirt
.
Leider ist mein PC derzeit außer Betrieb, aber ich werde damit experimentieren, sobald er wieder läuft.
call
ist für den Aufruf von nicht-virtuellen, statischen oder Superklassen-Methoden gedacht, d.h. das Ziel des Aufrufs unterliegt nicht dem Overriding. callvirt
ist für den Aufruf virtueller Methoden (so dass, wenn this
eine Unterklasse ist, die die Methode außer Kraft setzt, wird stattdessen die Version der Unterklasse aufgerufen).
Wenn ich mich richtig erinnere call
den Zeiger vor der Ausführung des Aufrufs nicht auf Null prüft, etwas, das callvirt
Das ist offensichtlich notwendig. Deshalb ist callvirt
wird manchmal vom Compiler ausgegeben, auch wenn er nicht-virtuelle Methoden aufruft.
Ah. Danke für den Hinweis (ich bin kein .NET-Experte). Die Analogien, die ich verwende, sind call => invokespecial und callvirt => invokevirtual in JVM Bytecode. Im Fall der JVM überprüfen beide Anweisungen "this" auf Nichtigkeit (ich habe gerade ein Testprogramm geschrieben, um das zu überprüfen).
Vielleicht sollten Sie den Leistungsunterschied in Ihrer Antwort erwähnen, da dies der Grund dafür ist, dass es überhaupt eine "Call"-Anweisung gibt.
Aus diesem Grund können die versiegelten Klassen von .NET eine bessere Leistung beim Methoden-Dispatching aufweisen als ihre nicht versiegelten Gegenstücke.
Leider ist dies nicht der Fall. Callvirt macht noch eine andere Sache, die es nützlich macht. Wenn für ein Objekt eine Methode aufgerufen wird, prüft Callvirt, ob das Objekt existiert, und löst andernfalls eine NullReferenceException aus. Callvirt springt einfach an die Speicherstelle, auch wenn die Objektreferenz dort nicht vorhanden ist, und versucht, die Bytes an dieser Stelle auszuführen.
Das bedeutet, dass callvirt immer vom C#-Compiler (bei VB bin ich mir nicht sicher) für Klassen verwendet wird, und call wird immer für structs verwendet (weil sie niemals null oder subclassed sein können).
bearbeiten Als Antwort auf den Kommentar von Drew Noakes: Ja, es scheint, dass man den Compiler dazu bringen kann, einen Aufruf für eine beliebige Klasse auszugeben, aber nur in dem folgenden, sehr speziellen Fall:
public class SampleClass
{
public override bool Equals(object obj)
{
if (obj.ToString().Equals("Rubber Ducky", StringComparison.InvariantCultureIgnoreCase))
return true;
return base.Equals(obj);
}
public void SomeOtherMethod()
{
}
static void Main(string[] args)
{
// This will emit a callvirt to System.Object.Equals
bool test1 = new SampleClass().Equals("Rubber Ducky");
// This will emit a call to SampleClass.SomeOtherMethod
new SampleClass().SomeOtherMethod();
// This will emit a callvirt to System.Object.Equals
SampleClass temp = new SampleClass();
bool test2 = temp.Equals("Rubber Ducky");
// This will emit a callvirt to SampleClass.SomeOtherMethod
temp.SomeOtherMethod();
}
}
ANMERKUNG Die Klasse muss nicht versiegelt sein, damit dies funktioniert.
Es sieht also so aus, als würde der Compiler einen Aufruf ausgeben, wenn alle diese Dinge zutreffen:
Das macht Sinn. Ich hatte CIL studiert und eine Vermutung über das Verhalten des Compilers angestellt. Danke für die Klarstellung. Ich werde meine Antwort aktualisieren.
Ich glaube nicht, dass Sie hier zu 100 % richtig liegen. Ich habe meinen Beitrag aktualisiert (edit 2).
Hallo Cameron. Können Sie klären, ob die Analyse dieses Codes mit einem Release-Build durchgeführt wurde?
Laut MSDN:
Die Aufrufanweisung ruft die Methode auf, die in dem mit der Anweisung übergebenen Methodendeskriptor angegeben ist. Der Methodendeskriptor ist ein Metadaten-Token, das die aufzurufende Methode angibt... Das Metadaten-Token enthält genügend Informationen, um festzustellen, ob es sich bei dem Aufruf um eine statische Methode, eine Instanzmethode, eine virtuelle Methode oder eine globale Funktion handelt. In all diesen Fällen wird die Zieladresse vollständig aus dem Methoden-Deskriptor ermittelt (im Gegensatz zu der Callvirt-Anweisung für den Aufruf virtueller Methoden, bei der die Zieladresse auch vom Laufzeittyp der vor dem Callvirt geschobenen Instanzreferenz abhängt).
CallVirt :
Die Anweisung callvirt ruft eine spät gebundene Methode für ein Objekt auf. Das heißt, die Methode wird anhand des Laufzeittyps von obj und nicht anhand der im Methodenzeiger sichtbaren Kompilierzeitklasse ausgewählt . Mit Callvirt können sowohl virtuelle als auch Instanzmethoden aufgerufen werden.
Grundsätzlich werden also verschiedene Wege beschritten, um die Instanzmethode eines Objekts aufzurufen, ob sie nun überschrieben wird oder nicht:
Aufruf: Variable -> die Variable Typ Objekt -> Methode
CallVirt: Variable -> Objektinstanz -> das Objekt Typ Objekt -> Methode
Eine Sache, die man vielleicht noch zu den vorherigen Antworten hinzufügen sollte, ist, es scheint nur ein Gesicht zu geben, wie der "IL-Aufruf" tatsächlich ausgeführt wird, und zwei Gesichter, wie "IL callvirt" ausgeführt wird.
Nehmen Sie diese Beispielkonfiguration.
public class Test {
public int Val;
public Test(int val)
{ Val = val; }
public string FInst () // note: this==null throws before this point
{ return this == null ? "NO VALUE" : "ACTUAL VALUE " + Val; }
public virtual string FVirt ()
{ return "ALWAYS AN ACTUAL VALUE " + Val; }
}
public static class TestExt {
public static string FExt (this Test pObj) // note: pObj==null passes
{ return pObj == null ? "NO VALUE" : "VALUE " + pObj.Val; }
}
Erstens ist der CIL-Körper von FInst() und FExt() 100% identisch, Opcode zu Opcode (außer dass die eine als "Instanz" und die andere als "statisch" deklariert ist) -- allerdings wird FInst() mit "callvirt" und FExt() mit "call" aufgerufen.
Zweitens: FInst() und FVirt() werden beide mit "callvirt" aufgerufen -- auch wenn der eine virtuell ist und der andere nicht -- aber es ist nicht der "gleiche callvirt", der wirklich ausgeführt wird.
So sieht der ungefähre Ablauf nach dem JIT-Tuning aus:
pObj.FExt(); // IL:call
mov rcx, <pObj>
call (direct-ptr-to) <TestExt.FExt>
pObj.FInst(); // IL:callvirt[instance]
mov rax, <pObj>
cmp byte ptr [rax],0
mov rcx, <pObj>
call (direct-ptr-to) <Test.FInst>
pObj.FVirt(); // IL:callvirt[virtual]
mov rax, <pObj>
mov rax, qword ptr [rax]
mov rax, qword ptr [rax + NNN]
mov rcx, <pObj>
call qword ptr [rax + MMM]
Der einzige Unterschied zwischen "call" und "callvirt[instance]" besteht darin, dass "callvirt[instance]" absichtlich versucht, auf ein Byte von *pObj zuzugreifen, bevor es den direkten Zeiger der Instanzfunktion aufruft (um möglicherweise "auf der Stelle" eine Ausnahme zu machen).
Wenn Sie sich also darüber ärgern, wie oft Sie den "Prüfungsteil" von
var d = GetDForABC (a, b, c);
var e = d != null ? d.GetE() : ClassD.SOME_DEFAULT_E;
Sie können nicht "if (this==null) return SOME_DEFAULT_E;" in ClassD.GetE() selbst einfügen (da die Semantik von "IL callvirt[instance]" dies verbietet) aber es steht Ihnen frei, es in .GetE() zu verschieben, wenn Sie .GetE() irgendwo in eine Erweiterungsfunktion verschieben (da die Semantik des "AWL-Aufrufs" dies erlaubt - aber leider verlieren Sie den Zugriff auf private Mitglieder usw.)
Allerdings hat die Ausführung von "callvirt[instance]" mehr Gemeinsamkeiten mit "call" als mit "callvirt[virtual]", da letztere möglicherweise eine dreifache Umleitung ausführen muss, um die Adresse Ihrer Funktion zu finden. (Umleitung auf typedef base, dann auf base-vtab-oder-irgendeine-schnittstelle, dann auf den eigentlichen Slot)
Ich hoffe, das hilft, Boris
CodeJaeger ist eine Gemeinschaft für Programmierer, die täglich Hilfe erhalten..
Wir haben viele Inhalte, und Sie können auch Ihre eigenen Fragen stellen oder die Fragen anderer Leute lösen.