Параллельное выполнение в bash

Параллельное выполнение в bashВ большинстве командных оболочек команды выполняются по умолчанию последовательно. И это, в принципе, нормально. Потому что человек с системой взаимодействует последовательно, обычно нет необходимости несколько команд выполнять параллельно. Bash в этом смысле тоже не исключение. Но при автоматизации возможность параллельного выполнения может быть полезной. Давайте посмотрим, как организовать параллельное выполнение в bash.

Использование фонового режима

Для организации параллельной работы нескольких программ часто используется запуск в фоновом режиме при помощи знака амперсанда — &. Например:

command1 &

Команда будет работать в фоне, при этом из текущей оболочки можно выполнять команды. Таким образом уже можно распараллелить какие-то действия. Можно запустить сразу несколько команд таким образом и дождаться, пока они все отработают. Для ожидания запущенных дочерних процессов используется команда wait. Эта команда без параметров ожидает окончания работы всех дочерних процессов, соответственно, для ожидания окончания 5 процессов понадобится выполнить команду всего 1 раз. В принципе, это легко реализуется через цикл. Например, так:

for i in {1..5}
do
    # запуск одного фонового процесса
    sleep 10 && echo $i &
done
# ожидание окончания работы
wait
echo Finished

И результат работы этого скрипта:

$ ./wait5.sh
1
5
4
2
3
Finished

Как видите, с виду одинаковые команды завершились не в том порядке, в котором мы их запустили. Давайте посмотрим теперь общее время выполнения скрипта.

$ time ./wait5.sh 
4
5
2
3
1
Finished

real	0m10.029s
user	0m0.000s
sys	0m0.008s

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

Использование пайпа

При использовании пайпов, которые перенаправляют вывод одной программы на вход другой, процессы выполняются параллельно. Отсюда вытекает еще один способ параллельного выполнения нескольких команд — перенаправить между ними какие-то данные с помощью пайпа. Например:

program1 | program2 | program3

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

command1 --option > dev/null | command2 param1 param2 > /dev/null | command3

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

Параллельное выполнение и ограничение количества фоновых задач в скрипте

Давайте рассмотрим такую практическую задачу — запустить 100 процессов параллельно, но так, чтобы работало одновременно не более 10 процессов. В общем, достаточно простая задача. Предположим, что все процессы работают произвольное количество времени. Пусть запуск одной задачи будет выглядеть как запуск команды sleep со случайным параметром от 0 до 29. Тогда скрипт будет выглядеть следующим образом:

#!/bin/bash

RANDOM=10
JOBS_COUNTER=0
MAX_CHILDREN=10
MY_PID=$$

for i in {1..100}
do
    echo Cycle counter: $i
    JOBS_COUNTER=$((`ps ax -Ao ppid | grep $MY_PID | wc -l`))
    while [ $JOBS_COUNTER -ge $MAX_CHILDREN ]
    do
        JOBS_COUNTER=$((`ps ax -Ao ppid | grep $MY_PID | wc -l`))
        echo Jobs counter: $JOBS_COUNTER
        sleep 1
    done
    sleep $(($RANDOM % 30)) &
done
echo Finishing children ...
# wait for children here
while [ $JOBS_COUNTER -gt 1 ]
do
    JOBS_COUNTER=$((`ps ax -Ao ppid | grep $MY_PID | wc -l`))
    echo Jobs counter: $JOBS_COUNTER
    sleep 1
done
echo Done

Смысл этого скрипта в целом такой: ограничиваем максимально число дочерних фоновых процессов так, чтобы их одновременно было не более 10. Как только один процесс заканчивает свою работу, запускаем следующий. И так далее, пока не выполним 100 фоновых задач. Для порядка отслеживаем в скрипте окончание работы дочерних процессов после запуска последних,  только потом заканчиваем работу самого скрипта.

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

Параллельное выполнение в bash: 9 комментариев

  1. Vladimir

    Надо заменить JOBS_COUNTER=$((`ps ax -Ao ppid | grep $MY_PID | wc -l`)) на
    JOBS_COUNTER=$(( `ps ax -Ao ppid | grep $MY_PID | wc -l` — 3 )).
    Т.к. вызов `ps ax -Ao ppid | grep $MY_PID | wc -l` порождает еще дополнительных 3 процесса.
    Иначе скрипт никогда не завершить свое выполнение.

    1. Maxim Norin Автор записи

      На самом деле этот вызов порождает только один дочерний процесс оболочки, в котором выполняются эти три команды, не являющиеся прямыми потомками скрипта. Можете запустить скрипт из статьи и удостовериться, что он все-таки завершает свою работу. При этом действительно, этот подпроцесс считается, но это 1, а никак не 3. В статье написано «Смысл этого скрипта в целом такой: ограничиваем максимально число дочерних фоновых процессов так, чтобы их одновременно было не более 10», поэтому соглашусь только с тем, что фоновых процессов все-таки выполняется не более 9, а не 10. 10-й — это не фоновый child process.

  2. Евгений

    Здравствуйте! А у меня обратная задача. Нужно, чтобы все команды были выполнены строго последовательно и следующая команда выполнялась только по завершению предыдущей. Вот скрипт, он рабочий, но сайт перестает работать после того, как выполняется данный скрипт:

    #!/bin/bash
    chown mysql:mysql ./data/*
    find ./data -type f -exec chmod 660 {} \;
    service mysql stop
    mv ./data/* /var/lib/mysql/data
    service mysql start
    exit

    Подозреваю, что mysql не успевает остановиться при команде service mysql stop, когда уже стала выполняться команда mv

    Подскажите, как это можно исправить? Спасибо большое!

    1. Maxim Norin Автор записи

      Добрый день.
      Все команды в этом скрипте выполняются строго последовательно, и следующая команда выполняется только тогда, когда предыдущая закончила свою работу. Теоретически да, процесс mysqld может не успеть завершить свою работу, но чтобы в этом убедиться, надо более внимательно последить за процессами. Попробуйте сделать следующее: добавить после команды «service mysql stop» следующую строчку:
      while pgrep mysqld; do sleep 1; done
      Это позволит убедиться, что процессов mysqld в системе нет. И, если это так, значит проблема в другом месте. Можно посмотреть, что пишется в лог mysql при остановке и старте сервиса

      1. Евгений

        Максим, спасибо огромное! Вы меня выручили!

        Вот окончательный вариант скрипта (заодно упростил строчку с командой chmod), отрабатывает как задумано:

        #!/bin/bash
        chown mysql:mysql ./data/*
        chmod 660 ./data/*
        service mysql stop
        while pgrep mysqld; do sleep 1; done
        mv ./data/* /var/lib/mysql/data
        service mysql start
        exit

        Может еще кому-нибудь такой скрипт пригодится, когда надо с локального компа залить по FTP обновления базы в промежуточный каталог на сервере, а затем безопасно и почти мгновенно обновить файлы БД.

        Остановка mysqld действительно не мгновенная, даже визуально заметно, что когда в консоли вводишь команду service mysql stop, то проходит примерно секунда по времени, прежде чем появляется приглашение от Ubuntu на ввод новой команды.

        1. Maxim Norin Автор записи

          Всегда пожалуйста.
          Если чем-то еще могу помочь — пишите

  3. Олег

    Спасибо, за статьи.

    Если делать программу для работы (без вывода реального числа процессов) можно было бы обойтись без grep, например так:

    MAX_CHILDREN=5
    ZERO_LEVEL=$(ps —ppid $MY_PID |wc -l)
    MAX_CHILDREN=$(($MAX_CHILDREN + $ZERO_LEVEL))

    #skip
    JOBS_COUNTER=$(ps —ppid $MY_PID | wc -l)
    while [ $JOBS_COUNTER -ge $MAX_CHILDREN

  4. Вячеслав

    Добрый день, Максим.

    Есть ли вероятность того, что скрипт
    `ps ax -Ao ppid | grep $MY_PID | wc -l`
    будет возвращать кроме дочерних процессов еще и процессы, PPID которых будет включать в себя значение $MY_PID? Можно ли избежать этого, используя следующую конструкцию
    ps ax -Ao ppid | tr -d [[:blank:]] | grep -xc $MY_PID
    то есть проверяя полное совпадение (и сразу подсчитывая количество таких совпадений) без учета WHITESPACES.

    1. Maxim Norin Автор записи

      Добрый день.
      Да, есть такая вероятность. Отличная мысль :)

Обсуждение закрыто.