Как синхронизировать параллельные шелл-процессы, используя named pipes (FIFO-файлы) в качестве условных переменных. Как выполнить параллельно зависимые задач в топологическом порядке с минимумом средств: POSIX shell, mkfifo, POSIX kernel. Как параллельный запуск ускоряет загрузку встраиваемых систем и *BSD (rc-этап FreeBSD с 27 до 7 секунд) или старт приложений в пользовательских контейнерах Docker, LXC и jail. Как это повышает аптайм в отказоустойчивых кластерах Jet9.
1 Зачем нужены задачи с зависимостями и что дает их параллельный запуск
2 Параллельное vs последовательное
3 Выполнение задач с зависимостями
3.1 rcorder, топологическая сортировка и последовательное выполнение
3.2 upstart, события и асинхронное выполнение
3.3 systemd, зависимости и асинхронное выполнение
3.4 runit, параллельное выполнение и синхронизация при выполнении одной задачи
4 Супервизоры или планировщики
4.1 multitask-flow, параллельное выполнение зависимых задач
5 Средства для синхронизации параллельных шелл-процессов
5.1 Лок-файл в качестве мьютекса
5.2 FIFO-файл в качестве условной переменной
5.3.1 Конструкции для условной переменной в shell
5.3.2 Требования к рабочему окружению
6 Устройство multitask-flow
6.1 Параллельное выполнение и синхронизация на FIFO-файлах
6.2 Как можно и как нельзя применять multitask-flow
7 Ссылки по теме
Часто встречающиеся задачи с явно выраженными зависимостями — загрузка или изменение режима работы сервера и старт/остановка пользовательских контейнеров. Большая часть нашего оборудования, работающего под Linux или FreeBSD, загружается не в стандартную инсталляцию дистрибутива, а в наше собственное операционное окружение. Это компактный образ, размещаемый на RAM-диске, в котором имеются системные программы, требуемые для основного профиля работы, и различные вспомогательные утилиты. Старт сервиса, инициализация устройства, применение настроек — это отдельные задачи, которые присутствуют или отсутствуют в системе в зависимости от назначения сервера, и эти задачи должны быть выполнены в правильной последовательности. Например, если конфигурация сети начнет выполняться до того, как загружен драйвер сетевой карты, сеть работать не будет. В пользовательских контейнерах работают различные приложения, корректность работы одних может зависить от работы других, из-за чего бывает важен порядок, в котором они запускаются и останавливаются. Например, если сервер приложений будет запущен раньше сервера баз данных, то он может сразу остановиться из-за невозможности подключиться к базе данных.
Для старта системы сейчас существуют два общепринятых подхода. Первый — указывать порядок выполнения задач в явном виде. Для этого можно либо в перечислить задачи в порядке выполнения в шелл-скрипте, либо нумеровать задачи и использовать номер в названии файла задачи, а затем выполнять их, отсортировав по названию файла:
Второй способ старта — указать зависимости между стартовыми скриптами, то есть перечислить какие условия нужны для выполнения задачи и какие условия создает задача после своего выполнения:
Указывание зависимостей хоть и реализуется сложнее нумерации, но намного удобнее и теперь применяется практически везде.
Главный недостаток, который имеет явная нумерация задач — разработчику необходимо сначала выявить зависимости от других задач, затем найти номера этих зависимостей, и подобрать подходящий номер для новой задачи, в процессе чего может обнаружиться, что нумерация была сделана неудачно и ее требуется менять для части скриптов. При выполнении по зависимостями главным преимуществом является простота добавления задач в уже сложившийся процесс — для новой задачи достаточно только перечислить ее зависимости от других задач. Другим удобством становится возможность параллельного выполнений задач, ведь поочередно должны выполняться только зависимые задачи, а задачи, не зависящие друг от друга, можно выполнять параллельно.
Параллельное выполнение при выполнении задач имеет два ключевых преимущества:
В отношении последнего, вероятно, некоторым знакома ситуация, когда при последовательном старте из-за сетевых проблем
Нужно учитывать, что отсутствие явно указанной зависимости не означает отсутствие зависимости вообще — зависимость может быть опосредованная, например, несколько задач могут одновременно обратиться к ресурсу, который этого не допускает (одновременная запись в один файл, изменение конфигурации программы или инициализация устройства). При последовательном выполнении задач такая ситуация невозможна и поэтому может не приниматься во внимание, но при параллельном выполнении может потребоваться выявлять такие ресурсы и предотвращать их одновременное использование. В практике такое случается редко, так как большинство задач в конечном счете запускают системные утилиты, которые самостоятельно занимаются блокировкой доступа, либо это делает ядро, если обращение выполняется к его ресурсам.
Самый простой способ выполнения по зависимостям — rcorder(8) — появился в NetBSD 1.5 в 2000 г. В rc-скриптах с помощью аннотаций PROVIDE, REQUIRE и BEFORE описываются зависимости между скриптами. Утилита
Выполнение скриптов в таком порядке обеспечивает соблюдение условия, что зависимые скрипты будут запущены только после того, как будут завершены скрипты-зависимости. Скрипты выполняются поочередно, и возможность параллельного запуска не реализована. Поэтому не требуется синхронизация работы параллельных процессов и реализация является простой и надежной.
Похожий механизм позже появился в Debian Lenny, где в SysV-init скрипты были добавлены аннотации Required-Start, Required-Stop, Provides, с помощью которых описываются зависимости для разных действий (start и stop). Далее по этим зависимостям определяется порядок выполнения, в названия скриптов добавляются номера и скрипты помещаются в стартовый каталог:
Upstart появлися в 2006 г. в Ubuntu и позже его стали использовать в множестве других дистрибутивах. Вместо указания отношений между задачами здесь с помощью директивы start on описываются условие — событие или логическое выражение наличия событий, при выполнении которого должна быть выполнена задача. Например, для сервисов, требующих сеть, можно указать условием старта событии NETWORK_AVAILABLE и тогда они будут запущены после того, как кто-нибудь передаст в систему событие NETWORK_AVAILABLE. В условии можно указать не только зависимость на какую-то другую задачу, но и более сложные варианты, например, runlevel и состояния других задач.
Данный способ выполнения приводит к тому, что задачи выполняются асинхронно в тот момент, когда появилось событие, и когда условие, включающее это событие, оказалось истинным. Если оказалось несколько задач, ожидающих одно событие, то они будут выполнены одновременно. Диспетчеризацией событий и выполнением задач заниматся сервис
Systemd появился позже, развернулся на еще более широкое число функций, захватил место
В отличие от
Задачи выполняются параллельно, как и в
Runit позиционируется как легковесная замена
Как таковых зависимостей в
Из приведенных выше средств только
Нацеленность на захват всех аспектов деятельности операционной системы и монолитность приводят к тому, что
Как
Эволюционировавшие потомки
В некоторых случаях автоматический рестарт упавшего сервиса будет опасен, так как если он как-то связан с сохранением данных, то из-за каких-то несохранившихся данных или нарушенной структуры, в сохраненных данных возникнет ошибка. И если после аварийного рестарта работа будет продолжена без принятия мер по проверке целостности данных и устранению возможных ошибок, то ошибки будут умножаться. Поэтому для простого и надежного функционирования систем более корректным будет подход, где контроля работы сервисов и реакция на их нарушения в работе будут настроены на особенности каждого конкретного сервиса, а не делаться автоматически под одну гребенку для всех.
Использование больших и активно растущих комплексов типа
Для старта и управления режимами работы различного нашего оборудования требовалось минималистичное средство для выполнения задач с зависимостями, которое не старалось бы выполнять дополнительные функии по контролю состояний и рестарту, а просто хорошо делало бы свою основную работу (выполнение по зависимостям) и которое легко бы интегрировалось в разные участки. Кроме удобной интеграции требовались кросс-платформенность и легкость сопровождения. С некоторыми усовершенствованиями оно потом нашло и другие применения — старт пользовательских контейнеров, управление режимами работы кластеров, старт и управление режимами работы других подсистем.
Вариантом по умолчанию была программа на C. Зависимости самостоятельно извлекаются из скриптов или получются внешним скриптом и подаются в виде, совместимом с
Применение shell дает очевидные премущества: кросс-платформенность, то есть работа в любом окружении, где есть POSIX shell, и гибкость, которая позволяет расширить скрипт под конкретные особенности управляемых им скриптов — способ описания зависимостей, способ выполнения. Недостатки тоже есть: язык позволяет легко допускать труднообнаруживаемые ошибки и, являясь интерпретатором, приводит к низкой скорость выполнения. В силу небольшого размера кода первая проблема оказывается несущественной, а скорость выполнения для нашего применения тоже не является проблемой: время, требуемое на выполнение функций управления оказывается пренебрежительно мало по сравнению с временем выполнения задач.
Выглядит парадоксально, но время выполнения набора из нескольких сотен задач при использовании планировщика на
В диалектах шелла, где есть возможност запускать параллельные процессы (их в этой области называют фоновыми процессами), присутствует также команда
Мьютексы в виде лок-файлов для предотвращения одновременного обращения к ресурсу в шелл-скриптах применяются уже давно. Как правило, для этого используется атомарная операция — создания файла или создание каталога через
Это простой сценарий и он отлично подходит для большинства случаев — когде требуется запретить дублирование выполнения скрипта, например, при частом запуске через
Засыпание экономит процессор, но при этом снижается скорость реакции на освобождение блокировки. Раньше, когда команда
Одним из способов для остановки и асинхронного возобновления работы после этого является передача сигналов, например, засыпание и передача
В Unix для межпроцессного взаимодействия с давних пор существуют named pipes — специальный вид файла, в котором чтение и запись выполняются не в файловую систему, а в буфер ядра. Это очень похоже на один из основных механизмов Unix — пайпы
Логика работы пайпов в стандартном режиме подразумевает, что если в пайпе данных нет (закончились или еще не поступили), то процессы, которые читают из пайпа, блокируются до тех пор, пока данные в нем не появятся. Если пайп открывается на чтение, то это процесс блокируется до тех пор, пока не появится другой процесс, открывший пайп на запись. При завершении записи, то есть в тот момент, когда для пайпа больше не осталось дескрипторов в режиме записи, читающие процессы разблокируются, получают конец файла и завершают чтение.
Эта логика отлично подходит для реализации работы условной переменной: для захвата ресурса создается отвечающий за него FIFO-файл; те, кто ждут освобождения ресурса, открывают этот файл на чтение; при освобождении ресурса процесс открывает FIFO-файл на запись и закрывает его, разблокируя таким образом всех ожидающих. Передавать данные через пайп при это не нужно, мы используем только открытие и закрытие пайпа на чтение или запись.
Применяя аналогии из конструкций различных библиотек или языков программирования, блокировку по такой схеме на FIFO-файле можно рассматривать как ожидание
Захват (блокировка), присутствие файла означает, что блокировка уже есть:
Ожидание, отсутствие файла означает отсутствие блокировки:
Освобождение (broadcast), для избежания race condition, в котором кто-то успеет открыть файл на чтение перед тем, как он будет удален, файл сначала переименовывается:
Для работы с FIFO-файлами требуется ядро с поддержкой
Есть нюанс: для разблокирования ждущих процессов нужно открыть FIFO-файл на запись и сразу же закрыть. Но если нет ни одного читающего из пайпа процесса (ресурс никого не интересует), то будет заблокирован процесс, открывающий файл на запись. Это блокирование будет продолжаться до тех пор, пока не появится кто-нибудь, читающий из пайпа, и если никто не появится, блокировка будет вечной. Для Linux и FreeBSD эта проблема решается тем, что FIFO-файл можно открыть одновременно и на запись, и на чтение, и в этом случае процесс никогда не блокируется. Таким образом соблюдается логика, по которой FIFO-файл не блокирует при открытии процессы, если он открыт и на чтение, и на запись, даже если этот будет один и тот же единственный процесс. В POSIX такое поведение никак не описано, по в ядре Linux (fs/fifo.c) по этому поводу отдельно упомянуто с обоснованием, почему такое открытие не блокируется:
В исходниках FreeBSD про обоснования не пишут, а молча снимают блокировку при выполнении условия, что читающих и пишущих дескрипторов больше нуля.
Отсутствие определения в стандарте может означать, что в каких-нибудь других ОС логика разработчика могла оказаться другой и блокировка таким образом сниматься не будет. Например, в AIX честно предупреждают, что поведение при O_RDWR неопределенное. В таких случаях можно перед записью в FIFO-файл порождать отдельный процесс, который будет имитировать заинтересованность в ресурсе, открыв FIFO-файл на чтение и сразу же завершившись.
Первоначально это был скрипт на сотню строк, учитывающий только непосредственные зависимости на задачи, и в котором задачи-скрипта при включении в shell сами добавляли себя в список зависимостей. Но когда понадобилось использовать его в разных окружениях, пришлось разделить функции на несколько групп, отвечающих за разные уровни взаимодействия.
В одну группу вынесены функции, планирующие и координирующие выполнения по зависимостям. В другую группу выделены четыре функции, реализующие поверх FIFO-файлов примитивы блокировки и ожидания. В третью группу вынесены функции, извлекающие зависимости задач и реализующие выполнение задач.
Планировщик выполнения по зависимостям реализован в простейшем виде и не использует ни графы зависимостей, ни отслеживания статусов процессов, ни какие-то другие сложные структуры данных или алгоритмы. Все пущено на самотек.
Каждую задачу мы запускаем в отдельном параллельном субшелле, в котором перед выполнением задачи ждем выполнение всех ее зависимостей, а после выполнения задачи отмечаем задачу и обеспечиваемые ей зависимости как выполненные. Ожидание зависимостей реализовано как ожидание с блокировкой на условных переменных (FIFO-файлах), соответствующих названиям зависимостей. Отметка выполнения — broadcast-освобождение блокировок условных переменных. Перед запуском субшеллов все зависимости инициализируеются в заблокированное состояние. Таким образом, процессы без зависимостей сразу же выполняются, а остальные блокируются на своих зависимостях и ждут их освобождения.
Такой способ в некоторой степени расточителен в расходах, так как на каждую задачу будет запущен отдельный субшелл, а следовательно, отдельный процесс. Для выполнения потока из сотни задач будет запущена сотня процессов. Но этот расход не так велик, как может показаться — сегменты кода и данных почти полностью остаются общими с главным процессом и собственными для каждого процесса будет только стек и какой-нибудь еще небольшой кусок выделенной памяти. Для Linux на 64-битной Intel-архитектуре для dash на ядре 3.16 это около 160 Кб на процесс, для FreeBSD в аналогичной ситуации около 120 Кб. То есть одновременное ожидание из 100 задач требует 12-16 Мб.
Блокировка, ожидание и уведомление о снятии блокировки реализованы по описанному ранее принципу на FIFO-файлах и вынесены в отдельные функции (
Функции последней группы организовывают взаимодействие с реальными задачами — со скриптами запуска системы, с скриптами сервисов и т.п. С помощью одних функций получаются задачи, зависимости и отношения между задачами, с помощью других функций задачи выполняются. Можно переопределить эти функции под конкретные правила аннотации зависимостей и запуска init-скриптов и оставить сами скрипты без изменений. Например, по такому принципу сделан параллельный запуск rc-скриптов для FreeBSD.
В предыдущих релизах программа называлась
Заменить ею
В Jet9 мы перевели на
Во всех случаях
С использованием интерфейсной библиотеки сделан параллельный старт FreeBSD. В текущей версии сделан на скорую руку достаточно тяжелый способ получения зависимостей через awk (три запуска awk на каждый скрипт), и это однозначно требует доработки. Кроме этого нужно протестировать несколько сотен системных rc-скриптов и скриптов из packages — в них могут быть неполностью прописанны зависимости и конфликты одновременного доступа. При последовательном старте неправильные зависимости могли маскироваться, но в параллельном старте они могут проявиться.
Скорее всего, с помощью
В ближайшее время мы обновим пользовательское веб-окружение Jet9 (будет отдельный анонс). А сейчас параллельно начата подготовка следующего релиза, по которому остался один нерешенный вопрос.
В релиз планируется включить поддержку новой платформы: Ruby, Python или Java. Все три есть в сыром виде и на доведение до релизного состояния требуется одинаковое время. Среди сотрудников есть партия любителей питона, предлагающая питон, партия любителей руби, предлагающая руби, и партия любителей перл, предлагающая джаву. Релиз не нужно затягивать, поэтому выбрать можно только одну платформу.
1 Зачем нужены задачи с зависимостями и что дает их параллельный запуск
2 Параллельное vs последовательное
3 Выполнение задач с зависимостями
3.1 rcorder, топологическая сортировка и последовательное выполнение
3.2 upstart, события и асинхронное выполнение
3.3 systemd, зависимости и асинхронное выполнение
3.4 runit, параллельное выполнение и синхронизация при выполнении одной задачи
4 Супервизоры или планировщики
4.1 multitask-flow, параллельное выполнение зависимых задач
5 Средства для синхронизации параллельных шелл-процессов
5.1 Лок-файл в качестве мьютекса
5.2 FIFO-файл в качестве условной переменной
5.3.1 Конструкции для условной переменной в shell
5.3.2 Требования к рабочему окружению
6 Устройство multitask-flow
6.1 Параллельное выполнение и синхронизация на FIFO-файлах
6.2 Как можно и как нельзя применять multitask-flow
7 Ссылки по теме
Зачем нужены задачи с зависимостями и что дает их параллельный запуск
Часто встречающиеся задачи с явно выраженными зависимостями — загрузка или изменение режима работы сервера и старт/остановка пользовательских контейнеров. Большая часть нашего оборудования, работающего под Linux или FreeBSD, загружается не в стандартную инсталляцию дистрибутива, а в наше собственное операционное окружение. Это компактный образ, размещаемый на RAM-диске, в котором имеются системные программы, требуемые для основного профиля работы, и различные вспомогательные утилиты. Старт сервиса, инициализация устройства, применение настроек — это отдельные задачи, которые присутствуют или отсутствуют в системе в зависимости от назначения сервера, и эти задачи должны быть выполнены в правильной последовательности. Например, если конфигурация сети начнет выполняться до того, как загружен драйвер сетевой карты, сеть работать не будет. В пользовательских контейнерах работают различные приложения, корректность работы одних может зависить от работы других, из-за чего бывает важен порядок, в котором они запускаются и останавливаются. Например, если сервер приложений будет запущен раньше сервера баз данных, то он может сразу остановиться из-за невозможности подключиться к базе данных.
Для старта системы сейчас существуют два общепринятых подхода. Первый — указывать порядок выполнения задач в явном виде. Для этого можно либо в перечислить задачи в порядке выполнения в шелл-скрипте, либо нумеровать задачи и использовать номер в названии файла задачи, а затем выполнять их, отсортировав по названию файла:
S23ntp
S25mdadm
S91apache2
Второй способ старта — указать зависимости между стартовыми скриптами, то есть перечислить какие условия нужны для выполнения задачи и какие условия создает задача после своего выполнения:
# REQUIRE: apache
# PROVIDE: nginx
Указывание зависимостей хоть и реализуется сложнее нумерации, но намного удобнее и теперь применяется практически везде.
Главный недостаток, который имеет явная нумерация задач — разработчику необходимо сначала выявить зависимости от других задач, затем найти номера этих зависимостей, и подобрать подходящий номер для новой задачи, в процессе чего может обнаружиться, что нумерация была сделана неудачно и ее требуется менять для части скриптов. При выполнении по зависимостями главным преимуществом является простота добавления задач в уже сложившийся процесс — для новой задачи достаточно только перечислить ее зависимости от других задач. Другим удобством становится возможность параллельного выполнений задач, ведь поочередно должны выполняться только зависимые задачи, а задачи, не зависящие друг от друга, можно выполнять параллельно.
Параллельное vs последовательное
Параллельное выполнение при выполнении задач имеет два ключевых преимущества:
- если задачи не ограничены единственным ресурсом (например, одним процессором), то при параллельном выполнении они завершатся раньше, чем при последовательном;
- если одна из задач по какой-то причине остановилась, то она не блокирует выполнение других задач, которые от нее не зависят и они продолжат выполняться.
В отношении последнего, вероятно, некоторым знакома ситуация, когда при последовательном старте из-за сетевых проблем
sendmail
долго не запускается и ждет DNS-ответов десятки минут, в результате чего на сервер зайти невозможно, так как sshd
оказался в стартовой последовательности позже sendmail
. При параллельном старте, если для sshd
не указана зависимость от sendmail
, то тот его не остановит.Нужно учитывать, что отсутствие явно указанной зависимости не означает отсутствие зависимости вообще — зависимость может быть опосредованная, например, несколько задач могут одновременно обратиться к ресурсу, который этого не допускает (одновременная запись в один файл, изменение конфигурации программы или инициализация устройства). При последовательном выполнении задач такая ситуация невозможна и поэтому может не приниматься во внимание, но при параллельном выполнении может потребоваться выявлять такие ресурсы и предотвращать их одновременное использование. В практике такое случается редко, так как большинство задач в конечном счете запускают системные утилиты, которые самостоятельно занимаются блокировкой доступа, либо это делает ядро, если обращение выполняется к его ресурсам.
Выполнение задач с зависимостями
rcorder, топологическая сортировка и последовательное выполнение
Самый простой способ выполнения по зависимостям — rcorder(8) — появился в NetBSD 1.5 в 2000 г. В rc-скриптах с помощью аннотаций PROVIDE, REQUIRE и BEFORE описываются зависимости между скриптами. Утилита
rcorder
получает список скриптов, извлекает из них отношения, строит граф зависимости и выдает список скриптов в порядке топологической сортировки графа зависимости.Выполнение скриптов в таком порядке обеспечивает соблюдение условия, что зависимые скрипты будут запущены только после того, как будут завершены скрипты-зависимости. Скрипты выполняются поочередно, и возможность параллельного запуска не реализована. Поэтому не требуется синхронизация работы параллельных процессов и реализация является простой и надежной.
Похожий механизм позже появился в Debian Lenny, где в SysV-init скрипты были добавлены аннотации Required-Start, Required-Stop, Provides, с помощью которых описываются зависимости для разных действий (start и stop). Далее по этим зависимостям определяется порядок выполнения, в названия скриптов добавляются номера и скрипты помещаются в стартовый каталог:
S23ntp
S25mdadm
S91apache2
upstart, события и асинхронное выполнение
Upstart появлися в 2006 г. в Ubuntu и позже его стали использовать в множестве других дистрибутивах. Вместо указания отношений между задачами здесь с помощью директивы start on описываются условие — событие или логическое выражение наличия событий, при выполнении которого должна быть выполнена задача. Например, для сервисов, требующих сеть, можно указать условием старта событии NETWORK_AVAILABLE и тогда они будут запущены после того, как кто-нибудь передаст в систему событие NETWORK_AVAILABLE. В условии можно указать не только зависимость на какую-то другую задачу, но и более сложные варианты, например, runlevel и состояния других задач.
Данный способ выполнения приводит к тому, что задачи выполняются асинхронно в тот момент, когда появилось событие, и когда условие, включающее это событие, оказалось истинным. Если оказалось несколько задач, ожидающих одно событие, то они будут выполнены одновременно. Диспетчеризацией событий и выполнением задач заниматся сервис
init
. Присутствуют все преимущества в виде скорости старта и локализации проблемных ветвей.systemd, зависимости и асинхронное выполнение
Systemd появился позже, развернулся на еще более широкое число функций, захватил место
upstart
и теперь уже используется в качестве подсистемы конфигурации, управления, выполнения сервисов и логгинга на большинстве популярных дистрибутивов Linux.В отличие от
upstart
, для запуска сервисов systemd
использует не события, а зависимости. Эти зависимости более сложные и разнообразные, чем в rcorder
— здесь это могут быть отношения порядка (запуск задачи до или после другой задачи) и требования (желательно присутствие, должна присутствовать или должна отсутствовать другая задача). Задачи (юниты) делятся на несколько типов — сервисы (демоны), устройства, монтирование файловых систем и т.д.Задачи выполняются параллельно, как и в
upstart
. Способ описания зависимостей позволяет проверять их во время выполнения на наличие дедлоков, то есть циклических зависимостей (когда задача через цепочку других задач в конечно счете зависит от себя же). systemd
по мере возможности старается решать такие проблемы и удаляет из транзакции выполнения создающие циклы необязательные задачи (Wants=), а также проверяет на зависимости-конфликты (Conflicts=), не позволяя запускаться конфликтующим задачам.runit, параллельное выполнение и синхронизация при выполнении одной задачи
Runit позиционируется как легковесная замена
init
и системы запуска скриптов, с понятным устройством и простым конфигурированием. Еще эту программу удобно использовать для надзора (supervision) за работой сервисов или групп сервисов, например, для старта отдельных сервера приложений и базы данных. Из-за простоты и кросс-платформенности, runit
часто используется на встроенных системах или для управления веб-приложениями.Как таковых зависимостей в
runit
нет. Вместо них в скрипте старта сервиса указывается команда sv
, стартующая другие сервисы. sv
самостоятельно проверяет запущен сервис или нет, и предотвращает от дублирующих запусков одного и того же сервиса. Этого достаточно для того, чтобы можно было запускать программы параллельно, но не позволяет обнаруживать циклические зависимости, приводящих к дедлокам.runit
предназначен для управления сервисами, то есть постоянно запущенными демонами. Он проверяет наличие процесса и рестартует его при необходимости. Но в его логику плохо укладывается выполнение разовых задач с зависимотями, например, получение клиентом настроек сети по DHCP.Супервизоры или планировщики
Из приведенных выше средств только
rcorder
является в чистом виде планировщиком для запуска зависимых задач. Все остальные предназначены для запуска сервисов с зависимостями и последующим контролем существования сервиса, то есть являются супервизорами сервисов. systemd
и upstart
также поддерживают разовые задачи (Type=oneshot или task) с сохранением всей логики зависимостей и таким образом позволяют удобно управлять загрузкой и работой компьютера с учетом большинства мелочей. Более формальный способ описания зависимостей в systemd
, и использование целей (targets) в качестве замены режимов работы (runlevels), являются серьезной эволюцией после upstart
. Но и upstart
, и его последователь systemd
, являются сложными и громоздкими инструментами, которые с большим трудом внедряются в операционную среду, отличающуюся от их основного предназначения.Нацеленность на захват всех аспектов деятельности операционной системы и монолитность приводят к тому, что
systemd
оказывается трудно либо невозможно интегрировать в среду, в которой на нее возлагался бы узкая ответственность. По этой причине для управления отдельными сервисами и для встраиваемых систем по прежнему продолжают использовать runit
, daemontools, eye, supervisord или собственные скрипты, управляющие демонами с учетом особенностей их работы.Как
upstart
, так и systemd
вместе с runit
, заявлены как средство для обеспечения работоспособности сервисов (демонов). Это утверждение содержит большую долю лукавства, так как под обеспечением работоспоосбности подразумевается перезапуск упавшего сервиса. В этом отслеживается генетическая связь с init
, одним из предназначений которого было запускать getty
— процессы обслуживания терминалов. Так как основным средством работы и управления в Unix были терминалы, то работоспособность терминала можно было назвать второй по критичности функцией после работоспособности ядра. Соответственно, на самый главный и самый первый процесс была возложена роль перезапускать процессы getty
как при штатном освобождении терминала, так и при аварийных падениях getty
. Причина, по которой падал getty
или освобождался терминал была несущественной по отношению к факту, что на терминале не было getty
и терялась возможность входа в компьютер.Эволюционировавшие потомки
init
взялись за перезапуск не только getty
, но и всех постоянно работающих сервисов. Но надзор за сервисами остался на первобытном уровне — если демон упал, его просто перезапускают. Более серьезный мониторинг как за качеством работы сервисов (отсутствие внутренних ошибок, обслуживание запросов в срок), так и контроль за их техническими параметрами (объем памяти, потребление процессорного времени), все-равно приходится выполнять другими средствами. Поэтому использование systemd
и его аналогов в чистом виде для надежной работы мало подходит, а при интегрировании с другими средствами мониторинга и управления приходится еще дополнительно учитывать конкурирующее воздействие систем супервизионинга.В некоторых случаях автоматический рестарт упавшего сервиса будет опасен, так как если он как-то связан с сохранением данных, то из-за каких-то несохранившихся данных или нарушенной структуры, в сохраненных данных возникнет ошибка. И если после аварийного рестарта работа будет продолжена без принятия мер по проверке целостности данных и устранению возможных ошибок, то ошибки будут умножаться. Поэтому для простого и надежного функционирования систем более корректным будет подход, где контроля работы сервисов и реакция на их нарушения в работе будут настроены на особенности каждого конкретного сервиса, а не делаться автоматически под одну гребенку для всех.
Использование больших и активно растущих комплексов типа
systemd
приводит еще к одному риску — либо привязанность к выбранной версии программы и сложности с бэкпортирование исправлений ошибок, либо постоянная вероятность столкнуться с большими переделками при обновлении версии.multitask-flow, параллельное выполнение зависимых задач
Для старта и управления режимами работы различного нашего оборудования требовалось минималистичное средство для выполнения задач с зависимостями, которое не старалось бы выполнять дополнительные функии по контролю состояний и рестарту, а просто хорошо делало бы свою основную работу (выполнение по зависимостям) и которое легко бы интегрировалось в разные участки. Кроме удобной интеграции требовались кросс-платформенность и легкость сопровождения. С некоторыми усовершенствованиями оно потом нашло и другие применения — старт пользовательских контейнеров, управление режимами работы кластеров, старт и управление режимами работы других подсистем.
Вариантом по умолчанию была программа на C. Зависимости самостоятельно извлекаются из скриптов или получются внешним скриптом и подаются в виде, совместимом с
tsort(1)
. По списку строится орграф зависимостей, проверяется на циклы, после чего задачи выполняются в порядке обратного обхода или обнуления счетчика зависимостей. В отличие от upstart
и systemd
, которые для своих нужд требуют Linux kernel 2.6.24 и 3.0 соответственно, в данном случае достаточно обычной схемы fork-exec-wait и любое ядро POSIX. Но затем была выбрана более удобная альтернатива — утилита и мелкая библиотека на POSIX shell: jet9-multitask-flow.Применение shell дает очевидные премущества: кросс-платформенность, то есть работа в любом окружении, где есть POSIX shell, и гибкость, которая позволяет расширить скрипт под конкретные особенности управляемых им скриптов — способ описания зависимостей, способ выполнения. Недостатки тоже есть: язык позволяет легко допускать труднообнаруживаемые ошибки и, являясь интерпретатором, приводит к низкой скорость выполнения. В силу небольшого размера кода первая проблема оказывается несущественной, а скорость выполнения для нашего применения тоже не является проблемой: время, требуемое на выполнение функций управления оказывается пренебрежительно мало по сравнению с временем выполнения задач.
Выглядит парадоксально, но время выполнения набора из нескольких сотен задач при использовании планировщика на
sh
оказывается в разы быстрее, чем при использовании аналогичного планировщика на C. У этого есть рациональное объяснение — шелл-скрипты, использующие большую общую библиотеку, будут работать быстрее, если требуемые библиотеку один раз загрузить в родительский процесс shell-планировщика и затем выполнять все сотни скриптов в субшеллах. Если же используется планировщик на C и запускает сотни скриптов через exec()
, то требуется заметно больше времени на инициализацию шелла и загрузку всех библиотек каждым скриптом.Средства для синхронизации параллельных шелл-процессов
Лок-файл в качестве мьютекса
В диалектах шелла, где есть возможност запускать параллельные процессы (их в этой области называют фоновыми процессами), присутствует также команда
wait
. Эта команда при выполнении в родительском процессе ожидает завершения всех процессов-потомков. Аналогия из многопоточного программирования — создание нескольких тредов и затем ожидание через join
завершения всех тредов, созданных из главного потока. Средством синхронизации это является, но во многих случаях средство малоподходящее: кроме того, что ждать придется все процессы-потомки, нельзя ждать процессы, которые не являются потомками.Мьютексы в виде лок-файлов для предотвращения одновременного обращения к ресурсу в шелл-скриптах применяются уже давно. Как правило, для этого используется атомарная операция — создания файла или создание каталога через
mkdir
. Если эта операция не удается, ресурс считается занятым и скрипт прерывает выполнение. При операциях на файлах может использоваться флаг no clobber для предотвращения записи в уже существующие файлы, а создание каталога при наличии существующего невозможно. Но атомарность таких операций обеспечивает не язык или среда выполнения, а файловая система, поэтому в разных условиях поведение может отличаться.Это простой сценарий и он отлично подходит для большинства случаев — когде требуется запретить дублирование выполнения скрипта, например, при частом запуске через
cron
долгоживущих скриптов или при запуске демонов. Если требуется не прерывание работы, а ожидание освобождения ресурса, то обычным приемом для этого является периодическая проверка отсутствия лока, по сути, спинлок с засыпанием: если файл или каталог существует, то скрипт засыпает на некоторое время, после чего повторяет попытку. Без засыпаний спинлок на шелле делать бессмысленно и вредно — в силу характера использования шелл-скриптов, время блокировки может быть как доли секунды, так и несколько десятков секунд, из-за чего безостановочная проверка будет загружать и процессор, и ядро ОС, отнимая ресурсы у всех и замедляя, в том числе, конкурирующий процесс.Засыпание экономит процессор, но при этом снижается скорость реакции на освобождение блокировки. Раньше, когда команда
sleep
понимала только целочисленные аргументы, минимальным временем засыпания была 1 секунда. Сейчас это значение уже может быть меньше, но, в любом случае, это будет некоторая константа, которая может оказаться слишком велика для коротких действий в несколько миллисекунд. Поэтому для быстрой реакции желателен механизм, реализующий ожидание освобождения ресурса без засыпания на фиксированное время, но и без активного ожидания.Одним из способов для остановки и асинхронного возобновления работы после этого является передача сигналов, например, засыпание и передача
SIGALRM
. Способ доступен и для шелла, но требует дополнительной сложных действий для оповещения владельца лока об ожидающих процессах. Другим способом напрашивается использования механизмов блокировки файлов с ожиданием освобождения блока на базе flock(2)
или fcntl(2)
, но, к сожалению, напрямую из шелл-скриптов управлять блокировкой файлов невозможно и для этого будет нужно использовать стороннюю утилиту.FIFO-файл в качестве условной переменной
В Unix для межпроцессного взаимодействия с давних пор существуют named pipes — специальный вид файла, в котором чтение и запись выполняются не в файловую систему, а в буфер ядра. Это очень похоже на один из основных механизмов Unix — пайпы
pipe(2)
, использующиеся для передачи вывода одного процесса на ввод другого. Пайп — это пара файловых дескрипторов, в которые можно писать и читать. Записанная информация попадает в буфер и выдается из буфера при чтении в том же порядке, в котором она записывалась в буфер. Чтение и запись при этом действуют точно так же, как и для обычных файлов, сокетов и т.д., программа может даже не знать, что она работает не с файлом, а с пайпом. Разница между пайпом, созданным с помощью pipe(2)
и именованным пайпом, созданным как FIFO-файл через mkfifo(2)
заключается в том, в первом случае пайпом могут пользоваться только создавший его процесс и его порожденные после создания пайпа потомки, а FIFO-файл находится в обычной файловой системе и пользоваться им могут любые процессы, которые имеют права на чтение или запись в этот файл.Логика работы пайпов в стандартном режиме подразумевает, что если в пайпе данных нет (закончились или еще не поступили), то процессы, которые читают из пайпа, блокируются до тех пор, пока данные в нем не появятся. Если пайп открывается на чтение, то это процесс блокируется до тех пор, пока не появится другой процесс, открывший пайп на запись. При завершении записи, то есть в тот момент, когда для пайпа больше не осталось дескрипторов в режиме записи, читающие процессы разблокируются, получают конец файла и завершают чтение.
Эта логика отлично подходит для реализации работы условной переменной: для захвата ресурса создается отвечающий за него FIFO-файл; те, кто ждут освобождения ресурса, открывают этот файл на чтение; при освобождении ресурса процесс открывает FIFO-файл на запись и закрывает его, разблокируя таким образом всех ожидающих. Передавать данные через пайп при это не нужно, мы используем только открытие и закрытие пайпа на чтение или запись.
Применяя аналогии из конструкций различных библиотек или языков программирования, блокировку по такой схеме на FIFO-файле можно рассматривать как ожидание
wait
на условной переменной, а снятие блокировки и освобождение всех ожидающих процессов как broadcast
или notifyAll
.Конструкции для условной переменной в shell
Захват (блокировка), присутствие файла означает, что блокировка уже есть:
mkfifo $lock_file
Ожидание, отсутствие файла означает отсутствие блокировки:
read none < $lock_file
Освобождение (broadcast), для избежания race condition, в котором кто-то успеет открыть файл на чтение перед тем, как он будет удален, файл сначала переименовывается:
mv $lock_file $lock_file.$$
: <> $lock_file
rm -f $lock_file.$$
Требования к рабочему окружению
Для работы с FIFO-файлами требуется ядро с поддержкой
mkfifo(2)
(стандарт POSIX.1), системная утилита mkfifo(1)
(стандарт POSIX.2) и любой шелл, который может работать с файлами, включая минимальный POSIX shell. Под эти требования подходят и Linux, и FreeBSD, и, теоретически, все остальные Unix-like операционные системы.Есть нюанс: для разблокирования ждущих процессов нужно открыть FIFO-файл на запись и сразу же закрыть. Но если нет ни одного читающего из пайпа процесса (ресурс никого не интересует), то будет заблокирован процесс, открывающий файл на запись. Это блокирование будет продолжаться до тех пор, пока не появится кто-нибудь, читающий из пайпа, и если никто не появится, блокировка будет вечной. Для Linux и FreeBSD эта проблема решается тем, что FIFO-файл можно открыть одновременно и на запись, и на чтение, и в этом случае процесс никогда не блокируется. Таким образом соблюдается логика, по которой FIFO-файл не блокирует при открытии процессы, если он открыт и на чтение, и на запись, даже если этот будет один и тот же единственный процесс. В POSIX такое поведение никак не описано, по в ядре Linux (fs/fifo.c) по этому поводу отдельно упомянуто с обоснованием, почему такое открытие не блокируется:
/* * O_RDWR * POSIX.1 leaves this case "undefined" when O_NONBLOCK is set. * This implementation will NEVER block on a O_RDWR open, since * the process can at least talk to itself. */
В исходниках FreeBSD про обоснования не пишут, а молча снимают блокировку при выполнении условия, что читающих и пишущих дескрипторов больше нуля.
Отсутствие определения в стандарте может означать, что в каких-нибудь других ОС логика разработчика могла оказаться другой и блокировка таким образом сниматься не будет. Например, в AIX честно предупреждают, что поведение при O_RDWR неопределенное. В таких случаях можно перед записью в FIFO-файл порождать отдельный процесс, который будет имитировать заинтересованность в ресурсе, открыв FIFO-файл на чтение и сразу же завершившись.
Устройство multitask-flow
Параллельное выполнение и синхронизация на FIFO-файлах
Первоначально это был скрипт на сотню строк, учитывающий только непосредственные зависимости на задачи, и в котором задачи-скрипта при включении в shell сами добавляли себя в список зависимостей. Но когда понадобилось использовать его в разных окружениях, пришлось разделить функции на несколько групп, отвечающих за разные уровни взаимодействия.
В одну группу вынесены функции, планирующие и координирующие выполнения по зависимостям. В другую группу выделены четыре функции, реализующие поверх FIFO-файлов примитивы блокировки и ожидания. В третью группу вынесены функции, извлекающие зависимости задач и реализующие выполнение задач.
Планировщик выполнения по зависимостям реализован в простейшем виде и не использует ни графы зависимостей, ни отслеживания статусов процессов, ни какие-то другие сложные структуры данных или алгоритмы. Все пущено на самотек.
Каждую задачу мы запускаем в отдельном параллельном субшелле, в котором перед выполнением задачи ждем выполнение всех ее зависимостей, а после выполнения задачи отмечаем задачу и обеспечиваемые ей зависимости как выполненные. Ожидание зависимостей реализовано как ожидание с блокировкой на условных переменных (FIFO-файлах), соответствующих названиям зависимостей. Отметка выполнения — broadcast-освобождение блокировок условных переменных. Перед запуском субшеллов все зависимости инициализируеются в заблокированное состояние. Таким образом, процессы без зависимостей сразу же выполняются, а остальные блокируются на своих зависимостях и ждут их освобождения.
Такой способ в некоторой степени расточителен в расходах, так как на каждую задачу будет запущен отдельный субшелл, а следовательно, отдельный процесс. Для выполнения потока из сотни задач будет запущена сотня процессов. Но этот расход не так велик, как может показаться — сегменты кода и данных почти полностью остаются общими с главным процессом и собственными для каждого процесса будет только стек и какой-нибудь еще небольшой кусок выделенной памяти. Для Linux на 64-битной Intel-архитектуре для dash на ядре 3.16 это около 160 Кб на процесс, для FreeBSD в аналогичной ситуации около 120 Кб. То есть одновременное ожидание из 100 задач требует 12-16 Мб.
Блокировка, ожидание и уведомление о снятии блокировки реализованы по описанному ранее принципу на FIFO-файлах и вынесены в отдельные функции (
dependency_init
, dependency_wait
, dependency_mark
) по несколько строк каждая. Схема использования их в общих чертах похожа на wait + notifyAll/broadcast, но делает эти операции не на одном мьютексе, а сразу на нескольких. Для защиты от одновременного выполнения требующих этого задач по принципу мьютекса с ожиданием или примитива sinchronized, добавлена также функция ожидания и атомарного захвата нескольких мьютексов (dependency_lock
). На низшем уровне мьютексы реализованы созданием и удалением FIFO-файлов, проверкой существования файла и открытием на чтение или запись.Функции последней группы организовывают взаимодействие с реальными задачами — со скриптами запуска системы, с скриптами сервисов и т.п. С помощью одних функций получаются задачи, зависимости и отношения между задачами, с помощью других функций задачи выполняются. Можно переопределить эти функции под конкретные правила аннотации зависимостей и запуска init-скриптов и оставить сами скрипты без изменений. Например, по такому принципу сделан параллельный запуск rc-скриптов для FreeBSD.
Как можно и как нельзя применять multitask-flow
В предыдущих релизах программа называлась
jet9-multitask-init
, так как в этом варианте она главным образом использовалась для управления сервисами пользовательских контейнеров и смены состояний серверов Jet9, то есть была для нас заменой init
в этих областях. Но для общего случая init
в названии вводит в заблуждение и программа была переименована в jet9-multitask-flow
.Заменить ею
init
нельзя, так как она не занимается ни жатвой зомби, ни рестартом демонов. Она выполняет только одну роль init
— выполнение скриптов установки режима работы (runlevel). В этой роли она применяется на специализированном оборудовании (управление автоматикой и файл-серверы), используемом в инфраструктуре TrueVirtual. Используется компактное операционное окружении на базе Linux нашей разработки c init
из Debian 6 и предыдущей версией jet9-multitask-init
.runit
легко интегрируется с multitask-flow
и их вместе можно применять как для встраиваемых систем или специального оборудования, так и для организации быстрого старта большого числа микросевисов, в частности, в Docker. Основные возможности, которыми дополнется runit
:- выполнение по зависимостям разовых задач (инициализация оборудования, DHCP, service discovery, DNS registration)
- “виртуальные” задачи, с помощью которых можно регистрировать задачу через отношение mark в общем классе и указать зависимость на него, а не перечислять в зависимостях все конкретные задачи
- быстрое выполнение скриптов через субшеллы
- возможность в отдельном скрипте описать весь поток, включив в скрипт код всех задач и их зависимости
- проверка через
tsort
наличия циклических зависимостей, приводящих к дедлоку
В Jet9 мы перевели на
multitask-flow
старт пользовательских контейнеров. Для большинства сайтов используется окружение LAMP и в таком случае внутри контейнера работают только пользовательские apache
и mysqld
. У mysqld
есть собственный супервизор mysqld_safe
, у apache
функцию супервизора выполняет его корневой процесс. Еще один супервизор, надзирающий над этими супервизорами, явно избыточен. В тех редких случаях, когда универсальный супервизор все-же необходим, вместе с multitask-flow
используется runit
. Но и в других веб-окружениях чаще всего сервер приложений тоже имеет собственный супервизор и достаточно стартовать сервисы, как и в LAMP, через multitask-flow
с соблюдением зависимостей. Например, в веб-окружении Ruby on Rails с Unicorn, процессами-воркерами управляет мастер, который выполняет функцию супервизора. Встречающиеся рекомендации устанавливать runit
для того, чтобы запускать unicorn
, для большинства случаев неоправданы и, кроме того, усложняют или ломают правильную работу unicorn
. Существенно больше пользы будет от установки eye, который будет не только супервизором, но также и полноценным мониторингом сервисов, проверяя, например, HTTP-ответы приложения. Для проектов, которым мы оказываем техническое сопровождение, мониторинг и контроль качества выполняется с помощью eye
или аналогичного средства.Во всех случаях
multitask-flow
дает простое управление сервисами и нам, и пользователям. Что дает еще? Повышает аптайм. Для уменьшения времени простоя при аварии или штатной миграции в отказоустойчивом кластере, нужно как можно скорее запустить все сервисы в пользовательских контейнерах. На одном узле может быть несколько сотен контейнеров и несколько тысяч процессов. Пользователь, выбравший SLA с четырьмя девятками (99.99%, ~5 минут в месяц), чувствительен и к десяти секундам. Для сложных конфигураций с большим числом сервисов параллельное выполнение сокращает старт с несколько минут до 15-30 секунд.С использованием интерфейсной библиотеки сделан параллельный старт FreeBSD. В текущей версии сделан на скорую руку достаточно тяжелый способ получения зависимостей через awk (три запуска awk на каждый скрипт), и это однозначно требует доработки. Кроме этого нужно протестировать несколько сотен системных rc-скриптов и скриптов из packages — в них могут быть неполностью прописанны зависимости и конфликты одновременного доступа. При последовательном старте неправильные зависимости могли маскироваться, но в параллельном старте они могут проявиться.
Скорее всего, с помощью
multitask-flow
можно ускорить старт систем на busybox
и OpenWRT
, но на практике это пока еще не проверено. В OpenWRT уже есть наработки по интеграции с systemd
и, возможно, для него альтернатива уже не нужна.Ссылки по теме
- rcorder(8)
- tsort
- Топологическая сортировка
- Upstart
- Systemd
- Runit
- daemontools
- eye
- supervisord
- jet9-multitask-flow
- freebsd-multitask-flow
P.S.
В ближайшее время мы обновим пользовательское веб-окружение Jet9 (будет отдельный анонс). А сейчас параллельно начата подготовка следующего релиза, по которому остался один нерешенный вопрос.
Задача о трех языках
В релиз планируется включить поддержку новой платформы: Ruby, Python или Java. Все три есть в сыром виде и на доведение до релизного состояния требуется одинаковое время. Среди сотрудников есть партия любителей питона, предлагающая питон, партия любителей руби, предлагающая руби, и партия любителей перл, предлагающая джаву. Релиз не нужно затягивать, поэтому выбрать можно только одну платформу.
Подробнее про платформы:
- Java — OpenJDK 7, Tomcat 6, 7, 8
- Python mod_wsgi / Tornado, версии 2.6, 2.7, 3.3, 3.5
- Ruby mod_passenger / Unicorn / Puma, версии 1.8.7, 1.9.3, 2.0, 2.2
Вопросы
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
lolipop
справедливости ради, runit тоже умеет одноразовые задачи.
QuickAurum
А что именно?
madkite
В Stage 1? Я так понимаю, что речь шла об автоматическом разруливании зависимостей этих одноразовых задач, т.к. дальше по тексту шла фраза «с сохранением всей логики зависимостей». А этого в runit действительно в явном виде не предусмотрено, т.к. очень редко в этом есть острая необходимость.
lolipop
и в stage1 тоже. вообще, я имел в виду скрипт finish, который в дире с джобом. ничто не мешает там вбить свою логику, вроде sv stop $name.
например, я делал так:
madkite
А можно узнать зачем так сложно получать имя текущей директории (`basename $(dirname $(pwd)/fake)`), почему не просто `basename $PWD`? Какой corner case, требующий этих извращений?
lolipop
я же вроде написал, сделать джоб для ранита, который выполняется только один раз. в сочетании с runit-man весьма удобно.
cvss
Мне тоже интересно, почему name получается таким образом. Вопрос madkite подразумевает, что если при наличии простого способа выбран сложный, вероятно, есть какая-то неочевидная причина делать сложно. И такие вещи всегда полезно узнавать.
cvss
Это все-таки другое. Использовать инструмент не совсем по назначению, и нужно будет еще приделывать отметку и проверку того, что задача уже была выполнена. Для пары скриптов можно, а там где уже пара десятков — появляется путаница.
cvss
Острая необходимость мало в чем бывает, можно и вообще без init обойтись :) — из шелла фоновые процессы запустить. Дело не в том, что по другому вообще никак, а в том, что бывают неострые необходимости — чтобы было модульно, или чтобы выполнялось быстро, или чтобы логика работы показывалась четко и могла проверяться.
madkite
Тут уж на вкус и цвет — кому нравится что-то простое и понятное (типа runit) с минимум фитчей, но решающее из коробки 90% потребностей, а кому что-то навороченное (типа systemd), где предусмотрено всё что только можно.