Представьте типичную ситуацию: вы оптимизируете высоконагруженный бэкенд или сетевой сервис. И абсолютно неважно, на чем вы пишете — C++, Java, Go или C#. У вас есть несколько потоков, и вы решаете избавиться от медленных блокировок. Ведь мьютексы — это узкое горлышко, верно?
Вы применяете классический паттерн: вместо того чтобы потоки толкались локтями вокруг одной переменной, вы даете каждому потоку (или горутине) свой собственный, независимый счетчик. Нет общих данных — нет конфликтов. Вы запускаете нагрузочные тесты, ожидая увидеть красивое линейное ускорение, но профилировщик показывает странное. Потоки словно продолжают стоять в очереди, а код на многоядерной машине начинает работать едва ли не медленнее, чем на одном ядре.
Как такое возможно, если блокировок в коде больше нет?
Добро пожаловать в реальный мир, где абстракции вашего языка программирования разбиваются о суровое железо. Проблема в том, что процессору глубоко все равно на то, как вы логически разделили переменные в коде. Виной всему False Sharing (ложное разделение) — невидимая аппаратная блокировка.
Чтобы понять, почему ваш идеальный lock-free код тормозит, нам придется заглянуть под капот и посмотреть, как процессор на самом деле читает память.
Кэш-линии: Как процессор видит память
Этот раздел можете скипнуть, если вы знаете про кэш-линии.
Процессор никогда не читает память по одному байту. Это слишком медленно. Вместо этого он загружает данные целыми блоками — кэш-линиями. В современных архитектурах размер одной кэш-линии обычно составляет 64 байта.
Если процессору нужна переменная размером 4 байта, он заберет ее вместе с соседними 60 байтами и положит в свой кэш.
Как устроена иерархия памяти по времени доступа (этот самый кэш), упрощенно:
L1-кэш (у каждого ядра свой — и это ключевой момент для нас): самый быстрый (~1 наносекунда), но маленький (обычно 32–64 КБ на данные).
L2-кэш (у каждого ядра свой): чуть медленнее (~4 наносекунды), побольше (256 КБ – 1 МБ).
L3-кэш (общий на весь процессор): медленный (~15-20 наносекунд), но объемный (десятки мегабайт).
Оперативная память (RAM): «черепаха» по меркам процессора (~100 наносекунд).
Кстати, я в свое время запомнил эти уровни кеша как: карманы (L1), рюкзак (L2), схрон или склад (L3), другой город (RAM).
Когда L1 кэш ядра заполняется, процессор вытесняет самые старые, давно не используемые кэш-линии (алгоритм LRU), чтобы освободить место для новых.
Иллюзия независимости (пример со структурой)
Вернемся к нашим пакетам. Мы создали структуру, где лежат счетчики для двух ядер:
struct PacketCounters { int core1_count; // 4 байта int core2_count; // 4 байта }; struct PacketCounters counters;
Вопрос: Разве структура — это не одна сущность? Как одно ядро может обновлять одну часть ее, а другое ядро — другую часть?
Ответ кроется в том, что процессору... все равно на структуры. Структуры существуют только в языке программирования (в данном примере С). Процессор видит просто кусок памяти. core1_count и core2_count лежат в памяти впритирку друг к другу. Вместе они занимают всего 8 байт, а значит, гарантированно попадают в одну 64-байтную кэш-линию.
Эффект пинг-понга, или «Железный мьютекс»
Мы думали, что избавились от мьютекса, потому что Ядро 1 пишет только в core1_count, а Ядро 2 пишет только в core2_count. Но вот что происходит на уровне железа:
Ядро 1 инкрементирует
core1_count. Оно загружает всю 64-байтную линию в свой L1-кэш и меняет там значение.Процессор (упрощаем) видит, что данные в линии изменились. Чтобы избежать рассинхрона, он инвалидирует эту кэш-линию в кэшах всех остальных ядер.
Ядро 2 хочет инкрементировать
core2_count. Оно пытается обратиться к своему кэшу, но видит, что его кэш-линия сброшена! Ему приходится заново запрашивать ее из медленного L3-кэша или RAM.Ядро 2 меняет значение и... инвалидирует кэш для Ядра 1.
Даже если ядра работают строго параллельно, эта 64-байтная линия памяти носится между ними, как мячик в пинг-понге. Образуется так называемый «невидимый мьютекс» на аппаратном уровне.
Как это лечить?
Раз проблема в том, что независимые данные лежат слишком близко, решение очевидно — рассадить их подальше.
Нужно сделать так, чтобы счетчик каждого ядра жил в своей собственной кэш-линии. Для этого переменные выравнивают (добавляют пустые байты-прокладки), добивая размер до 64 байт:
#include <stdalign.h> // Используем выравнивание компилятора struct PaddedCounter { alignas(64) int count; // Компилятор сам добавит 60 байт пустоты }; struct PaddedCounter counters[2]; // Теперь они точно в разных кэш-линиях
Теперь
counters[0].count // Это теперь счетчик для Ядра 1 counters[1].count // Это теперь счетчик для Ядра 2
Небольшая сноска: всегда ли кэш-линия равна 64 байтам?
В примерах выше я захардкодил 64 байта — это стандарт для подавляющего большинства современных процессоров. Но в боевом коде писать магические числа руками — дурной тон (вдруг код запустят на архитектуре со 128-байтным кэшем?). Поэтому в реальных проектах размер узнают динамически: например, в C++17 для этого добавили кроссплатформенную константу std::hardware_destructive_interference_size, а в Java разработчикам вообще не нужно думать о байтах — достаточно повесить аннотацию @Contended, и виртуальная машина сама разнесет данные по разным кэш-линиям.
Итог: Избавление от блокировок (lock-free) — это отлично. Но если забыть о том, как процессор работает с кэш-линиями 64-байтными порциями, можно получить код, который на многоядерной машине работает медленнее, чем на одном ядре.
Комментарии (11)

TimID
08.05.2026 02:42Ой, ну если бы так всё было просто... Язык Си - стековый. Если вы не работаете через указатели, то структура сначала бросится в стек одного потока, потом заблокируется до возврата из функции обработки...
Так что пример негодный. Тормозить существенно из-за процессора в данном случае код не будет.
anton_dolganin Автор
08.05.2026 02:42То, что вы описываете — это поведение обычного синхронного вызова функции.
Но в многопоточности на Си это так не работает:
1. В примере структура counters объявлена глобально.
2. Даже если создавать потоки, вы физически не можете передать туда структуру по значению.
Так что в реальном многопоточном коде потоки работают параллельно с общим куском памяти, никаких "блокировок до возврата" там нет.
TimID
08.05.2026 02:42Я имел ввиду, что пример не очень жизненный у вас. Всё таки кэш затем придуман, чтобы ускорять в первую очередь чтение (в том числе повторное чтение) одним потоком.
Но то, что проблема с пониманием работы аппаратуры есть, я не отрицаю.

anton_dolganin Автор
08.05.2026 02:42Бенч на 500000000 показал такое мне до этого.
False Sharing: 8.062 секунд
выравнивание: 0.977 секунд

max-zhilin
08.05.2026 02:42А зачем вообще складывать счетчики в общий массив? Объяви локальную переменную в стеке, и проблема даже не возникнет. Стек у каждого потока свой.

anton_dolganin Автор
08.05.2026 02:42Суть примера в том, что нам нужно собирать общую статистику пакетов. Если каждый поток будет считать в свою локальную переменную:
1. Поток метрик не сможет их прочитать (стек приватен).
2. При завершении/пересоздании потока данные исчезнут вместе со стеком.
3. Чтобы как-то собрать итог, потокам все равно придется сбрасывать свои локальные суммы в глобальную переменную через Mutex или Atomic, создавая узкое горлышко.
Ну и вопрос обратный вам — зачем-то же придумали Sharded Counters, что по сути я здесь и рассказал? Есть ли у вас более жизненный пример для применения такого паттерна? ;)

Dark_Furia
08.05.2026 02:42Еще одной ценной добавкой к статье была бы демонстрация способов диагностики проблемы, даже на этом простом примере, чтобы у новичков было понимание, на какие метрики обратить внимание:)

anton_dolganin Автор
08.05.2026 02:42Хорошая идея про тулзы написать и бенчи (хотя последнее уж, думаю, каждый делает сам). Спасибо :)

aapsoftware
08.05.2026 02:42Интересная статья, мне лично, очень в тему. Я как раз работаю над многопоточным проектом и наступил на озвученные в статье грабли. В связи с этим вопрос. В приложении несколько потоков работают с неким глобальным объектом, доступным по чтению. Помимо него, каждый поток имеет свои куски данных в общем массиве записей. Эти куски достаточно большие по размеру (десятки и сотни килобайт) и не пересекаются. Работа потоков заключается в сравнении некоторых значений из глобального объекта со своими данными. В однопоточном режиме скорость поиска порядка 0.5 секунды, а в многопоточном увеличиваеся до 2 секунд. При этом сам процессор имеет 14 ядер, 28 потоков. Правильно ли я понял, что чтение тоже создаёт блокировку работы с памятью или то что описано выше относится только к записи?
Botanegg
https://en.cppreference.com/cpp/thread/hardware_destructive_interference_size
anton_dolganin Автор
Спасибо за дополнение, но статья преследует именно общий подход, а код и вовсе сишный.
Но я действительно не упомянул в статье, что нельзя хардкодить 64, это я поправлю.