Особенности и ловушки модели памяти в Go: тайны синхронизации. Часть 2
- четверг, 23 апреля 2026 г. в 00:00:09
Описание модели памяти Go начинается со слов «если вы читаете этот документ — вы излишне умный, остановитесь». Многие и правда остановились, но не автор этой статьи.
Привет, Хабр! Я — Игорь Панасюк, и это вторая часть материала по мотивам моего выступления на GolangConf, где я рассказывал о модели памяти Go. В первой мы разобрались с отношением happens before, формализмом, посмотрели практические примеры и многое другое. Сегодня поговорим о линеаризуемости исполнения, барьерах памяти (можно ли опустить абстракцию модели памяти), гарантии для программ с data race и использовании продвинутых техник.

Что важно понимать:
Без синхронизации нет гарантий на порядок операций между горутинами
С практической точки зрения так сложилось, ибо процессор может переставить строчки (out-of-order execution) из соображений оптимизации, агрессивный компилятор тоже может поменять строчки местами. С точки зрения нашей модели, которую мы рассматривали в первой части статьи, нам просто не дают никаких гарантий.
Можно ли формализовать порядок операций, не используя memory model Go, полагаясь на гарантии инструкций конкретного железа?
На самом деле, да. Это очень большая тема, которая называется барьеры памяти.
Основная проблема заключается в том, что компилятор и процессор могут переставлять наши строчки местами, но это происходит не просто так.

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

Если этот код на Go переписать на C++, он будет выглядеть следующим образом:

В C++ вторым аргументом передаётся memory_order. Это и есть то связанное с моделью памяти.
Идея в том, что когда происходит синхронизированная операция, в некоторых языках программирования, например, в C++, вы можете явно задать, какую степень консистентности (уровень memory_order) вы хотите у этой операции. В C++ я пишу std::memory_order_release.

Эта штука запрещает процессору переставлять присваивание вниз на уровне memory_order. Но я могу написать так, сказать, что у меня теперь std::memory_order_relaxed.

В этом случае у меня останется atomic, всё ещё не будет никаких data race’ов, но при этом процессор разрешит переставлять эту строчку вниз.
Go скрывает это от нас, мы, как программисты, не можем контролировать барьеры памяти.
Additionally, all the atomic operations executed in a program behave as though executed in some sequentially consistent order. This definition provides the same semantics as C++'s sequentially consistent atomics and Java's volatile variables.
В каком-то смысле memory_order в языке прибит гвоздями.
Но в других языках программирования такая возможность есть. В крайнем случае — можно написать код на Ассемблере и что-то похожее реализовать, у меня как раз есть доклад про это.
Важно понимать, что барьеры памяти — это:
Низкоуровневый механизм для контроля порядка исполнения инструкций
В C++ вы можете написать memory_order_relaxed, если понимаете, от чего вы отказываетесь в угоду производительности. В этом случае вы теряете консистентность, но выигрываете в перформансе. Всё просто. В Go, к сожалению или к счастью, это всё скрыто под ковёр.
С другой стороны, мы, как программисты, можем брать модель памяти, пользоваться ей и не заморачиваться, хотя иногда приходится выходить за рамки.
Мы разобрали, как бороться с тем, чтобы у нас не было data race’ов и программа была корректная. А что, если в ней уже есть гонка по данным, какие гарантии даёт нам Go?
Разберём на примере. Есть две горутины: первая присваивает value 0, вторая 1. Эти горутины дальше в цикле пытаются вывести значения.

В Go есть приятное свойство — он гарантирует так называемое отсутствие значений из воздуха.

Даже, если в программе есть data race, мы не увидим никаких необъяснимых результатов. Например, если у вас есть присваивание больше одного машинного слова, вы можете увидеть какие-то половинки операций, но в целом это можно обосновать. В C++ же, например, у вас будет просто UB. Go в этом месте довольно приятный, но поведение программы с data race всё равно ненадёжно, гарантии в этом месте очень слабые.
Итак, у нас в программе нет гонок по данным, но значит ли это, что у нас корректная многопоточная программа?
Как люди, которые уже знают memory model Go, попробуем спроектировать какой-нибудь примитив синхронизации, например, sync once. Его обычно используют, чтобы сделать какое-то неидемпотентное действие в n горутин. Например, есть горутины, которые раз в какое-то время вызывают loadConfig или initConfig.

Спроектируем этот примитив синхронизации. Мы хотим, чтобы Do у нас идемпотентно выполнял функцию. Казалось бы, легко. Я предлагаю такую реализацию:

Заходим в Do, используем CompareAndSwap, чтобы получить консенсус. У этого примитива как раз консенсусное число — бесконечность.
Как вы думаете, это корректная реализация sync.Once или нет? Я пытаюсь сдвинуть с 0 на 1. Если получилось, то исполняю функцию f.
Тут, конечно, стоит сказать, что корректность определяется тем, что мы хотим. То есть, в одной ситуации у нас одно множество инвариантов, в другой может быть другое. Как раз при проектировании примитива синхронизации мы выбираем подмножество инвариантов, которые хотим поддерживать.
Вернемся к реализации выше, разработчики Go говорят, что она некорректная. Казалось бы, мы сдвинули один раз, CompareAndSwap, всё отлично! Почему так?

У нас есть горутина G0, она вызывает loadConfig. Приходит горутина G1. До того, как G0 закончила, вызывает ещё раз и выходит.

CompareAndSwap не сработал, всё отлично. Здесь важно увидеть, что горутина G1 вышла из Do до того, как функция f фактически закончилась.

Это проблема. Если G1 потом вызовет getConfig до того, как f отработает, она получит ошибку, потому что Config ещё не проинициализировали.

В этом случае у нас проблема. Её можно описать более формально с помощью так называемой линеаризуемости.
В программировании существует термин линеаризуемости, который часто применяют к многопоточным программам. Это свойство, при котором для любого исполнения программы можно предъявить эквивалентное ему однопоточное исполнение с сохранением последовательной спецификации всех объектов и исходного отношения happens before, то есть после любой операции все инварианты объектов сохраняются c учетом используемых примитивов синхронизации.. Поясню на примере:
Есть «гошный» канал, две горутины (одна пишет значение, другая читает). Я пытаюсь сопоставить эквивалентное однопоточное исполнение.

Пожалуйста — однопоточное, я записал 2 — прочитал 2, записал 9 — прочитал 9.
Важно, чтобы здесь сохранялся инвариант однопоточной спецификации объекта. То есть, я не могу записать 2, прочитать 9, потому что у нас «first in first out», очередь. Это первое.
Второе — у нас должно сохраняться исходное отношение happens before. Если в многопоточной программе мы сделали какую-то блокировку, в итоговом многопоточном исполнении она должна сохраниться.
Говорят, что программа линеаризуема, если любое её исполнение линеаризуемо. На практике автор библиотеки пишет линеаризуемую (thread-safe) реализацию, расписывая гарантии и инварианты, которые он заложил в последовательную спецификацию.

Например, в документации к Once говорится, что в терминологии memory model «the return from f» должен быть до «the return from any call of once.Do(f)». Здесь как раз sync once описывает гарантии, которые он даёт.
Попробуем линеаризовать исполнение, которое мы получили.

У нас есть два потока, две горутины. GetConfig вызывается после loadConfig. Мы получаем ошибку — getConfig не загружен, это проблема. В таком случае исполнение нелинеаризуемо.
На практике можно это проверить. Некоторые проекты, например, etcd в kubernetes, брали свои логи, парсили их и скармливали фреймворку, который называется porcupine.

Это фреймворк для линеаризуемости исполнений: вы скармливаете ему однопоточную спецификацию объекта. Далее пишите код теста с помощью фреймворка, в самом тесте загружаете историю того, что было с многопоточным примитивом синхронизаций (если была какая-то очередь — скормлю фреймворку, какие операции и результаты были с этой очередью).

Здесь я скормил ему, что у меня было с моим sync once. Фреймворк porcupine за нас пытается линеаризовать исполнение. Всё, что ему нужно, — это однопоточная модель (однопоточная спецификация) того, что вы хотите проверить. Если это очередь, вы пишете однопоточную модель очереди. То есть, мы сами задаем инварианты, которые хотим поддерживать.

Рассмотрим пример с встроенным sync.Once. Здесь первая горутина начала исполнять Once и получила 27, все остальные тоже. То есть мы имеем консенсус. Если такой объект линеаризуется, то ещё говорят, что этот объект имеет свойство thread-safe. Имеется в виду, что этот объект можно использовать из многих горутин, а операции над ним линеаризуемы (например, push, pop, чтение, запись с канала).
Теперь посмотрим на наш Once, который мы написали на CompareAndSwap, здесь есть проблема.

Горутина 1 очень долго исполняет функцию f. Остальные горутины вышли и получили неожидаемый результат.. Мы, как программисты, которые дизайнят sync.Once, могли бы пропустить это и сказать, что гарантий нет. Но на самом деле, это довольно сильная гарантия, которая нужна людям, использующим sync once, поэтому мы хотим её поддерживать.
Напоследок посмотрим на корректную реализацию sync once.

На самом деле, это старая, добрая, простая блокировка. Проверяем: если у нас ещё sync once не вызван — идём в метод doSlow (так называемый fast path во многих реализациях в Go).

Зашли, взяли блокировку, проверили ещё раз — пока мы брали блокировочку, что-то могло поменяться. Затем проверили ещё раз и исполнили функцию.


Старая добрая блокировка — сперва быстро проверили через atomic counter, а потом, если нужно, взяли блокировку.
Теперь подведём итоги:
Корректный многопоточный код — это код, у которого любое исполнение линеаризуемо.

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

Используйте pprof, чтобы понимать, нужно ли вообще писать многопоточный код.

Это довольно практический вывод. Всегда стоит понимать, а нужно ли вам вообще писать многопоточный код — попрофилировали, проверили. Если нужно — тогда написали.
Пишите многопоточную реализацию только если уверены, что она не вывозит после всех оптимизаций. Стоит не забывать про закон Амдала для CPU-bound задач.
Для CPU-bound задач иногда лучше попытаться ускорить однопоточную реализацию, например, с O(n^2) до O(nlogn). Согласно закону Амдала, если у вас, например, 10% последовательного кода, то от 100 до 1 000 ядер довольно небольшой прирост.

В таблице слева указано количество кода в процентах, которое нельзя распараллелить. Сверху — количество ядер. Можно заметить, что переход от 100 до 1000 ядер довольно маленький. Это всего лишь 10% кода, который не распараллеливается. Нужно всегда подумать о возможности ускорить однопоточный код, это больше актуально для CPU-bound задач, с IO-bound все проще, там зачастую есть смысл писать многопоточный код.
Для многопоточной реализации в 95% случаев хватает грубой блокировки в виде mutex. Он сильно упрощает построение линеаризуемой реализации
Если у вас не какой-то high-performance, это точно подойдёт. Бонусом — блокировка автоматически даёт линеаризуемость, потому что над кодом работает всего одна горутина (поток), когда берёт блокировку. Поэтому, в целом, блокировка — это бесплатная линеаризуемость с минимальной когнитивной нагрузкой.
Прежде чем писать велосипеды, стоит изучить встроенные примитивы синхронизации и пакеты стандартной библиотеки (sync, atomic), а также гарантии, которые они предоставляют: Channels, Sync (Mutex, RWMutex, WaitGroup, Once, Pool), Atomic, Context, Time (Ticker).
Зачастую мы не хотим писать с нуля какой-то сложный примитив (изобретать велосипед), а хотим взять готовое решение.
Начиная от использования стандартной библиотеки, заканчивая каким-нибудь фреймворком в нашей компании. После этого доклада вы теперь знакомы с happens before и как раз сможете разобраться с гарантиями, которые предоставляет тот или иной примитив. Например, в стандартной библиотеке Go к каждому примитиву из пакета sync есть формальное описание в терминах Go Memory Model.
Если вам не хватает грубой блокировки и у вас всё плохо, стоит посмотреть в сторону параллелизма, используя тонкие блокировки или истории с более сложным ускорением (lock-free-алгоритмы или их аналоги).
А чтобы узнать больше о ноу-хау в мире Go, смотрите материалы конференции развития Golang Conf 2026! В этом году «Онтико» кардинально меняет подход к IT-мероприятиям: это были не только традиционные доклады, а много игровых и практических форматов.
Полезные ссылки
Поизучать:
Мой канал в Telegram, тут есть много полезных закрытых материалов
Код на Github — всё, что я тут показал
Посмотреть:
Почитать: