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

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

  3. Ядро 2 хочет инкрементировать core2_count. Оно пытается обратиться к своему кэшу, но видит, что его кэш-линия сброшена! Ему приходится заново запрашивать ее из медленного L3-кэша или RAM.

  4. Ядро 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-байтными порциями, можно получить код, который на многоядерной машине работает медленнее, чем на одном ядре.