MAATRIX / Блог / Что такое строка кеша и почему два потока дерутся за переменные, которых не делят

Что такое строка кеша и почему два потока дерутся за переменные, которых не делят

MAATRIX

Вы разносите данные по отдельным переменным специально, чтобы потоки не мешали друг другу: у каждого свой счётчик, свой указатель, своя структура. Блокировок нет, гонок по данным нет, логика проверена — а многопоточный код всё равно не масштабируется, и чем больше ядер вы добавляете, тем меньше прирост. Причина обычно не в коде и не в алгоритме, а в том, как процессор физически читает память: не байтами и не переменными, а целыми блоками фиксированного размера. Если две логически несвязанные переменные случайно оказались в одном таком блоке, ядра начинают воевать за него, даже не подозревая о существовании друг друга.

Почему процессор не читает память по одному байту

Оперативная память медленная по меркам ядра — сотни тактов на один промах в кеш. Чтобы не гонять по шине запрос на каждый байт, процессор всегда читает и пишет память кусками фиксированного размера — строками кеша (cache line). На подавляющем большинстве современных x86-64 и ARM64 серверных процессоров размер строки — 64 байта; это не универсальная константа физики, а инженерное решение конкретной архитектуры, и его стоит проверять для своей платформы, а не считать данностью.

Когда поток обращается к переменной, процессор подтягивает в кеш всю строку, в которую эта переменная попадает, — соседние 63 байта приезжают бесплатно, «за компанию». Это осознанный компромисс: программы обычно читают данные, лежащие рядом (локальность), и подтягивать сразу блок дешевле, чем гонять шину за каждым байтом отдельно. Но у этого компромисса есть обратная сторона: единицей когерентности — то есть единицей, за актуальность которой процессор следит между ядрами, — тоже является строка целиком, а не отдельная переменная внутри неё.

Практическое следствие: если в вашей структуре данных два int рядом стоят по соседству в памяти, у процессора физически нет способа сказать «это разные переменные, трогайте их независимо». Он видит только строку целиком.

MESI: как ядра держат кеши согласованными

У каждого ядра свой L1-кеш (обычно и приватный L2), и когда несколько ядер кешируют одну и ту же строку памяти, нужен протокол, который не даст им разойтись в показаниях. На практике для этого используются протоколы семейства MESI (Modified / Exclusive / Shared / Invalid) и их модификации вроде MESIF или MOESI — детали отличаются по вендорам, но суть одна.

Огрубляя до уровня, который важен для понимания проблемы:

  • строка в состоянии Shared — несколько ядер читают её одновременно, все копии одинаковы, это дёшево;
  • как только одно ядро пишет в строку, она переходит в Modified только у этого ядра, а копии у всех остальных ядер помечаются Invalid — становятся недействительными;
  • если другое ядро после этого хочет прочитать или записать ту же строку, оно не может использовать свою (уже недействительную) копию — ему нужно заново получить актуальные данные, что означает обмен между кешами ядер или обращение к более медленному общему уровню кеша.

Ключевое слово — «строка», а не «переменная». Протокол когерентности не умеет инвалидировать половину строки. Если ядро A пишет байт номер 3 в строке, а ядро B полагается на байт номер 40 той же строки, то с точки зрения MESI это одна и та же операция: строка целиком помечается Modified у A и Invalid у B, независимо от того, что байты, которые их реально интересуют, никак не пересекаются.

Это и есть механизм, который лежит в основе связки «кеш решает судьбу цикла ещё до его выполнения» — процессор годами оптимизировался под то, что данные, лежащие рядом, логически связаны. False sharing — это ситуация, где это допущение ломается.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Ложное совместное использование на конкретном примере

Возьмём типичный код с per-thread счётчиками — паттерн, который многие пишут именно для того, чтобы избежать блокировок:

struct Counters {
    long thread0_hits;   // пишет только поток 0
    long thread1_hits;   // пишет только поток 1
    long thread2_hits;   // пишет только поток 2
    long thread3_hits;   // пишет только поток 3
};

struct Counters counters;

void worker(int id) {
    for (long i = 0; i < N; i++) {
        // "своя" переменная, никакой блокировки не нужно
        (&counters.thread0_hits)[id]++;
    }
}

С точки зрения логики программы всё корректно: поток с id=0 трогает только thread0_hits, поток с id=1 — только thread1_hits, данные никогда не пересекаются, гонки по данным нет. Но long — обычно 8 байт, и все четыре поля структуры почти наверняка укладываются в одну 64-байтовую строку кеша (4 × 8 = 32 байта — меньше одной строки).

В результате каждый инкремент thread0_hits на ядре, где крутится поток 0, инвалидирует копию строки у ядра, где крутится поток 1, — хотя поток 1 к thread0_hits вообще не притрагивается. Поток 1 не может продолжить локально закешированную работу со «своим» thread1_hits — ему приходится заново синхронизировать строку. То же самое происходит в обратную сторону, и с потоками 2 и 3. Формально общих данных нет. Фактически все четыре потока по кругу отбирают друг у друга одну и ту же строку кеша, и трафик когерентности между ядрами растёт пропорционально числу потоков — то есть именно там, где вы рассчитывали на линейное ускорение от добавления ядер, получаете обратный эффект.

Та же ловушка регулярно всплывает не только в ручном C/C++: в Go — на соседних полях структуры, которые пишут разные горутины; в Java — на массивах объектов или полях класса, к которым обращаются разные потоки пула; в базах данных собственной разработки — на массивах статистики по шардам или партициям, лежащих в одном аллокейшене. Язык и рантайм здесь ни при чём — это свойство памяти и процессора, а не конкретной экосистемы.

Почему в профилировщике и в code review всё выглядит чисто

False sharing коварен именно тем, что не оставляет обычных следов. У вас нет:

  • гонки данных — санитайзеры вроде ThreadSanitizer её не найдут, потому что каждый поток честно владеет своей переменной;
  • deadlock или livelock — тема отдельного разбора про блокировки, взятые в разном порядке, но здесь речь не о блокировках вообще;
  • явного мьютекса, на котором видно contention в трейсе;
  • аномалии в самом коде — при чтении структуры глазами она выглядит абсолютно правильной, поля действительно независимы по смыслу.

Единственный видимый симптом — код не масштабируется по ядрам так, как должен: два потока быстрее одного, а четыре почти не быстрее двух, хотя алгоритм линейно параллелится. Со стороны это легко спутать с обычным spinlock-контентом — когда сервер занят, но не делает полезной работы — только источник другой: не логическая блокировка, а физическая структура памяти.

Обычные счётчики CPU-времени тоже не подсвечивают проблему напрямую — поток не блокируется, не спит, не ждёт ввода-вывода, он просто выполняет полезную работу медленнее из-за постоянных промахов кеша и трафика когерентности между ядрами. Нужны именно счётчики, чувствительные к когерентности: cache-misses, а на процессорах с поддержкой соответствующих событий — счётчики промахов на уровне L2/L3 и события, связанные именно с false sharing (в отличие от true sharing — обращения к реально общим данным) (например HITM — hit modified, когда данные приходится тянуть из кеша другого ядра, а не из памяти или общего кеша). Точные названия событий и их доступность зависят от конкретной модели CPU и версии perf, поэтому смотреть нужно perf list на своём железе, а не полагаться на чужой список.

Как поймать false sharing на своём сервере

На Linux-сервере с процессором Intel или AMD базовый инструмент — perf:

# общая картина по промахам кеша во время работы приложения
perf stat -e cache-references,cache-misses -p $(pgrep -f your_app)

# специализированный режим "cache-to-cache" — именно то,
# что нужно для диагностики false sharing
sudo perf c2c record -- ./your_app
sudo perf c2c report

Отчёт perf c2c показывает конкретные строки кеша, за которые активно борются разные ядра, — с точностью до адреса и (если бинарник собран с отладочной информацией) до строки исходного кода. Это самый прямой путь от симптома («не масштабируется») к причине («вот эта структура на этой строке»).

Если сервер работает под управлением гипервизора или в контейнере, добавляется ещё один слой: планировщик хоста может перекидывать виртуальные ядра между физическими, и «своя» строка кеша для потока меняется чаще, чем на голом железе. На системах с несколькими физическими процессорами (сокетами) эффект накладывается на NUMA — когда доступ к памяти соседнего процессора ощутимо дороже своей, и false sharing между потоками, которых NUMA-балансировщик развёл по разным сокетам, обходится дороже, чем между потоками на соседних ядрах одного сокета — трафик когерентности идёт через межпроцессорную шину, а не только через общий L3.

Практический признак, который стоит проверять до всякого профилирования: постройте график «время выполнения / число потоков» на реальной или синтетической нагрузке. Если кривая выходит на плато или разворачивается заметно раньше, чем можно объяснить числом физических ядер и конкуренцией за память в целом, — это повод подозревать false sharing и запускать perf c2c, а не сразу переписывать алгоритм.

Как разложить данные, чтобы ядра не мешали друг другу

Лечится проблема почти всегда одним и тем же приёмом — разнести «горячие», часто изменяемые независимыми потоками переменные так, чтобы они физически попадали в разные строки кеша. Способ 1 — явный паддинг структуры:

struct PaddedCounter {
    long value;
    char padding[64 - sizeof(long)]; // добивка до размера строки кеша
};

struct PaddedCounter counters[4]; // теперь каждый счётчик — на своей строке

Способ 2 — атрибуты выравнивания, которые современные компиляторы понимают напрямую, без ручного расчёта паддинга:

struct alignas(64) PaddedCounter {
    long value;
};

В Java с версии 8 для этой же задачи есть аннотация @Contended (в пакете jdk.internal.vm.annotation, требует флага -XX:-RestrictContended для кода вне самого JDK) — компилятор и JVM сами добавляют паддинг вокруг помеченного поля. В Go явной аннотации нет, паддинг добавляют вручную — полем-заглушкой нужного размера между «горячими» полями структуры.

Способ 3, часто более удачный архитектурно: не размазывать общий массив счётчиков по потокам вручную, а дать каждому потоку собственную приватную переменную (или структуру) в памяти, выделенную отдельно — например, через thread_local в C++ или через локальную переменную, которая никогда не публикуется в общую структуру, — а агрегировать значения в единое число только периодически, в момент, когда потоки не пишут в него одновременно. Это одновременно решает и false sharing, и снижает частоту любой синхронизации в принципе.

Здесь важно не удариться в другую крайность: паддинг каждой мелкой переменной до 64 байт бездумно раздувает структуры данных и портит эффективность кеша там, где переменные *действительно* используются вместе несколькими потоками для чтения (Shared-состояние в MESI — это дёшево и хорошо, страдает только конкурентная запись). Выравнивание имеет смысл точечно — там, где профилирование (perf c2c, а не догадка) показало реальный трафик когерентности между конкретными ядрами. Если структура пишется одним потоком и читается многими без изменений, false sharing ей, как правило, не грозит вовсе.

При выборе сервера для сильно многопоточной нагрузки — воркеров с большим числом счётчиков на поток, шардированных пулов соединений, паралелльных агрегаторов — имеет смысл заранее смотреть не только на число ядер, но и на размер L3-кеша и топологию сокетов; это разбирается отдельно при сравнении серверных процессоров AMD EPYC и Intel Xeon. Больше ядер без учёта того, как приложение раскладывает данные в памяти, само по себе false sharing не лечит — а иногда даже обнажает проблему сильнее, потому что больше ядер конкурируют за одну и ту же строку.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Какого размера строка кеша на моём сервере — точно 64 байта?

На большинстве современных серверных x86-64 и ARM64 CPU — да, 64 байта, но это архитектурная деталь конкретного процессора, а не универсальная константа. Проверить можно через getconf LEVEL1_DCACHE_LINESIZE в Linux или через cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size — не полагайтесь на память, посмотрите на своём железе.

Можно ли обнаружить false sharing без perf и без root-доступа?

Прямых признаков без счётчиков производительности почти нет — обычный профилировщик по времени CPU покажет только, что поток «работает», а не то, что он ждёт когерентности. Косвенно можно заподозрить проблему по графику масштабируемости (время / число потоков), но подтвердить причину без perf c2c или аналогичного инструмента (например, Intel VTune с модулем Memory Access) практически невозможно.

Верно ли, что false sharing бывает только при записи в общую строку?

В основном да — если все обращающиеся к строке потоки только читают её, MESI держит копии в состоянии Shared, и это дёшево: обмена данными между ядрами не требуется. Проблема начинается, как только хотя бы один поток регулярно пишет в строку, к которой другие потоки тоже обращаются (даже для чтения, даже к другим байтам той же строки) — тогда начинаются постоянные инвалидации.

Атомарные операции (atomic, std::atomic) защищают от false sharing?

Нет — атомарность гарантирует корректность конкретной операции над конкретной переменной (нет потерянных инкрементов), но не решает проблему совместного размещения в памяти. Атомарный счётчик, лежащий на одной строке кеша с другим активно изменяемым атомарным счётчиком другого потока, страдает от false sharing точно так же, как обычный long — здесь нужен паддинг или выравнивание, а не смена типа переменной.

Стоит ли из-за этого паддить абсолютно все структуры с несколькими полями?

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

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →