Start Debugging

Volatile.Read vs Volatile.ReadBarrier in .NET 10

Volatile.Read ist ein Acquire-Load einer einzelnen Speicherstelle. Volatile.ReadBarrier, neu in .NET 10, ist ein Fence, der allen früheren Lesezugriffen Acquire-Semantik gibt. Verwenden Sie Volatile.Read für Flags und veröffentlichte Referenzen und ReadBarrier, wenn mehrere einfache oder nicht atomare Lesezugriffe abgeschlossen sein müssen, bevor der nächste Speicherzugriff erfolgt, etwa bei einem Seqlock.

Volatile.Read(ref x) liest eine Speicherstelle mit Acquire-Semantik: Nichts, was im Code danach steht, kann vor diesen Lesezugriff wandern. Volatile.ReadBarrier(), neu in .NET 10, liest überhaupt nichts. Es ist ein Fence, der jedem Lesezugriff davor Acquire-Semantik gibt, sodass ein ganzer Stapel einfacher (auch nicht atomarer) Lesezugriffe abgeschlossen sein muss, bevor ein Speicherzugriff nach der Barriere stattfindet. Verwenden Sie Volatile.Read für den Normalfall eines Flags, eines Zählers oder einer veröffentlichten Referenz. Greifen Sie zu ReadBarrier, wenn mehrere gewöhnliche Lesezugriffe oder ein Lesezugriff, der zu groß für Atomarität ist, vor einer erneuten Prüfung abgeschlossen sein müssen. Der Lehrbuchfall ist die Leserseite eines Seqlocks.

Alles Folgende wurde mit .NET 10.0.10 (SDK 10.0.302), C# 14, auf einem Apple M4 (arm64) gemessen. Die Barrier-APIs gibt es in System.Threading.Volatile ab .NET 10. Unter .NET 9 und früher existiert außer Interlocked.MemoryBarrier() kein öffentliches Äquivalent.

Der Vergleich auf einen Blick

Volatile.Read(ref x)Volatile.ReadBarrier()
Verfügbar seit.NET Framework 4.5.NET 10
Liest einen WertJa, eine SpeicherstelleNein
Was Acquire-Semantik erhältDieser eine LesezugriffAlle Lesezugriffe vor dem Aufruf
Verhindert, dass spätere Lese- und Schreibzugriffe nach oben wandernJaJa
Macht den Lesezugriff atomarJa, für unterstützte Typen (einschließlich long/double unter 32 Bit)Nein, Atomarität ist Ihre Sache
Funktioniert mit beliebigem T, Structs, nativem SpeicherNein, feste Menge an ÜberladungenJa, es ordnet beliebige vorangehende Lesezugriffe
arm64-Codegenerierung (gemessen, .NET 10.0.10)ldapur (Load-Acquire)dmb ishld (Load-Fence)
x64-Codegenerierungeinfaches mov, nur Ordnung durch den Compilerkeine Instruktion, nur Ordnung durch den Compiler
Typischer EinsatzFlags, Double-Checked-Initialisierung, veröffentlichte ReferenzenSeqlocks, versionsvalidierte Caches, gebündelte Lesezugriffe

Was die beiden APIs garantieren

Die Spezifikation des .NET-Speichermodells (docs/design/specs/Memory-model.md in dotnet/runtime) führt beide unter “volatile reads have acquire semantics” auf, mit einer aufschlussreichen Fußnote zur Barriere: Sie “applies to all prior reads”. Acquire bedeutet, dass kein Lese- oder Schreibzugriff, der in Programmreihenfolge später kommt, vor dem Acquire-Lesezugriff ausgeführt werden darf.

Bei Volatile.Read(ref _version) hängt das Acquire am Laden von _version und an nichts sonst. Lesezugriffe, die in Programmreihenfolge davor stattfanden, werden überhaupt nicht eingeschränkt. Sie können weiterhin nach unten rutschen.

Bei Volatile.ReadBarrier() hängt das Acquire an jedem Ladevorgang, der dem Aufruf vorausgeht. Der API-Vorschlag (dotnet/runtime#98837) nennt das eine Read-ReadWrite-Barriere: Alle vorangehenden Lesezugriffe müssen abgeschlossen sein, bevor eine nachfolgende Speicheroperation stattfindet. Das Gegenstück, Volatile.WriteBarrier(), ist eine ReadWrite-Write-Barriere: Alle vorangehenden Speicheroperationen sind abgeschlossen, bevor ein nachfolgender Schreibzugriff stattfindet.

Die beiden APIs sind also keine zwei Stärken derselben Sache. Sie beantworten unterschiedliche Fragen:

Keine von beiden ist ein vollständiger Fence. Eine ReadBarrier tut nichts dagegen, dass ein früherer Schreibzugriff mit einem späteren Lesezugriff umsortiert wird (der Store-Load-Fall). Wenn Sie das brauchen, benötigen Sie weiterhin Interlocked.MemoryBarrier() oder eine Interlocked-Operation.

Was der JIT tatsächlich erzeugt

Der JIT behandelt beide Methoden als Intrinsics. Der Quelltext von Volatile.cs ist lediglich [Intrinsic] public static void ReadBarrier() => ReadBarrier();, und der Importer ersetzt den Aufruf durch einen Memory-Barrier-Knoten, der als nur ladend markiert ist (PR #107843). Um zu sehen, was daraus wird, habe ich eine kleine Klasse mit voller Optimierung kompiliert und mit DOTNET_JitDisasm ausgegeben:

// .NET 10.0.10, C# 14
// DOTNET_TieredCompilation=0 DOTNET_JitDisasm='Codegen:*' dotnet vb.dll
sealed class Codegen
{
    private int _x;
    private long _a, _b, _c, _d;

    [MethodImpl(MethodImplOptions.NoInlining)]
    public long AcquireFour() =>
        Volatile.Read(ref _a) + Volatile.Read(ref _b) +
        Volatile.Read(ref _c) + Volatile.Read(ref _d);

    [MethodImpl(MethodImplOptions.NoInlining)]
    public long PlainFourThenBarrier()
    {
        long sum = _a + _b + _c + _d;
        Volatile.ReadBarrier();
        return sum;
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    public void BarrierThenPlainFour(long v)
    {
        Volatile.WriteBarrier();
        _a = v; _b = v; _c = v; _d = v;
    }
}

Auf dem M4 waren dies die interessanten Instruktionen:

; AcquireFour: four separate load-acquire instructions
ldapur  x1, [x0, #0x08]
ldapur  x2, [x0, #0x10]
ldapur  x2, [x0, #0x18]
ldapur  x0, [x0, #0x20]

; PlainFourThenBarrier: two paired loads, then one load fence
ldp     x1, x2, [x0, #0x08]
ldp     x2, x0, [x0, #0x18]
dmb     ishld

; BarrierThenPlainFour: a full fence, then two paired stores
dmb     ish
stp     x1, x1, [x0, #0x08]
stp     x1, x1, [x0, #0x18]

Drei Dinge fallen auf.

Erstens wird Volatile.Read zu ldapur kompiliert, einem RCpc-Load-Acquire (die RCpc-Erweiterungen kamen mit ARMv8.3 und v8.4), den der M4 unterstützt. Kerne ohne RCpc erhalten stattdessen das ältere ldar. In beiden Fällen gibt es keine separate Fence-Instruktion.

Zweitens bleiben die einfachen Lesezugriffe vor ReadBarrier einfach, sodass der JIT sie frei zu ldp paaren kann (und bei einer 32-Byte-Struct-Kopie zu einem Paar 128-Bit-ldp q-Ladevorgängen). Bei vier Acquire-Ladevorgängen geht diese Freiheit verloren. Das ist das Effizienzargument des Vorschlags: ein Fence für N Lesezugriffe statt N geordneter Lesezugriffe.

Drittens ist Volatile.WriteBarrier() unter arm64 ein vollständiges dmb ish, genau das, was Interlocked.MemoryBarrier() erzeugt. Im JIT steht ein Kommentar, dass er unter arm64 derzeit keine reine Store-Barriere besser als eine vollständige erzeugen kann. Erwarten Sie also nicht, dass WriteBarrier dort günstiger ist als ein vollständiger Fence.

Unter x64 erzeugen beide Barrieren überhaupt keine Instruktion. Der Codegen-Kommentar des PR ist eindeutig: Load-only- und Store-only-Barrieren “are no-ops on xarch”, weil das TSO-Modell von x86 Ladevorgänge ohnehin gegenüber späteren Lade- und Speichervorgängen geordnet hält und Speichervorgänge gegenüber früheren Speicheroperationen. Wichtig sind sie auf x64 trotzdem: Sie hindern den JIT selbst daran, Speicherzugriffe über die Barriere hinweg umzusortieren, zwischenzuspeichern oder zu eliminieren. Für diesen Durchlauf hatte ich keine x64-Maschine, daher stammt die x64-Zeile der Tabelle aus dem JIT-Quelltext, nicht aus einem Disassembly.

Ein Seqlock: der Fall, für den ReadBarrier gebaut wurde

Der erste Kunde war die Runtime selbst. GenericCache und CastCache in CoreLib verwendeten ein internes Interlocked.ReadMemoryBarrier() und wurden im selben PR auf Volatile.ReadBarrier() umgestellt. Ihr Kommentar beschreibt das Muster: “we must read in this order: version -> [entry parts] -> version”.

Das ist ein Seqlock. Ein einzelner Schreiber erhöht eine Version auf eine ungerade Zahl, schreibt die Daten und erhöht sie dann auf die nächste gerade Zahl. Leser lesen die Version, kopieren die Daten mit gewöhnlichen Ladevorgängen und lesen die Version erneut. Stimmen beide Lesezugriffe überein und sind gerade, ist die Kopie konsistent. Die Daten können beliebig groß sein: Ein 32-Byte-Struct ist auf keiner Plattform atomar, und das ist in Ordnung, weil die Versionsprüfung zerrissene Kopien erkennt.

Hier die minimale Version, mit beiden Barrieren an den Stellen, an die sie gehören:

// .NET 10, C# 14
struct Snapshot { public long A, B, C, D; }

sealed class SeqLockBox
{
    private int _version;          // even = stable, odd = write in progress
    private Snapshot _data;

    // Single writer only.
    public void Write(long n)
    {
        int v = _version;
        _version = v + 1;          // mark "writing" (odd)
        Volatile.WriteBarrier();   // odd version is published before any data write below
        _data.A = n; _data.B = n; _data.C = n; _data.D = n;
        Volatile.Write(ref _version, v + 2); // release: data writes complete before the even version
    }

    public bool TryRead(out Snapshot snapshot)
    {
        int v1 = Volatile.Read(ref _version); // acquire: the data reads below cannot move above this
        snapshot = _data;                     // plain, non-atomic 32-byte copy
        Volatile.ReadBarrier();               // every read above completes before the re-check
        return (v1 & 1) == 0 && _version == v1;
    }
}

Beachten Sie, wie der Leser beide APIs verwendet. Der erste Versions-Lesezugriff ist ein Volatile.Read, weil die Datenzugriffe unterhalb davon bleiben müssen. Die Datenkopie ist einfach. Dann hält ReadBarrier die Datenzugriffe oberhalb des zweiten Versions-Lesezugriffs. Kein einzelnes Volatile.Read kann diese zweite Einschränkung ausdrücken, denn Volatile.Read schränkt nur ein, was nach der gelesenen Speicherstelle kommt, und hier muss geordnet werden, was davor kam.

Der Schreiber spiegelt das. Volatile.Write auf die letzte gerade Version ist ein Release, sodass die Datenschreibzugriffe nicht darunter absinken können. Ein Release verhindert aber nicht, dass die Datenschreibzugriffe über den früheren Store der ungeraden Version steigen. WriteBarrier deckt diese Seite ab.

Beweis, dass jede Hälfte notwendig ist

Ich habe Leser und Schreiber auf zwei Threads je fünf Sekunden pro Szenario laufen lassen und gezählt, wie viele akzeptierte Snapshots widersprüchliche A, B, C, D hatten. Jedes Szenario entfernt ein Stück der Ordnung:

// .NET 10, C# 14: the reader variants in the stress test
public bool TryReadAcquireOnly(out Snapshot snapshot)   // no ReadBarrier
{
    int v1 = Volatile.Read(ref _version);
    snapshot = _data;
    return (v1 & 1) == 0 && _version == v1;
}

public bool TryReadBarrierOnly(out Snapshot snapshot)   // no acquire on the first read
{
    int v1 = _version;
    snapshot = _data;
    Volatile.ReadBarrier();
    return (v1 & 1) == 0 && _version == v1;
}

Ergebnisse auf dem M4, .NET 10.0.10, Release-Build, zwei Läufe:

SzenarioAkzeptierte Snapshots (Lauf 1 / Lauf 2)Zerrissen und akzeptiert (Lauf 1 / Lauf 2)
Überhaupt keine Ordnung (einfache Lesezugriffe)1,014,876,206 / 1,003,303,309547,804 / 515,135
Nur Volatile.Read, keine ReadBarrier164,358,676 / 152,032,56199 / 357
Nur ReadBarrier, einfacher erster Lesezugriff34,543,735 / 27,982,88454 / 62
Schreiber ohne WriteBarrier, korrekter Leser354,942,744 / 384,287,32466,155,404 / 54,597,163
Beide Barrieren (der Code oben)62,659,697 / 66,048,7380 / 0

Jede Halbheit lieferte zerrissene Daten, die die Validierung bestanden. Die seltenen sind die gefährlichen: 99 fehlerhafte Lesezugriffe aus 164 Millionen sind genau die Art von Bug, die jeden Testlauf übersteht und in Produktion auf einer Graviton- oder Ampere-Maschine auftaucht. Die fehlende WriteBarrier war der lauteste Fehler, und das Disassembly zeigt warum: Die beiden Schreibermethoden kompilieren bis auf das einzelne dmb ish zu identischem Code, sodass jeder dieser über 54 Millionen Risse darauf beruht, dass der arm64-Kern die Datenspeicherungen vor dem Store der ungeraden Version sichtbar macht.

Unter x64 würden Sie bei den meisten dieser Zeilen sehr wahrscheinlich null Risse sehen, weil die Hardware in diesen Richtungen nicht umsortiert. Genau deshalb werden diese Bugs ausgeliefert. Der Code ist auf x64 trotzdem falsch, da der JIT einfache Zugriffe umsortieren darf, und er wird sichtbar falsch, sobald er auf arm64 läuft.

Auch der JIT sortiert um, nicht nur die CPU

Die erste Version meines Stresstest-Harness hing endlos, und es lohnt sich zu zeigen, warum. Der fehlerhafte Leser lief in einer Schleife, bis er eine gerade Version sah:

// .NET 10, C# 14: do not do this
public void WaitForEvenBroken()
{
    while ((_version & 1) != 0) { }
}

Der JIT kompilierte das zu einem Ladevorgang und einem Sprung auf sich selbst:

ldr     w0, [x0, #0x08]
and     w0, w0, #1
G_M000_IG03:
cbnz    w0, G_M000_IG03

Das Laden von _version wurde aus der Schleife herausgezogen, was bei einem gewöhnlichen Feldzugriff ohne dazwischenliegende Synchronisierung zulässig ist. Landet der erste Lesezugriff zufällig auf einer ungeraden Version, dreht sich der Thread für immer. Ein Volatile.Read(ref _version) in der Bedingung behebt das, ebenso eine ReadBarrier im Schleifenrumpf. Das ist der Teil von “volatile”, den x64-Entwickler tatsächlich erleben, und er ist der Grund, warum die Barrieren keine leeren Aufrufe sind, selbst wenn sie keine Instruktion erzeugen.

Wann Sie Volatile.Read wählen

In all diesen Fällen ist die Ordnung an einen einzelnen Lesezugriff gebunden, sodass Volatile.Read genau ausdrückt, was Sie meinen, und auf keiner Architektur einen Fence erzeugt.

Wann Sie Volatile.ReadBarrier wählen

Die Kosten, gemessen

Es gibt zwei korrekte Wege, den Seqlock-Leser ohne ReadBarrier zu schreiben: jeden Datenzugriff zu einem Volatile.Read machen oder dort, wo die Barriere hingehört, ein vollständiges Interlocked.MemoryBarrier() verwenden. Ich habe alle gegen den ungeordneten (fehlerhaften) Leser mit BenchmarkDotNet 0.15.8 verglichen. Jeder Aufruf führt 1,024 Einzelthread-Lesezugriffe auf den 32-Byte-Snapshot aus, und die Tabelle nennt die Kosten pro Lesezugriff:

// .NET 10.0.10, C# 14, BenchmarkDotNet 0.15.8, Apple M4 (arm64)
[Benchmark(OperationsPerInvoke = N)]
public long VolatileReadPlusReadBarrier()
{
    long sum = 0;
    for (int i = 0; i < N; i++)
    {
        int v1 = Volatile.Read(ref _version);
        Snapshot s = _data;
        Volatile.ReadBarrier();
        if ((v1 & 1) == 0 && _version == v1) sum += s.A + s.B + s.C + s.D;
    }
    return sum;
}
Leser (pro Snapshot-Lesezugriff)MittelwertVerhältnis
Keine Ordnung (fehlerhaft)0.916 ns1.00
Volatile.Read auf die Version und auf alle vier Felder1.135 ns1.24
Volatile.Read + Volatile.ReadBarrier0.929 ns1.01
Volatile.Read + Interlocked.MemoryBarrier0.930 ns1.02

Die Barrier-Version kostet etwa so viel wie die fehlerhafte. Die Version mit durchgehend volatilen Lesezugriffen ist rund 24% langsamer, hauptsächlich weil vier geordnete ldapur-Ladevorgänge nicht wie die einfache Kopie zu zwei breiten Ladevorgängen verschmolzen werden können. Bei einem größeren Struct wächst die Lücke mit der Feldanzahl, während die Barriere eine Instruktion bleibt.

Zwei ehrliche Einschränkungen. Dies ist eine unkonkurrierte Einzelthread-Schleife: Ein dmb ist billig, wenn der Kern keinen ausstehenden Speicherverkehr abwarten muss, weshalb auch der vollständige Fence hier kostenlos aussieht. Unter echter Schreibkonkurrenz kostet ein vollständiger Fence typischerweise mehr als ein reiner Load-Fence, aber ich habe keinen Benchmark mit Konkurrenz durchgeführt und nenne deshalb keine Zahl. Und all das gilt für arm64. Unter x64 erzeugen beide Barrieren keine Instruktion, sodass Sie nur vergleichen, was der JIT um sie herum tun darf.

Fallstricke

Die Platzierung ist alles. ReadBarrier ordnet Lesezugriffe vor ihr gegenüber Zugriffen nach ihr. Sie am Anfang eines Lesers zu platzieren, wo man instinktiv einen “volatile”-Lesezugriff hinsetzt, ordnet nichts, was Sie interessiert. In einem Seqlock steht sie nach der Datenkopie und vor dem zweiten Versions-Lesezugriff.

Sie ist kein vollständiger Fence. Ein Store, gefolgt von ReadBarrier, gefolgt von einem Load, kann weiterhin umsortiert werden. Dekker-artiger Code, bei dem jeder Thread sein eigenes Flag schreibt und dann das des anderen liest, braucht Interlocked.MemoryBarrier() oder eine Interlocked-Operation.

Sie macht nichts atomar. Die Spezifikation des Speichermodells ist deutlich: Volatile-Semantik impliziert keine Atomarität. Wenn Sie die Versionsvalidierung weglassen, ordnet eine Barriere bereitwillig einen zerrissenen Lesezugriff.

Sie ist kein Lock. Ein Seqlock wie hier geschrieben unterstützt genau einen Schreiber. Zwei Schreiber müssen sich mit einem Interlocked.CompareExchange auf die Version serialisieren (so macht es GenericCache) oder mit einem echten Lock. Wenn Sie zu Barrieren greifen, weil sich ein Lock langsam anfühlte, messen Sie zuerst: Der Beitrag zu lock vs Monitor vs SemaphoreSlim vs System.Threading.Lock zeigt, wie günstig ein unkonkurrierter Lock bereits ist.

C#-volatile-Felder sind nicht dasselbe Werkzeug. Ein volatile-Feld veranlasst den C#-Compiler, jeden Zugriff mit dem IL-Präfix volatile. auszugeben, sodass jeder Lesezugriff ein Acquire und jeder Schreibzugriff ein Release ist. Das ist die Semantik von Volatile.Read/Volatile.Write pro Zugriff, niemals eine Barriere über einen Stapel, und sie deaktiviert die oben gezeigte Paarung von Ladevorgängen.

Das Fazit

Verwenden Sie standardmäßig Volatile.Read. Es ist das richtige Werkzeug für nahezu jedes lockfreie Flag-, Veröffentlichungs- und Lazy-Init-Muster, kostet auf x64 nichts und ist auf moderner arm64-Hardware eine einzige Load-Acquire-Instruktion. Verwenden Sie Volatile.ReadBarrier (ab .NET 10) nur, wenn das, was Sie ordnen müssen, ein Stapel früherer Lesezugriffe ist, typischerweise eine nicht atomare Kopie, die Sie anschließend validieren. Kombinieren Sie sie dann auf der Schreiberseite mit Volatile.WriteBarrier, testen Sie auf arm64 und denken Sie daran, dass WriteBarrier unter arm64 so teuer ist wie ein vollständiger Fence.

Verwandte Themen

Quellen

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Zurück