Приветствую! Хочу рассказать о главных, не всегда очевидных, недостатках системы сборки Make, делающих её часто не пригодной для использования, а также рассказать о прекрасной альтернативе и решении проблемы — гениальнейшей по своей простоте, системе redo. Задумка известнейшего DJB, криптография которого где только не применяется. Лично меня, redo настолько впечатлил life-changing простотой, гибкостью и куда лучшим выполнением задач сборки, что я практически во всех своих проектах им полностью заменил Make (там где не заменил — значит ещё руки не дошли), у которого я не смог найти ни одного преимущества или причины оставлять в живых.


Yet another Make?


Make много кого не устраивает, иначе не было бы десятков других систем сборки и десятков диалектов одного только Make. А redo это ещё одна очередная альтернатива? С одной стороны конечно же да — только крайне простая, но способная решать абсолютно все те же задачи что и Make. С другой стороны — а разве у нас есть какой-то общий для всех и единый Make?

Большинство «альтернативных» систем сборки рождалось потому что не хватало родных возможностей Make, не хватало гибкости. Многие системы занимаются только генерированием Makefile-ов, не производя сборку самостоятельно. Многие заточены под экосистему определённых языков программирования.

Ниже я постараюсь показать что redo является куда более заслуживающей внимания системой, не просто yet another решением.

Make всё равно всегда есть


Лично я всё равно всегда косо смотрел на всю эту альтернативу, ибо она или сложнее, или ecosystem/language-specific, или является дополнительной зависимостью которую нужно ставить и изучать как ею пользоваться. А Make это такая вещь, с которой плюс-минус все знакомы и умеют пользоваться на базовом уровне. Поэтому всегда и везде старался использовать POSIX Make, предполагая что это то, что в любом случае у каждого есть в (POSIX) системе из коробки, как например компилятор C. И задачи в Make выполнять только для которых он предназначен: распараллеливаемое выполнение целей (команд) с учётом зависимостей между ними.

В чём проблема просто писать на Make и быть уверенным что на любых системах это заработает? Ведь можно же (нужно!) писать на POSIX shell и не заставлять пользователей ставить какие-нибудь монструозные громадные GNU Bash. Проблема только в том, что работать будет только POSIX Make диалект, достаточно скудный даже для многих небольших простых проектов. Make в современных BSD системах более сложен и feature-full. Ну а с GNU Make мало с кем идёт в сравнение, хотя его возможностей по полной почти никто и не использует и не знает как ими пользоваться. Но GNU Make не поддерживает диалект современных BSD систем. А BSD системы не имеют GNU Make в своём составе (и их можно понять!).

Использовать BSD/GNU диалект — значит потенциально заставлять пользователя всё равно ставить дополнительный софт, не идущий из коробки. В этом случае, возможное преимущество Make — его наличие в системе, сводится на нет.

Использовать и писать на POSIX Make — можно, но сложно. Лично у меня с ходу вспоминается два очень раздражающих случая:

  • Какие-то Make реализации при выполнении $(MAKE) -C «переходят» в директорию выполнения нового Make, а какие-то нет. Можно ли написать Makefile так, чтобы оно одинаково работало везде? Безусловно:

    tgt:
        (cd subdir ; $(MAKE) -C ...)
    

    Удобно? Безусловно нет. И неприятно тем, что о подобных мелочах надо постоянно помнить.
  • В POSIX Make нет оператора выполняющего shell-вызов и его результат сохраняющий в переменную. В GNU Make до 4.x версии можно сделать:

    VAR = $(shell cat VERSION)
    

    а начиная с 4.x, а также в BSD диалектах можно выполнить:

    VAR != cat VERSION
    

    Не совсем аналогичным действием можно сделать:

    VAR = `cat VERSION`
    

    но оно буквально подставляет это выражение в ваши shell-команды описанные в целях. Этот подход применяют в suckless проектах, но это, конечно же, костыль.

Лично я в подобных местах часто писал Makefile-ы сразу под три диалекта (GNU, BSD и POSIX):

$ cat BSDmakefile
GOPATH != pwd
VERSION != cat VERSION
include common.mk

$ cat GNUmakefile
GOPATH = $(shell pwd)
VERSION = $(shell cat VERSION)
include common.mk

Удобно? Отнюдь! Хотя задачи крайне просты и распространены. Вот и выходит, что или:

  • Писать параллельно для нескольких диалектов Make. Размен времени разработчика на удобство пользователя.
  • Помня о множестве нюансов и мелочей, возможно с неэффективными подстановками (`cmd ...`), пытаться писать на POSIX Make. Лично для меня, с многолетним опытом с GNU/BSD Make, этот вариант самый трудозатратный (проще писать на нескольких диалектах).
  • Писать на одном из диалектов Make, заставляя пользователя ставить сторонний софт.

Технические проблемы Make


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

  • mtime не даёт никаких гарантий, а Make оценивает свежесть целей исключительно по mtime, сравнивания его значение у выполненных целей. Если гранулярность временных штампов вашей файловой системы такая, что быстрый компьютер способен обновлять файлы быстрее, то Make будет бессилен понять изменение файлов. mtime не обязан монотонно возрастать! Обновлённый mtime также и не обязан быть ни больше, ни меньше, ни равным текущему времени! Как с mtime работают системы контроля версий — по разному, но никаких гарантий об его обновлении не дают. FUSE файловые системы вообще могут отдавать mtime хоть всегда нулевого значения. mmap обновит ваш mtime… когда-нибудь, или пока не вызван msync (это штатное POSIX поведение). А если у вас NFS? Всё это приводит к тому, что работать Make ожидаемо может только на системах: медленных (или хорошей гранулярностью времени на ФС), с всегда идущими вперёд часами, без FUSE/NFS/mmap/VCS.
  • Цели выполняются не атомарно. А должны? Make считает что не его забота. Но преобладающее большинство людей всё равно же захочет и будет писать цели вида:

    tgt-zstd:
        zstd -d < tgt-zstd.zst > tgt
    
    tgt-fetch:
        fetch -o tgt-fetch SOME://URL
    

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

    Решить эту проблему можно:

    tgt-zstd:
        zstd -d < tgt-zstd.zst > tgt-zstd.tmp
        fsync tgt-zstd.tmp
        mv tgt-zstd.tmp tgt-zstd
    

    Но кому захочется в такие tmp/fsync/mv вызовы оборачивать каждое описание цели? Более того, в примере выше могут быть проблемы при параллельном запуске Make-ов, параллельно собирающих и записывающих в tgt.tmp.
  • Цели не зависят от своего описания. Если вы поменяли описание цели (её команды) Makefile, то будет ли Make производить пересборку этой изменённой цели? Нет. А если вы обновили какие-то переменные типа $(CFLAGS)? Тоже нет.

    Но никто же не мешает вам прописать зависимость цели от самого Makefile! Изменение описания целей будет приводить к пересборке. Но если у вас один Makefile на множество целей, то изменение только одной из них, приведёт к пересборке и всех остальных. Хотя, безусловно, уж лучше пересобрать что-то лишнее, чем недособрать что требуется.

    А что если описание целей вынести в свои отдельные Makefile и их просто:

    $ cat Makefile
    include tgt1.mk
    include tgt2.mk
    ...
    

    Это решит проблему пересборки лишнего. Но удобно ли? Отнюдь!
  • Повезёт, если будут работать рекурсивные цели. Отличная небольшая статья Recursive Make Considered Harmful описывает простейшие случаи, когда рекурсивные Makefile-ы, где нижестоящие Makefile-ы зависят от каких-то целей описанных в сторонних, и, в зависимости от того как Make проходит граф зависимостей, могут легко возникать ситуации где всё это не будет работать корректно и пересобирать что надо. Один единственный большой Makefile на весь проект — решение. Удобно? Отнюдь, иначе бы не писали рекурсивные Makefile.

    А если включить распараллеленную сборку? Тогда обход графа зависимостей снова будет нарушен, нередко и непредсказуемым образом. Например при сборке всяких FreeBSD портов, именно поэтому, по умолчанию, отключают распараллеливание, так как с ним часто может всё ломаться и вести себя непредсказуемым образом.
  • Динамически сгенерированные цели сделать нельзя. А ведь так хочется чтобы автоматически, если я написал #include «tgt.h», .c файл зависел от tgt.h, ведь эту информацию можно узнать из .c выполнив какой-нибудь sed вызов.

    tgt.o: tgt.c `sed s/.../ tgt.c`
    

    сделать не выйдет. Иногда можно попытаться сгенерировать новый .mk Makefile с этими зависимостями и сделать его include. Будет ли это работать? Зависит от конкретной реализации Make, но скорее всего не так как ожидается: .mk честно пересоберётся, но проинтерпретирована будет его прошлая версия, прочитанная на этапе чтения всех Makefile-ов с include-ами.
  • Цели Makefile-ов не совсем обычный shell, а каждая строка запускается в отдельном интерпретаторе, из-за чего часто приходится или писать крайне некрасивые многострочные, но объединённые через \\$, скрипты, или выносить их в отдельные .sh файлы, вызываемые из Make. И сам Make это новый язык/формат, так и его shell это тоже не совсем привычный и удобный shell, в котором не забывать и про экранирование корректное придётся. Удобно?

Давайте честно признаемся: как часто и сколько приходилось делать make clean или пересобирать без распараллеливания, потому что что-то недособралось или не пересобралось вопреки ожиданиям? В общем случае, безусловно, это связано не с идеально корректно, правильно и полно написанными Makefile-ами, что говорит о сложности их грамотного и работоспособного написания. Инструмент должен помогать.

Требования redo


Чтобы перейти к описанию redo, я для начала расскажу что он из себя представляет как реализация и что придётся выучить его «пользователю» (разработчику описывающему цели и зависимости между ними).

  • redo, в общем случае, вообще не привязан к какому-либо языку для описания целей. redo не заставляет изучать новый диалект или формат файлов. Знаний POSIX shell полностью достаточно. Но можно писать все цели на Python или вообще в виде исполняемых файлов. Минимальный порог входа: ни нового формата, ни языка, ни кучи команд.
  • redo реализаций много на разных языках: POSIX shell, GNU bash, Python, Haskell, Go, C++, Inferno Shell. Средний разработчик способен написать его реализацию за день.
  • Реализация с полноценными учётом состояния и распараллеливанием сборки на чистом C занимает менее тысячи строк кода, включая полную реализацию SHA256, а результирующий исполняемый файл у меня занимает 27KB. На чистом POSIX shell можно написать в 100 строк. Это также означает и то, что вы можете просто встроить POSIX shell реализацию redo в свои tarball-ы с софтом и пользователю ничего не придётся ставить дополнительно.
  • Он не имеет ни одной описанной выше проблемы Make-а, превосходно выполняя все его задачи (с распараллеливанием).

Обычные redo цели


Правила сборки цели являются обычным POSIX shell скриптом в файле имя-цели.do. Напомню в последний раз, что это может быть и любой другой язык (если добавить shebang) или просто исполняемый бинарный файл, но по умолчанию это POSIX shell. Скрипт запускается с set -e и тремя аргументами:

  • $1 — имя цели
    $2 — базовое имя цели (об этом ниже)
    $3 — имя файла результата

    Вот треть redo я уже и описал. Результатом выполнения цели является или весь выловленный stdout или созданный $3 файл. Почему так? Где-то удобнее не возиться с промежуточными файлами, а где-то не все программы умеют свой результат писать сразу в stdout. Например приводимые для примера цели в начале статьи в redo:

    $ cat tgt-zstd.do
    zstd -d < $1.zst
    
    $ cat tgt-fetch.do
    fetch -o $3 SOME://URL
    

    Предполагаем, что fetch не умеет писать в stdout. stdout сохраняется во временном файле, как и $3. После завершения работы скрипта, выполняется его fsync и переименование в название цели. Тем самым гарантируя атомарность её выполнения! И только при успешном выполнении, только при отработке fsync и записи результат в директории — только тогда будет успешно выполнена цель.

    Некоторые цели, типа (make) clean, как правило, не генерируют никаких результатов. Большинство redo реализаций не создают пустой файл, что удобно. По умолчанию, большинство реализаций выполняют all цель.

    default цели


    Очень часто многие цели собираются одними и теми же командами. В POSIX Make можно делать такие правила сборки всех .c:

    .c:
        $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $<
    

    в redo для этого используются default.do файлы, а точнее default.ВОЗМОЖНО-КАКИЕ-ТО-РАСШИРЕНИЯ.do. Аналогом Make выше будет:

    $ cat default.c.do
    $CC $CLFAGS $LDFLAGS -o $3 $1
    

    Часто хочется узнать имя цели без расширения — для этого используется $2 аргумент, совпадающий с $1 в «обычных» redo целях. В default-ных он будет:

    a.b.c.do       -> $2=a.b.c
    default.do     -> $2=a.b.c
    default.c.do   -> $2=a.b
    default.b.c.do -> $2=a
    

    Цели можно указывать спокойно и в любых директориях, что уровнем ниже, что выше. Вместо cd dir; redo tgt можно писать redo dir/tgt. Цель всегда будет выполняться в той же директории что и .do файл. Поэтому использование относительных путей будет надёжно работать, раз всегда известно где будет рабочая директория во время выполнения цели.

    Если файла имя-цели.do не найдено, то ищется default.do файл. А с учётом возможных расширений, поиск .do файла для цели ../a/b/xtarget.y будет такой:

    ./../a/b/xtarget.y.do
    ./../a/b/default.y.do
    ./../a/b/default.do
    ./../a/default.y.do
    ./../a/default.do
    ./../default.y.do
    ./../default.do
    

    Вот уже 2/3 всей redo системы описано.

    Зависимости


    Зависимости для цели задаются путём вызова в ней redo-ifchange команды:

    $ cat hello-world.do
    redo-ifchange hello-world.o ../config
    . ../config
    $CC $CFLAGS -o $3 hello-world.o
    
    $ cat hello-world.o.do
    redo-ifchange hw.c hw.h ../config
    . ../config
    $CC $CFLAGS -c -o $3 hw.c
    
    $ cat ../config
    CC=cc
    CFLAGS=-g
    
    $ cat ../all.do
    # этот файл в корне проекта для красоты, чтобы, набрав <em>redo</em>, он собрал
    # hw/hello-world программу по умолчанию
    redo-ifchange hw/hello-world
    
    # Очистка проекта для красоты
    $ cat ../clean.do
    redo hw/clean
    
    $ cat clean.do
    rm -f *.o hello-world
    

    Именно тут коренное отличие redo и кроется: у него есть state. Для каждой цели в нём сохраняются зависимости и информация чтобы понять их свежесть. redo-ifchange запишет, что при выполнении такой-то цели, ей требовались такие-то зависимости, а также проверит не изменились ли они, все ли они свежи, а если нет, то запустить их сборку. Зависимость от .do файла автоматическая. В примере выше, изменение config файла приведёт к пересборке всего что касается hello-world программы.

    Где хранится state? Зависит от реализации. Кто-то хранит в виде TSV-like файла имя-цели.do.state, кто-то аналогично, но в .redo директории, кто-то в SQLite3 СУБД .redo директории.

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

    Что сохраняется в state? Автор redo предлагает вообще хранить криптографический хэш от зависимости: если хоть бит изменится, независимо от FUSE/mmap/NFS/VCS, то гарантированно это изменение будет обнаружено. Некоторые реализации хранят набор из ctime, inode number, размера и кучи всего другого с хэшом — тогда не придётся его пересчитывать, если мы всё равно увидели что размер обновился.

    Наличие state на файловой системе позволяет иметь и lock-и и поэтому проблем аналогичных рекурсивному Make — нет. Есть общий для всех (в пределах проекта) state с lock-ами и всеми известными зависимостями. Это также безопасно позволяет запускать сколько угодно параллельных процессов сборки.

    Динамика


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

    redo-ifchange $2.c
    gcc -o $3 -c $2.c -MMD -MF $2.deps
    read deps < $2.deps
    redo-ifchange ${deps#*:}
    

    В него можно подставлять любые пути, хоть автоматически сгенерированные на основе include-ов:

    $ cat default.o.do
    deps=`sed -n 's/^#include "\(.*\)"$/\1/p' < $2.c`
    redo-ifchange ../config $deps
    [...]
    

    Зависеть от всех собранных *.c?

    for f in *.c ; do echo ${f%.c}.o ; done | xargs redo-ifchange
    

    Без проблем можно и сгенерировать .do (....do.do цель) файл на лету и иметь от него зависимость. А чтобы не писать в куче .do файлах одни и те же $CC $CFLAGS..., то можно это сохранить в отдельном файле «компиляции кода»:

    $ cat tgt.do
    redo-ifchange $1.c cc
    ./cc $3 $1.c
    
    $ cat cc.do
    redo-ifchange ../config
    . ../config
    cat > $3 <<EOF
    #!/bin/sh -e
    $CC $CFLAGS $LDFLAGS -o \$1 \$@ $LDLIBS
    EOF
    chmod +x $3
    

    Хочется сгенерировать compile_flags.txt для интеграции с Clang LSP демоном?

    $ cat compile_flags.txt.do
    redo-ifchange ../config
    . ../config
    echo "$PCSC_CFLAGS $TASN1_CFLAGS $CRYPTO_CFLAGS $WHATEVER_FLAGS $CFLAGS" |
        tr " " "\n" | sed "/^$/d" | sort | uniq
    

    А как получить все эти $PCSC_CFLAGS, $TASN1_CFLAGS? Конечно же, используя pkg-config, без громоздких и медленных autotools!

    $ cat config.do
    cat <<EOF
    [...]
    PKG_CONFIG="${PKG_CONFIG:-pkgconf}"
    
    PCSC_CFLAGS="${PCSC_CFLAGS:-`$PKG_CONFIG --cflags libpcsclite`}"
    PCSC_LDFLAGS="${PCSC_LDFLAGS:-`$PKG_CONFIG --libs-only-L libpcsclite`}"
    PCSC_LDLIBS="${PCSC_LDLIBS:-`$PKG_CONFIG --libs-only-l libpcsclite`}"
    
    TASN1_CFLAGS="${TASN1_CFLAGS:-`$PKG_CONFIG --cflags libtasn1`}"
    TASN1_LDFLAGS="${TASN1_LDFLAGS:-`$PKG_CONFIG --libs-only-L libtasn1`}"
    TASN1_LDLIBS="${TASN1_LDLIBS:-`$PKG_CONFIG --libs-only-l libtasn1`}"
    [...]
    EOF
    

    Если кому-то всё же любо видеть единственный .do файл, аналогичный такому Makefile:

    foo: bar baz
        hello world
    
    .c:
        $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $<
    

    то это легко сделать:

    $ cat default.do
    case $1 in
    foo)
        redo-ifchange bar baz
        hello world
        ;;
    *.c)
        $CC $CFLAGS $LDFLAGS -o $3 $1
        ;;
    esac
    

    но при этом не забывать, что изменение default.do приведёт к пересборке всех целей им созданных. А если надо ровно для одного .o файла сделать особые команды или дополнительные зависимости? Ну так и написать для него special.o.do, а остальные будут fallback делать до default.o.do и default.do правил.

    Заключение


    Про redo я слышал наверное ещё лет десять назад, но не придал ему значения и даже повёл носом от того, что «мне что, придётся каждую цель в отдельном файле описывать!?» (про default я не знал). Но решил попробовать, опять же, без уверенности что переезд будет безболезненным и, тем более, стоящим. А я большой любитель минималистичных и suckless подходов (уж извините, но CMake, собирающийся дольше чем многие GCC, с документацией более ёмкой чем pure-C реализация redo — это перебор).

    • Гора ручной работы убрана из-за возможности автоматизации и динамического создания зависимостей.
    • Полностью решённые проблема с совместимостью на разношёрстных системах (*BSD vs GNU) — POSIX shell работает везде одинаково, совершенно разные (Python, C, shell) реализации redo вели себя одинаково.
    • Минус целый язык/формат Makefile-ов.
    • Гарантированно работающее распараллеливание сборок.
    • Небывалая и невиданная точность задания зависимостей (потому что легко и просто) и, соответственно, система сборки честно пересобирает только то что связано и изменилось.
    • Каждая цель в своём файле — оказалось очень удобным, так как можно узнать все доступные цели для выполнения, сделав l **.do.

    О чём я пожалел и есть ли всё же помехи/недостатки?

    • Жалею только об огромном количестве потраченных часов на борьбу с Make за все эти годы, абсолютно ничем не компенсирующиеся.
    • Мне потребовался не один месяц чтобы отучиться от рефлекса делать redo clean, так как уже привычка после Make, что что-нибудь обязательно да не (пере)соберётся.

    Рекомендую документацию apenwarr/redo реализации, с огромным количеством примеров и пояснений.

    Сергей Матвеев, шифропанк, Python/Go/C-разработчик, главный специалист ФГУП «НТЦ „Атлас“.