Bel en Callvirt

Wat is het verschil tussen de CIL-instructies “Bellen” en “Callvirt”?


Antwoord 1, autoriteit 100%

Als de runtime een call-instructie uitvoert, roept het een exact stuk code (methode) aan. Er is geen twijfel over waar het bestaat. Zodra de IL is JITted, is de resulterende machinecode op de oproepsite een onvoorwaardelijke jmp-instructie.

De instructie callvirtdaarentegen wordt gebruikt om virtuele methoden op een polymorfe manier aan te roepen. De exacte locatie van de code van de methode moet tijdens runtime worden bepaald voor elke aanroep. De resulterende JITted-code omvat enige indirectheid via vtable-structuren. Daarom is de oproep langzamer uit te voeren, maar is hij flexibeler omdat hij polymorfe oproepen mogelijk maakt.

Merk op dat de compiler callinstructies voor virtuele methoden kan uitzenden. Bijvoorbeeld:

sealed class SealedObject : object
{
   public override bool Equals(object o)
   {
      // ...
   }
}

Overweeg de oproepcode:

SealedObject a = // ...
object b = // ...
bool equal = a.Equals(b);

Hoewel System.Object.Equals(object)een virtuele methode is, is er bij dit gebruik geen manier om de Equalsmethode te overbelasten. SealedObjectis een verzegelde klasse en kan geen subklassen hebben.

Om deze reden kunnen de sealedklassen van .NET betere methode-dispatchprestaties hebben dan hun niet-verzegelde tegenhangers.

EDIT:Het bleek dat ik het bij het verkeerde eind had. De C#-compiler kan geen onvoorwaardelijke sprong maken naar de locatie van de methode omdat de referentie van het object (de waarde van thisbinnen de methode) null kan zijn. In plaats daarvan zendt het callvirtuit, dat de null-controle uitvoert en indien nodig gooit.

Dit verklaart eigenlijk een aantal bizarre code die ik in het .NET-framework vond met Reflector:

if (this==null) // ...

Het is mogelijk voor een compiler om verifieerbare code uit te zenden die een null-waarde heeft voor de thispointer (local0), alleen csc doet dit niet.

Dus ik denk dat callalleen wordt gebruikt voor statische methoden en structs van klassen.

Gezien deze informatie lijkt het me nu dat sealedalleen nuttig is voor API-beveiliging. Ik vond een andere vraagdie lijkt te suggereren dat er geen prestatievoordelen zijn je lessen verzegelen.

EDIT 2:Er is meer aan de hand dan het lijkt. De volgende code zendt bijvoorbeeld een call-instructie uit:

new SealedObject().Equals("Rubber ducky");

In zo’n geval is er natuurlijk geen kans dat de objectinstantie null is.

Interessant is dat in een DEBUG-build de volgende code callvirtuitzendt:

var o = new SealedObject();
o.Equals("Rubber ducky");

Dit komt omdat je een breekpunt op de tweede regel kunt instellen en de waarde van okunt wijzigen. In release-builds stel ik me voor dat de aanroep een callzou zijn in plaats van callvirt.

Helaas is mijn pc momenteel buiten werking, maar ik zal hiermee experimenteren zodra hij weer aan de gang is.


Antwoord 2, autoriteit 85%

callis voor het aanroepen van niet-virtuele, statische of superklasse-methoden, d.w.z. het doel van de aanroep is niet onderhevig aan opheffing. callvirtis voor het aanroepen van virtuele methoden (zodat als thiseen subklasse is die de methode overschrijft, in plaats daarvan de subklasseversie wordt aangeroepen).


Antwoord 3, autoriteit 18%

Om deze reden kunnen de verzegelde klassen van .NET betere prestaties leveren bij het verzenden van methoden dan hun niet-verzegelde tegenhangers.

Helaas is dit niet het geval. Callvirt doet nog iets dat het nuttig maakt. Als een object een methode heeft die wordt aangeroepen, zal callvirt controleren of het object bestaat, en zo niet, dan wordt een NullReferenceException gegenereerd. De oproep springt gewoon naar de geheugenlocatie, zelfs als de objectreferentie er niet is, en probeert de bytes op die locatie uit te voeren.

Wat dit betekent is dat callvirt altijd wordt gebruikt door de C#-compiler (niet zeker over VB) voor klassen, en call wordt altijd gebruikt voor structs (omdat ze nooit null of subklasse kunnen zijn).

BewerkenAls reactie op de opmerking van Drew Noakes: Ja, het lijkt erop dat je de compiler een aanroep voor elke klasse kunt laten uitzenden, maar alleen in het volgende zeer specifieke geval:

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();
    }
}

OPMERKINGDe klas hoeft niet verzegeld te zijn om dit te laten werken.

Het lijkt er dus op dat de compiler een aanroep zal uitzenden als al deze dingen waar zijn:

  • De methode-aanroep is direct na het maken van het object
  • De methode is niet geïmplementeerd in een basisklasse

Antwoord 4, autoriteit 12%

Volgens MSDN:

Bel:

De aanroepinstructie roept de methode aan die wordt aangegeven door de methodedescriptor die bij de instructie is doorgegeven. De methodedescriptor is een metadatatoken dat aangeeft welke methode moet worden aangeroepen… De metadatatoken bevat voldoende informatie om te bepalen of de aanroep een statische methode, een instantiemethode, een virtuele methode of een globale functie is. In al deze gevallen wordt het bestemmingsadres volledig bepaald op basis van de methodedescriptor(contrast dit met de Callvirt-instructie voor het aanroepen van virtuele methoden, waarbij het bestemmingsadres ook afhangt van het runtime-type van de instantieverwijzing die eerder is gepusht de Callvirt).

CallVirt:

De callvirt instructie roept een late-bound methode aan op een object. Dat wil zeggen, de methode wordt gekozen op basis van het runtime-type van obj in plaats van de compile-time-klasse die zichtbaar is in de methode-aanwijzer. Callvirt kan worden gebruikt om zowel virtuele als instantiemethoden aan te roepen.

Dus in principe worden verschillende routes gevolgd om de instantiemethode van een object aan te roepen, al dan niet overschreven:

Oproep: variabele -> variabeletype object -> methode

CallVirt: variabele -> objectinstantie -> objecttype object -> methode


Antwoord 5, autoriteit 7%

Eén ding dat misschien de moeite waard is om aan de vorige antwoorden toe te voegen, is:
er lijkt maar één gezicht te zijn voor hoe “IL-oproep” daadwerkelijk wordt uitgevoerd,
en twee gezichten op hoe “IL callvirt” wordt uitgevoerd.

Neem deze voorbeeldconfiguratie.

   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; }
    }

Ten eerste is de CIL-body van FInst() en FExt() 100% identiek, opcode-naar-opcode
(behalve dat de ene is gedeclareerd als “instantie” en de andere als “statisch”)
— FInst() wordt echter aangeroepen met “callvirt” en FExt() met “call”.

Ten tweede zullen FInst() en FVirt() beide worden aangeroepen met “callvirt”
— ook al is de ene virtueel maar de andere niet —
maar het is niet de “dezelfde callvirt” die echt zal worden uitgevoerd.

Dit is wat er ongeveer gebeurt na JITting:

   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]  

Het enige verschil tussen “call” en “callvirt[instance]” is dat “callvirt[instance]” opzettelijk probeert toegang te krijgen tot één byte van *pObj voordat het de directe aanwijzer van de instantiefunctie aanroept (om mogelijk een uitzondering “daar en dan”).

Dus als je je ergert aan het aantal keren dat je het “controlegedeelte” van

moet schrijven

var d = GetDForABC (a, b, c);
var e = d != null ? d.GetE() : ClassD.SOME_DEFAULT_E;

Je kunt niet op “if (this==null) return SOME_DEFAULT_E;” drukken naar ClassD.GetE() zelf
(aangezien de semantiek van “IL callvirt[instance]” u dit verbiedt)
maar je bent vrij om het in .GetE() te duwen als je .GetE() ergens naar een extensiefunctie verplaatst (zoals de “IL call”-semantiek dit toestaat — maar helaas, de toegang tot privé-leden verliezen enz.)

Dat gezegd hebbende, de uitvoering van “callvirt[instance]” heeft meer gemeen
met “call” dan met “callvirt[virtual]”, aangezien de laatste mogelijk een drievoudige indirecte moet uitvoeren om het adres van uw functie te vinden.
(indirect naar typedef base, dan naar base-vtab-or-some-interface, dan naar daadwerkelijk slot)

Ik hoop dat dit helpt,
Boris


Antwoord 6, autoriteit 2%

Ik voeg alleen maar toe aan de bovenstaande antwoorden, ik denk dat de wijziging al lang geleden is doorgevoerd, zodat Callvirt IL-instructies worden gegenereerd voor alle instance-methoden en Call IL-instructies worden gegenereerd voor statische methoden.

Referentie:

Pluralsight-cursus “C# Language Internals – Part 1 door Bart De Smet (video — Belinstructies en call-stacks in CLR IL in een notendop)

en ook
https://blogs .msdn.microsoft.com/ericgu/2008/07/02/why-does-c-always-use-callvirt/

Other episodes