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-разработчик, главный специалист ФГУП «НТЦ „Атлас“.
Sklott
Мне кажется сейчас заниматься написанием shell скриптов уже как-то не комильфо. Рассматривались ли какие-либо альтернативы? Тот-же ninja и системы его использующие? Типа cmake/ninja, gn/ninja и т.п.
stargrave2 Автор
А в чём проблем shell-а? В чём не комильфовость? Всё равно на нём же пишутся (в Unix-like системах, конечно же) все вызовы того как и что надо собирать. Но redo не обязывает писать на shell — многие реализации позволяют использовать любой язык или просто исполняемые файлы, лишь бы вызывали redo-* команды.
Sklott
Ну конечно сильно зависито от того что вы делаете. Если у вас такая куча разнообразных целей, что вам на каждую нужно писать отдельный скрипт, то может так оно и лучше. Но когда речь идет об обычных проектах где кроме компиляции и возможно нескольких кастомных шагах особо инчего нет, то смысла каждый раз самому все это прописывать, вместо того чтобы декларативно описать структуру проекта я смысла не вижу…
stargrave2 Автор
А какая разница как описать структуру проекта? Декларативно или в виде набора команд с флагами разными? Грубо говоря, количество информации которое вам, как разработчику, нужно ввести в компьютер, чтобы объяснить ему правила для сборки — и в том и в другом случае одинаковое. Но для декларативного описания нужно изучать этот yet another декларативный формат/язык. Если все ваши .c файлы (например) собираются во всём проекте с одними и теми же флагами, то можно обойтись одним мизерным default.*.do файлом хоть в корне проекта. Описания опций/флагов/параметров/путей до зависимостей — что в CMake файлах, что в shell скрипте занимают одинаково места. С CMake у меня несколько месяцев назад был небольшой опыт в большом проекте — с ходу вот прям не вспомню где бы он мне ощутимо больше/лучше помог, чем redo-like подход.
Sklott
Ну на мой взгляд это примерно как сравнивать C и prolog. Понятно что на C можно написать все что хочешь. Но задачи для которых «заточен» prolog удобней писать все-же на нем.
Вот тут я не соглашусь. Ну точней, если у вас уже есть какой-нибудь обширный «билд-фремворк» для redo в который вы прсто вставляете свою «структуру проекта». То наверно можно сказать что будет ± одинаково. Но если писать все с нуля, то на спецализированных системах/языках все будет гораздо компактней и скорее всего понятней.
TargetSan
К слову, CMake умеет такую штуку как INTERFACE_* свойства таргетов, которые подтягиваются из зависимостей. Умеет ли redo вытаскивание значений свойств из зависимостей, без замусоривания глобального контекста?
AMDmi3
Ну конечно же нет, в том то и дело. Количество информации различается на порядки. Команды и флаги будут разными на разных системах, компиляторах, дистрибутивах и версиях одного дистрибутива. Например, чтобы подключить потоки может потребоваться
-pthread
,-lpthread
,-lthr
только на BSD.-ldl
на FreeBSD не существует, а на Linux нужен. C++17 может включаться как-std=c++17
,-std=c++1z
,/std:c++17
, где-то вообще быть по умолчанию. Банальная сборка статической библиотеки это два ни разу не очевидных вызова ar/runlib только на *nix, в windows вообще по другому. У install есть несовместимости в аргументах между GNU и BSD версиями. Как и у sed, awk, grep и что вы там ещё будете звать из ваших скриптов.В CMake каждая из этих операций делается ровно одной строкой, которая описывает сама себя и работает везде, даже на solaris и haiku, даже если вы о них первый раз слышите.
Sazonov
Немного оффтопик. Можете пояснить, почему все так любят ninja? Мне его много кто советовал.
Мой стек: windows/cmake/ninja/clang (CLion) не может собрать gRPC, валится с непонятными ошибками.
Sklott
Ну я точно не скажу, вопрос не изучал, просто ее сейчас многие используют как backend для разных билд-систем. Вроде как там пофикшены многие проблемы make описанные в статье.
telhin
Clang откуда стандартную библиотеку берет? Microsoft VS? MinGW?
Sazonov
Microsoft. Я не совсем понимаю, как виндовый clang можно скрестить с mingw.
Sklott
mingw вроде в последнее время и так на clang-е живет, зачем их скрещивать. Другое дело что он не сможет MS-ную библиотеку съесть. Если нужно использовать MS-ные либы/инклуды используйте clang-cl.
Sazonov
Ну так я и использую clang-cl установленный через msvs. Вместе с CLion/cmake/ninja