Привет, Хабр!

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

Дело в том, что процессор синхронизирует память не по переменным, а кусками фиксированного размера. Две независимые переменные, попавшие в один кусок, для железа становятся одной.

Дальше мы напишем маленькую программу, где это воспроизводится и меряется, найдём проблему инструментом, который под неё и написан, починим и посчитаем, во что починка обходится. В конце разберём, где такое прячется в обычном коде и почему выравнивать всё подряд — плохая идея.

Программа на 40 строк

Четыре потока, каждый миллиард раз увеличивает свой счётчик. Общих данных нет, блокировок нет, задача параллельная идеально.

#include <pthread.h>
#include <stdio.h>
#include <stdint.h>
#include <time.h>

#define THREADS 4
#define ITERS   200000000L

struct counters {
    volatile int64_t c[THREADS];
};

static struct counters shared;

static void *worker(void *arg) {
    long idx = (long) arg;
    for (long i = 0; i < ITERS; i++) {
        shared.c[idx]++;
    }
    return NULL;
}

int main(void) {
    pthread_t t[THREADS];
    struct timespec a, b;

    clock_gettime(CLOCK_MONOTONIC, &a);
    for (long i = 0; i < THREADS; i++)
        pthread_create(&t[i], NULL, worker, (void *) i);
    for (int i = 0; i < THREADS; i++)
        pthread_join(t[i], NULL);
    clock_gettime(CLOCK_MONOTONIC, &b);

    double sec = (b.tv_sec - a.tv_sec) + (b.tv_nsec - a.tv_nsec) / 1e9;
    printf("%.2f s, sum=%lld\n", sec,
           (long long)(shared.c[0] + shared.c[1] + shared.c[2] + shared.c[3]));
    return 0;
}

Собираем и запускаем:

gcc -O2 -pthread false_sharing.c -o fs && ./fs
3.87 s, sum=800000000

Теперь поставьте THREADS 1, оставив то же общее число итераций, и время упадёт примерно до секунды. Четыре ядра сделали ту же работу вчетверо медленнее, при том что потоки не пересекаются ни по одному байту.

Что происходит внутри

Четыре счётчика по 8 байт занимают 32 байта и целиком влезают в одну кеш‑линию. На Intel и AMD линия — это 64 байта, и проверить размер можно прямо в терминале:

getconf LEVEL1_DCACHE_LINESIZE
64

Линия — минимальный кусок, которым ядра обмениваются между собой, и когерентность отслеживается для неё целиком, а не для отдельных байтов внутри. Чтобы записать хоть один байт, ядро должно забрать всю линию в исключительное владение и обнулить копии в кешах остальных ядер.

Отсюда и наши три секунды. Первый поток пишет в c[0], забирает линию себе и выбивает её у трёх соседей. Второй тут же пишет в c[1] и забирает линию у первого. Третий забирает у второго — и так по кругу миллиард раз. Логически потоки независимы, физически они перебрасывают одну линию между ядрами, и каждая передача стоит на порядок дороже попадания в свой кеш.

Разделения данных нет, а платим мы как за разделение. Отсюда и название — ложное.

Как найти это в чужом коде

Если добавление потоков не ускоряет работу или замедляет её, а блокировок в горячем пути нет — подозревайте кеш‑линии.

Смотрим на промахи:

perf stat -e cache-references,cache-misses,L1-dcache-load-misses ./fs
     4 812 933 011      cache-references
     3 967 421 884      cache-misses              #   82,43% of all cache refs
     4 021 887 553      L1-dcache-load-misses

82% промахов на программе, которая трогает 32 байта памяти. Другого объяснения, кроме перебрасывания линии, тут просто нет.

Общие счётчики не покажут, какая строка виновата, зато в Linux есть инструмент под эту задачу:

perf c2c record ./fs
perf c2c report --stdio

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

=================================================
           Shared Data Cache Line Table
=================================================
       Total      Tot  ----- LLC Load Hitm -----
Index  Records    Hitm  Total  LclHitm  RmtHitm      Cacheline
    0   142905  98.31%  46792    46792        0  0x55f8c2a4e0c0

=================================================
      Shared Cache Line Distribution Pareto
=================================================
   Num  Offset      Code address    Symbol       Source
    ------------------------------------------------------
     0   0x0        0x55f8c2a01249  worker       false_sharing.c:18
     1   0x8        0x55f8c2a01249  worker       false_sharing.c:18
     2   0x10       0x55f8c2a01249  worker       false_sharing.c:18
     3   0x18       0x55f8c2a01249  worker       false_sharing.c:18

Одна линия собрала 98% всех конфликтов, а внутри неё обращения идут по смещениям 0, 8, 16 и 24, то есть по нашим счётчикам, все из строки 18.

Многие процессоры подтягивают соседнюю линию заранее, и конфликт может размазаться на две линии подряд. Для таких случаев в отчёт добавили отдельный режим:

perf c2c report --stdio --double-cl

Он группирует события по двойной линии — пригодится, если выравнивания по 64 байта не хватило.

Посмотреть раскладку структуры, не залезая в отладчик, помогает pahole:

pahole -C counters ./fs

Он покажет смещение каждого поля и отметит, где кончается одна линия и начинается следующая.

Починка

Разводим счётчики по разным линиям в C:

struct padded_counter {
    _Alignas(64) volatile int64_t value;
};

static struct padded_counter shared[THREADS];
gcc -O2 -pthread false_sharing_fixed.c -o fs_fixed && ./fs_fixed
0.98 s, sum=800000000

Почти вчетверо быстрее от одной строчки. Промахи тоже приходят в норму:

       302 118 774      cache-references
         1 984 552      cache-misses              #    0,66% of all cache refs

Только вот цифра 64 не универсальна. У процессоров Apple серии M линия вдвое больше — 128 байт, у некоторых моделей встречается и 256. Код с жёсткой шестьдесяткой на макбуке продолжит страдать. В C++ с 17-го стандарта есть константа, которую компилятор подставит под целевую платформу:

#include <new>
#include <atomic>

struct alignas(std::hardware_destructive_interference_size) Counter {
    std::atomic<long> value{0};
};

Рядом с ней живёт парная — hardware_constructive_interference_size, и означает она обратное: сколько данных гарантированно влезет в одну линию. Её берут, когда поля, наоборот, надо держать рядом ради локальности. В ядре Linux ту же задачу решает макрос __cacheline_aligned.

Сколько стоит выравнивание

Ускорение вчетверо выглядит бесплатным до момента, когда посчитаешь память. Счётчик на 8 байт, выровненный по 64, занимает все 64 — 87% структуры превращается в дырки.

Бьёт это не только по объёму. L1 на 32 килобайта вмещает 8000 плотно упакованных четырёхбайтовых значений и всего 500 выровненных по линии: эффективный кеш сжался в 16 раз. Каждая выборка из памяти по‑прежнему тянет 64 байта, из которых полезны 4.

Отсюда правило: выравнивают только те поля, в которые пишут разные потоки. Данные, которые многие читают и никто не меняет, ложного разделения не создают вовсе — линия спокойно лежит в кешах всех ядер, и разносить её вредно, вы просто теряете локальность.

Если набивку добавить некуда

Со структурами всё просто — добавил поле‑заполнитель и разошлись. А если у вас плоский массив примитивов, куда набивку не воткнёшь?

Тогда разносят не память, а индексы. Вместо того чтобы держать счётчик потока в c[tid], заводят массив в 8 раз больше и берут c[tid * 8]:

#define STRIDE 8                      // 8 * sizeof(int64_t) = 64 байта
static volatile int64_t c[THREADS * STRIDE];

static void *worker(void *arg) {
    long idx = (long) arg * STRIDE;
    for (long i = 0; i < ITERS; i++) {
        c[idx]++;
    }
    return NULL;
}

Соседние счётчики разъезжаются на 64 байта, память тратится та же, что при выравнивании структур, зато работает это на любом массиве и в любом языке. Тот же приём подходит для матриц, которые обходят по столбцам разные потоки: сдвигаете начало каждого столбца, и потоки перестают драться за общие линии.

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

static void *worker(void *arg) {
    long idx = (long) arg;
    int64_t local = 0;                // живёт на стеке этого потока
    for (long i = 0; i < ITERS; i++) {
        local++;
    }
    shared.c[idx] = local;            // одна запись вместо миллиарда
    return NULL;
}

Линия по‑прежнему общая, но пишут в неё четыре раза за всю программу вместо восьмисот миллионов, и конфликт исчезает сам. Если промежуточное значение никому не нужно в реальном времени, это самое дешёвое решение из всех.

Ложное разделение против настоящего

Отчёт perf c2c показывает конфликты, но сам по себе не говорит, ложные они или настоящие.

Различает их колонка смещения. Обращения по разным смещениям, как у нас — 0, 8, 16, 24, — значит потоки пишут в разные переменные, и это ложное разделение, снимается набивкой. Все обращения на одно смещение — потоки бьются за одну переменную, и выравнивание не поможет ничем. Тут нужен другой алгоритм: раскидать счётчик по шардам, копить локально и агрегировать редко, развести потоки по разным данным.

Вторая полезная колонка делит попадания на локальные и удалённые. Локальное — линию забрали у ядра на том же сокете. Удалённое — она приехала с другого процессорного узла, и стоит это в разы дороже. Ненулевой удалённый счётчик на многосокетной машине значит, что потоки с общими данными разъехались по узлам, и до всякой набивки стоит посмотреть на привязку:

numactl --hardware
taskset -c 0-7 ./fs        # держим потоки на одном сокете

Порядок разбора получается такой: сначала смещения — ложное разделение или настоящее, потом локальность — не разъехались ли потоки, и только потом набивка.

Если perf под рукой нет

Не везде есть root и не везде разрешено ставить профилировщики, а проверить гипотезу хочется. Есть пара способов.

Первый — посмотреть, как программа масштабируется. Прогоняете одну и ту же общую работу на 1, 2 и 4 потоках и делите время на число итераций:

for n in 1 2 4; do
  sed -i "s/#define THREADS .*/#define THREADS $n/" false_sharing.c
  gcc -O2 -pthread false_sharing.c -o fs && echo -n "$n потока: " && ./fs
done
1 потока: 0.96 s
2 потока: 2.41 s
4 потока: 3.87 s

Здоровый параллельный код при таком раскладе показывает примерно одинаковое время — работа‑то поделилась. Растущее время означает, что потоки друг другу мешают, и если блокировок нет, остаются кеш‑линии.

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

Такие задачи быстро показывают, где знания C уже работают на автомате, а где остаются слепые зоны. Это можно проверить, пройдя вступительный тест «Программист C» и понять, что имеет смысл подтянуть дальше.

То же самое там, где есть сборщик мусора

Может показаться, что всё это касается только тех, кто пишет на C. Воспроизводится оно везде, где потоки пишут в соседние поля, и проверяется обычным бенчмарком:

@State(Scope.Benchmark)
public class FalseSharingBench {

    static class Packed {
        volatile long a, b;
    }

    static class Padded {
        volatile long a;
        long p1, p2, p3, p4, p5, p6, p7;
        volatile long b;
    }

    Packed packed = new Packed();
    Padded padded = new Padded();

    @Benchmark @Group("packedG") @GroupThreads(1)
    public void packedA() { packed.a++; }

    @Benchmark @Group("packedG") @GroupThreads(1)
    public void packedB() { packed.b++; }

    @Benchmark @Group("paddedG") @GroupThreads(1)
    public void paddedA() { padded.a++; }

    @Benchmark @Group("paddedG") @GroupThreads(1)
    public void paddedB() { padded.b++; }
}

Семь длинных полей между a и b — это 56 байт, которые вместе с самим значением заполняют линию и разводят счётчики.

Benchmark                    Mode  Cnt     Score     Error   Units
FalseSharingBench.packedG   thrpt    5    92,417 ±   4,118  ops/us
FalseSharingBench.paddedG   thrpt    5   418,663 ±  12,904  ops/us

Разница в четыре с половиной раза, а изменился только порядок полей в классе.

Ручная набивка полями, правда, ненадёжна: виртуальная машина вправе переставлять поля при раскладке объекта, а оптимизатор — выкинуть неиспользуемые. Проверять получившееся надо через JOL:

System.out.println(ClassLayout.parseClass(Padded.class).toPrintable());

Из‑за этой ненадёжности и появились штатные механизмы.

Что дают языки

В Java есть аннотация, которая просит машину добавить набивку вокруг поля. По умолчанию она доступна только внутренним классам, так что нужен флаг:

import jdk.internal.vm.annotation.Contended;

public class Counters {
    @Contended volatile long a;
    @Contended volatile long b;
}
java --add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED \
     -XX:-RestrictContended Counters

Ширина набивки задаётся флагом -XX:ContendedPaddingWidth, по умолчанию — 128 байт, с запасом на ту самую предвыборку соседней линии.

Для самого частого случая — горячего счётчика, в стандартной библиотеке уже лежит готовое: LongAdder раскладывает значение по массиву ячеек с набивкой и суммирует их при чтении.

LongAdder requests = new LongAdder();
requests.increment();          // потоки бьют по разным ячейкам
long total = requests.sum();   // сумма считается при чтении

В Go аннотаций нет, набивку добавляют полем прямо в структуре:

type shard struct {
    counter atomic.Int64
    _       [56]byte // добиваем до 64
}

Проверить раскладку можно через unsafe.Offsetof — распечатать смещения полей и убедиться, что они разъехались.

Как понять, что это про вас

Начинайте с той же грубой проверки: потоков стало больше, а быстрее не стало. Дальше одна команда:

perf stat -e cache-misses,cache-references ./your_app

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

Чинить надо адресно. Набивка на каждом поле раздувает структуры, съедает кеш и портит однопоточную скорость, поэтому её добавляют там, где инструмент показал конфликт, и обязательно перемеряют после. Структура, которую пишет один поток, а читают многие, в набивке не нуждается вообще.

И держите в голове, что 64 — не мировая константа. Код, выровненный под неё, на процессорах Apple серии M продолжит гонять линии между ядрами, а на платформах с предвыборкой страдать может и правильно выровненный код — ради этого в perf c2c и добавили режим двойной линии. Если платформ несколько, берите размер из стандартной библиотеки или из системы при сборке.

Когда многопоточный код начинает замедляться по мере добавления потоков, проблема часто лежит глубже прикладной логики. Умение спуститься до кешей, памяти и поведения процессора помогает отличить реальное ограничение архитектуры от случайной просадки — и чинить именно причину, а не симптомы.

Хотите глубже разобраться в производительности приложений, профилировании и системной диагностике? Приходите на бесплатные демо-уроки:

  • 10 августа, 20:00. «Вход в ядро: системные вызовы и граница между user space и kernel space». Записаться

  • 9 сентября, 20:00. «Go-профилирование: как найти и исправить „тормоза“ в продакшене». Записаться

  • 23 сентября, 20:00. «eBPF: рентгеновское зрение для production». Записаться

Полный список бесплатных уроков августа смотрите в дайджесте.

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