GNU parallel и xargs. Параллельный запуск нескольких копий команды с разными аргументами

Задача

GNU Parallel

Есть консольная команда вида:

./do-something.sh -x 1

Значение аргумента x может меняться в диапазоне от 1 до 30 000. Выполнение команды для одного аргумента занимает от 30 секунд до 15 минут. Нужно максимально быстро выполнить эту команду для заданного диапазона аргументов на N-ядерном сервере максимально используя ресурсы сервера.

Возможные варианты решения

  1. Простой цикл от 1 до 30 тысяч с запуском команды на каждой итерации будет использовать только 1 ядро. Это решение неприемлемо: оно будет работать слишком долго и не задействует все доступные ресурсы сервера.
  2. Можно вручную разбить диапазон на N частей и запустить N циклов вида:
   for i in `seq 1 1000`
   do
    ./do-something.sh -x $i
   done

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

Решение с xargs

Утилита xargs, входящая во все современные дистрибутивы Linux, позволяет выполнить заданную команду для списка аргументов поступивших на стандартный ввод. Полезные ссылки:

В следующем примере берется список файлов текущей директории ls (в примере использован корень проекта на фреймворке Yii2, ничего секретного) и для каждого файла в директории применяется команда file, определяющая тип файла:

ls | xargs file
assets:             directory
commands:           directory
composer.json:      ASCII text
composer.lock:      UTF-8 Unicode text
config:             directory
controllers:        directory
mail:               directory
migrations:         directory
models:             directory
modules:            directory
requirements.php:   PHP script, ASCII text
runtime:            directory
tests:              directory
vendor:             directory
views:              directory
web:                directory
yii:                a /usr/bin/env php script, ASCII text executable
yii.bat:            DOS batch file, ASCII text

Аргумент -P позволяет задать сколько параллельных потоков будет использовано для выполнения задачи.

Поэкспериментируем. Возьмем следующий скрипт и назовем его do-something.sh:

#!/usr/bin/env bash

# Check for command line arguments
if [ $# -lt 1 ]
then
    echo "No options found!"
    exit 1
fi

# Get number
while getopts "x:" opt
do
    case $opt in
        x) num=$OPTARG
        ;;
        *) echo "No reasonable options found!";;
    esac
done

rnd=$(shuf -i 1-100 -n 1)
rnd=$(echo "$rnd 100" | awk '{printf "%.2f \n", $1/$2}')
sleep $rnd
echo $num

Этот скрипт берет на вход число и выводит его на экран с задержкой от 0 до 1 секунды. Теперь запустим этот скрипт командой time echo {1..10} | xargs -n 1 ./do-something.sh -x. Эта команда выполняет следующие задачи:

  • генерирует последовательность чисел от 1 до 10: echo {1..10},
  • передает эти числа по одному в наш скрипт (за это отвечает аргумент -n 1, без него вся последовательность будет воспринята как один длинный аргумент, так как значения разделены пробелом, а не переводом строки),
  • в конце работы скрипта командой time выводит затраченное время.

В результате мы получим примерно такой вывод:

time echo {1..20} | xargs -n 1 ./do-something.sh -x
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

real  0m10.301s
user  0m0.042s
sys 0m0.194s

Результат 1

А теперь запустим ту же команду с опцией -P 4, что заставит скрипт выполняться в 4 потока:

time echo {1..20} | xargs -n 1 -P 4 ./do-something.sh -x
4
2
6
1
3
8
10
5
12
9
7
11
13
15
14
19
16
17
20
18

real  0m2.651s
user  0m0.032s
sys 0m0.215s

Результат 2

В этом примере мы видим, что команды выполнились в 4 раза быстрее, так как запускались параллельно. То что на результат практически не влияет генератор случайных чисел легко убедиться заменив случайную паузу константой.

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

У xargs есть один недостаток. Давайте заменим в скрипте do-something.sh sleep $rnd на sleep 0.1. Это сделает задержку не случайной, а постоянной. Теперь еще раз выполним time echo {1..20} | xargs -n 1 -P 4 ./do-something.sh -x:

time echo {1..20} | xargs -n 1 -P 4 ./do-something.sh -x
1
3
2
4
5
6
7
8
9
10
12
11
13
14
15
16
17
18
19
20

real  0m0.560s
user  0m0.034s
sys 0m0.186s

Результат 3

Видно, что результаты выводятся не последовательно, это не всегда приемлемо.

Решение с GNU Parallel

Ниже перевод введения из мануала к утилите:

GNU Parallel — утилита командной строки для параллельного запуска задач на одном или нескольких компьютерах. Задача в данном контексте — это одна команда или скрипт, который должен быть запущен для каждого входящего аргумента. Типичный набор аргументов — это список файлов, хостов, пользователей, урлов или таблиц. Аргументы также могут быть переданы через пайп. GNU parallel может разделить аргументы и параллельно передать их командам.

Если вы используете xargs, то вы легко сможете использовать parallel, так как эта утилита поддерживает те же аргументы командной строки что и xargs. Если вы используете циклы в шелл-скриптах, то, вероятно, parallel поможет вам избавиться от них и ускорить выполнение за счет параллельного запуска команд.

GNU parallel возаращает результаты выполнения команд в том же порядке как если бы они были запущены последовательно. Это делает возможным использование результатов работы parallel как входных данных для других программ.

Для каждой входящей строки GNU parallel запустит команду и передаст ей эту строку в качетсве аргументов. Если команда не задана, то входящая строка будет исполнена. Несколько строк будут выполнены одновременно. GNU parallel может быть использована как замена для xargs и cat | bash.

У этой утилиты как минмум 2 видимых преимущества перед xargs:

  • она позволяет запускать команды не в рамках одного сервера, а сразу на нескольких,
  • руководство обещает, что результаты будут выводиться последовательно.

Испытаем. Поверим обещаниям того, что parallel принимает те же аргументы, что и xargs и просто заменим имя одной утилиты на другую в команде, которую использовали ранее:

time echo {1..20} | parallel -n 1 -P 4 ./do-something.sh -x
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20

real  0m0.562s
user  0m0.135s
sys 0m0.096s

Результат 4

Работает! Команда выполнилась примено за те же 0,5 секунд, что и xargs и результат возвращен в правильной последовательности.

Теперь попробуем вернуть обратно случайную задержку, зменим в скрипте do-something.sh sleep 0.1 на sleep $rnd и запустим еще раз. Результат будет возвращен опять в правильной последовательности, несмотря на то, что из-за разной задержки команды запущенные позже могут быть выполнены раньше предыдущих команд (это хорошо видно во втором результате выше).

Единственным недостатком является то, что xargs возвращает результаты как только они готовы, а parallel — только тогда когда выполнение всех команд завершено. Но это цена, которую приходится платить за корректную последовательность результатов. Если запустить parallel с аргументом --bar, то во время работы будет выводиться прогресс бар, показывающий процент выполненных команд.

Теперь испытаем еще одну киллер-фичу parallel — возможность запустить команду на нескольких серверах сразу. Для этого воспользуемся примером из доки: https://www.gnu.org/software/parallel/man.html#EXAMPLE:-Parallel-grep.

# Добавим список серверов в конфиг. В моем случае сервера имеют имена dev и test
(echo dev; echo test) > .parallel/my_cluster

# Убедимся, что существует файл .ssh/config и забэкапим его
touch .ssh/config
cp .ssh/config .ssh/config.backup

# Временно отключим StrictHostKeyChecking
(echo 'Host *'; echo StrictHostKeyChecking no) >> .ssh/config
parallel --slf my_cluster --nonall true

# Откатываем назад изменения StrictHostKeyChecking в конфиге SSH
mv .ssh/config.backup .ssh/config

Теперь сервера из файла .parallel/my_cluster добавлены в .ssh/known_hosts.

Наконец, нужно скопировать скрипт do-something.sh в домашнюю директорию текущего пользователя на удаленных серверах (в моем примере test и dev).

После выполненной подготовки мы можем запустить команду на серверах dev и test добавив к вызову parallel опцию --sshlogin dev,test.

Попробуем:

time echo {1..3200} | parallel -n 1 -P 4 --sshlogin test,dev ./do-something.sh -x

real  0m0.334s
user  0m0.080s
sys 0m0.032s

Результат 5

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