Представьте типичную ситуацию: вы оптимизируете высоконагруженный бэкенд или сетевой сервис. И абсолютно неважно, на чем вы пишете — 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-байтными порциями, можно получить код, который на многоядерной машине работает медленнее, чем на одном ядре.