67 Stimmen

Anruf und Anrufvirt

Was ist der Unterschied zwischen den CIL-Anweisungen "Call" und "Callvirt"?

63voto

Drew Noakes Punkte 282438

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.

4 Stimmen

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.

54voto

Chris Jester-Young Punkte 212385

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

43 Stimmen

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.

2 Stimmen

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

3 Stimmen

Vielleicht sollten Sie den Leistungsunterschied in Ihrer Antwort erwähnen, da dies der Grund dafür ist, dass es überhaupt eine "Call"-Anweisung gibt.

13voto

Cameron MacFarland Punkte 67889

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:

  • Der Methodenaufruf erfolgt unmittelbar nach der Objekterstellung
  • Die Methode ist nicht in einer Basisklasse implementiert

0 Stimmen

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.

1 Stimmen

Ich glaube nicht, dass Sie hier zu 100 % richtig liegen. Ich habe meinen Beitrag aktualisiert (edit 2).

0 Stimmen

Hallo Cameron. Können Sie klären, ob die Analyse dieses Codes mit einem Release-Build durchgeführt wurde?

8voto

smwikipedia Punkte 56976

Laut MSDN:

Rufen Sie an. :

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

6voto

Boris Punkte 51

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

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.

Powered by:

X