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

Классический симптом: вы распараллелили счётчики по числу потоков, чтобы избежать блокировок, запустили на восьмиядерной машине и увидели, что восемь потоков работают медленнее двух. Профилировщик показывает высокий процент времени в загрузке из памяти, хотя рабочий набор — несколько десятков байт и целиком помещается в L1. Причина почти всегда одна: несколько потоков пишут в разные переменные, которые лежат на одной кэш-линии. Это ложное разделение (false sharing), и на многоядерных x86 оно легко превращает линейное ускорение в замедление.
Разберём, откуда берётся эффект на уровне протокола когерентности кэша, как его увидеть в цифрах на конкретном микробенчмарке и чем чинить в C++17 и новее. Примеры собирались GCC 13 и Clang 17 на x86-64 с 64-байтными кэш-линиями.
Почему соседние переменные тормозят друг друга
Кэш процессора оперирует не отдельными байтами, а линиями фиксированного размера. На большинстве современных x86-64 это 64 байта. Когерентность между ядрами поддерживается протоколом типа MESI: каждая линия в кэше ядра находится в одном из состояний — Modified, Exclusive, Shared, Invalid.
Когда ядро хочет записать в линию, ему нужно перевести её в состояние Modified, а для этого — разослать остальным ядрам сигнал инвалидации их копий этой линии. После инвалидации соседнее ядро, попытавшись прочитать или записать свою переменную из той же линии, обнаруживает копию в состоянии Invalid и вынуждено заново тянуть линию — из кэша соседа или из общего L3.
Ключевой момент: ядрам всё равно, что они пишут в разные переменные. Единица когерентности — линия, а не переменная. Пока обе переменные делят одну линию, каждая запись одного потока обесценивает кэш другого. Линия «пингует» между ядрами, и вместо доступа за единицы тактов вы платите за межъядерный трафик десятки, а через L3 — сотни тактов.
Ложное разделение — это когда данные логически независимы, а физически связаны границей кэш-линии. Компилятор об этом не знает и разложит ваши поля вплотную.
Микробенчмарк: увидеть эффект в цифрах
Возьмём типичную ситуацию — массив счётчиков по числу потоков. Каждый поток крутит свой счётчик миллионы раз. Логически конфликта нет, а физически четыре int64_t подряд — это 32 байта, все на одной 64-байтной линии.
// g++ -O2 -std=c++17 -pthread false_sharing.cpp
#include <atomic>
#include <cstdint>
#include <thread>
#include <vector>
#include <chrono>
#include <cstdio>
constexpr int kThreads = 4;
constexpr int64_t kIters = 200'000'000;
// Плотная упаковка: все счётчики на одной кэш-линии
struct CounterPacked {
std::atomic<int64_t> value;
};
int main() {
std::vector<CounterPacked> counters(kThreads);
auto start = std::chrono::steady_clock::now();
std::vector<std::thread> workers;
for (int t = 0; t < kThreads; ++t) {
workers.emplace_back([&counters, t] {
for (int64_t i = 0; i < kIters; ++i) {
// relaxed: нам важна только запись, не порядок
counters[t].value.fetch_add(1, std::memory_order_relaxed);
}
});
}
for (auto& w : workers) w.join();
auto end = std::chrono::steady_clock::now();
double ms = std::chrono::duration<double, std::milli>(end - start).count();
printf("packed: %.1f ms\n", ms);
return 0;
}На восьмиядерном x86-64 версия с плотной упаковкой у меня показала порядок сотен миллисекунд при четырёх потоках — и почти не ускорялась при добавлении потоков, потому что все они дрались за одну линию. Абсолютные числа зависят от процессора и частоты, поэтому важна не миллисекунда сама по себе, а отношение до/после на вашей машине.
Как измерять честно
- Прогрейте кэш и планировщик: сделайте один-два холостых прогона до замера.
- Закрепите потоки за ядрами (на Linux —
tasksetилиpthread_setaffinity_np), иначе миграция потоков смажет картину. - Отключите турбо-буст и зафиксируйте частоту через governor
performance, если сравниваете абсолютные числа. - Сравнивайте не «стало быстрее», а масштабирование: время при 1, 2, 4, 8 потоках. При ложном разделении время растёт или стоит на месте вместо падения.
Как чинить: развести переменные по линиям
Идея одна — разложить конфликтующие данные так, чтобы каждое поле, в которое пишет отдельный поток, попадало на свою кэш-линию. Дальше вопрос инструмента.
C++17: hardware_destructive_interference_size
Стандарт C++17 ввёл в <new> константу std::hardware_destructive_interference_size — рекомендуемый минимальный отступ между объектами, чтобы избежать ложного разделения. Выравниваем структуру на неё через alignas:
#include <new> // std::hardware_destructive_interference_size
#include <atomic>
#include <cstdint>
// Каждый счётчик на своей кэш-линии
struct alignas(std::hardware_destructive_interference_size) CounterPadded {
std::atomic<int64_t> value;
};
// sizeof(CounterPadded) == 64 на x86-64 с 64-байтной линиейЗаменив в бенчмарке CounterPacked на CounterPadded, вы получите линейное масштабирование: четыре потока начинают работать примерно вчетверо быстрее одного, потому что линии больше не инвалидируются взаимно.
Осторожно: у hardware_destructive_interference_size есть нюанс. GCC выдаёт предупреждение -Winterference-size, потому что значение зашивается в момент компиляции и может не совпасть с целевым процессором, если бинарник переносят. Если это важно — определите свою константу (например, 64) явно или считайте её в рантайме из /sys/devices/system/cpu/cpu0/cache/ на Linux.
Ручной паддинг
Если константа из C++17 недоступна (старый компилятор, экзотическая платформа), доводите структуру до размера линии руками:
constexpr std::size_t kCacheLine = 64;
struct CounterManual {
std::atomic<int64_t> value;
// добиваем структуру до полной линии
char padding[kCacheLine - sizeof(std::atomic<int64_t>)];
};
static_assert(sizeof(CounterManual) == kCacheLine,
"структура должна занимать ровно одну линию");static_assert здесь не для красоты: он ловит момент, когда кто-то добавит поле и паддинг перестанет сходиться.
Сравнение подходов
| Подход | Плюс | Минус |
|---|---|---|
| Плотная упаковка (по умолчанию) | Минимум памяти | Ложное разделение при параллельной записи |
alignas(hardware_destructive_interference_size) | Стандартно, читаемо, платформенно | Значение фиксируется при компиляции, предупреждение GCC |
| Ручной паддинг до 64 байт | Полный контроль, работает везде | Магическое число, легко разъехаться при правках |
| Локальная агрегация в потоке | Ноль межъядерного трафика на горячем пути | Нужен барьер и слияние результатов в конце |
Когда паддинг — не лучший ответ
Прежде чем раздувать структуры до 64 байт, проверьте, нужна ли вообще общая память на горячем пути. Часто самый быстрый вариант — вообще не писать в общий кэш во внутреннем цикле:
// Каждый поток копит в локальной переменной на стеке,
// в общий счётчик пишет один раз в конце
workers.emplace_back([&counters, t] {
int64_t local = 0; // живёт в регистре/стеке потока
for (int64_t i = 0; i < kIters; ++i) {
++local;
}
counters[t].value.store(local, std::memory_order_relaxed);
});Здесь во внутреннем цикле нет ни атомарных операций, ни межъядерного трафика вообще — только запись в регистр. Такой вариант обгоняет даже корректно выровненные атомарные счётчики, потому что убирает не только ложное разделение, но и стоимость самих атомарных инструкций. Паддинг нужен там, где потокам действительно приходится часто трогать соседние поля общей структуры — очереди, состояние воркеров, статистика в реальном времени.
Обратная сторона: истинное разделение и раздувание
- Выравнивание на 64 байта раздувает структуры. Массив из миллиона выровненных счётчиков займёт 64 МБ вместо 8 МБ и убьёт локальность при последовательном чтении. Паддинг оправдан для небольшого числа горячих объектов, а не для всего подряд.
- Если потоки пишут в одну и ту же переменную, это истинное разделение — паддинг не поможет, тут нужна другая структура доступа.
- На процессорах Intel с семейства Sandy Bridge префетчер может подтягивать пару соседних линий (adjacent cache line prefetch), поэтому иногда безопаснее ориентироваться на 128 байт. В C++17 для этого и предусмотрено, что
hardware_destructive_interference_sizeможет быть больше размера линии.
Что делать дальше
Соберите свой бенчмарк в двух версиях — упакованной и выровненной — и снимите время при 1, 2, 4, 8 потоках с закреплением за ядрами. Если масштабирование ломается, подтвердите диагноз через perf: смотрите на счётчики mem_load_uops_l3_hit_retired.xsnp_hitm и подобные события HITM (hit-modified в чужом кэше) — их всплеск и есть отпечаток ложного разделения. Дальше применяйте паддинг точечно к горячим объектам и повторяйте замер: цель не «стало быстрее на глаз», а восстановленное линейное масштабирование по числу потоков.
Транскрибатор встреч: Запись созвона или диктофона — в текст с разделением по спикерам, протоколом и списком задач.
Спросить за 25 ₽Источники: std::hardware_destructive_interference_size — cppreference, Intel 64 and IA-32 Architectures Optimization Reference Manual
Частые вопросы
Как понять, что тормозит именно ложное разделение, а не что-то ещё?
А если у меня кэш-линия не 64 байта?
Почему GCC ругается предупреждением на hardware_destructive_interference_size?
Не сработало — время не изменилось после паддинга. В чём дело?
Стоит ли выравнивать вообще все структуры на всякий случай?
Помогает ли просто заменить atomic на mutex?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.