False sharing в C++: как соседние переменные крадут скорость
Два потока пишут в разные переменные, а масштабирования нет. Разбираем, почему счётчики в одном массиве душат друг друга через кэш-линию и как это измерить и починить.

Восемь потоков инкрементируют восемь независимых счётчиков — по одному на поток, никаких общих данных, никаких блокировок. По логике время должно остаться таким же, как для одного потока: работы столько же, ядер больше. На практике версия с массивом int counters[8] отрабатывает в разы медленнее версии, где каждый счётчик лежит в своей переменной. Причина — не конкуренция за данные, а конкуренция за кэш-линию. Это и есть false sharing: логически независимые переменные оказались на одной 64-байтной линии, и ядра начали гонять её туда-обратно.
Разбираем на уровне того, что происходит в кэше: почему так, как это увидеть в цифрах и в perf, и какие приёмы устранения дают выигрыш, а какие только раздувают структуры. Примеры на C++17, замеры зависят от вашего процессора — важны не абсолютные числа, а разница между вариантами на одной машине.
Что происходит на уровне кэша
Кэш процессора работает не байтами и не переменными, а линиями фиксированного размера — на большинстве x86-64 и ARM это 64 байта. Когда ядро читает или пишет любой адрес, в кэш затягивается вся линия целиком. Когерентность между ядрами поддерживается протоколом вроде MESI: если одно ядро пишет в линию, копии этой линии в кэшах других ядер помечаются недействительными.
Дальше начинается пинг-понг. Ядро 0 пишет в counters[0] — линия переходит в состояние Modified у ядра 0, у остальных инвалидируется. Ядро 1 хочет писать в counters[1], которая физически на той же линии, — оно вынуждено сначала запросить линию себе, забрать её у ядра 0 (это трафик по межъядерной шине), пометить у ядра 0 как Invalid. Потом ядро 0 снова хочет писать в свой счётчик — и всё повторяется. Данные независимы, но линия одна, и она мигрирует между кэшами L1/L2 разных ядер сотни тысяч раз в секунду.
False sharing не нарушает корректность — программа считает всё правильно. Она просто отдаёт большую часть выигрыша от многопоточности на трафик когерентности, который вы нигде явно не писали.
Кэш-линия*
* Минимальная единица переноса данных между оперативной памятью и кэшем процессора, а также единица, на которой работает протокол когерентности. Узнать размер на Linux можно из /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size или в C++17 через std::hardware_destructive_interference_size, если компилятор его поддерживает.
Воспроизводим проблему
Соберём минимальный бенчмарк на C++17. Каждый поток миллионы раз инкрементирует свой счётчик. В первой версии счётчики лежат в плотном массиве.
// g++ -O2 -std=c++17 -pthread false_sharing.cpp -o fs
#include <atomic>
#include <thread>
#include <vector>
#include <chrono>
#include <cstdio>
constexpr int THREADS = 8;
constexpr long ITER = 100'000'000;
// Плотный массив: все счётчики в пределах одной-двух кэш-линий
struct PackedCounters {
std::atomic<long> value[THREADS];
};
int main() {
PackedCounters counters{};
std::vector<std::thread> workers;
auto start = std::chrono::steady_clock::now();
for (int i = 0; i < THREADS; ++i) {
workers.emplace_back([&counters, i] {
for (long n = 0; n < ITER; ++n) {
// relaxed: нам не нужна синхронизация между потоками,
// проблема именно в физическом расположении в памяти
counters.value[i].fetch_add(1, std::memory_order_relaxed);
}
});
}
for (auto& t : workers) t.join();
auto end = std::chrono::steady_clock::now();
double ms = std::chrono::duration<double, std::milli>(end - start).count();
std::printf("packed: %.1f ms\n", ms);
return 0;
}
Восемь std::atomic<long> по 8 байт — это ровно 64 байта, вся структура помещается в одну кэш-линию. Восемь ядер дерутся за одну линию на каждой итерации.
Версия с выравниванием
Теперь разведём счётчики по разным линиям. Каждый элемент выравниваем и добиваем до размера линии, чтобы соседний счётчик гарантированно попал на другую.
// Каждый счётчик занимает целую кэш-линию
struct alignas(64) PaddedCounter {
std::atomic<long> value{0};
// padding до 64 байт: 8 байт value + 56 байт заполнителя
char pad[64 - sizeof(std::atomic<long>)];
};
struct AlignedCounters {
PaddedCounter value[THREADS];
};
Всё остальное — тело потока, замер — без изменений, меняется только тип. На типичном настольном 8-ядерном x86-64 разница между packed и aligned в таком тесте обычно кратная: несколько раз, иногда порядок. Точное число зависит от процессора, топологии кэшей и того, легли ли потоки на разные физические ядра, поэтому гоняйте оба варианта на своей машине, а не верьте чужим цифрам.
Как убедиться, что дело именно в кэше
Замер по времени показывает, что стало лучше, но не почему. Подтверждение даёт perf на Linux — интересует рост промахов последнего уровня кэша и трафик когерентности:
# Считаем промахи кэша для обеих сборок
perf stat -e cache-misses,cache-references ./fs
# Более прицельно — события когерентности зависят от модели CPU;
# список доступных событий смотрите так:
perf list | grep -i -E 'cache|coheren|hitm'
У версии с плотным массивом cache-misses будут заметно выше при той же вычислительной работе. Конкретные имена событий когерентности (например, связанные с HITM — попаданием в изменённую линию чужого кэша) различаются между Intel и AMD и между поколениями, поэтому не полагайтесь на фиксированное имя события — сверяйтесь с выводом perf list на вашем железе.
Способы устранения и их цена
Padding — не единственный вариант, и не всегда лучший. У каждого приёма своя плата памятью и читаемостью.
| Приём | Что делает | Цена | Когда уместен |
|---|---|---|---|
| alignas(64) + padding | Разводит поля по линиям вручную | +56 байт на 8-байтное поле; раздувает структуру | Горячие счётчики, разделяемые между потоками |
| std::hardware_destructive_interference_size | То же, но размер линии берётся из компилятора | Не везде реализован; значение может быть консервативным (128) | Портируемый код на C++17+ при поддержке |
| Thread-local накопление | Каждый поток считает в своей памяти, свод в конце | Нужна фаза сведения; данные не видны в реальном времени | Счётчики, итог которых нужен только по завершении |
| Реорганизация структуры | Разносит часто пишущиеся поля от часто читаемых | Требует понимания паттерна доступа | Структуры с смешанным доступом из разных потоков |
Про std::hardware_destructive_interference_size есть тонкость: он введён в C++17, но некоторые версии libstdc++ и libc++ его либо не давали, либо выдавали предупреждение об ABI-стабильности. Прежде чем полагаться на него в проекте, проверьте, что ваш компилятор его определяет, и учтите, что значение может быть 128, а не 64, — это осознанный запас на процессоры, где префетчер тянет пару линий.
Thread-local как альтернатива padding
Если промежуточное значение счётчика никому не нужно во время работы, самое дешёвое по трафику решение — вообще не трогать общую память в горячем цикле. Каждый поток накапливает у себя, а сведение делается один раз:
#include <atomic>
#include <thread>
#include <vector>
std::atomic<long> global_total{0};
void worker(long iter) {
long local = 0; // на стеке потока, чужие ядра его не видят
for (long n = 0; n < iter; ++n) {
++local; // обычный инкремент, без атомиков и когерентности
}
global_total.fetch_add(local, std::memory_order_relaxed); // один раз в конце
}
Здесь false sharing исчезает не потому, что мы развели данные, а потому, что горячий цикл вообще не пишет в разделяемую линию. Плата — итог доступен только после join, а не в любой момент.
Где это ломается и на что смотреть
- Padding не спасает от истинного sharing. Если несколько потоков реально пишут в один счётчик, выравнивание бесполезно — там нужна другая архитектура (шардирование счётчика, thread-local со сводом).
- Ложное срабатывание диагностики. Рост cache-misses бывает и от честного промаха по большому рабочему множеству, а не от false sharing. Отличить помогает эксперимент: развели поля — стало быстрее, значит был false sharing.
- Раздутые структуры бьют по L1. Если налепить
alignas(64)на всё подряд, структура перестанет помещаться в кэш и вы проиграете там, где выиграли на когерентности. Padding — для горячих, часто пишущихся из разных потоков полей, а не для всех. - Динамическое выделение съедает выравнивание.
alignas(64)у типа соблюдается для стека и статики, ноnewпо умолчанию не гарантирует такого выравнивания в старых стандартах. В C++17 over-aligned new обязан его учитывать, но в кастомных аллокаторах и пулах это легко потерять — проверяйте адрес объекта. - Массивы padded-структур. Даже если каждый элемент выровнен на 64, начало самого массива должно быть выровнено — иначе линии съедут. Для
std::vector<PaddedCounter>это не гарантировано без выровненного аллокатора.
Что делать дальше
Начните с профиля, а не с padding: без замеров вы будете лечить проблему, которой нет, и раздувать структуры зря. Соберите оба варианта бенчмарка на целевом железе, снимите perf stat по cache-misses и убедитесь, что разница есть именно на многопоточной нагрузке. Дальше — найдите в горячем коде поля, в которые пишут разные потоки, и проверьте, не лежат ли они на одной линии: посмотрите смещения полей через offsetof и размер типа. Для контроля выравнивания в проде добавьте static_assert(alignof(PaddedCounter) == 64) и static_assert(sizeof(PaddedCounter) == 64), чтобы правка структуры однажды не сломала раскладку молча. И держите в голове главное правило: padding решает только false sharing, но не заменяет нормальную архитектуру, если данные разделяются по-настоящему.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: cppreference: std::hardware_destructive_interference_size, perf stat — Linux perf documentation
Частые вопросы
Как понять, что у меня именно false sharing, а не обычный промах кэша?
У меня ARM, а не x86 — размер линии тот же?
Компилятор ругается на std::hardware_destructive_interference_size — что делать?
Разве fetch_add с memory_order_relaxed не убирает синхронизацию совсем?
Если добавить padding и станет медленнее — почему?
Помогает ли просто разложить счётчики в отдельные переменные вместо массива?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.