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

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


  1. Botanegg
    08.05.2026 02:42

    1. anton_dolganin Автор
      08.05.2026 02:42

      Спасибо за дополнение, но статья преследует именно общий подход, а код и вовсе сишный.
      Но я действительно не упомянул в статье, что нельзя хардкодить 64, это я поправлю.


  1. TimID
    08.05.2026 02:42

    Ой, ну если бы так всё было просто... Язык Си - стековый. Если вы не работаете через указатели, то структура сначала бросится в стек одного потока, потом заблокируется до возврата из функции обработки...
    Так что пример негодный. Тормозить существенно из-за процессора в данном случае код не будет.


    1. anton_dolganin Автор
      08.05.2026 02:42

      То, что вы описываете — это поведение обычного синхронного вызова функции.

      Но в многопоточности на Си это так не работает:
      1. В примере структура counters объявлена глобально.
      2. Даже если создавать потоки, вы физически не можете передать туда структуру по значению.

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


      1. TimID
        08.05.2026 02:42

        Я имел ввиду, что пример не очень жизненный у вас. Всё таки кэш затем придуман, чтобы ускорять в первую очередь чтение (в том числе повторное чтение) одним потоком.
        Но то, что проблема с пониманием работы аппаратуры есть, я не отрицаю.


    1. anton_dolganin Автор
      08.05.2026 02:42

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


  1. max-zhilin
    08.05.2026 02:42

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


    1. anton_dolganin Автор
      08.05.2026 02:42

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

      Ну и вопрос обратный вам — зачем-то же придумали Sharded Counters, что по сути я здесь и рассказал? Есть ли у вас более жизненный пример для применения такого паттерна? ;)


  1. Dark_Furia
    08.05.2026 02:42

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


    1. anton_dolganin Автор
      08.05.2026 02:42

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


  1. aapsoftware
    08.05.2026 02:42

    Интересная статья, мне лично, очень в тему. Я как раз работаю над многопоточным проектом и наступил на озвученные в статье грабли. В связи с этим вопрос. В приложении несколько потоков работают с неким глобальным объектом, доступным по чтению. Помимо него, каждый поток имеет свои куски данных в общем массиве записей. Эти куски достаточно большие по размеру (десятки и сотни килобайт) и не пересекаются. Работа потоков заключается в сравнении некоторых значений из глобального объекта со своими данными. В однопоточном режиме скорость поиска порядка 0.5 секунды, а в многопоточном увеличиваеся до 2 секунд. При этом сам процессор имеет 14 ядер, 28 потоков. Правильно ли я понял, что чтение тоже создаёт блокировку работы с памятью или то что описано выше относится только к записи?