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 не являются двумя уровнями одной и той же гарантии. Они отвечают на разные вопросы:
Volatile.Read: “прочитай это значение и убедись, что всё после него видит память не менее свежей”.Volatile.ReadBarrier: “убедись, что всё, что я уже прочитал, завершено, прежде чем я снова обращусь к памяти”.
Ни один из них не является полным барьером. 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,309 | 547,804 / 515,135 |
Только Volatile.Read, без ReadBarrier | 164,358,676 / 152,032,561 | 99 / 357 |
Только ReadBarrier, обычное первое чтение | 34,543,735 / 27,982,884 | 54 / 62 |
Писатель без WriteBarrier, корректный читатель | 354,942,744 / 384,287,324 | 66,155,404 / 54,597,163 |
| Оба барьера (код выше) | 62,659,697 / 66,048,738 | 0 / 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
- Флаг или сигнал остановки.
while (!Volatile.Read(ref _stop))это канонический случай. Одна ячейка, одно значение, и вы хотите, чтобы последующие чтения видели то, что писатель опубликовал перед установкой флага. - Публикация ссылки. Писатель создаёт объект, затем
Volatile.Write(ref _instance, obj); читатель делаетVolatile.Read(ref _instance)и затем читает поля через неё. Acquire при чтении ссылки это всё, что нужно. - Ленивая инициализация с двойной проверкой. Та же схема, что и публикация, и причина, по которой
LazyInitializerвнутри использует volatile-чтения. - Вы ориентируетесь на .NET 9 или более раннюю версию.
ReadBarrierтам не существует.
Во всех этих случаях упорядочивание привязано к одному чтению, поэтому Volatile.Read говорит ровно то, что вы имеете в виду, и не генерирует барьер ни на одной архитектуре.
Когда выбирать Volatile.ReadBarrier
- Читатели seqlock и кеши с проверкой версии. Шаблон выше, тот же, что используют
CastCacheиGenericCacheв CoreLib. - Данные, которые нельзя прочитать атомарно. Структуры больше указателя,
Int128, диапазоны байтов или структура с несколькими полями. Для них нет перегрузкиVolatile.Read, аReadBarrierпозволяет копировать их обычными загрузками и проверять после. - Чтения из нативной памяти или через
Unsafe. Если вы читаете через указатель илиrefв неуправляемый буфер, управляемого поля, которое можно передать вVolatile.Read, может не быть. Барьер упорядочивает такие загрузки так же. - Много чтений, которым нужна одна точка упорядочивания. Один
dmb ishldпосле N обычных загрузок вместо N acquire-загрузок, при этом JIT может объединять обычные загрузки в пары.
Стоимость в измерениях
Есть два корректных способа написать читатель 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 ns | 1.00 |
Volatile.Read для версии и для всех четырёх полей | 1.135 ns | 1.24 |
Volatile.Read + Volatile.ReadBarrier | 0.929 ns | 1.01 |
Volatile.Read + Interlocked.MemoryBarrier | 0.930 ns | 1.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 стоит столько же, сколько полный барьер.
Связанные материалы
- How to use the new System.Threading.Lock type, правильный ответ, когда lock-free код вам на самом деле не нужен.
- lock vs Monitor vs SemaphoreSlim vs System.Threading.Lock in C# для выбора примитива синхронизации.
- How to cancel a long-running Task without deadlocking, где за каждой проверкой отмены стоит
Volatile.Read. - record vs class vs struct in C#, актуально, когда ваше общее состояние это структура из нескольких полей, которую нельзя прочитать атомарно.
Источники
- Volatile.ReadBarrier method и Volatile class на MS Learn.
- API proposal: Volatile barrier APIs, dotnet/runtime#98837.
- Implement volatile barrier APIs, dotnet/runtime#107843, включая кодогенерацию JIT и изменения кешей в CoreLib.
- .NET memory model specification.
- GenericCache.cs on release/10.0, боевой читатель seqlock, использующий
Volatile.ReadBarrier.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.