惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

美团技术团队
Blog — PlanetScale
Blog — PlanetScale
阮一峰的网络日志
阮一峰的网络日志
M
MIT News - Artificial intelligence
月光博客
月光博客
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
U
Unit 42
博客园_首页
WordPress大学
WordPress大学
H
Hackread – Cybersecurity News, Data Breaches, AI and More
J
Java Code Geeks
F
Fortinet All Blogs
腾讯CDC
罗磊的独立博客
IT之家
IT之家
I
InfoQ
V
V2EX
博客园 - 叶小钗
A
About on SuperTechFans
Y
Y Combinator Blog
C
Check Point Blog
量子位
Martin Fowler
Martin Fowler
Vercel News
Vercel News

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания
Структуры данных на практике. Глава 13: Структуры данных ...
PatientZero · 2026-04-27 · via Все публикации подряд на Хабре

Время на прочтение9 мин

Охват и читатели369

Перевод

«Блокировки — это goto конкурентного программирования», — Морис Херлихи

Оглавление

Глава 1: Разрыв в производительности

Глава 2: Иерархия памяти

Глава 3: Бенчмаркинг и профилирование

Глава 4: Массивы и локальность кэша

Глава 5: Связанные списки — убийцы кэша

Глава 6: Стеки и очереди

Глава 7: Хэш-таблицы и конфликты кэша

Глава 8: Динамические массивы и управление памятью

Глава 9: Двоичные деревья поиска

Глава 10: B-деревья и деревья, эффективно использующие кэш

Глава 11: Префиксные деревья и базисные деревья

Глава 12: Кучи и очереди с приоритетом

Глава 13: Структуры данных без блокировок

Проблема 60%

Наша система логгинга тратила 60% своего времени на ожидание снятия блокировок. Не на выполнение полезной работы, только на одно ожидание.

Восемь ядер, пытавшихся записывать сообщения логов, имели общий кольцевой буфер. Реализация была простой: буфер защищался мьютексом. При высокой нагрузке, когда все ядра записывали логи одновременно, профилировщик демонстрировал ужасный паттерн: 60% тактов CPU тратилось на операции с мьютексом.

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

«Можно ли улучшить ситуацию, отказавшись от блокировок?», — спросил меня мой менеджер во время ревью производительности.

Этот вопрос привёл к полной смене архитектуры. Простое решение на основе мьютекса выглядело так:

typedef struct {
    char buffer[LOG_SIZE];
    int head;
    int tail;
    pthread_mutex_t lock;
} log_buffer_t;

void log_write(log_buffer_t *log, const char *msg) {
    pthread_mutex_lock(&log->lock);
    // Запись сообщения в буфер
    int next = (log->tail + 1) % LOG_SIZE;
    strcpy(&log->buffer[log->tail], msg);
    log->tail = next;
    pthread_mutex_unlock(&log->lock);
}

Простое, корректное... и медленное.

При большой нагрузке (одновременном логгинге всех восьми ядер) система тратила 60% своего времени на ожидание снятия блокировок:

$ perf stat -e cycles,instructions,cache-misses ./logger_mutex
  Performance counter stats:
    8,500,000,000 cycles
    2,100,000,000 instructions
       45,000,000 cache-misses
       
Lock contention: 60% of cycles spent in mutex operations
Throughput: 850,000 messages/second

Я реализовал кольцевой буфер без блокировок на основе атомарных операций. Результаты оказались такими:

$ perf stat -e cycles,instructions,cache-misses ./logger_lockfree
  Performance counter stats:
    3,200,000,000 cycles
    2,400,000,000 instructions
       12,000,000 cache-misses
       
Lock contention: 0%
Throughput: 2,400,000 messages/second

В 2,8 раза быстрее с нулевыми конфликтами блокировок. Однако код стал гораздо сложнее.

В этой главе мы поговорим о том, когда структуры данных без блокировок оправдывают свою сложность.


Что нам рассказывают учебники

Нам обещают, что структуры данных без блокировок:

  • Не имеют блокировок: потокам не нужно ждать снятия блокировок

  • Лучше масштабируются: производительность растёт с увеличением количества ядер

  • Не имеют взаимных блокировок: без блокировок не получится и взаимных блокировок

  • Гарантируют прогресс: по крайней мере, один поток всегда имеет прогресс

Благодаря такой рекламе из учебников они как будто идеально подходят для многоядерных систем.


Проверка реальностью

Программирование без блокировок — это сложная задача. Вот, на что не делают упор учебники:

1. Сложности порядка работы с памятью

В современных CPU порядок операций с памятью можно менять. Рассмотрим этот «простой» флаг без блокировки:

// Поток 1
data = 42;
ready = 1;  // Сигнализирует о том, что данные готовы

// Поток 2
if (ready) {
    use(data);  // Возможно, видит старое значение данных!
}

Проблема: CPU может изменить порядок записи в Потоке 1, поэтому Поток 2 увидит ready = 1 до data = 42.

Решение: барьеры памяти (fence):

// Поток 1
data = 42;
__atomic_store_n(&ready, 1, __ATOMIC_RELEASE);  // Снимаем барьер

// Поток 2
if (__atomic_load_n(&ready, __ATOMIC_ACQUIRE)) {  // Ставим барьер
    use(data);  // Теперь поток гарантированно увидит data = 42
}

2. Проблема ABA

У классического стека без блокировок есть малозаметный баг:

typedef struct node {
    int value;
    struct node *next;
} node_t;

node_t *top;  // Вершина стека

void push(int value) {
    node_t *new_node = malloc(sizeof(node_t));
    new_node->value = value;
    do {
        new_node->next = top;
    } while (!__atomic_compare_exchange_n(&top, &new_node->next, new_node,
                                          0, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST));
}

Проблема ABA:

  1. Поток 1 считывает top = A

  2. Поток 2 извлекает A, извлекает B, снова записывает A (на вершине снова A)

  3. Операция сравнения с обменом (CAS) Потока 1 завершается успешно (на вершине по-прежнему A), но стек повреждён!

Решение: использовать тегированные указатели или указатели опасности (сложно!).

3. Пинг-понг линий кэша

Отсутствие блокировок не означает удобство для кэша. Рассмотрим счётчик без блокировок:

atomic_int counter = 0;

// Все восемь потоков выполняют инкремент
__atomic_fetch_add(&counter, 1, __ATOMIC_SEQ_CST);

Каждый инкремент заставляет линию кэша перескакивать между ядрами:

Ядро 0: считывание счётчика (промах кэша)
Ядро 0: инкремент, запись обратно
Ядро 1: считывание счётчика (промах кэша - инвалидирован Ядром 0)
Ядро 1: инкремент, запись обратно
Ядро 2: считывание счётчика (промах кэша - инвалидирован Ядром 1)
...

Результат: при сильных конфликтах хуже, чем мьютекс!

Я измерил это в нашей системе логгинга:

8 ядер, атомарный счётчик:
  Промахи кэша: 45 миллионов (столько же, сколько и с мьютексом!)
  Пропускная способность: 900 тысяч операций/с (чуть лучше, чем с мьютексом)

8 ядер, счётчики для каждого ядра (не общий):
  Промахи кэша: 2 миллиона
  Пропускная способность: 6,5 миллиона операций/с

Вывод: по возможности следует избегать общих атомарных переменных между ядрами.


Кольцевой буфер без блокировок: практический пример

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

Архитектура

Важнейшее решение: отдельные индексы чтения и записи, использование атомарных операций только для обновлений индексов.

typedef struct {
    char buffer[LOG_SIZE];
    atomic_int head;  // Индекс чтения
    atomic_int tail;  // Индекс записи
} lockfree_log_t;

bool log_write(lockfree_log_t *log, const char *msg, int len) {
    int current_tail, next_tail, current_head;

    do {
        current_tail = __atomic_load_n(&log->tail, __ATOMIC_ACQUIRE);
        next_tail = (current_tail + len) % LOG_SIZE;
        current_head = __atomic_load_n(&log->head, __ATOMIC_ACQUIRE);

        // Проверка буфера на заполненность
        if (next_tail == current_head) {
            return false;  // Буфер заполнен
        }

    } while (!__atomic_compare_exchange_n(&log->tail, &current_tail, next_tail,
                                          0, __ATOMIC_RELEASE, __ATOMIC_ACQUIRE));

    // Теперь мы владеем диапазоном [current_tail, next_tail)
    memcpy(&log->buffer[current_tail], msg, len);

    return true;
}

Почему это работает

  1. Операция CAS с хвостом: затребовать диапазон может только один поток

  2. Отсутствие блокировок данных: после получения диапазона запись выполняется без конфликтов

  3. Порядок операций с памятью: ACQUIRE/RELEASE обеспечивает видимость

Бенчмарк

Я сравнил три реализации:

Тест: 8 ядер, 10 миллионов сообщений логов

На основе мьютекса:
  Такты: 8,5 миллиарда
  Промахи кэша: 45 миллионов
  Пропускная способность: 850 тысяч сообщений/с
  Конфликты блокировок: 60%

На основе спин-блокировки:
  Такты: 7,2 миллиарда
  Промахи кэша: 52 миллиона (хуже!)
  Пропускная способность: 1,1 миллиона сообщений/с
  Конфликты блокировок: 45%

Без блокировок (атомарные CAS):
  Такты: 3,2 миллиарда
  Промахи кэша: 12 миллиона
  Пропускная способность: 2,4 миллиона сообщений/с
  Конфликты блокировок: 0%

Версия без блокировок оказалась в 2,8 раз быстрее, чем версия с мьютексом, и в 2,2 раза быстрее, чем со спин-блокировкой.

Почему:

  • Отсутствие системных вызовов (мьютекс использует фьютекс)

  • Отсутствие циклов (спин-блокировка тратит такты)

  • Меньше трафика когерентности кэша (атомарны только индексы)


Когда разумно не пользоваться блокировками

Реализовав множество структур данных без блокировок, я понял, когда они оправдывают свою сложность.

1. Высокая конфликтность, короткие критические части

Если потоки постоянно конфликтуют за один ресурс, а работа внутри него выполняется быстро (всего несколько команд), то отсутствие блокировок может быть выгодным.

Пример: наша система логгинга

  • Высокая конфликтность: 8 ядер выполняют логгинг одновременно

  • Короткие критические части: просто обновление индекса

  • Результат: ускорение в 2,8 раза

2. Системы реального времени

В системах с жёстким реальным временем невозможно допускать инверсию приоритетов (поток с низким приоритетом удерживает блокировку, препятствуя потоку с высоким приоритетом).

Структуры без блокировок обеспечивают гарантии отсутствия ожидания и отсутствия блокировок.

Пример: обработчики прерываний

  • Невозможность блокировки контекста прерывания

  • Запросы без блокировок обеспечивают возможность связи прерывание → поток

  • Используется в кольцевых буферах ядра Linux

3. Нагрузки с большой долей операций чтения

Если количество операций чтения сильно превышает количество операций записи, то RCU (Read-Copy-Update) может полностью устранить блокировки на стороне чтения.

Пример: RCU ядра Linux

  • Читатели: без блокировок, без атомарных операций, только барьеры памяти

  • Писатели: редки, используют синхронизацию

  • Результат: миллионы операций чтения в секунду с нулевыми конфликтами

4. Когда НЕ использовать структуры данных без блокировки

Низкая конфликтность: если потоки редко конфликтуют, мьютекс проще и имеет такую же скорость.

Тест: 2 ядра, низкая конфликтность
  Мьютекс: 1,2 миллиона операций/с
  Без блокировки: 1,3 миллиона операций/с (всего на 8% быстрее, не оправдывает повышения сложности)

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

Отладка: баги с отсутствием блокировок крайне сложно воссоздавать и отлаживать. Если доказанной проблемы с производительностью нет, используйте блокировки.


Пример из реального мира: переменные ядра Linux для каждого CPU

Чтобы не использовать атомарные операции, в ядре Linux применяется хитрый трюк: переменные каждого CPU.

Вместо одного общего счётчика:

atomic_int global_counter;  // Общий счётчик, приводящий к пинг-понгу кэша

применяется по одному счётчику на каждый CPU:

DEFINE_PER_CPU(int, local_counter);  // По одному на CPU, без общего счётчика

void increment_counter(void) {
    int cpu = smp_processor_id();
    per_cpu(local_counter, cpu)++;  // Атомарность не нужна!
}

int read_total(void) {
    int total = 0;
    for_each_possible_cpu(cpu) {
        total += per_cpu(local_counter, cpu);
    }
    return total;
}

Почему это работает:

  • У каждого CPU есть собственная линия кэша

  • Отсутствие трафика когерентности кэша

  • Операции чтения редки (только когда нужна общая сумма)

Я использовал этот паттерн в нашей системе логгинга для статистики:

По одному счётчику на CPU (сообщения логируются для каждого ядра):
  Промахи кэша: 0 (у каждого ядра есть собственная линия кэша)
  Производительность: 8 миллионов инкрементов/с (8 ядер × 1 миллион на каждое)

Общий атомарный счётчик:
  Промахи кэша: 45 миллионов
  Производительность: 900 тысяч инкрементов/с

Ускорение в 8,9 раза благодаря отсутствию общего счётчика!


Порядок операций с памятью RISC-V

RISC-V имеет relaxed-модель памяти (RVWMO - RISC-V Weak Memory Ordering). Из-за этого ему нужны явно задаваемые fence.

Команды fence

# Полный fence (для всех операций с памятью)
fence rw, rw

# Fence получения (загрузка-загрузка, загрузка-хранение)
fence r, rw

# Fence освобождения (загрузка-хранение, хранение-хранение)
fence rw, w

Атомарные операции C11 на RISC-V

GCC сопоставляет атомарные операции C11 с командами RISC-V:

// __ATOMIC_ACQUIRE
__atomic_load_n(&x, __ATOMIC_ACQUIRE);

Это компилируется в следующий код:

ld   a0, 0(a1)      # Загрузка
fence r, rw         # Fence получения
// __ATOMIC_RELEASE
__atomic_store_n(&x, 42, __ATOMIC_RELEASE);

Компилируется в:

fence rw, w         # Fence освобождения
sd   a0, 0(a1)      # Хранение

Затраты на fence

Fence не бесплатны. Я измерил оверхед:

Тест: 1 миллион атомарных загрузок (RISC-V U74 @ 1.2 GHz)

Relaxed (без fence):
  Такты: 1,2 миллиона (1 такт на загрузку)

Получение (fence r, rw):
  Такты: 8,5 миллиона (8,5 тактов на загрузку)

Последовательная (fence rw, rw):
  Такты: 12,3 миллиона (12,3 такта на загрузку)

Вывод: используйте корректную самый слабый порядок операций с памятью. Не выбирайте по умолчанию __ATOMIC_SEQ_CST.


Подведём итог

Проблема 60 процентов была решена. Кольцевой буфер без блокировок увеличил производительность с 850 тысяч до 2,4 миллиона сообщений в секунду (ускорение в 2,8 раза). Конфликты блокировок снизились с 60% до нуля. Однако код стал существенно сложнее, он требует большого внимания к порядку операций с памятью и проблеме ABA.

Ключевые наблюдения:

  1. Отсутствие блокировок не всегда быстрее. В частях кода с малым количеством конфликтов или сложным критичным кодом мьютексы проще и имеют такую же скорость.

  2. Необходимо внимание к порядку операций с памятью. Для обеспечения видимости между ядрами нужны барьеры ACQUIRE/RELEASE. Из-за relaxed-модели памяти RISC-V они должны быть явными.

  3. Важная когерентность кэша. Атомарные операции приводят к пинг-понгу линий кэша. По возможности избегайте общих атомарных переменных между ядрами.

  4. Проблема ABA. Чтобы избежать повреждений, стекам и очередям без блокировок нужны тегированные указатели или указатели опасности.

  5. Наличие переменных для каждого CPU позволяет устранить конфликты. Если можно разбить данные на CPU, то можно полностью избежать атомарных операций. Часто это оказывается в 5-10 быстрее, чем общие атомарные операции.

Показатели нашей системы логгинга:

  • Мьютекс: 850 тысяч сообщений/с, конфликты блокировок 60%

  • Без блокировок: 2,4 миллиона сообщений/с, 0% конфликтов

  • Статистика для каждого CPU: 8 миллионов инкрементов/с (при общем атомарном счётчике 900 тысяч)

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

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