Это сокращённый и адаптированный перевод моей статьи на DEV Community.

Однажды я заменил std::deque на boost::circular_buffer в своем аудиоплеере Kalinka. Буфер является критическим компонентом и используется для передачи данных между узлами аудиографа: от считывания из сети или файла, декодирования и воспроизведения. Кольцевой буфер казалось бы, лучше подходил для задачи, микробенчмарк показывал ускорение в 2–3 раза, а сама замена выглядела почти очевидной.

После неё загрузка процессора на Raspberry Pi при воспроизведении выросла примерно с 2% до 25%.

Через неделю я вернул std::deque.

Зачем вообще понадобился кольцевой буфер

В Kalinka Player данные проходят через несколько ограниченных FIFO-буферов:

сеть/файл → декодер → аудиовыход

Один поток записывает данные в конец, другой читает их из начала. Размер буфера фиксирован, старые непрочитанные данные не перезаписываются.

Изначально там использовался std::deque<uint8_t>:

data.insert(data.end(), source, source + size);

std::copy_n(data.begin(), size, destination);
data.erase(data.begin(), data.begin() + size);

Но std::deque хранит данные отдельными блоками. А boost::circular_buffer заранее выделяет один массив и просто перемещает позиции начала и конца.

Для ограниченного FIFO это выглядело правильнее:

  • одна аллокация;

  • фиксированная ёмкость;

  • удаление из начала за O(1);

  • хорошая локальность данных.

Я написал тест с элеметнами фиксированного размера (32-байта). Результат подтвердил ожидания: circular_buffer был быстрее примерно в 2–3 раза.

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

А потом я открыл top

До изменения плеер потреблял около 1–3% CPU. После — 20–30%. Я не сразу понял почему.

Через некоторое время нашлась очевидная ошибка. Я удалял данные обычным erase():

data.erase(data.begin(), data.begin() + size);

У boost::circular_buffer для этого есть специализированный метод:

data.erase_begin(size);

Замена ускорила само удаление почти в сто раз. Нагрузка снизилась, но всё ещё оставалась намного выше исходной.

Значит, проблема была не только в erase().

Микробенчмарк тестировал не совсем то

Первый тест измерял добавление и удаление небольших отдельных объектов.

Реальная программа делала другое: копировала блоки байтов размером примерно 16 КБ.

Я переписал тест под настоящую нагрузку:

  • буфер на 768 КБ;

  • блоки по 16 КБ;

  • несколько гигабайт данных;

  • тот же код insert() и copy_n(), что использовался в плеере.

И получил противоположный результат.

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

Контейнер

Скорость записи

std::deque

33 420 МБ/с

boost::circular_buffer

3671 МБ/с

В этой операции std::deque оказался почти в девять раз быстрее. Бывает, но я не мог оставить это так и пытался понять.

Почему так произошло

Для uint8_t стандартная библиотека может заменить копирование диапазона оптимизированным memcpy или memmove.

Хотя std::deque не хранит данные одним массивом, используемая мной реализация libstdc++ умеет эффективно копировать его внутренние блоки.

У boost::circular_buffer физическая память непрерывна, но логические данные могут переходить через конец массива:

[ вторая часть ][ свободно ][ первая часть ]

Обычный итератор должен на каждом шаге проверять, не пора ли перейти в начало массива. Для универсального std::copy_n() это уже не один непрерывный диапазон.

В моей версии Boost и libstdc++ код через итераторы не превращался в несколько крупных вызовов memmove. Вместо этого выполнялся значительно более дорогой проход по элементам.

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

Можно ли было исправить circular_buffer

Да. У него есть array_one() и array_two(), которые возвращают два физически непрерывных участка.

Копирование можно было бы написать вручную:

auto first = data.array_one();
auto second = data.array_two();

std::memcpy(destination, first.first, first.second);

if (second.second > 0) {
    std::memcpy(
        destination + first.second,
        second.first,
        second.second
    );
}

Но это означало бы больше кода, больше граничных случаев и больше тестов.

std::deque уже обеспечивал нужную производительность, поэтому я просто вернул его.

Что я из этого вынес

Микробенчмарк не соврал. Он честно показал, что circular_buffer быстрее в том сценарии, который я придумал.

Проблема была в том, что это был не мой сценарий.

Выбирать структуру данных только потому, что она «правильно» звучит для задачи, опасно. Даже асимптотически подходящий контейнер может проиграть из-за деталей реализации итераторов, алгоритмов стандартной библиотеки и конкретного размера операций.

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

Полный код тестов можно посмотреть в GitHub Gist.

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


  1. vadimr
    23.07.2026 09:53

    Так нужна-то автору была очередь или кольцевой буфер? Какой смысл обсуждать производительность, если это разные вещи по смыслу?


    1. MadEnvel Автор
      23.07.2026 09:53

      Нужна была FIFO очередь аудиоданных. Изначальна она работала на std::deque, но затем я заменил ее на кольцевой буфер boost::circular_buffer - естесственный выбор для аудио, тем более что ALSA сама использует кольцевой буфер. Семантика не изменилась, поэтому сравнение корректно; неожиданно, кольцевой буфер оказался значительно медленнее.


      1. vadimr
        23.07.2026 09:53

        Как же семантика не изменилась, если кольцевой буфер закольцован? Его назначение - вытеснять старые блоки.


        1. MadEnvel Автор
          23.07.2026 09:53

          Закольцованность - это организация памяти, а вытеснение - лишь политика при переполнении. У меня запись шла только при наличии места, поэтому буфер оставался обычно ограниченной FIFO очередью.


          1. vadimr
            23.07.2026 09:53

            Ну так и получили оверхед на поддержке ненужного вам вытеснения.


  1. atd
    23.07.2026 09:53

    просто boost::circular_buffer это плохой буфер, полно более хороших реализаций


    1. MadEnvel Автор
      23.07.2026 09:53

      Похоже на то - по крайней мере в моем сценарии boost::circular_buffer значительно проиграл std::deque. Если можете посоветовать конкретную реализацию, было бы интересно сравнить.


      1. Dead_Bit
        23.07.2026 09:53

        Кольцевой буфер (непотокобезопасный, с фиксированным размером) довольно простая структура, под вашу задачу пилится за 2-3 часа в 200-400 строк кода. Плюс самописной реализации в том, что вы можете подогнать ее под ваши требования и выжать максимум.


        1. Rubiorif
          23.07.2026 09:53

          Смысл своего велосипеда есть только если тебе нужны специфичные гарантии по latency, которых нет в стд


          1. Dead_Bit
            23.07.2026 09:53

            Странно, программируя на C++, так бояться велосипедов. Структура в пару сотен строк с примитивной логикой и уже тащить стороннюю библиотеку? Если так в себе не уверены, зачем вообще прогать на плюсах, есть питон, где все из коробки. Структуры STL носят универсальный характер и обязаны давать кучу гарантий из-за чего не могут быть максимально производительны для конкретной задачи, могут быть приемлимы.

            Автор пилит аудиоочередь, где оптимальным был бы кольцевой буфер, вместо deque, так как кеш-френдли, выравнивание по 64 байт для avx512 и т.д. Но его не устроила усложненная реализация boost, которая опять же универсальна.


            1. ReadOnlySadUser
              23.07.2026 09:53

              Я (условно я) пытаюсь написать обработку аудио, а не кольцевой буфер. Зачем мне писать кольцевой буфер?


              1. Dead_Bit
                23.07.2026 09:53

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

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


  1. Void-Cowboy
    23.07.2026 09:53

    жиза жизовая) в поисках тонкого места иногда можно набрести на вообще неожиданные места

    а еще во многих языках програмирования некторые веши оптимизированы асм-вставками под популярные процессоры и потому на реальных тестах может оказатся что "не оптимальный" алгоритм работы на самом деле оптимальнее чем "сделаный правильно"


    1. MadEnvel Автор
      23.07.2026 09:53

      Да, живой код регулярно ломает красивые теории :)


    1. Rubiorif
      23.07.2026 09:53

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


  1. Rubiorif
    23.07.2026 09:53

    Кольцевой буфер на то и кольцевой, что при чтении блоками всегда будет проблема перехода через границу массива)


    1. DrGluck07
      23.07.2026 09:53

      А кто им мешал сделать это через две быстрые операции: чтение до конца массива и чтение от начала и до нужного индекса? Там же, наверное, не побайтное чтение?