Мудрость Спольски

В 2001 году Джоэл Спольски, сочинения которого сильно повлияли на мою карьеру, написал великолепную статью Back to Basics. Суть её заключается в том, что практикующим программистам нужно разбираться в основах: какой бы абстрактной и далёкой от базовых алгоритмов ни казалась наша работа, от них неожиданно глубоко зависят наши проекты.

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

Удивительно, насколько часто паттерн маляры Шлемиля встречается1 даже в 2026 году.

Мой сервис не выдерживает нагрузки

Несколько дней назад я опубликовал пост о выпущенном мной сервисе анализа шахматных партий2. Это преимущественно сервис на Rust с базой данных SQLite, который распределяет задачи анализа между раннерами. Каждый такой раннер обрабатывает до нескольких сотен шахматных партий и возвращает основному сервису большой набор JSON‑пакетов. Там они обрабатываются, а затем записываются в SQLite.

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

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

Ещё больше меня озадачило то, что операция полностью загружала одно ядро процессора, то есть была ограничена производительностью CPU. Как простая вставка JSON в SQLite могла отнимать столько процессорного времени?

На сцену вышел маляр Шлемиль. Спойлер: в проект его привёл компилятор cc из Zig и реализованная в нём замена функций стандартной библиотеки.

Небольшая проблема Zig становится серьёзной проблемой производительности

Проект написан не на Zig, а на Rust. Крейту sqlx для SQLite требуется компиляция под целевую платформу — сама SQLite, разумеется, написана на C. Поэтому для упрощения кроссплатформенной сборки, позволяющей создавать на этой машине двоичные файлы для Mac ARM64 и Linux ARM64 / x86-64, используется zig cc: при сборке под Linux он компилирует SQLite, обеспечивая кроссплатформенную поддержку через cargo‑zigbuild.

С помощью одного скрипта сборки и развёртывания на моём Mac я могу собирать приложения для все целевых платформ без контейнеров и отдельных машин для сборки. Изящная библиотека libzigc из Zig заново реализует стандартную библиотеку, позволяя статически компоновать необходимую функциональность.

Zig использует musl (переписанную реализацию стандартной библиотеки), но постепенно разработчики переносят её компоненты на чистый Zig (в составе libzigc), по одной устраняя зависимости от C.

Именно здесь и появилась проблема с производительностью.

В переписанной strspn, заменяющей реализацию musl вариантом на чистом Zig, первым делом вычисляется полная длина строки. То же самое можно увидеть в новых реализациях strcspn, strpbrk и strstr.

Это представляет проблему для токенизаторов. Токенизатор получает огромную строку; типичный шаг при её обработке — пропустить символы пробелов, перейти к новой позиции в строке, а затем снова повторить эту последовательность действий. Из‑за появившегося в этих новых реализациях паттерна «маляра Шлемиля» при продвижении по тексту токенизатор каждый раз заново вычисляет длину оставшейся части строки.

Именно это и стало причиной проблем с производительностью. К тому же это объясняет, почему они проявились в Linux, но не возникали на моём Mac (где Zig при компиляции SQLite не использовался).

Токенизация файла JSON размером 1,2 МБ, передаваемого SQLite, при использовании этого паттерна происходит примерно в 12 тысяч раз медленнее. По мере увеличения размера файла замедление растёт квадратично.

Повторение классических проблем

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

Изначально я устранил эту проблему в своём коде предварительным вырезанием пробелов из JSON, что существенно снизило затраты на токенизацию в sqlite3, но поскольку я не знал, где ещё могут возникнуть проблемы, то перенёс сборки для Linux в Linux‑контейнеры.

Примечания

  1. Много лет назад, когда практически обязательным был бенчмарк Sunspider, а браузеры хвалились своими показателями в нём, тот же паттерн можно было наблюдать при обработке конкатенации строк в Firefox, из‑за чего его показатели в этом бенчмарке оказывались низкими. Выполнение бенчмарка Sunspider каким‑то образом приводило к огромному количеству конкатенаций строк (даже если тесты не были связаны со строками), и простое профилирование3 прогона бенчмарка демонстрировало, что существенную долю времени исполнения занимало постоянное распределение постепенно увеличивающейся памяти под строки, копирование в новое место и повтор этого паттерна. Это как раз и был паттерн маляра Шлемиля.

    Я устранил эту проблему: выделял для строк память с запасом, размер которого зависел от текущей длины строки. Благодаря этому при последующих конкатенациях можно было повторно использовать тот же буфер, пока не возникала необходимость увеличить его размер. При каждом таком увеличении снова выделялся дополнительный запас. Я написал об этом в блоге, Брендан Айк обратил внимание на эту идею, и вуаля — Firefox вышел в лидеры бенчмарка4. Такая мелочь превратила его из медлительного аутсайдера в лидера тестирования.

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

  3. Профилировщики — удивительные программы. Это самые мощные из имеющихся у нас инструментов, поэтому мне кажется странным, что их настолько редко применяют.

  4. И стоит отметить, что конкатенация строк была чрезвычайно распространена практически во всех веб‑проектах, поэтому выигрыш был весьма ощутим и в реальных условиях. Существовала рекомендация использовать для этого специальные string builder, но очень часто разработчики просто соединяли строки в цикле.

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


  1. vanxant
    10.11.2026 11:05

    Не понял, зачем переписывать libc на zig, чтобы что? Ну фор фан понятно, но зачем тащить этот недоделанный овнокод в прод?


    1. zelenin
      10.11.2026 11:05

      чтобы кросс-компилить статические приложения


      1. vanxant
        10.11.2026 11:05

        ... и чем обычная libc на C этому мешает?

        это раз. Ну и два, а вы вообще пробовали что-нибудь запустить на машине без libc.so? Я пробовал, запустить можно аж целое ничего:)


        1. zelenin
          10.11.2026 11:05

          видимо ключевое слово кросс


    1. Gaikotsu
      10.11.2026 11:05

      Видимо потому что оригинальный libc имеет фатальный недостаток :)


    1. Siemargl
      10.11.2026 11:05

      А фокус тут в том, что не овнокод)

      Просто идиому работы со строками/слайсами/массивами тупо перенесли с языка на язык. А она разная.

      Типичная кстати ошибка будет для ии-трансляций.

      А сам перенос для генерации бинарников без зависимостей, как это сделано в go, например. Кстати, в Циг еще замахивались на аналогичную тему в виндовс, перейти на прямые вызовы ядра.


  1. Chupaka
    10.11.2026 11:05

    Почувствовал себя немного Шемелем, листая вручную к сноскам и обратно, без прямых ссылок...


  1. oracle_schwerpunkte
    10.11.2026 11:05

    Ну зачем SQLite ? Это же просто fopen() на стероидах. Гениальный инструмент, но не для сервисов. Сейчас вы решили одну проблему, но вас ждут еще много удивительных приключений https://habr.com/ru/articles/1073978/.


    1. Siemargl
      10.11.2026 11:05

      Скорее непонятно, зачем джейсоны хранить в эскулайте.


      1. randomsimplenumber
        10.11.2026 11:05

        Удобно же. Nosqlite не придумали вроде.


  1. sorosgpt
    10.11.2026 11:05

    только мюсли штаны отстирали со своим нетакусиковым crypt(), а тут уже и свидетели ещёболеенетакусиковеого zig-а подоспели.


  1. aegoroff
    10.11.2026 11:05

    В переписанной strspn, заменяющей реализацию musl вариантом на чистом Zig, первым делом вычисляется полная длина строки. 

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


  1. slonopotamus
    10.11.2026 11:05

    Напомнило эту статью.


  1. DmitryR1974
    10.11.2026 11:05

    IMHO переписывать libc - это как Кин-Дза-Дзу переснимать.


  1. lazarus_net
    10.11.2026 11:05

    А не вариант вместо json что-нибудь более нормальное использовать? На одних скобках и кавычках можно куче места съэкономить.

    Попробовали бы результаты партии или в CSV завернуть, или в какой-нибудь формат, который или SQLite может нарэпрчмуб импортировать или который пансион удобнее. Например, в старые добрые времена когда надо было большие текстовые файлы парсить каждая строка начиналась с ее длинны, а потом уже данные, что позволяло существенно ускорить разбор. …..