Start Debugging

Volatile.Read и Volatile.ReadBarrier в .NET 10

Volatile.Read выполняет чтение одной ячейки с семантикой acquire. Volatile.ReadBarrier, новый в .NET 10, это барьер, который придаёт семантику acquire всем предшествующим чтениям. Используйте Volatile.Read для флагов и опубликованных ссылок, а ReadBarrier, когда нужно, чтобы пачка обычных или неатомарных чтений завершилась до следующего обращения к памяти, как в seqlock.

Volatile.Read(ref x) читает одну ячейку с семантикой acquire: ничто из последующего кода не может переместиться выше этого чтения. Volatile.ReadBarrier(), добавленный в .NET 10, вообще ничего не читает. Это барьер, который придаёт семантику acquire каждому чтению перед ним, так что целая пачка обычных (даже неатомарных) чтений должна завершиться до любого обращения к памяти после барьера. Используйте Volatile.Read в типичном случае флага, счётчика или опубликованной ссылки. Обращайтесь к ReadBarrier, когда нужно, чтобы несколько обычных чтений, или одно чтение, слишком большое для атомарного, завершились до повторной проверки. Хрестоматийный пример: сторона читателя в seqlock.

Всё ниже измерено на .NET 10.0.10 (SDK 10.0.302), C# 14, на Apple M4 (arm64). API барьеров есть в System.Threading.Volatile начиная с .NET 10; в .NET 9 и более ранних версиях публичного аналога нет, кроме Interlocked.MemoryBarrier().

Сравнение в одной таблице

Volatile.Read(ref x)Volatile.ReadBarrier()
Доступен с.NET Framework 4.5.NET 10
Читает значениеДа, одну ячейкуНет
Что получает семантику acquireЭто одно чтениеВсе чтения перед вызовом
Не даёт последующим чтениям и записям перемещаться вышеДаДа
Делает чтение атомарнымДа, для поддерживаемых типов (включая long/double на 32-разрядных системах)Нет, атомарность остаётся на вашей совести
Работает с любым T, структурами, нативной памятьюНет, фиксированный набор перегрузокДа, упорядочивает любые предшествующие чтения
Кодогенерация arm64 (измерено, .NET 10.0.10)ldapur (load-acquire)dmb ishld (барьер загрузки)
Кодогенерация x64обычный mov, только упорядочивание на уровне компиляторанет инструкции, только упорядочивание на уровне компилятора
Типичное применениефлаги, инициализация с двойной проверкой, опубликованные ссылкиseqlock, кеши с проверкой версии, пакетные чтения

Что обещают эти два API

Спецификация модели памяти .NET (docs/design/specs/Memory-model.md в dotnet/runtime) перечисляет оба метода в разделе “volatile reads have acquire semantics” с одной показательной сноской о барьере: он “applies to all prior reads”. Acquire означает, что никакое чтение или запись, идущие позже в порядке программы, не могут выполниться раньше читающей операции acquire.

В случае Volatile.Read(ref _version) acquire привязан к загрузке _version и больше ни к чему. Чтения, которые были выполнены до неё в порядке программы, не ограничены вообще. Они по-прежнему могут сместиться ниже.

В случае Volatile.ReadBarrier() acquire привязан к каждой загрузке, предшествующей вызову. Предложение API (dotnet/runtime#98837) называет это барьером Read-ReadWrite: все предшествующие чтения должны завершиться до любой последующей операции с памятью. Его парный метод, Volatile.WriteBarrier(), это барьер ReadWrite-Write: все предшествующие операции с памятью завершаются до любой последующей записи.

Так что эти два API не являются двумя уровнями одной и той же гарантии. Они отвечают на разные вопросы:

Ни один из них не является полным барьером. ReadBarrier ничего не делает, чтобы помешать переупорядочиванию более ранней записи с более поздним чтением (случай store-load). Если это нужно, по-прежнему требуется Interlocked.MemoryBarrier() или операция Interlocked.

Что на самом деле генерирует JIT

JIT рассматривает оба метода как intrinsic. Исходник Volatile.cs это просто [Intrinsic] public static void ReadBarrier() => ReadBarrier();, а импортёр заменяет вызов узлом барьера памяти, помеченным как только для загрузок (PR #107843). Чтобы увидеть, во что это превращается, я скомпилировал небольшой класс с полной оптимизацией и сделал дамп через DOTNET_JitDisasm:

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

На M4 интересными оказались такие инструкции:

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

Выделяются три момента.

Во-первых, Volatile.Read компилируется в ldapur, RCpc load-acquire (расширения RCpc появились в ARMv8.3 и v8.4), который поддерживает M4. Ядра без RCpc получают более старую ldar. В любом случае отдельной инструкции барьера нет.

Во-вторых, обычные чтения перед ReadBarrier остаются обычными, поэтому JIT вправе объединять их в пары ldp (а при копировании 32-байтовой структуры в пару 128-битных загрузок ldp q). С четырьмя acquire-загрузками эта свобода теряется. Это и есть довод об эффективности из предложения: один барьер на N чтений вместо N упорядоченных чтений.

В-третьих, Volatile.WriteBarrier() на arm64 это полный dmb ish, ровно то, что генерирует Interlocked.MemoryBarrier(). В JIT есть комментарий о том, что сейчас он не может сгенерировать на arm64 барьер только для записи лучше полного, так что не стоит ожидать, что WriteBarrier там будет дешевле полного барьера.

На x64 оба барьера вообще не генерируют инструкций. Комментарий о кодогенерации в PR недвусмыслен: барьеры только для загрузок и только для записей “are no-ops on xarch”, потому что модель TSO в x86 уже сохраняет порядок загрузок относительно последующих загрузок и записей, а записей относительно предыдущих операций с памятью. Тем не менее они важны и на x64: они не дают самому JIT переупорядочивать, кешировать или устранять обращения к памяти через барьер. Машины с x64 для этого прогона у меня не было, поэтому строка x64 в таблице взята из исходников JIT, а не из дизассемблера.

Seqlock: случай, для которого создавался ReadBarrier

Первым потребителем стал сам runtime. GenericCache и CastCache в CoreLib использовали внутренний Interlocked.ReadMemoryBarrier() и в том же PR были переведены на Volatile.ReadBarrier(). Их комментарий описывает шаблон: “we must read in this order: version -> [entry parts] -> version”.

Это seqlock. Единственный писатель увеличивает версию до нечётного числа, записывает данные, затем увеличивает её до следующего чётного числа. Читатели читают версию, копируют данные обычными загрузками и снова читают версию. Если оба чтения совпадают и версия чётная, копия согласована. Данные могут быть любого размера: 32-байтовая структура не атомарна ни на одной платформе, и это нормально, потому что проверка версии ловит разорванные копии.

Вот минимальная версия, где оба барьера стоят на своих местах:

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

Обратите внимание, как читатель использует оба API. Первое чтение версии это Volatile.Read, потому что нам нужно, чтобы чтения данных оставались ниже него. Копирование данных обычное. Затем ReadBarrier удерживает чтения данных выше второго чтения версии. Ни один отдельный Volatile.Read не может выразить это второе ограничение, потому что Volatile.Read ограничивает только то, что идёт после читаемой им ячейки, а здесь нужно упорядочить то, что было до.

Писатель устроен зеркально. Volatile.Write для итоговой чётной версии это release, поэтому записи данных не могут опуститься ниже неё. Но release ничего не делает, чтобы записи данных не поднялись выше более раннего сохранения нечётной версии. Эту сторону закрывает WriteBarrier.

Доказательство, что необходима каждая половина

Я запускал читателя и писателя на двух потоках по пять секунд на сценарий и считал, сколько принятых снимков имели расхождения между A, B, C, D. В каждом сценарии убирается одна часть упорядочивания:

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

Результаты на M4, .NET 10.0.10, сборка Release, два запуска:

СценарийПринятые снимки (запуск 1 / запуск 2)Разорванные и принятые (запуск 1 / запуск 2)
Вообще без упорядочивания (обычные чтения)1,014,876,206 / 1,003,303,309547,804 / 515,135
Только Volatile.Read, без ReadBarrier164,358,676 / 152,032,56199 / 357
Только ReadBarrier, обычное первое чтение34,543,735 / 27,982,88454 / 62
Писатель без WriteBarrier, корректный читатель354,942,744 / 384,287,32466,155,404 / 54,597,163
Оба барьера (код выше)62,659,697 / 66,048,7380 / 0

Каждая полумера давала разорванные данные, прошедшие проверку. Редкие случаи самые опасные: 99 плохих чтений на 164 миллиона это баг, который переживает каждый прогон тестов и проявляется в продакшене на машине с Graviton или Ampere. Отсутствие WriteBarrier дало самый громкий сбой, и дизассемблер объясняет почему: два метода писателя компилируются в идентичный код, за исключением единственного dmb ish, так что каждый из этих 54+ миллионов разрывов означает, что ядро arm64 делает записи данных видимыми раньше сохранения нечётной версии.

На x64 вы, скорее всего, увидели бы ноль разрывов в большинстве этих строк, потому что оборудование не переупорядочивает в этих направлениях. Именно поэтому такие баги попадают в релиз. Код всё равно неверен и на x64, так как JIT вправе переупорядочивать обычные обращения, и ошибка становится заметной, как только код запускается на arm64.

JIT тоже переупорядочивает, не только процессор

Первая версия моего стресс-теста зависала навсегда, и стоит показать почему. Сломанный читатель крутился в цикле, пока не видел чётную версию:

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

JIT скомпилировал это в одну загрузку и переход на саму себя:

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

Загрузка _version была вынесена из цикла, что допустимо для обычного чтения поля без промежуточной синхронизации. Если первое чтение случайно попало на нечётную версию, поток вращается вечно. Volatile.Read(ref _version) в условии это исправляет, как и ReadBarrier в теле цикла. Это та часть “volatile”, с которой разработчики на x64 всё же сталкиваются, и именно поэтому барьеры не являются пустыми вызовами даже там, где не генерируют инструкций.

Когда выбирать Volatile.Read

Во всех этих случаях упорядочивание привязано к одному чтению, поэтому Volatile.Read говорит ровно то, что вы имеете в виду, и не генерирует барьер ни на одной архитектуре.

Когда выбирать Volatile.ReadBarrier

Стоимость в измерениях

Есть два корректных способа написать читатель seqlock без ReadBarrier: сделать каждое чтение данных через Volatile.Read или поставить в место барьера полный Interlocked.MemoryBarrier(). Я сравнил все варианты с неупорядоченным (сломанным) читателем в BenchmarkDotNet 0.15.8. Каждый вызов выполняет 1,024 однопоточных чтения 32-байтового снимка, а в таблице указана стоимость одного чтения:

// .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;
}
Читатель (на одно чтение снимка)СреднееОтношение
Без упорядочивания (сломан)0.916 ns1.00
Volatile.Read для версии и для всех четырёх полей1.135 ns1.24
Volatile.Read + Volatile.ReadBarrier0.929 ns1.01
Volatile.Read + Interlocked.MemoryBarrier0.930 ns1.02

Версия с барьером стоит примерно столько же, сколько сломанная. Версия, где всё читается через volatile, медленнее примерно на 24%, в основном потому, что четыре упорядоченные загрузки ldapur нельзя объединить в две широкие загрузки, как это можно сделать при обычном копировании. Для структуры побольше разрыв растёт вместе с числом полей, а барьер остаётся одной инструкцией.

Две честные оговорки. Это цикл без конкуренции, в одном потоке: dmb дёшев, когда у ядра нет незавершённого обмена с памятью, которого нужно дожидаться, поэтому полный барьер здесь тоже выглядит бесплатным. При реальной конкуренции записей полный барьер обычно стоит дороже барьера только для загрузок, но конкурентный бенчмарк я не запускал, поэтому числа не называю. И всё это относится к arm64. На x64 оба барьера не генерируют инструкций, так что сравнивать можно лишь то, что JIT вправе делать вокруг них.

Подводные камни

Расположение решает всё. ReadBarrier упорядочивает чтения до него относительно обращений после него. Если поставить его в начало читателя, куда инстинктивно ставят “volatile”-чтение, он не упорядочит ничего нужного. В seqlock он стоит после копирования данных и перед вторым чтением версии.

Это не полный барьер. Запись, затем ReadBarrier, затем загрузка всё равно могут быть переупорядочены. Код в стиле Деккера, где каждый поток пишет свой флаг и затем читает чужой, требует Interlocked.MemoryBarrier() или операции Interlocked.

Он ничего не делает атомарным. Спецификация модели памяти категорична: семантика volatile не подразумевает атомарности. Если пропустить проверку версии, барьер охотно упорядочит и разорванное чтение.

Это не блокировка. Seqlock в таком виде поддерживает ровно одного писателя. Двум писателям нужно сериализоваться через Interlocked.CompareExchange по версии (так делает GenericCache) или через настоящую блокировку. Если вы тянетесь к барьерам потому, что блокировка показалась медленной, сначала измерьте: статья про lock vs Monitor vs SemaphoreSlim vs System.Threading.Lock показывает, насколько дёшева уже неконкурентная блокировка.

Поля C# volatile это другой инструмент. Поле volatile заставляет компилятор C# выдавать каждое обращение с IL-префиксом volatile., так что каждое чтение это acquire, а каждая запись это release. Это семантика Volatile.Read/Volatile.Write на каждое обращение, а не барьер над пачкой, и она отключает объединение загрузок в пары, показанное выше.

Вердикт

По умолчанию используйте Volatile.Read. Это правильный инструмент почти для любого lock-free шаблона флага, публикации и ленивой инициализации, он ничего не стоит на x64, а на современном arm64 это одна инструкция load-acquire. Используйте Volatile.ReadBarrier (в .NET 10 и новее) только когда нужно упорядочить пачку более ранних чтений, как правило неатомарную копию, которую вы проверяете после. В этом случае сочетайте его с Volatile.WriteBarrier на стороне писателя, тестируйте на arm64 и помните, что на arm64 WriteBarrier стоит столько же, сколько полный барьер.

Связанные материалы

Источники

Comments

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

< Назад