Данная статья направлена на повышение уровня понимания принципов работы барьеров памяти, которые лежат в основе атомарных операций. Она не описывает историю и первопричины появления данного механизма, а служит объяснением основных подходов.

Идеей было донести простыми словами и примерами механизмов работы барьеров памяти, поэтому в данной статье нет углубления в синтаксис ассемблер команд или архитектур процессоров.


Введение

В введении рассказываются основные понятия, необходимые для понимания тематики статьи. Если вам знакомы понятия гонки данных и состояния гонки можете переходить к разделу Основы.

Перед объяснением что такое барьеры памяти стоит сформировать понимание гонки данных и состояния гонки. Эти два определения напрямую связаны с асинхронным и параллельным программированием.

Асинхронность - поочередное исполнение задач, не обязательно доделывая их до конца и возвращаясь к ним в дальнейшем.

Простой пример

Хорошим примером асинхронности является готовка двух и более блюд. Все этапы подготовки ингредиентов могут выполняться без завершения того или иного блюда. Например мы можем почистить картошку на гарнир, потом порезать огурцы в салат. А пока мы готовим нам могут позвонить и мы отложим все и поговорим по телефону, вернувшись потом к нашему занятию.

Этот пример хорош тем, что демонстрирует одного исполнителя и множество разных задач между которыми он переключается.

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

Простой пример

Два повара готовят разные блюда.

Каждого из них могут отвлечь звонком. Это не остановит готовки второго.

Состояние гонки - Race condition - от того кто раньше закончил исполнение зависит результат.

Простой пример

Самый демонстративный пример про гонцов. Есть два гонца они несут разные сообщения, от того кто первый его доставит будет зависеть ответ на письмо.

Содержание обоих писем: «Это мой доверенный гонец, всё дальнейшее общение только через письма от него.»

Казалось бы, сообщения одинаковые, а результат разный. Условно от того кто пришел второй могут даже не принять сообщение.

Гонка данных - Data race - одновременное обращение к одному ресурсу для как минимум одной операции записи при условии наличия операций чтения и более если операций записей несколько. Тут важно понимать, что одновременное чтение не приводит к гонке, только при наличии записи(ей) наблюдается подобный эффект.

Простой пример

Есть более одного человека, которые условились по каким-то причинам общаться с помощью записей в одной конкретной книге. У них бурная беседа и они активно делают записи и читают. Очередь естественно никакая не выстроена и все хотят друг друга «перебивать». В один момент происходит ситуация, пока писал первый из участников, второй вырвал книгу чтобы записать свой, только что пришедший в голову аргумент. Запись первого участника не закончена и не понятно что он хотел донести.

Потом третий участник не дождавшись окончания записи второго, вырывает книгу. Он не может прочитать последние пару записей и думает что записей не было.

Основы

В простейшем представлении путь от кода/скрипта до работающего приложения состоит из нескольких этапов. Компиляция/трансляция → исполнение (допускаю возможность написания программ на машинном коде, но данная статья рассматривает более общие подходы).

Компиляция/трансляция — по своей сути разные операции, которые реализуют разные подходы. Но задача у них одна, сформировать инструкции которые сможет исполнить тот или иной процессор.

Процессор — исполнитель которому мы пишем инструкции, чтобы достичь желаемого результата.

Процесс компиляции/трансляции может вносить свои корректировки и это может быть связанно с множеством причин. В самом распространенном случае для повышения скорости работы.

Процессор так же может вносить свои корректировки во время исполнения(для понимания причин данного механизма необходимо углубиться в архитектуру процессора, а в частности в механизмы Store Buffer и Invalidation Queue. Тут данная тема не освещена для упрощения понимания, а перестановки операций описаны с точки зрения влияния на память).

Допустимые оптимизации
Допустимые оптимизации

К сожалению подобного рода оптимизации могут вызывать гонки данных(при асинхронном или параллельном взаимодействии). Например параллельно мы можем дожидаться пока в а появятся данные, явным показателем этого будет значение done = true. При оптимизационной перестановке к сожалению данный маркер не даст нам гарантий и возникнет гонка данных.

Барьер памяти — определенное ограничение оптимизационных перемещений, что дает гарантию порядка исполнения.

Всего существует две операции с памятью, чтение и запись.

Чтение Load — процессор идет и забирает себе какое‑то значение из памяти.

Запись Store — процессор идет и кладет какое-то свое значение в память.

Поскольку основных операций с памятью всего две, то существует всего четыре типа основных барьеров памяти, сформированные их комбинациями. Представляют они из себя XY, где X и Y — Load или Store.

Невозможное поведение

Если барьер типа XY — это означает, что все операции типа X до барьера выполнятся гарантированно до всех операций типа Y после барьера. То есть не может быть ситуации, когда операции X спустятся вниз за барьер XY, а операции Y поднимутся наверх.

Пример: если стоит барьер типа LoadStore, то операции типа Load не могут спуститься вниз (выполниться позже), но могут подняться. Так же операции типа Store не могут подняться (выполниться раньше), но могут спуститься.

acquire/release
acquire/release

Использование данных типов барьеров позволяет создать область из которой операции типа Load или Store не выносятся.

Достигается подобное поведение использованием LoadLoad + LoadStore барьера сверху. Снизу StoreStore + LoadStore.

По данному принципу эти барьеры были сгруппированы в acquire и release соответственно, а семантику назвали acquire/release.

Стоит отметить что комбинация всех трех использованных типов барьеров позволит гарантировать что ни одна операция не будет перемещена через барьеры. Соответственно acquire + release позволит получить full барьер.

Это принцип работы mutex. Lock() - производит захват (acquire), а Unlock() — производит освобождение (release). Что по факту создает секцию из которой наши команды гарантированно не перемещаются.

writeBarrier

В рамках Golang так же существует понятие writeBarrier. Оно относится к алгоритму сборщика мусора.

В рамках кода можно встретить gcWriteBarrier.

Нужен он для того чтобы сборщик мог корректно отслеживать изменения в указателях в куче.

Оба этих понятия относятся к сборке мусора и в рамках данной статьи рассматриваться не будут.

Модели памяти

Хоть данная статья и несет более абстрактный характер, но следует знать что наличие и типы барьеров определяются моделью памяти архитектуры процессора и компилятора/транслятора. Можно выделить четыре основных:

  • Sequential Consistency — любая операция чтения или записи использует все барьеры памяти (как если бы у процессора не было Store Buffer и Invalidation Queue. Но поскольку это более обширная тема, тут она освещаться не будет).

  • Total store‑ordered (TSO) или Strong‑ordered — любая операция чтения или записи использует acquire и release барьеры (x86/64 архитектура).

  • Weak‑ordered — все барьеры необходимо применять вручную(ARMv7 архитектура).

  • Super‑weak — все барьеры необходимо применять в ручную, а так же нет гарантий что взаимозависимые строчки не будут переупорядочены.


Лезем в исходный код

Таким образом, чтобы мы проще смогли увидеть использование барьеров памяти, нам необходимо рассматривать код Go под ARM архитектуру.

Ниже будут представлены основные методы пакета sync.atomic:

  • Load — атомарно получает данные

  • Store — атомарно сохраняет данные

  • CAS — CompareAndSwap(сравнивает и меняет значения — основа для sync.Mutex)

Это не все существующие методы, но их достаточно чтобы понять как используются барьеры памяти в Go.

Где найти

Все исходники ассемблерных инструкций атомиков можно найти в файлах internal/runtime/atomic/atomic_*.s. Где * это архитектура процессора.

В репозитории Golang
Локально

От корня установленного Golang

internal/runtime/atomic/atomic_arm64.s

Реализация Load

Go использует специальный ассемблер (Go ASM), который не является традиционным языком ассемблера. Это промежуточная форма, генерируемая компилятором Go, и она позволяет взаимодействовать с низкоуровневым машинным кодом.

// uint32 ·Load(uint32 volatile* addr)
TEXT ·Load(SB),NOSPLIT,$0-12
	MOVD	ptr+0(FP), R0
	LDARW	(R0), R0        ; Load Acquire Register Word 
    MOVW	R0, ret+8(FP)
	RET

На данном примере мы видим использование LDARW(Load Acquire Register Word) инструкции. Что запрещает перемещение инструкции MOVW выше нашей команды. Это и есть Acquire барьер.

Копнём глубже

ARM Architecture Reference Manual (ARMv8-A) - https://developer.arm.com/documentation/ddi0487/latest

В разделе C3.2.8 Load-Acquire/Store-Releaseдокументации можно найти информацию которая нам подходит.

The Load-Acquire, Load-AcquirePC, and Store-Release instructions can remove the requirement to use the explicit DMB memory barrier instruction. For more information about the ordering of Load-Acquire, Load-AcquirePC, and Store-Release, see Load-Acquire, Load-AcquirePC, and Store-Release.

(Инструкции Load-Acquire, Load-AcquirePC и Store-Release могут устранить необходимость использования явного барьера памяти DMB. Подробнее о порядке выполнения Load-Acquire, Load-AcquirePC и Store-Release см. в разделе Load-Acquire, Load-AcquirePC и Store-Release.)

Далее идет табличка в которой наблюдается инструкция LDAR.

Таблица инструкций ARM
Таблица инструкций ARM

Хоть LDAR уже похоже на найденную нами инструкцию, но стоит убедиться точно ли такую инструкцию мы будем наблюдать при переходе от Go ASM к чистому ASM.

Нам в этом поможет doc.go расположенный в src/cmd/internal/obj/arm64/doc.go.

  1. Most instructions use width suffixes of instruction names to indicate operand width rather than using different register names.

(Большинство инструкций используют суффиксы ширины имен команд для указания ширины операнда, а не используют разные имена регистров.)

Что говорит что в нашем случае W это суффикс.

Далее в примерах мы не будем так глубоко рассматривать похожие инструкции, подход к их поиску аналогичен.

Таким образом мы от метода спустились к ассемблер инструкции. Более детально мы сопоставим дальше.

Реализация Store

TEXT ·Store(SB), NOSPLIT, $0-12
	MOVD	ptr+0(FP), R0
	MOVW	val+8(FP), R1
	STLRW	R1, (R0)        ; Store Release Register Word
	RET

На данном примере мы видим использование STLRW(Store Release Register Word) инструкции. Что запрещает инструкциям MOVD и MOVW переместиться за данную команду. Это дает гарантию завершения всех операций чтения и записи до ее наступления. Что в свою очередь и есть Release барьер.

Реализация CAS

Тут код будет выглядеть сложнее из-за наличия ветвления, но позволяет наглядно сравнить разные реализации.

TEXT ·Cas(SB), NOSPLIT, $0-17
	MOVD	ptr+0(FP), R0
	MOVW	old+8(FP), R1
	MOVW	new+12(FP), R2
#ifndef GOARM64_LSE
	MOVBU	internal∕cpu·ARM64+const_offsetARM64HasATOMICS(SB), R4
	CBZ 	R4, load_store_loop
#endif
	MOVD	R1, R3
	CASALW	R3, (R0), R2            ; Compare-And-Swap Acquire-Release Word
	CMP 	R1, R3
	CSET	EQ, R0
	MOVB	R0, ret+16(FP)
	RET
#ifndef GOARM64_LSE
load_store_loop:
	LDAXRW	(R0), R3                ; Load Acquire Exclusive Register Word
	CMPW	R1, R3
	BNE	ok
	STLXRW	R2, (R0), R3            ; Store Release Exclusive Register Word
	CBNZ	R3, load_store_loop
ok:
	CSET	EQ, R0
	MOVB	R0, ret+16(FP)
	RET
#endif

Как мы можем заметить тут есть две разные ветки исполнения. Первая с учетом инструкций GOARM64_LSE, вторая без. По результатам данные подходы идентичные, но демонстрируют поведение если часть инструкций не поддерживается архитектурой.

Рассмотрим пример с инструкцией CAS.

MOVD	R1, R3
CASALW	R3, (R0), R2            ; Compare-And-Swap Acquire-Release Word
CMP 	R1, R3
CSET	EQ, R0
MOVB	R0, ret+16(FP)
RET

Тут явно используется CASALW(Compare-And-Swap Acquire-Release Word). Что представляет из себя комбинацию Acquire и Release барьеров. То есть ни одна операция не может пересечь данный барьер (Full барьер). А операция будет выполнена атомарно и неделимо.

Рассмотрим пример без CAS инструкции.

LDAXRW	(R0), R3                ; Load Acquire Exclusive Register Word
CMPW	R1, R3
BNE	ok
STLXRW	R2, (R0), R3            ; Store Release Exclusive Register Word
CBNZ	R3, load_store_loop

Тут можно заметить использование комбинации барьеров более явно. LDAXRW(Load Acquire Exclusive Register Word) выполняет роль Acquire барьера, а STLXRW(Store Release Exclusive Register Word) выполняет роль Release барьера. Таким образом мы получаем комбинацию Acquire и Release барьеров и ни одна инструкция не может быть перемещена через них (Full барьер).

Тут очень важно обратить внимание на Exclusive. При чтении устанавливается эксклюзивный монитор, который сбросится если кто-то параллельно изменит данные по адресу или произойдет прерывание. При записи если монитор не сброшен, то она пройдет успешно, иначе мы повторяем всю цепочку (load_store_loop). Этот механизм позволяет гарантировать атомарность операций чтения+записи(в x86 используется Lock, который работает с кеш линией). Для своей архитектуры вы можете сами посмотреть какой механизм гарантий эксклюзивности используется.

Прерывание — механизм процессора для остановки обработки в пользу других. Оно может происходить по многим причинам, о которых не хотелось бы говорить в рамках данной статьи.

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

От ассемблера к Golang

Как уже говорилось выше, мы рассмотрели наглядные и простые реализации sync/atomic методов, использующих барьеры памяти.

Далее мы поднимемся на уровень кода рассматриваемого языка. Для простоты объяснения и наглядности рассмотрим sync.Mutex.

Его можно найти в internal/sync/mutex.go.

Нас интересует метод Lock.

// Lock locks m.
//
// See package [sync.Mutex] documentation.
func (m *Mutex) Lock() {
	// Fast path: grab unlocked mutex.
	if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
		if race.Enabled {
			race.Acquire(unsafe.Pointer(m))
		}
		return
	}
	// Slow path (outlined so that the fast path can be inlined)
	m.lockSlow()
}

Можно заметить использование CAS, что дает нам гарантию атомарного захвата мьютекса. Так же он выполняет роль барьера памяти как мы уже рассмотрели выше (Full барьер), благодаря чему ни одна инструкция не может пересечь барьер и попасть в область захваченного мьютекса или покинуть её.

race.Enabled ветка

Ветка race.Enabled существует для того чтобы сказать рейс детектору о том что тут началась область Acquire. Это необходимо для корректного определения гонок данных.

Unlock же выглядит иначе.

// Unlock unlocks m.
//
// See package [sync.Mutex] documentation.
func (m *Mutex) Unlock() {
	if race.Enabled {
		_ = m.state
		race.Release(unsafe.Pointer(m))
	}

	// Fast path: drop lock bit.
	new := atomic.AddInt32(&m.state, -mutexLocked)
	if new != 0 {
		// Outlined slow path to allow inlining the fast path.
		// To hide unlockSlow during tracing we skip one extra frame when tracing GoUnblock.
		m.unlockSlow(new)
	}
}

Тут можно заметить уже использование Add метода. То есть происходят операции чтение и запись (как и в CAS методе). Тут так же используется Full барьер, который запрещает переходы в область и из захваченного мьютекса.

На данном этапе рассмотренные методы позволяют сформировать секцию кода, перемещение инструкций в и за пределы которой невозможно. Это важное свойство лежит в основе мьютексов.

От пакета к коду

Мы рассмотрели реализацию, и посмотрим а так ли оно на самом деле.

package main

import (
	"sync/atomic"
)

func main() {
	var x int64
	atomic.StoreInt64(&x, 42) // тут внутри есть барьер
}

Чтобы увидеть ассемблер представление нашей программы необходимо собрать с флагом -S (go build -gcflags="all=-S -N -l" -o main main.go). Флаг -N отключает оптимизации, а флаг -l отключает встраивание простых функций на места их вызовов.

Если у вас не ARM архитектура, важно явно указать переменные окружения (GOOS=linux;GOARCH=arm64;), которые позволят выполнять сборку под архитектуру ARM и систему Linux.

PS (...)> go build -o main.exe -gcflags="-S -N -l" main.go
# command-line-arguments
main.main STEXT nosplit size=48 args=0x0 locals=0x18 funcid=0x0 align=0x0 leaf
        0x0000 00000 (...)        TEXT    main.main(SB), NOSPLIT|LEAF|ABIInternal, $32-0
        0x0000 00000 (...)        MOVD.W  R30, -32(RSP)
        0x0004 00004 (...)        MOVD    R29, -8(RSP)
        0x0008 00008 (...)        SUB     $8, RSP, R29
        0x000c 00012 (...)        FUNCDATA        $0, gclocals·FzY36IO2mY0y4dZ1+Izd/w==(SB)
        0x000c 00012 (...)        FUNCDATA        $1, gclocals·FzY36IO2mY0y4dZ1+Izd/w==(SB)
        0x000c 00012 (...)        MOVD    ZR, main.x-8(SP)
        0x0010 00016 (...)        MOVD    $42, R0
        0x0014 00020 (...)        MOVD    $main.x-8(SP), R1
        0x0018 00024 (...)        STLR    R0, (R1)
        0x001c 00028 (...)        ADD     $24, RSP, R29
        0x0020 00032 (...)        ADD     $32, RSP
        0x0024 00036 (...)        RET     (R30)
...

Вывод был немного отформатирован и убрана не нужная информация для повышения читаемости.

Нас интересует команда STLR. Она атомарная и выполняет роль Release барьера.

Даже если в ассемблере своего кода вы не видите барьеров, это не значит что нет гарантий упорядоченности инструкций. Возможно у вас более строгая модель памяти(TSO).

Чтобы увидеть чистый ASM, а не Go ASM необходимо воспользоваться утилитой aarch64-linux-gnu-objdump (aarch64-linux-gnu-objdump -d main > asm.txt, где флаг -d указывает на получение ASM команд).

00000000000789d0 <main.main>:
   789d0:	f81e0ffe 	str	x30, [sp, #-32]!
   789d4:	f81f83fd 	stur	x29, [sp, #-8]
   789d8:	d10023fd 	sub	x29, sp, #0x8
   789dc:	f9000bff 	str	xzr, [sp, #16]
   789e0:	d2800540 	mov	x0, #0x2a         
   789e4:	910043e1 	add	x1, sp, #0x10
   789e8:	c89ffc20 	stlr	x0, [x1]
   789ec:	910063fd 	add	x29, sp, #0x18
   789f0:	910083ff 	add	sp, sp, #0x20
   789f4:	d65f03c0 	ret
	...

Нас интересуют строки:

  1. mov x0, #0x2a — кладем в процессор число 42

  2. add x1, sp, #0x10 — вычисляем адрес памяти куда хотим положить значение

  3. stlr x0, [x1] — атомарно кладем значение из процессора в память с использованием Release барьера

На данном живом примере мы подтвердили выдвинутые выше тезисы по получению ASM из Go ASM. Что вы можете проверить так же самостоятельно.


Применение

Давайте теперь разберемся, а как использовать барьеры памяти. Вначале статьи была описана проблема гонки данных, вызванной оптимизационной перестановкой.

Далее я не буду наглядно показывать ASM представление программы, поскольку поиск гонки данных подобным методом является трудоемким, а демонстрация оптимизационных перестановок не менее трудный процесс(на уровне процессора невозможен напрямую, только эмуляция (SDE — x86/QEMU — ARM)).

Если же мы не можем увидеть, как оно на самом деле исполняется процессором, то как нам понять где нужно применять барьеры память? На самом деле ответ содержится в проблеме. Нам не нравятся гонки данных, определить которые возможно. В Go для этого необходимо запускать код с параметром --race(go run --race .\main.go).

Рассмотрим простой код:

package main

import (
	"log"
	"runtime"
)

var a string
var done bool

func setup() {
	a = "Hello data race"
	done = true

	if done {
		log.Println(len(a))
	}
}

func main() {
	go setup()

	for !done { // ждем пока done не примет значение true
		runtime.Gosched()
	}

	log.Println(a)
}

Тут мы задаем значение переменной a, далее задаем значение переменной done. Параллельно ждем чтобы done имел значение, ведь если это так то и a имеет значение.

Детектор гонок данных нам говорит, что есть две гонки и указывает на наши переменные(a, done).

PS (...)> go run --race .\main.go
...
==================
2025/07/30 06:40:31 15
2025/07/30 06:40:31 Hello data race
Found 2 data race(s)
exit status 66

Давайте устраним данную проблему используя atomic:

package main

import (
	"log"
	"runtime"
	"sync/atomic"
)

var a string
var done atomic.Bool

func setup() {
	a = "Hello data race"
	done.Store(true)

	if done.Load() {
		log.Println(len(a))
	}
}

func main() {
	go setup()

	for !done.Load() { // ждем пока done не примет значение true
		runtime.Gosched()
	}

	log.Println(a)
}

Наличие значения в done все также является показателем того, что в a есть значения. Но теперь есть Release барьер памяти, который при исполнении не позволит заданию значения a стать ниже, чем задание значения done.

Детектор гонок в этом случае отработает без ошибок:

PS (...)> go run --race .\main.go
2025/07/30 06:43:57 15
2025/07/30 06:43:57 Hello data race

На данном примере хорошо видно, что использование барьеров памяти позволяет синхронизировать асинхронный/параллельный код. Но он так же демонстрирует что если другой разработчик, просто переставит в данной функции местами две строчки, то вернется гонка данных (ведь done перестанет гарантировать значение у a). Что по факту показывает опасность такого использования. Получается лучше использовать везде atomic операции? — В идеале да. Если не стоит задача сделать очень быстрый код. Поскольку использование барьеров памяти, мешает части оптимизаций и построение алгоритмов на их основе это последнее к чему стоит прибегать в языке Go, где барьеры памяти не явные.

Цитата на этот счет из документации к модели памяти Go

https://go.dev/ref/mem

Programs that modify data being simultaneously accessed by multiple goroutines must serialize such access.

To serialize access, protect the data with channel operations or other synchronization primitives such as those in the sync and sync/atomic packages.

If you must read the rest of this document to understand the behavior of your program, you are being too clever.

Don't be clever.

(Программы, которые изменяют данные, к которым одновременно обращаются несколько подпрограмм, должны сериализовать такой доступ. Чтобы сериализовать доступ, защитите данные с помощью операций с каналами или других примитивов синхронизации, таких как пакеты sync и sync/atomic. Если вам необходимо прочитать остальную часть этого документа, чтобы понять поведение вашей программы, вы слишком умничаете. Не умничайте )

Задачка из головы

Необходимо обработать файлы, состоящие из заголовочной информации о пользователе и данных.

Важно чтобы пользователь из заголовка мог видеть статус обработки (Processing/Done).

Важно чтобы наш модуль (сервис) обработки не хранил состояний.

Данную задачу я бы разделил на две. Первая это работа с заголовками. Вторая с данными файла.

Код

Важно понимать что код сведен к вычислительной нагрузке, чтобы просто продемонстрировать подход и разницу. Вместо []atomic.bool можно использовать WaitGroup, но реализованный код аналогичен по функциональности и просто нагляднее в сравнении с примером выше.

Код 1(с использованием барьеров памяти)

func SpeedProcessing() int64 {
	workersCount := 5
	sumList := make([]int64, workersCount)
	readyList := make([]atomic.Bool, workersCount)

	for i := 0; i < workersCount; i++ {
		go func() {
			for is := range 100 {
				sumList[i] += int64(is) // Обработка заголовков
			}

			readyList[i].Store(true) // Заголовки прочитаны и валидны
			// ... Обработка тела файла
		}()
	}

	for {
		var res int
		for i := 0; i < workersCount; i++ {
			if readyList[i].Load() {
				res++ // Вызов какогото метода индивидуально для заголовков которые успели уже обработаться
			}
		}

		if res == workersCount {
			break
		} else {
			runtime.Gosched()
		}
	}

	globalSum := int64(0)
	for i := 0; i < workersCount; i++ {
		globalSum += sumList[i] // Отдаем ответ пользователю что все его файлы приняты к обработке
	}

	return globalSum
}

Код 2(с использованием atomic)

func SpeedssProcessing2() int64 {
	workersCount := 5
	sumList := make([]atomic.Int64, workersCount)
	readyList := make([]atomic.Bool, workersCount)

	for i := 0; i < workersCount; i++ {
		go func() {
			for is := range 100 {
				sumList[i].Add(int64(is)) // Обработка заголовков(или Load -> Calculations -> Store)
			}

			readyList[i].Store(true) // Заголовки прочитаны и валидны
			// ... Обработка тела файла
		}()
	}

	for {
		var res int
		for i := 0; i < workersCount; i++ {
			if readyList[i].Load() {
				res++ // Вызов какогото метода индивидуально для заголовков которые успели уже обработаться
			}
		}

		if res == workersCount {
			break
		} else {
			runtime.Gosched()
		}
	}

	globalSum := int64(0)
	for i := 0; i < workersCount; i++ {
		globalSum += sumList[i].Load() // Отдаем ответ пользователю что все его файлы приняты к обработке
	}

	return globalSum
}

Это усложненный код из примеров выше. Гонок данных нет.

Если производить сравнение по скорости исполнения, разница будет большой и не в пользу подхода с большим количеством атомиков.

Бенчмарки
goos: windows
goarch: amd64
cpu: Intel(R) Core(TM) i7-14700KF
BenchmarkSampleSpeedF-28    	 1753281	      6800 ns/op	     11923 total_ms	     392 B/op	       7 allocs/op
BenchmarkSampleSpeedF-28    	 1756315	      6801 ns/op	     11945 total_ms	     392 B/op	       7 allocs/op
BenchmarkSampleSpeedF-28    	 1762320	      6843 ns/op	     12059 total_ms	     392 B/op	       7 allocs/op
BenchmarkSampleSpeedF-28    	 1770810	      6829 ns/op	     12093 total_ms	     392 B/op	       7 allocs/op
BenchmarkSampleSpeedF-28    	 1782883	      6792 ns/op	     12109 total_ms	     392 B/op	       7 allocs/op
BenchmarkSampleSpeedF-28    	 1760726	      6880 ns/op	     12114 total_ms	     392 B/op	       7 allocs/op
BenchmarkSampleSpeedF-28    	 1763070	      6817 ns/op	     12018 total_ms	     392 B/op	       7 allocs/op
BenchmarkSampleSpeedF-28    	 1773086	      6853 ns/op	     12150 total_ms	     392 B/op	       7 allocs/op
BenchmarkSampleSpeedF-28    	 1771946	      6869 ns/op	     12170 total_ms	     392 B/op	       7 allocs/op
BenchmarkSampleSpeedF-28    	 1731908	      6862 ns/op	     11883 total_ms	     392 B/op	       7 allocs/op
PASS
ok  	command-line-arguments	190.045s
goos: windows
goarch: amd64
cpu: Intel(R) Core(TM) i7-14700KF
BenchmarkSampleSpeedG-28    	  921216	     12619 ns/op	     11624 total_ms	     392 B/op	       7 allocs/op
BenchmarkSampleSpeedG-28    	  923557	     12695 ns/op	     11724 total_ms	     392 B/op	       7 allocs/op
BenchmarkSampleSpeedG-28    	  938389	     12527 ns/op	     11754 total_ms	     392 B/op	       7 allocs/op
BenchmarkSampleSpeedG-28    	  906385	     12774 ns/op	     11578 total_ms	     392 B/op	       7 allocs/op
BenchmarkSampleSpeedG-28    	  944427	     12786 ns/op	     12076 total_ms	     392 B/op	       7 allocs/op
BenchmarkSampleSpeedG-28    	  947355	     12911 ns/op	     12231 total_ms	     392 B/op	       7 allocs/op
BenchmarkSampleSpeedG-28    	  962623	     12646 ns/op	     12173 total_ms	     392 B/op	       7 allocs/op
BenchmarkSampleSpeedG-28    	  933207	     12606 ns/op	     11764 total_ms	     392 B/op	       7 allocs/op
BenchmarkSampleSpeedG-28    	  942800	     12537 ns/op	     11819 total_ms	     392 B/op	       7 allocs/op
BenchmarkSampleSpeedG-28    	  939336	     12655 ns/op	     11886 total_ms	     392 B/op	       7 allocs/op
PASS
ok  	command-line-arguments	120.567s

Сверху результат с одним атомиком. Нас интересует ns/op. По нему видно что метод приблизительно в 2 раза быстрее.

Заключение

Хоть барьеры памяти и являются мощным инструментом разработки, следует работать с ними осторожно. Само понимание механизма их работы позволит более низко понимать код.

Так же не стоит забывать про различия архитектур и моделей памяти при собственном анализе инструкций. Их разнообразие может запутать, поэтому подходить к формированию понимания механизмов барьеров памяти стоит со стороны абстрактной составляющей вопроса и не углубляться в детали реализаций (конечно если такой задачи не стоит).

Комментарии (7)


  1. tkutru
    01.08.2025 16:15

    Годная статья.


  1. gohrytt
    01.08.2025 16:15

    Какой ад, хорошо что на практике об это не нужно думать


    1. Sly_tom_cat
      01.08.2025 16:15

      Думать может и не надо, но пользоваться инструментом всегда проще понимая как он работает.


    1. Sly_tom_cat
      01.08.2025 16:15

      И, да, это еще не весь ад, весь ад если подтянуть реализацию в много ядерном процессоре и его кешах. Вот там адище еще тот....


    1. xcono
      01.08.2025 16:15

      В зависимости от практики, конечно, но в широком смысле в материале описан подход к линеаризации процесса. Актуален даже при построении workflow мышкой. В go есть реализация в виде sync.RWMutex, можно поделать весёлые `livelock`и, чтобы проникнуться статьей.


  1. apevzner
    01.08.2025 16:15

    Кажется, явтор не понимает, что есть три явления, и они все разные:

    1. При параллельном исполнении нескольких нитей, оперирующих общими данными, трудно угадать, как между собой будут упорядочены действия, осуществляемые разными нитями

    2. Даже в пределах одного потока исполнения (одной нити), компилятор может изменить последовательность действий, если это ему так удобно и если это не меняет семантики кода. При этом для данной конкретной нити ничего не изменится, но если нить имеет доступ к памяти, к которой имеет доступ другие нити, то "глядя со стороны", порядок действий с памятью может измениться.

    3. При исполнении последовательного кода процессор может переупорядочивать инструкции или операции обращения к памяти таким образом, что с точки зрения самого этого последовательного кода ничего не меняется, но глядя с другого ядра (а не нити, как в предыдущем случае) можно увидеть другой порядок обращения к памяти

    Чтобы со всем этим можно было жить, существуют примитивы синхронизации, и они разные для разных случаев.

    Барьеры памяти - это примитивы синхронизации между ядрами процессора (но разумеется, и компилятор не переставляет последовательные действия мимо барьера).


    1. artyc99 Автор
      01.08.2025 16:15

      По поводу трех описанных явлений, мне сложно что-то ответить, поскольку они и описаны в статье.

      Чтобы со всем этим можно было жить, существуют примитивы синхронизации, и они разные для разных случаев.

      Вот я и постарался понять а такие ли примитивы синхронизации разные. Об этом и статья. Если Вы укажите по вашему списку какие примитивы синхронизации для какого явления стоит использовать я был бы благодарен. Обязательно посмотрю их реализацию и дополню статью.

      Барьеры памяти - это примитивы синхронизации между ядрами процессора (но разумеется, и компилятор не переставляет последовательные действия мимо барьера).

      Во время изучения темы я узнал про StoreBuffer. Когда поток изменяет данные, они не кладутся в кеш на прямую. А кладутся в некий буфер. Этот буфер не является общим, т.е. он принадлежит только этому потоку. Чтобы два потока могли синхронизироваться, эти данные нужно сбросить в общий ресурс. Тут на помощь приходят барьеры памяти. Которые и выполняют эту роль, именно поэтому они и наблюдаются в ассемблерных инструкциях примитивов синхронизации. Но возможно мои источники недостоверны, буду рад если поправите.